Implements the remaining items from issue #20: - version is now forward-permissive: any value >= 1 is accepted; a newer-than-known version loads best-effort (unknown fields ignored, warning printed) instead of hard-failing on "must be 1" - from_job input reference: `inputs: [{ from_job: <job> }]` resolves to that job's single-file output + format and implies a dependency edge; combined depends_on + from_job graph gets topological ordering and cycle detection - logfile size-rotation, on by default (5MB, keep 3), overridable per job (log_max_size / log_keep) or file-wide via a top-level defaults block - new commands: split (schema/table subsetting via select:), inspect (rule validation -> markdown/json report, fails job on enforced-rule errors), diff (compare exactly two schemas, never fails), scripts-exec (run SQL script dirs against a live PostgreSQL database) - atomic single-file output/report writes (temp file + rename) - symlink-escape hardening in SafeJoin via EvalSymlinks preflight Updates docs/JOB_FILES.md and examples/jobs/relspec.yml accordingly.
14 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
This is a deliberately small, safe contract. Every capability is offline-testable
except live database execution (scripts-exec), which is validated and planned
offline and only connects at run time.
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 |
split |
read one or more schemas, keep the selected schemas/tables, write one output |
scripts-list |
deterministically list SQL scripts across one or more directories |
scripts-exec |
execute SQL scripts across one or more directories against a live PostgreSQL database |
templ |
apply a custom Go text template to one or more input schemas |
inspect |
validate one or more schemas against rules and write a report |
diff |
compare exactly two schemas and write a differences report |
convert, merge and split are producers: their file output can be fed
directly into another job with from_job (see below).
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,report.path,rules,script_dirs[],template,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. - At run time each path is additionally resolved through its symlinks: a symlink inside the job-file directory that points outside it is rejected before the path is opened.
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— forward-permissive: any version>= 1is accepted. An omittedversionis treated as the current one. A version newer than this build understands loads best-effort (unknown YAML fields are ignored and a warning is printed); at the current version unknown YAML fields are still rejected. - duplicate job names across files
- unknown / missing
command - per-command input/output shape:
convertneeds ≥1 input + output;mergeneeds ≥2 inputs + outputsplitneeds ≥1 input + a file output, plus an optionalselect:blockscripts-listneedsscript_dirsand forbids inputs/outputscripts-execneedsscript_dirsandoutput.conn_env(pgsql only)inspectneeds ≥1 input +report:(formatmarkdown|json)diffneeds exactly 2 inputs +report:(formatsummary|json|html)
- unknown input/output
format from_jobtargets exist, are producers (convert/merge/split) and write a single-file output- path traversal / absolute / home-relative paths
depends_onandfrom_jobtargets exist- dependency cycles over the combined
depends_on+from_jobgraph (reported asa -> b -> c -> a)
Then, immediately before running, per-job pre-flight resolves paths and checks:
- every input file exists and is a file (a
from_jobinput is exempt — its producer runs earlier in the same plan) - every
script_direxists and is a directory - every
conn_envvariable is set output.path/report.pathdoes not already exist unless the matchingoverwrite: trueis setrules(inspect), when given, exists and is a file- symlinks in every resolved path stay inside the job-file directory
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's dependency closure first (depends_onplus anyfrom_jobproducers), in topological order (deterministic), then the job.--no-depsruns only the named job and is incompatible withfrom_jobinputs.--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". inspectfails the job when the report contains rule errors (enforced rules); warnings do not fail it.diffnever fails on differences.- Single-file outputs and reports are written to a temporary file in the target
directory and atomically renamed into place, so an interrupted run never
leaves a partial file. Directory-emitting formats (
gorm,bun,drizzle,typeorm,prisma) are written in place.
Logfile rotation
When a job has a logfile, it is size-rotated before each run. Defaults are
5 MB with 3 rotated files kept (build.log → build.log.1 → …). Override
per job with log_max_size / log_keep, or for a whole file with a top-level
defaults: block. log_max_size accepts B/KB/MB/GB suffixes (e.g.
"512KB", "5MB").
Schema reference
version: 1 # optional; any value >= 1 is accepted
defaults: # optional, file-wide
log_max_size: 5MB # B / KB / MB / GB
log_keep: 3
jobs:
<job-name>:
command: convert | merge | split | scripts-list | scripts-exec | templ | inspect | diff
description: "free text" # optional, shown by `job list`
depends_on: [other-job, ...] # optional
inputs: # convert (≥1) / merge (≥2) / split (≥1) / inspect (≥1) / diff (exactly 2)
- path: relative/file.dbml # file inputs
format: dbml
- format: pgsql # live-connection inputs
conn_env: SOURCE_DB_URL # env var NAME
- from_job: build-schema # consume another job's file output
script_dirs: # scripts-list / scripts-exec (≥1)
- migrations/core
- migrations/tenant
template: templates/schema.tmpl # templ (required)
mode: table # templ: database/schema/script/table
filename_pattern: "{{.Name}}.go" # templ multi-output modes
select: # split (optional; default = keep everything)
schemas: [public]
tables: [users, orders]
exclude_schemas: []
exclude_tables: []
database_name: SubsetDB # optional rename of the output database
rules: .relspec-rules.yaml # inspect (optional; built-in defaults if omitted)
report: # inspect (required) / diff (required)
format: json # inspect: markdown|json ; diff: summary|json|html
path: build/report.json # required, except a diff "summary" (goes to the log)
overwrite: false
output: # convert / merge / split (required); scripts-exec (required, conn_env)
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 / scripts-exec 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, size-rotated
log_max_size: 5MB # optional per-job override
log_keep: 3 # optional per-job override
For templ, inputs use the same file or pgsql/conn_env source forms as
schema conversion. output is optional (empty means stdout); when present it
contains only path and overwrite, because templates do not select a schema
writer format.
A from_job input takes no path, format or conn_env: it resolves to the
named job's output.path and inherits its format, and implies a dependency on
that job. The producer must be a convert, merge or split job writing a
single-file output.
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
Chain jobs with from_job, then lint the result
version: 1
jobs:
build-json:
command: convert
inputs:
- { path: schema/core.dbml, format: dbml }
- { path: schema/tenant.dbml, format: dbml }
output: { format: json, path: build/schema.json, overwrite: true }
lint-schema:
command: inspect
inputs:
- from_job: build-json # implies depends_on: [build-json]
rules: .relspec-rules.yaml # optional; built-in rules if omitted
report:
format: markdown
path: build/lint-report.md
overwrite: true
relspec job run lint-schema runs build-json first, then inspects its output.
The job fails (exit 1) if any enforced rule is violated.
Split a subset out of a larger schema
version: 1
jobs:
posts-only:
command: split
inputs:
- { path: schema/core.dbml, format: dbml }
- { path: schema/tenant.dbml, format: dbml }
select:
tables: [posts]
output: { format: dbml, path: build/posts.dbml, overwrite: true }
Diff two schemas
version: 1
jobs:
drift:
command: diff
inputs: # exactly two
- { path: build/schema.json, format: json }
- format: pgsql
conn_env: PROD_DB_URL
report:
format: summary # summary → logfile; json/html need a path
diff reports differences and always exits 0.
Execute migration scripts against a live database
version: 1
jobs:
apply-migrations:
command: scripts-exec
script_dirs:
- migrations/core
- migrations/tenant
output:
conn_env: TARGET_DB_URL # pgsql only; no path
options:
continue_on_error: false
logfile: .relspec/log/apply-migrations.log