Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions database/nats/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,11 @@
// {"op": "create_consumer", "stream": "ORDERS", "config": { ...jetstream.ConsumerConfig... }},
// {"op": "kv_put", "bucket": "settings", "key": "k", "value_b64": "<base64>"}
// ]
//
// 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
5 changes: 3 additions & 2 deletions database/nats/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 6 additions & 3 deletions database/nats/examples/migrations/001_create_kv.down.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[
{"op":"delete_kv","config":{"bucket":"example"}}
]
{
"$schema": "../../migration.schema.json",
"ops": [
{"op":"delete_kv","bucket":"example"}
]
}
9 changes: 6 additions & 3 deletions database/nats/examples/migrations/001_create_kv.up.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[
{"op":"create_kv","config":{"bucket":"example"}}
]
{
"$schema": "../../migration.schema.json",
"ops": [
{"op":"create_kv","config":{"bucket":"example"}}
]
}
93 changes: 93 additions & 0 deletions database/nats/migration.schema.json
Original file line number Diff line number Diff line change
@@ -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"] }
}
]
}
}
}
18 changes: 14 additions & 4 deletions database/nats/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}

Expand Down
8 changes: 8 additions & 0 deletions database/temporal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
11 changes: 7 additions & 4 deletions database/temporal/examples/migrations/0001_init.down.json
Original file line number Diff line number Diff line change
@@ -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"}}
]
}
11 changes: 7 additions & 4 deletions database/temporal/examples/migrations/0001_init.up.json
Original file line number Diff line number Diff line change
@@ -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"}}}}}}
]
}
79 changes: 79 additions & 0 deletions database/temporal/migration.schema.json
Original file line number Diff line number Diff line change
@@ -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"] }
}
]
}
}
}
17 changes: 14 additions & 3 deletions database/temporal/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
5 changes: 3 additions & 2 deletions database/temporal/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down