diff --git a/.mise.toml b/.mise.toml index 3783056..b6814b7 100644 --- a/.mise.toml +++ b/.mise.toml @@ -12,3 +12,6 @@ golangci-lint = "2.12.2" lefthook = "2.1.9" # https://mise-tools.jdx.dev/tools/temporal temporal = "1.30.5" +# https://mise-tools.jdx.dev/tools/jsonschema +jsonschema = "15.6.3" + diff --git a/Makefile b/Makefile index 4f002c3..06f96fc 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ go.work: .PHONY: check ## Run lint & tests -check: tidy generate lint.fix test audit +check: tidy generate lint.fix test schema audit .PHONY: lint ## Run linter @@ -152,6 +152,14 @@ godocs: actionlint: @actionlint +.PHONY: schema +## Validate migration JSON schemas & example migrations +schema: + @echo "〉jsonschema validate" + @jsonschema metaschema database/nats/migration.schema.json database/temporal/migration.schema.json + @jsonschema validate database/nats/migration.schema.json database/nats/examples/migrations/ + @jsonschema validate database/temporal/migration.schema.json database/temporal/examples/migrations/ + .PHONY: help # https://patorjk.com/software/taag/#p=display&f=Tmplr&t=MIGRATE&x=none&v=4&h=4&w=80&we=false ## Show help text diff --git a/database/nats/doc.go b/database/nats/doc.go index 065eaf6..8fb41c9 100644 --- a/database/nats/doc.go +++ b/database/nats/doc.go @@ -23,4 +23,11 @@ // {"op": "create_consumer", "stream": "ORDERS", "config": { ...jetstream.ConsumerConfig... }}, // {"op": "kv_put", "bucket": "settings", "key": "k", "value_b64": ""} // ] +// +// The body may also be an object so editors can reference the schema inline: +// +// {"$schema": "../../migration.schema.json", "ops": [ ...same ops... ]} +// +// migration.schema.json describes both forms; "make schema" validates the +// example migrations against it. package nats diff --git a/database/nats/driver_test.go b/database/nats/driver_test.go index a6f23f1..2d792be 100644 --- a/database/nats/driver_test.go +++ b/database/nats/driver_test.go @@ -95,10 +95,11 @@ func TestRunCreatesStreamAndConsumer(t *testing.T) { endpoint := startJetStream(t) d := openDriver(t, endpoint) - migration := `[ + // Object form (with inline $schema) — array form is covered by the other tests. + migration := `{"$schema":"../../migration.schema.json","ops":[ {"op":"create_stream","config":{"name":"ORDERS","subjects":["orders.>"]}}, {"op":"create_consumer","stream":"ORDERS","config":{"durable_name":"worker","ack_policy":"explicit"}} - ]` + ]}` require.NoError(t, d.Run(strings.NewReader(migration))) js := newJSForTest(t, endpoint) diff --git a/database/nats/examples/migrations/001_create_kv.down.json b/database/nats/examples/migrations/001_create_kv.down.json index b8d6705..4cdaf08 100644 --- a/database/nats/examples/migrations/001_create_kv.down.json +++ b/database/nats/examples/migrations/001_create_kv.down.json @@ -1,3 +1,6 @@ -[ - {"op":"delete_kv","config":{"bucket":"example"}} -] +{ + "$schema": "../../migration.schema.json", + "ops": [ + {"op":"delete_kv","bucket":"example"} + ] +} diff --git a/database/nats/examples/migrations/001_create_kv.up.json b/database/nats/examples/migrations/001_create_kv.up.json index d112257..9b14f5f 100644 --- a/database/nats/examples/migrations/001_create_kv.up.json +++ b/database/nats/examples/migrations/001_create_kv.up.json @@ -1,3 +1,6 @@ -[ - {"op":"create_kv","config":{"bucket":"example"}} -] +{ + "$schema": "../../migration.schema.json", + "ops": [ + {"op":"create_kv","config":{"bucket":"example"}} + ] +} diff --git a/database/nats/migration.schema.json b/database/nats/migration.schema.json new file mode 100644 index 0000000..71adde4 --- /dev/null +++ b/database/nats/migration.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/foomo/migrate/database/nats/migration.schema.json", + "title": "NATS JetStream migration", + "description": "A migration file (.up.json / .down.json) is either a bare array of ops or an object {\"$schema\", \"ops\": [...]}. Empty = no-op.", + "oneOf": [ + { "$ref": "#/$defs/ops" }, + { + "type": "object", + "additionalProperties": false, + "required": ["ops"], + "properties": { + "$schema": { "type": "string" }, + "ops": { "$ref": "#/$defs/ops" } + } + } + ], + "$defs": { + "ops": { + "type": "array", + "items": { "$ref": "#/$defs/op" } + }, + "op": { + "type": "object", + "additionalProperties": false, + "required": ["op"], + "properties": { + "op": { + "type": "string", + "enum": [ + "create_stream", + "update_stream", + "delete_stream", + "create_consumer", + "update_consumer", + "delete_consumer", + "create_kv", + "update_kv", + "delete_kv", + "kv_put", + "kv_delete" + ] + }, + "config": { + "type": "object", + "description": "Raw jetstream.StreamConfig / ConsumerConfig / KeyValueConfig, forwarded verbatim to nats.go." + }, + "stream": { "type": "string" }, + "bucket": { "type": "string" }, + "name": { "type": "string" }, + "key": { "type": "string" }, + "value_b64": { + "type": "string", + "contentEncoding": "base64" + } + }, + "allOf": [ + { + "if": { "properties": { "op": { "enum": ["create_stream", "update_stream"] } } }, + "then": { "required": ["config"] } + }, + { + "if": { "properties": { "op": { "const": "delete_stream" } } }, + "then": { "required": ["name"] } + }, + { + "if": { "properties": { "op": { "enum": ["create_consumer", "update_consumer"] } } }, + "then": { "required": ["stream", "config"] } + }, + { + "if": { "properties": { "op": { "const": "delete_consumer" } } }, + "then": { "required": ["stream", "name"] } + }, + { + "if": { "properties": { "op": { "enum": ["create_kv", "update_kv"] } } }, + "then": { "required": ["config"] } + }, + { + "if": { "properties": { "op": { "const": "delete_kv" } } }, + "then": { "required": ["bucket"] } + }, + { + "if": { "properties": { "op": { "const": "kv_put" } } }, + "then": { "required": ["bucket", "key", "value_b64"] } + }, + { + "if": { "properties": { "op": { "const": "kv_delete" } } }, + "then": { "required": ["bucket", "key"] } + } + ] + } + } +} diff --git a/database/nats/run.go b/database/nats/run.go index 5b1413a..fb4ec86 100644 --- a/database/nats/run.go +++ b/database/nats/run.go @@ -41,9 +41,11 @@ const ( opKVDelete = "kv_delete" ) -// Run reads a JSON array of ops from the reader and applies each in order. -// An empty body is a no-op so callers can use ".down" files that revert -// nothing (e.g. a migration that only seeded data and chose not to undo it). +// Run reads ops from the reader and applies each in order. The body is either +// a bare JSON array of ops or an object {"$schema": "...", "ops": [...]} (the +// object form lets editors reference migration.schema.json inline). An empty +// body is a no-op so callers can use ".down" files that revert nothing (e.g. a +// migration that only seeded data and chose not to undo it). func (d *Driver) Run(migration io.Reader) error { raw, err := io.ReadAll(migration) if err != nil { @@ -55,7 +57,15 @@ func (d *Driver) Run(migration io.Reader) error { } var ops []Op - if err := json.Unmarshal(trimmed, &ops); err != nil { + if trimmed[0] == '{' { + var doc struct { + Ops []Op `json:"ops"` + } + if err := json.Unmarshal(trimmed, &doc); err != nil { + return fmt.Errorf("nats: parse migration json: %w", err) + } + ops = doc.Ops + } else if err := json.Unmarshal(trimmed, &ops); err != nil { return fmt.Errorf("nats: parse migration json: %w", err) } diff --git a/database/temporal/README.md b/database/temporal/README.md index 6e6ce44..62c530e 100644 --- a/database/temporal/README.md +++ b/database/temporal/README.md @@ -31,8 +31,16 @@ Migration files are `.up.json` / `.down.json` containing a JSON array of ops. Ea ] ``` +The body may also be an object so editors can reference the schema inline: + +```json +{"$schema": "../../migration.schema.json", "ops": [ ...same ops... ]} +``` + An empty body (or all-whitespace) is a no-op — useful for `.down` files that should revert nothing. +[`migration.schema.json`](migration.schema.json) describes both forms; `make schema` validates the example migrations against it. + ## Op kinds | Op | Temporal API | Notes | diff --git a/database/temporal/examples/migrations/0001_init.down.json b/database/temporal/examples/migrations/0001_init.down.json index a6cfda6..912fa57 100644 --- a/database/temporal/examples/migrations/0001_init.down.json +++ b/database/temporal/examples/migrations/0001_init.down.json @@ -1,4 +1,7 @@ -[ - {"op":"delete_schedule","request":{"namespace":"orders","scheduleId":"nightly-report"}}, - {"op":"delete_namespace","request":{"namespace":"orders"}} -] +{ + "$schema": "../../migration.schema.json", + "ops": [ + {"op":"delete_schedule","request":{"namespace":"orders","scheduleId":"nightly-report"}}, + {"op":"delete_namespace","request":{"namespace":"orders"}} + ] +} diff --git a/database/temporal/examples/migrations/0001_init.up.json b/database/temporal/examples/migrations/0001_init.up.json index 12a9eab..e074cc7 100644 --- a/database/temporal/examples/migrations/0001_init.up.json +++ b/database/temporal/examples/migrations/0001_init.up.json @@ -1,4 +1,7 @@ -[ - {"op":"register_namespace","request":{"namespace":"orders","workflowExecutionRetentionPeriod":"259200s","description":"orders service"}}, - {"op":"create_schedule","request":{"namespace":"orders","scheduleId":"nightly-report","schedule":{"spec":{"cronString":["0 2 * * *"]},"action":{"startWorkflow":{"workflowId":"nightly-report-wf","workflowType":{"name":"NightlyReport"},"taskQueue":{"name":"reports"}}}}}} -] +{ + "$schema": "../../migration.schema.json", + "ops": [ + {"op":"register_namespace","request":{"namespace":"orders","workflowExecutionRetentionPeriod":"259200s","description":"orders service"}}, + {"op":"create_schedule","request":{"namespace":"orders","scheduleId":"nightly-report","schedule":{"spec":{"cronString":["0 2 * * *"]},"action":{"startWorkflow":{"workflowId":"nightly-report-wf","workflowType":{"name":"NightlyReport"},"taskQueue":{"name":"reports"}}}}}} + ] +} diff --git a/database/temporal/migration.schema.json b/database/temporal/migration.schema.json new file mode 100644 index 0000000..e4e7a49 --- /dev/null +++ b/database/temporal/migration.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/foomo/migrate/database/temporal/migration.schema.json", + "title": "Temporal migration", + "description": "A migration file (.up.json / .down.json) is either a bare array of ops or an object {\"$schema\", \"ops\": [...]}. Empty = no-op.", + "oneOf": [ + { "$ref": "#/$defs/ops" }, + { + "type": "object", + "additionalProperties": false, + "required": ["ops"], + "properties": { + "$schema": { "type": "string" }, + "ops": { "$ref": "#/$defs/ops" } + } + } + ], + "$defs": { + "ops": { + "type": "array", + "items": { "$ref": "#/$defs/op" } + }, + "op": { + "type": "object", + "additionalProperties": false, + "required": ["op"], + "properties": { + "op": { + "type": "string", + "enum": [ + "register_namespace", + "update_namespace", + "delete_namespace", + "create_schedule", + "update_schedule", + "delete_schedule", + "raw" + ] + }, + "request": { + "type": "object", + "description": "protojson-encoded Temporal gRPC request body for the selected op." + }, + "service": { + "type": "string", + "enum": ["workflow", "operator"], + "description": "gRPC service for 'raw' ops. Defaults to 'workflow'." + }, + "method": { + "type": "string", + "description": "gRPC method name for 'raw' ops, e.g. RegisterNamespace." + } + }, + "allOf": [ + { + "if": { + "properties": { + "op": { + "enum": [ + "register_namespace", + "update_namespace", + "delete_namespace", + "create_schedule", + "update_schedule", + "delete_schedule" + ] + } + } + }, + "then": { "required": ["request"] } + }, + { + "if": { "properties": { "op": { "const": "raw" } } }, + "then": { "required": ["method", "request"] } + } + ] + } + } +} diff --git a/database/temporal/run.go b/database/temporal/run.go index eb6ad31..ef2882a 100644 --- a/database/temporal/run.go +++ b/database/temporal/run.go @@ -33,19 +33,30 @@ const ( opRaw = "raw" ) -// Run reads a JSON array of ops and applies each in order. Empty body = no-op. +// Run reads ops and applies each in order. The body is either a bare JSON +// array of ops or an object {"$schema": "...", "ops": [...]} (the object form +// lets editors reference migration.schema.json inline). Empty body = no-op. func (d *Driver) Run(migration io.Reader) error { raw, err := io.ReadAll(migration) if err != nil { return fmt.Errorf("temporal: read migration: %w", err) } - if len(skipWhitespace(raw)) == 0 { + trimmed := skipWhitespace(raw) + if len(trimmed) == 0 { return nil } var ops []Op - if err := json.Unmarshal(raw, &ops); err != nil { + if trimmed[0] == '{' { + var doc struct { + Ops []Op `json:"ops"` + } + if err := json.Unmarshal(trimmed, &doc); err != nil { + return fmt.Errorf("temporal: parse migration json: %w", err) + } + ops = doc.Ops + } else if err := json.Unmarshal(trimmed, &ops); err != nil { return fmt.Errorf("temporal: parse migration json: %w", err) } diff --git a/database/temporal/run_test.go b/database/temporal/run_test.go index 9e9a668..412f67e 100644 --- a/database/temporal/run_test.go +++ b/database/temporal/run_test.go @@ -25,9 +25,10 @@ func TestRunRegisterNamespace(t *testing.T) { c := startTemporal(t) d, _ := temporal.WithInstance(c, &temporal.Config{Namespace: "default"}) - body := `[ + // Object form (with inline $schema) — array form is covered by the other tests. + body := `{"$schema":"../../migration.schema.json","ops":[ {"op":"register_namespace","request":{"namespace":"created_by_migrate","workflowExecutionRetentionPeriod":"86400s"}} - ]` + ]}` if err := d.Run(strings.NewReader(body)); err != nil { t.Fatalf("Run register_namespace: %v", err) }