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.
366 lines
14 KiB
Markdown
366 lines
14 KiB
Markdown
# 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.
|
|
|
|
```bash
|
|
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:
|
|
|
|
1. `relspec.yml` / `relspec.yaml` (the default file), then
|
|
2. `relspec.<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: pgsql` with `conn_env`) reference an **environment variable name**
|
|
via `conn_env:`. The connection string itself is never stored in the
|
|
manifest.
|
|
* A `conn_env` value that looks like a connection string (contains `:`, `/`,
|
|
`@`, `=`, spaces) is rejected.
|
|
* Missing/empty environment variables are reported during pre-flight, before
|
|
execution.
|
|
* Job logs and `--plan` output show `env:<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 `>= 1` is accepted.
|
|
An omitted `version` is 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:
|
|
* `convert` needs ≥1 input + output; `merge` needs ≥2 inputs + output
|
|
* `split` needs ≥1 input + a file output, plus an optional `select:` block
|
|
* `scripts-list` needs `script_dirs` and forbids inputs/output
|
|
* `scripts-exec` needs `script_dirs` and `output.conn_env` (pgsql only)
|
|
* `inspect` needs ≥1 input + `report:` (format `markdown`|`json`)
|
|
* `diff` needs **exactly 2** inputs + `report:` (format `summary`|`json`|`html`)
|
|
* unknown input/output `format`
|
|
* `from_job` targets exist, are producers (`convert`/`merge`/`split`) and write a
|
|
single-file output
|
|
* path traversal / absolute / home-relative paths
|
|
* `depends_on` and `from_job` targets exist
|
|
* dependency cycles over the combined `depends_on` + `from_job` graph
|
|
(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 (a `from_job` input is exempt — its
|
|
producer runs earlier in the same plan)
|
|
* every `script_dir` exists and is a directory
|
|
* every `conn_env` variable is set
|
|
* `output.path` / `report.path` does not already exist unless the matching
|
|
`overwrite: true` is set
|
|
* `rules` (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_on` plus any `from_job` producers), in topological order
|
|
(deterministic), then the job. `--no-deps` runs only the named job and is
|
|
incompatible with `from_job` inputs.
|
|
* `--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 records `OK`. No separate success-marker file is written, so a
|
|
failure can never leave a stale "success".
|
|
* `inspect` fails the job when the report contains rule **errors** (enforced
|
|
rules); warnings do not fail it. `diff` never 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
|
|
|
|
```yaml
|
|
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
|
|
|
|
```yaml
|
|
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
|
|
|
|
```yaml
|
|
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
|
|
|
|
```yaml
|
|
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
|
|
|
|
```yaml
|
|
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
|
|
|
|
```yaml
|
|
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
|
|
|
|
```yaml
|
|
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
|
|
|
|
```yaml
|
|
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
|
|
|
|
```yaml
|
|
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
|
|
```
|