Add `relspec job list` and `relspec job run <name>` driven by YAML job manifests (relspec.yml / relspec.<name>.yml), so multi-file merge and conversion workflows can be expressed declaratively instead of as long shell command lines. v1 contract (see docs/JOB_FILES.md): - `command` is a closed allow-list (convert, merge, scripts-list); no field accepts a shell string or executable path. - Deterministic discovery: default file first, then named files sorted lexically; all files merged into one namespace; duplicate job names across files are a hard error. - Every path resolves relative to the job file's directory; absolute, home-relative and directory-escaping paths are rejected at validation. - Database credentials referenced by env-var name via `conn_env:`; connection strings are never stored and are redacted from logs/plan. - Full validation (version, unknown fields, command/format, per-command input/output shape, path traversal, depends_on targets, dependency cycles) runs before anything is read, written or executed; per-job pre-flight then checks input existence, script dirs, env vars and the output overwrite policy for the whole plan. - `depends_on` closure runs in deterministic topological order; `--no-deps` runs only the named job. - `--dry-run` (alias `--plan`) prints the resolved plan and exits 0 without touching inputs, outputs or databases. - A failing job propagates the underlying non-zero exit status, logs FAILED (never OK), and writes no success marker. pkg/jobs is side-effect free (discovery/parse/validate/plan only); execution adapters live in cmd/relspec/job.go. Includes unit tests for discovery, validation, planning and path safety, plus CLI tests for end-to-end convert/merge, scripts-list across multiple directories, dry-run, dependency chains, exit-code propagation and log redaction. Deferred: live `scripts execute` from jobs, split/inspect/diff/templ commands, job-to-job output wiring, log rotation/retention. Refs #20 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
8.0 KiB
RelSpec Job Files
Job files let you declare named, repeatable RelSpec workflows in YAML and run
them with relspec job run <name> instead of retyping long command lines.
relspec job list # deterministic list of discovered jobs
relspec job run build-schema --plan # validate + print plan, execute nothing
relspec job run build-schema # run the job (and its dependencies)
Design contract (first release)
This is the smallest coherent contract that is safe and useful end to end. Anything not listed under "Supported" is intentionally deferred.
Not a shell
command is a closed allow-list. There is no field anywhere that accepts a
shell string, an executable path, or arbitrary arguments. Adding a new command
means adding a vetted adapter in the RelSpec source.
| command | what it does |
|---|---|
convert |
read one or more input schemas, additively merge them, write one output |
merge |
like convert but requires ≥2 inputs and exposes skip_* merge options |
scripts-list |
deterministically list SQL scripts across one or more directories |
Deferred (documented, not implemented here): scripts execution against a live
database, split, inspect, diff, templ, job-to-job output wiring,
log rotation/retention. Live SQL execution already exists as
relspec scripts execute; wiring it into the job runner is a follow-up because
it needs live database credentials and cannot be covered by offline tests.
Discovery and precedence
relspec job (no --file) scans --dir (default .) for:
relspec.yml/relspec.yaml(the default file), thenrelspec.<name>.yml/relspec.<name>.yaml(extra files),
each group sorted lexically. Order is stable across runs. Use --file <path>
(repeatable) to load explicit files and skip discovery.
All discovered/selected files are merged into one job namespace. A job name defined by more than one file is a hard error naming both files. YAML maps already forbid duplicate keys within a single file.
Paths
- Every path (
inputs[].path,output.path,script_dirs[],logfile) is relative to the directory containing the job file that declared the job, not the process working directory. - Absolute paths,
~-relative paths and any path that resolves outside the job file directory (../,a/../../b, …) are rejected during validation — before anything runs.
Credentials
- Database inputs (
format: pgsql/mssql) and database execution outputs (format: pgsqlwithconn_env) reference an environment variable name viaconn_env:. The connection string itself is never stored in the manifest. - A
conn_envvalue that looks like a connection string (contains:,/,@,=, spaces) is rejected. - Missing/empty environment variables are reported during pre-flight, before execution.
- Job logs and
--planoutput showenv:<NAME>, never the value. Resolved secret values and anything matching a connection-string password are redacted (***) from the logfile and diagnostics.
Validation happens before execution
relspec job list and relspec job run both fully validate the selected set
first. Nothing is read, written, connected to, or executed if validation fails.
Checks include:
- schema
version(must be1), unknown YAML fields rejected - duplicate job names across files
- unknown / missing
command - per-command input/output shape (
convert/mergeneed inputs + output;scripts-listneedsscript_dirsand forbids inputs/output) - unknown input/output
format - path traversal / absolute / home-relative paths
depends_ontargets exist- dependency cycles (reported as
a -> b -> c -> a)
Then, immediately before running, per-job pre-flight resolves paths and checks:
- every input file exists and is a file
- every
script_direxists and is a directory - every
conn_envvariable is set output.pathdoes not already exist unlessoutput.overwrite: true
If any pre-flight check fails for any job in the plan, no job runs.
Execution and exit codes
relspec job run <name>runs the job'sdepends_onclosure first, in topological order (deterministic), then the job.--no-depsruns only the named job.--dry-run(alias--plan) prints the resolved plan and exits 0 without touching inputs, outputs or databases.- A failing job returns the underlying non-zero status (the process exits 1)
and the error names the job. The logfile records
FAILED: <error>; a successful job recordsOK. No separate success-marker file is written, so a failure can never leave a stale "success".
Schema reference
version: 1 # required, must be 1
jobs:
<job-name>:
command: convert | merge | scripts-list # required
description: "free text" # optional, shown by `job list`
depends_on: [other-job, ...] # optional
inputs: # convert (≥1) / merge (≥2)
- path: relative/file.dbml # file inputs
format: dbml
- format: pgsql # live-connection inputs
conn_env: SOURCE_DB_URL # env var NAME
script_dirs: # scripts-list (≥1)
- migrations/core
- migrations/tenant
output: # convert / merge (required)
format: pgsql
path: build/schema.sql # file output, OR:
conn_env: TARGET_DB_URL # execute against DB (pgsql only)
overwrite: false # default false
options:
flatten_schema: false
schema: public
package: models # for gorm/bun output
continue_on_error: false # pgsql output
skip_relations: false # merge only
skip_enums: false
skip_views: false
skip_domains: false
skip_sequences: false
logfile: .relspec/log/<job-name>.log # optional; appended to
Supported input formats
dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle,
prisma, typeorm, sqlite (file, via path); pgsql, mssql
(live, via conn_env).
Supported output formats
dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle,
prisma, typeorm, pgsql, mssql, sqlite (file, via path); pgsql also
supports conn_env to execute the generated DDL against a live database.
Examples
Merge many schema files, emit PostgreSQL DDL
version: 1
jobs:
build-schema:
command: convert
inputs:
- { path: schema/core.dbml, format: dbml }
- { path: schema/billing.dbml, format: dbml }
- { path: schema/tenant.dbml, format: dbml }
output:
format: pgsql
path: build/schema.sql
overwrite: true
logfile: .relspec/log/build-schema.log
Multiple script directories
version: 1
jobs:
migration-order:
command: scripts-list
script_dirs:
- migrations/core
- migrations/tenant
- migrations/reporting
logfile: .relspec/log/migration-order.log
Job depending on another job
version: 1
jobs:
build-schema:
command: convert
inputs:
- { path: schema/core.dbml, format: dbml }
- { path: schema/tenant.dbml, format: dbml }
output: { format: json, path: build/schema.json, overwrite: true }
build-docs:
command: convert
depends_on: [build-schema]
inputs:
- { path: schema/core.dbml, format: dbml }
output: { format: yaml, path: build/schema.yaml, overwrite: true }
Reading from a remote database
version: 1
jobs:
snapshot-prod:
command: convert
inputs:
- format: pgsql
conn_env: PROD_DB_URL # export PROD_DB_URL=postgres://...
output:
format: dbml
path: snapshots/prod.dbml
overwrite: true