@warlock.js/cascade 5.19.0 → 5.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/cjs/index.cjs +78 -7
- package/cjs/index.cjs.map +1 -1
- package/esm/context/database-transaction-context.d.mts +10 -0
- package/esm/context/database-transaction-context.d.mts.map +1 -1
- package/esm/context/database-transaction-context.mjs +17 -0
- package/esm/context/database-transaction-context.mjs.map +1 -1
- package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-driver.mjs +13 -5
- package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
- package/esm/drivers/postgres/postgres-driver.mjs +8 -3
- package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
- package/esm/index.d.mts +2 -1
- package/esm/index.mjs +2 -1
- package/esm/migration/migration-runner.mjs +1 -1
- package/esm/transactions/after-commit.d.mts +24 -0
- package/esm/transactions/after-commit.d.mts.map +1 -0
- package/esm/transactions/after-commit.mjs +47 -0
- package/esm/transactions/after-commit.mjs.map +1 -0
- package/llms-full.txt +2783 -2766
- package/llms.txt +15 -15
- package/package.json +6 -6
- package/skills/aggregate-data/SKILL.md +165 -165
- package/skills/alter-migration/SKILL.md +531 -531
- package/skills/configure-delete-strategy/SKILL.md +130 -130
- package/skills/define-model/SKILL.md +205 -205
- package/skills/define-relations/SKILL.md +171 -171
- package/skills/index.md +23 -0
- package/skills/manage-data-sources/SKILL.md +140 -140
- package/skills/manage-transactions/SKILL.md +208 -191
- package/skills/paginate-results/SKILL.md +122 -122
- package/skills/perform-atomic-ops/SKILL.md +156 -156
- package/skills/query-data/SKILL.md +213 -213
- package/skills/run-cascade-cli/SKILL.md +125 -125
- package/skills/search-by-vector/SKILL.md +127 -127
- package/skills/subscribe-to-model-events/SKILL.md +148 -148
- package/skills/track-changes/SKILL.md +127 -127
- package/skills/write-migration/SKILL.md +206 -206
|
@@ -1,206 +1,206 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: write-migration
|
|
3
|
-
description:
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Write a migration
|
|
7
|
-
|
|
8
|
-
The model class doesn't auto-create tables. Migrations declare the schema change in a versioned file; the CLI applies them in order for a reproducible DB shape across environments.
|
|
9
|
-
|
|
10
|
-
The primary form is **declarative**: `Migration.create(Model, { columns })` reads the table name from the model and builds the columns from the object you pass. Reach for the imperative `extends Migration` class form only when a migration is genuinely procedural.
|
|
11
|
-
|
|
12
|
-
## Minimal example — `Migration.create`
|
|
13
|
-
|
|
14
|
-
```ts title="src/app/users/models/user/migrations/05-11-2026_10-00-00-user.migration.ts"
|
|
15
|
-
import { Migration, text, uuid } from "@warlock.js/cascade";
|
|
16
|
-
import { User } from "../user.model";
|
|
17
|
-
|
|
18
|
-
export default Migration.create(User, {
|
|
19
|
-
name: text().notNullable(),
|
|
20
|
-
email: text().unique().notNullable(),
|
|
21
|
-
status: text().notNullable(),
|
|
22
|
-
});
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
What happens:
|
|
26
|
-
|
|
27
|
-
- `Migration.create(Model, columns)` reads `User.table` for the table name and builds the DDL from the column map; it infers the rollback for you.
|
|
28
|
-
- Column helpers (`text`, `uuid`, `string`, `integer`, …) are imported from `@warlock.js/cascade`. Each returns a builder you chain modifiers onto (`.notNullable()`, `.unique()`, `.nullable()`, `.default(...)`, `.references(table)`).
|
|
29
|
-
- The `id` primary key and `createdAt` / `updatedAt` timestamps are added **automatically** — don't declare them. Naming follows the data source convention (snake_case on Postgres, camelCase on MongoDB).
|
|
30
|
-
- The soft-delete column is added **automatically when the model's delete strategy resolves to `"soft"`** — don't declare it. See below.
|
|
31
|
-
- `export default` is required — the runner imports each file's default export.
|
|
32
|
-
|
|
33
|
-
Evolve an existing table with `Migration.alter(Model, { ... })` (add / drop / rename / modify columns and indexes); it's declarative the same way.
|
|
34
|
-
|
|
35
|
-
## Soft-delete column is auto-wired
|
|
36
|
-
|
|
37
|
-
If the model uses soft deletes, `Migration.create` adds the `deletedAt` column for you — you don't declare it, the same way you don't declare `createdAt` / `updatedAt`. The strategy is resolved exactly as `destroy()` resolves it: model static `deleteStrategy` → data source `defaultDeleteStrategy` → `"permanent"`. Since soft delete is usually an app-wide policy set on the data source, every `Migration.create` then gets the column with zero extra config.
|
|
38
|
-
|
|
39
|
-
```ts
|
|
40
|
-
// User (or the data source) has deleteStrategy "soft" → deletedAt is added.
|
|
41
|
-
export default Migration.create(User, {
|
|
42
|
-
name: text().notNullable(),
|
|
43
|
-
});
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
- The column name comes from the model's `deletedAtColumn` (default `"deletedAt"`), so it matches what `destroy()` writes at runtime.
|
|
47
|
-
- It only fires for the `"soft"` strategy — `"permanent"` and `"trash"` add nothing. No driver defaults to `"soft"`, so this never fires unless soft delete is opted into.
|
|
48
|
-
- Opt out for one table with `{ softDeletes: false }`; force it on with `{ softDeletes: true }`. A model with `deletedAtColumn = false` is never wired. An already-declared `deletedAt` in the map is not duplicated.
|
|
49
|
-
|
|
50
|
-
See [`@warlock.js/cascade/configure-delete-strategy/SKILL.md`](@warlock.js/cascade/configure-delete-strategy/SKILL.md) for the strategies themselves.
|
|
51
|
-
|
|
52
|
-
## Running migrations
|
|
53
|
-
|
|
54
|
-
```bash
|
|
55
|
-
npx cascade migrate # apply pending migrations
|
|
56
|
-
npx cascade migrate:rollback # undo the last batch
|
|
57
|
-
npx cascade migrate:list # which migrations have been executed
|
|
58
|
-
npx cascade migrate:export-sql # write .up.sql / .down.sql instead of executing
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
`cascade migrate` discovers migration files via the `-p`/`--path` glob (default `./migrations/**`), runs them in order, and records each in the `_migrations` table / collection. See [`@warlock.js/cascade/run-cascade-cli/SKILL.md`](@warlock.js/cascade/run-cascade-cli/SKILL.md) for every flag and the programmatic Operations API.
|
|
62
|
-
|
|
63
|
-
## File naming convention
|
|
64
|
-
|
|
65
|
-
Name files with a timestamp prefix (`MM-DD-YYYY_HH-MM-SS-<name>.migration.ts`) so Cascade orders runs deterministically and infers the migration name from the filename. A timestamp prefix prevents the "two devs picked the same number" merge conflict.
|
|
66
|
-
|
|
67
|
-
### How ordering actually works
|
|
68
|
-
|
|
69
|
-
Every pending migration's SQL is pooled and sorted **globally**, by three keys in order:
|
|
70
|
-
|
|
71
|
-
1. **Phase** — the dependency-safe DDL order, derived from the statement itself: extensions/types (1), `CREATE TABLE` (2), `ADD COLUMN` (3), foreign keys (4), drops/alters (5). A `CREATE TABLE` from a newer migration therefore still runs before an older migration's `ALTER`.
|
|
72
|
-
2. **`createdAt`** — parsed from the filename prefix.
|
|
73
|
-
3. **Migration name** — alphabetical, only when the timestamps can't decide.
|
|
74
|
-
|
|
75
|
-
Because phase wins, cross-migration ordering matters most for statements that share a phase — two migrations both altering the same table, for instance.
|
|
76
|
-
|
|
77
|
-
:::caution[`MM-DD` vs `DD-MM` is a guess]
|
|
78
|
-
The parser tells the two apart by whether the first field is greater than 12 — a month can't be. When both fields are ≤ 12 the stamp is genuinely ambiguous and it assumes `MM-DD`. Two migrations generated on `01-08` and `08-01` of the same year sort by that assumption rather than by fact. If exact ordering between two such files matters, make it unambiguous — a differing hour is enough.
|
|
79
|
-
:::
|
|
80
|
-
|
|
81
|
-
## Column helpers — the building blocks
|
|
82
|
-
|
|
83
|
-
Every column in a `Migration.create` / `Migration.alter` map starts with a helper imported from `@warlock.js/cascade`; each returns a builder you chain modifiers onto (`.notNullable()`, `.nullable()`, `.unique()`, `.default(value)`, `.index()`, `.primary()`, `.references(table)`):
|
|
84
|
-
|
|
85
|
-
```ts
|
|
86
|
-
import { Migration, text, integer, json, timestamp, uuid } from "@warlock.js/cascade";
|
|
87
|
-
import { User } from "../user.model";
|
|
88
|
-
|
|
89
|
-
export default Migration.create(Post, {
|
|
90
|
-
title: text().notNullable(),
|
|
91
|
-
body: text().nullable(),
|
|
92
|
-
author_id: uuid().references(User.table).onDelete("cascade").notNullable(),
|
|
93
|
-
status: text().notNullable(),
|
|
94
|
-
metadata: json(), // JSON / JSONB column
|
|
95
|
-
published_at: timestamp().nullable(),
|
|
96
|
-
});
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
`references(table)` defaults to the referenced table's `id` column; chain `.on("custom_id")` for a different one, and `.onDelete(...)` / `.onUpdate(...)` for FK actions. `id`, `createdAt`, and `updatedAt` are still added for you. The full helper vocabulary (`string`, `char`, `integer`, `bigInteger`, `decimal`, `boolCol`, `date`, `dateTime`, `uuid`, `ulid`, `enumCol`, `vector`, the `array*` family, …) lives in the migrations guide.
|
|
100
|
-
|
|
101
|
-
## Imperative escape hatch — `extends Migration`
|
|
102
|
-
|
|
103
|
-
When a migration is genuinely procedural (a runtime `hasIndex` check, branching on existing schema, interleaving DDL and data), drop to the class form. Here the column types are methods on `this`:
|
|
104
|
-
|
|
105
|
-
```ts
|
|
106
|
-
import { Migration } from "@warlock.js/cascade";
|
|
107
|
-
|
|
108
|
-
export default class BackfillStatuses extends Migration {
|
|
109
|
-
public readonly table = "posts";
|
|
110
|
-
|
|
111
|
-
public async up(): Promise<void> {
|
|
112
|
-
if (!(await this.hasColumn("status"))) {
|
|
113
|
-
this.string("status").defaultString("draft");
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
this.raw(`UPDATE posts SET status = 'published' WHERE published_at IS NOT NULL`);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
public down(): void {
|
|
120
|
-
this.dropColumn("status");
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
Class-form builders include `createTable()` / `createTableIfNotExists()`, `dropTable()` / `dropTableIfExists()`, `dropColumn(name)`, `renameTableTo(name)`, `timestamps()`, `index(...)`, `primaryUuid()`, and `raw(sql)` for a raw statement.
|
|
126
|
-
|
|
127
|
-
### Foreign-key columns that match the app's PK type — `this.foreignId`
|
|
128
|
-
|
|
129
|
-
`this.foreignId(name)` (5.8+) derives the column's type from `migrationDefaults.primaryKey` so a foreign-key column always matches the type of the `id` it points at, instead of hardcoding one:
|
|
130
|
-
|
|
131
|
-
| `migrationDefaults.primaryKey` | `foreignId` produces |
|
|
132
|
-
| --- | --- |
|
|
133
|
-
| `uuid` | `uuid` column |
|
|
134
|
-
| `bigInt` | `bigInteger` column |
|
|
135
|
-
| anything else (default) | `integer` column |
|
|
136
|
-
|
|
137
|
-
```ts
|
|
138
|
-
import { Migration } from "@warlock.js/cascade";
|
|
139
|
-
|
|
140
|
-
export default class AddUserToPosts extends Migration {
|
|
141
|
-
public readonly table = "posts";
|
|
142
|
-
|
|
143
|
-
public async up(): Promise<void> {
|
|
144
|
-
// uuid / bigInteger / integer, matching the app's chosen PK type
|
|
145
|
-
this.foreignId("user_id").index();
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
public down(): void {
|
|
149
|
-
this.dropColumn("user_id");
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
Reach for it on plain (non-`.references()`) FK id columns — e.g. a polymorphic `user_id` paired with a `user_type` — so a reference to a default-PK model is always type-compatible with that model's `id`.
|
|
155
|
-
|
|
156
|
-
## Raw SQL migrations
|
|
157
|
-
|
|
158
|
-
For SQL-only changes (Postgres), `Migration.rawSql` builds a migration class for you:
|
|
159
|
-
|
|
160
|
-
```ts
|
|
161
|
-
export default Migration.rawSql({
|
|
162
|
-
name: "2026-01-01-create-auth",
|
|
163
|
-
up: [`CREATE TABLE sessions (id UUID PRIMARY KEY, user_id UUID REFERENCES users(id))`],
|
|
164
|
-
down: [`DROP TABLE sessions`],
|
|
165
|
-
});
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
(Throws on MongoDB — use the declarative or class form there.)
|
|
169
|
-
|
|
170
|
-
## Reversibility
|
|
171
|
-
|
|
172
|
-
For the class form, `down()` should undo `up()` — the CLI calls it on rollback. (`Migration.create` infers the rollback automatically.) If a migration is genuinely one-way, throw inside `down()` so accidental rollbacks fail loudly:
|
|
173
|
-
|
|
174
|
-
```ts
|
|
175
|
-
public down(): void {
|
|
176
|
-
throw new Error("This migration is irreversible — restore from backup if you need to undo it.");
|
|
177
|
-
}
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
## Data-source-aware migrations
|
|
181
|
-
|
|
182
|
-
A `Migration.create` / `Migration.alter` migration **inherits its data source from the model** — whatever `static dataSource = "analytics"` the model declares, the migration runs against. You don't pass it in the options.
|
|
183
|
-
|
|
184
|
-
```ts
|
|
185
|
-
// AnalyticsEvent has `static dataSource = "analytics"`, so this migration
|
|
186
|
-
// targets the analytics database automatically.
|
|
187
|
-
export default Migration.create(AnalyticsEvent, {
|
|
188
|
-
type: text().notNullable(),
|
|
189
|
-
});
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
For a class-form migration not bound to a model, set `public readonly dataSource = "analytics"` directly. See [`@warlock.js/cascade/manage-data-sources/SKILL.md`](@warlock.js/cascade/manage-data-sources/SKILL.md) for the registry.
|
|
193
|
-
|
|
194
|
-
## Things NOT to do
|
|
195
|
-
|
|
196
|
-
- Don't reach for a `migration({ up(driver) {...} })` factory or `driver.createTable(name, (table) => {...})` — that API doesn't exist. Use `Migration.create(Model, { columns })`, or `extends Migration` with `this.createTable()` for the imperative case.
|
|
197
|
-
- Don't declare `id` / `createdAt` / `updatedAt` — they're added for you. Same for `deletedAt` when the model's strategy is `"soft"` — it's auto-wired (opt out with `{ softDeletes: false }`).
|
|
198
|
-
- Don't auto-run migrations from app code in production. Run them as a deploy step.
|
|
199
|
-
- Don't put irreversible data backfills in the same file as a schema change — split them so rollback only undoes the schema.
|
|
200
|
-
- Don't change a committed migration. Add a new one. Editing a migration that already ran in production puts environments out of sync.
|
|
201
|
-
|
|
202
|
-
## See also
|
|
203
|
-
|
|
204
|
-
- [`@warlock.js/cascade/run-cascade-cli/SKILL.md`](@warlock.js/cascade/run-cascade-cli/SKILL.md) — CLI flags + Operations API for programmatic runs
|
|
205
|
-
- [`@warlock.js/cascade/manage-data-sources/SKILL.md`](@warlock.js/cascade/manage-data-sources/SKILL.md) — multi-DB migrations
|
|
206
|
-
- [`@warlock.js/cascade/configure-delete-strategy/SKILL.md`](@warlock.js/cascade/configure-delete-strategy/SKILL.md) — soft / trash / permanent deletes and the `deletedAt` column
|
|
1
|
+
---
|
|
2
|
+
name: write-migration
|
|
3
|
+
description: 'Write a Cascade migration — the declarative `Migration.create(Model, { columns })` / `Migration.alter(Model, { ... })` factory is the primary form (column helpers `string` / `text` / `uuid` / `integer` imported from cascade, chained with `.notNullable()` / `.unique()` / `.references()`); the `extends Migration` class form is the imperative escape hatch. Run with the `cascade migrate` CLI; pin a source via `public dataSource`. Triggers: `Migration.create`, `Migration.alter`, `string()`, `text()`, `uuid()`, `.references`, `extends Migration`, `cascade migrate`; "write a migration", "create the users table", "add a column", "rollback the last batch"; typical import `import { Migration, text, uuid } from "@warlock.js/cascade"`. Skip: running migrations programmatically — `@warlock.js/cascade/run-cascade-cli/SKILL.md`; per-source migrations — `@warlock.js/cascade/manage-data-sources/SKILL.md`; competing tools `knex migrate`, `prisma migrate`, `typeorm migration`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Write a migration
|
|
7
|
+
|
|
8
|
+
The model class doesn't auto-create tables. Migrations declare the schema change in a versioned file; the CLI applies them in order for a reproducible DB shape across environments.
|
|
9
|
+
|
|
10
|
+
The primary form is **declarative**: `Migration.create(Model, { columns })` reads the table name from the model and builds the columns from the object you pass. Reach for the imperative `extends Migration` class form only when a migration is genuinely procedural.
|
|
11
|
+
|
|
12
|
+
## Minimal example — `Migration.create`
|
|
13
|
+
|
|
14
|
+
```ts title="src/app/users/models/user/migrations/05-11-2026_10-00-00-user.migration.ts"
|
|
15
|
+
import { Migration, text, uuid } from "@warlock.js/cascade";
|
|
16
|
+
import { User } from "../user.model";
|
|
17
|
+
|
|
18
|
+
export default Migration.create(User, {
|
|
19
|
+
name: text().notNullable(),
|
|
20
|
+
email: text().unique().notNullable(),
|
|
21
|
+
status: text().notNullable(),
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
What happens:
|
|
26
|
+
|
|
27
|
+
- `Migration.create(Model, columns)` reads `User.table` for the table name and builds the DDL from the column map; it infers the rollback for you.
|
|
28
|
+
- Column helpers (`text`, `uuid`, `string`, `integer`, …) are imported from `@warlock.js/cascade`. Each returns a builder you chain modifiers onto (`.notNullable()`, `.unique()`, `.nullable()`, `.default(...)`, `.references(table)`).
|
|
29
|
+
- The `id` primary key and `createdAt` / `updatedAt` timestamps are added **automatically** — don't declare them. Naming follows the data source convention (snake_case on Postgres, camelCase on MongoDB).
|
|
30
|
+
- The soft-delete column is added **automatically when the model's delete strategy resolves to `"soft"`** — don't declare it. See below.
|
|
31
|
+
- `export default` is required — the runner imports each file's default export.
|
|
32
|
+
|
|
33
|
+
Evolve an existing table with `Migration.alter(Model, { ... })` (add / drop / rename / modify columns and indexes); it's declarative the same way.
|
|
34
|
+
|
|
35
|
+
## Soft-delete column is auto-wired
|
|
36
|
+
|
|
37
|
+
If the model uses soft deletes, `Migration.create` adds the `deletedAt` column for you — you don't declare it, the same way you don't declare `createdAt` / `updatedAt`. The strategy is resolved exactly as `destroy()` resolves it: model static `deleteStrategy` → data source `defaultDeleteStrategy` → `"permanent"`. Since soft delete is usually an app-wide policy set on the data source, every `Migration.create` then gets the column with zero extra config.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// User (or the data source) has deleteStrategy "soft" → deletedAt is added.
|
|
41
|
+
export default Migration.create(User, {
|
|
42
|
+
name: text().notNullable(),
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- The column name comes from the model's `deletedAtColumn` (default `"deletedAt"`), so it matches what `destroy()` writes at runtime.
|
|
47
|
+
- It only fires for the `"soft"` strategy — `"permanent"` and `"trash"` add nothing. No driver defaults to `"soft"`, so this never fires unless soft delete is opted into.
|
|
48
|
+
- Opt out for one table with `{ softDeletes: false }`; force it on with `{ softDeletes: true }`. A model with `deletedAtColumn = false` is never wired. An already-declared `deletedAt` in the map is not duplicated.
|
|
49
|
+
|
|
50
|
+
See [`@warlock.js/cascade/configure-delete-strategy/SKILL.md`](@warlock.js/cascade/configure-delete-strategy/SKILL.md) for the strategies themselves.
|
|
51
|
+
|
|
52
|
+
## Running migrations
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx cascade migrate # apply pending migrations
|
|
56
|
+
npx cascade migrate:rollback # undo the last batch
|
|
57
|
+
npx cascade migrate:list # which migrations have been executed
|
|
58
|
+
npx cascade migrate:export-sql # write .up.sql / .down.sql instead of executing
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`cascade migrate` discovers migration files via the `-p`/`--path` glob (default `./migrations/**`), runs them in order, and records each in the `_migrations` table / collection. See [`@warlock.js/cascade/run-cascade-cli/SKILL.md`](@warlock.js/cascade/run-cascade-cli/SKILL.md) for every flag and the programmatic Operations API.
|
|
62
|
+
|
|
63
|
+
## File naming convention
|
|
64
|
+
|
|
65
|
+
Name files with a timestamp prefix (`MM-DD-YYYY_HH-MM-SS-<name>.migration.ts`) so Cascade orders runs deterministically and infers the migration name from the filename. A timestamp prefix prevents the "two devs picked the same number" merge conflict.
|
|
66
|
+
|
|
67
|
+
### How ordering actually works
|
|
68
|
+
|
|
69
|
+
Every pending migration's SQL is pooled and sorted **globally**, by three keys in order:
|
|
70
|
+
|
|
71
|
+
1. **Phase** — the dependency-safe DDL order, derived from the statement itself: extensions/types (1), `CREATE TABLE` (2), `ADD COLUMN` (3), foreign keys (4), drops/alters (5). A `CREATE TABLE` from a newer migration therefore still runs before an older migration's `ALTER`.
|
|
72
|
+
2. **`createdAt`** — parsed from the filename prefix.
|
|
73
|
+
3. **Migration name** — alphabetical, only when the timestamps can't decide.
|
|
74
|
+
|
|
75
|
+
Because phase wins, cross-migration ordering matters most for statements that share a phase — two migrations both altering the same table, for instance.
|
|
76
|
+
|
|
77
|
+
:::caution[`MM-DD` vs `DD-MM` is a guess]
|
|
78
|
+
The parser tells the two apart by whether the first field is greater than 12 — a month can't be. When both fields are ≤ 12 the stamp is genuinely ambiguous and it assumes `MM-DD`. Two migrations generated on `01-08` and `08-01` of the same year sort by that assumption rather than by fact. If exact ordering between two such files matters, make it unambiguous — a differing hour is enough.
|
|
79
|
+
:::
|
|
80
|
+
|
|
81
|
+
## Column helpers — the building blocks
|
|
82
|
+
|
|
83
|
+
Every column in a `Migration.create` / `Migration.alter` map starts with a helper imported from `@warlock.js/cascade`; each returns a builder you chain modifiers onto (`.notNullable()`, `.nullable()`, `.unique()`, `.default(value)`, `.index()`, `.primary()`, `.references(table)`):
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { Migration, text, integer, json, timestamp, uuid } from "@warlock.js/cascade";
|
|
87
|
+
import { User } from "../user.model";
|
|
88
|
+
|
|
89
|
+
export default Migration.create(Post, {
|
|
90
|
+
title: text().notNullable(),
|
|
91
|
+
body: text().nullable(),
|
|
92
|
+
author_id: uuid().references(User.table).onDelete("cascade").notNullable(),
|
|
93
|
+
status: text().notNullable(),
|
|
94
|
+
metadata: json(), // JSON / JSONB column
|
|
95
|
+
published_at: timestamp().nullable(),
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`references(table)` defaults to the referenced table's `id` column; chain `.on("custom_id")` for a different one, and `.onDelete(...)` / `.onUpdate(...)` for FK actions. `id`, `createdAt`, and `updatedAt` are still added for you. The full helper vocabulary (`string`, `char`, `integer`, `bigInteger`, `decimal`, `boolCol`, `date`, `dateTime`, `uuid`, `ulid`, `enumCol`, `vector`, the `array*` family, …) lives in the migrations guide.
|
|
100
|
+
|
|
101
|
+
## Imperative escape hatch — `extends Migration`
|
|
102
|
+
|
|
103
|
+
When a migration is genuinely procedural (a runtime `hasIndex` check, branching on existing schema, interleaving DDL and data), drop to the class form. Here the column types are methods on `this`:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { Migration } from "@warlock.js/cascade";
|
|
107
|
+
|
|
108
|
+
export default class BackfillStatuses extends Migration {
|
|
109
|
+
public readonly table = "posts";
|
|
110
|
+
|
|
111
|
+
public async up(): Promise<void> {
|
|
112
|
+
if (!(await this.hasColumn("status"))) {
|
|
113
|
+
this.string("status").defaultString("draft");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.raw(`UPDATE posts SET status = 'published' WHERE published_at IS NOT NULL`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public down(): void {
|
|
120
|
+
this.dropColumn("status");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Class-form builders include `createTable()` / `createTableIfNotExists()`, `dropTable()` / `dropTableIfExists()`, `dropColumn(name)`, `renameTableTo(name)`, `timestamps()`, `index(...)`, `primaryUuid()`, and `raw(sql)` for a raw statement.
|
|
126
|
+
|
|
127
|
+
### Foreign-key columns that match the app's PK type — `this.foreignId`
|
|
128
|
+
|
|
129
|
+
`this.foreignId(name)` (5.8+) derives the column's type from `migrationDefaults.primaryKey` so a foreign-key column always matches the type of the `id` it points at, instead of hardcoding one:
|
|
130
|
+
|
|
131
|
+
| `migrationDefaults.primaryKey` | `foreignId` produces |
|
|
132
|
+
| --- | --- |
|
|
133
|
+
| `uuid` | `uuid` column |
|
|
134
|
+
| `bigInt` | `bigInteger` column |
|
|
135
|
+
| anything else (default) | `integer` column |
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { Migration } from "@warlock.js/cascade";
|
|
139
|
+
|
|
140
|
+
export default class AddUserToPosts extends Migration {
|
|
141
|
+
public readonly table = "posts";
|
|
142
|
+
|
|
143
|
+
public async up(): Promise<void> {
|
|
144
|
+
// uuid / bigInteger / integer, matching the app's chosen PK type
|
|
145
|
+
this.foreignId("user_id").index();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
public down(): void {
|
|
149
|
+
this.dropColumn("user_id");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Reach for it on plain (non-`.references()`) FK id columns — e.g. a polymorphic `user_id` paired with a `user_type` — so a reference to a default-PK model is always type-compatible with that model's `id`.
|
|
155
|
+
|
|
156
|
+
## Raw SQL migrations
|
|
157
|
+
|
|
158
|
+
For SQL-only changes (Postgres), `Migration.rawSql` builds a migration class for you:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
export default Migration.rawSql({
|
|
162
|
+
name: "2026-01-01-create-auth",
|
|
163
|
+
up: [`CREATE TABLE sessions (id UUID PRIMARY KEY, user_id UUID REFERENCES users(id))`],
|
|
164
|
+
down: [`DROP TABLE sessions`],
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
(Throws on MongoDB — use the declarative or class form there.)
|
|
169
|
+
|
|
170
|
+
## Reversibility
|
|
171
|
+
|
|
172
|
+
For the class form, `down()` should undo `up()` — the CLI calls it on rollback. (`Migration.create` infers the rollback automatically.) If a migration is genuinely one-way, throw inside `down()` so accidental rollbacks fail loudly:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
public down(): void {
|
|
176
|
+
throw new Error("This migration is irreversible — restore from backup if you need to undo it.");
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Data-source-aware migrations
|
|
181
|
+
|
|
182
|
+
A `Migration.create` / `Migration.alter` migration **inherits its data source from the model** — whatever `static dataSource = "analytics"` the model declares, the migration runs against. You don't pass it in the options.
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// AnalyticsEvent has `static dataSource = "analytics"`, so this migration
|
|
186
|
+
// targets the analytics database automatically.
|
|
187
|
+
export default Migration.create(AnalyticsEvent, {
|
|
188
|
+
type: text().notNullable(),
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
For a class-form migration not bound to a model, set `public readonly dataSource = "analytics"` directly. See [`@warlock.js/cascade/manage-data-sources/SKILL.md`](@warlock.js/cascade/manage-data-sources/SKILL.md) for the registry.
|
|
193
|
+
|
|
194
|
+
## Things NOT to do
|
|
195
|
+
|
|
196
|
+
- Don't reach for a `migration({ up(driver) {...} })` factory or `driver.createTable(name, (table) => {...})` — that API doesn't exist. Use `Migration.create(Model, { columns })`, or `extends Migration` with `this.createTable()` for the imperative case.
|
|
197
|
+
- Don't declare `id` / `createdAt` / `updatedAt` — they're added for you. Same for `deletedAt` when the model's strategy is `"soft"` — it's auto-wired (opt out with `{ softDeletes: false }`).
|
|
198
|
+
- Don't auto-run migrations from app code in production. Run them as a deploy step.
|
|
199
|
+
- Don't put irreversible data backfills in the same file as a schema change — split them so rollback only undoes the schema.
|
|
200
|
+
- Don't change a committed migration. Add a new one. Editing a migration that already ran in production puts environments out of sync.
|
|
201
|
+
|
|
202
|
+
## See also
|
|
203
|
+
|
|
204
|
+
- [`@warlock.js/cascade/run-cascade-cli/SKILL.md`](@warlock.js/cascade/run-cascade-cli/SKILL.md) — CLI flags + Operations API for programmatic runs
|
|
205
|
+
- [`@warlock.js/cascade/manage-data-sources/SKILL.md`](@warlock.js/cascade/manage-data-sources/SKILL.md) — multi-DB migrations
|
|
206
|
+
- [`@warlock.js/cascade/configure-delete-strategy/SKILL.md`](@warlock.js/cascade/configure-delete-strategy/SKILL.md) — soft / trash / permanent deletes and the `deletedAt` column
|