@warlock.js/cascade 5.30.0 → 5.32.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 +17 -0
- package/README.md +1 -5
- package/cjs/index.cjs +104 -86
- package/cjs/index.cjs.map +1 -1
- package/esm/context/database-data-source-context.d.mts +2 -3
- package/esm/context/database-data-source-context.d.mts.map +1 -1
- package/esm/context/database-transaction-context.d.mts +2 -3
- package/esm/context/database-transaction-context.d.mts.map +1 -1
- package/esm/contracts/database-driver.contract.d.mts +56 -36
- package/esm/contracts/database-driver.contract.d.mts.map +1 -1
- package/esm/contracts/database-id-generator.contract.d.mts +7 -5
- package/esm/contracts/database-id-generator.contract.d.mts.map +1 -1
- package/esm/contracts/database-remover.contract.d.mts +3 -5
- package/esm/contracts/database-remover.contract.d.mts.map +1 -1
- package/esm/contracts/database-restorer.contract.d.mts +6 -6
- package/esm/contracts/database-restorer.contract.d.mts.map +1 -1
- package/esm/contracts/database-writer.contract.d.mts +4 -6
- package/esm/contracts/database-writer.contract.d.mts.map +1 -1
- package/esm/contracts/driver-blueprint.contract.d.mts +2 -3
- package/esm/contracts/driver-blueprint.contract.d.mts.map +1 -1
- package/esm/contracts/index.d.mts +2 -1
- package/esm/contracts/migration-driver.contract.d.mts +101 -53
- package/esm/contracts/migration-driver.contract.d.mts.map +1 -1
- package/esm/contracts/query-builder.contract.d.mts +51 -33
- package/esm/contracts/query-builder.contract.d.mts.map +1 -1
- package/esm/contracts/sync-adapter.contract.d.mts +25 -14
- package/esm/contracts/sync-adapter.contract.d.mts.map +1 -1
- package/esm/data-source/data-source-registry.d.mts +4 -5
- package/esm/data-source/data-source-registry.d.mts.map +1 -1
- package/esm/data-source/data-source-registry.mjs.map +1 -1
- package/esm/data-source/data-source.d.mts +8 -5
- package/esm/data-source/data-source.d.mts.map +1 -1
- package/esm/database-dirty-tracker.d.mts +2 -2
- package/esm/database-dirty-tracker.d.mts.map +1 -1
- package/esm/database-dirty-tracker.mjs +3 -2
- package/esm/database-dirty-tracker.mjs.map +1 -1
- package/esm/drivers/mongodb/mongodb-driver.d.mts +3 -4
- package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
- package/esm/drivers/mongodb/mongodb-id-generator.d.mts +2 -3
- package/esm/drivers/mongodb/mongodb-id-generator.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-id-generator.mjs.map +1 -1
- package/esm/drivers/mongodb/mongodb-migration-driver.d.mts +1 -3
- package/esm/drivers/mongodb/mongodb-migration-driver.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-builder.d.mts +2 -3
- package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-builder.mjs +8 -5
- package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-operations.d.mts +1 -3
- package/esm/drivers/mongodb/mongodb-query-operations.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-operations.mjs.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-parser.d.mts +7 -6
- package/esm/drivers/mongodb/mongodb-query-parser.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-parser.mjs +2 -6
- package/esm/drivers/mongodb/mongodb-query-parser.mjs.map +1 -1
- package/esm/drivers/mongodb/mongodb-sync-adapter.d.mts +1 -3
- package/esm/drivers/mongodb/mongodb-sync-adapter.d.mts.map +1 -1
- package/esm/drivers/mongodb/types.d.mts +11 -9
- package/esm/drivers/mongodb/types.d.mts.map +1 -1
- package/esm/drivers/postgres/index.d.mts +2 -1
- package/esm/drivers/postgres/index.mjs +1 -1
- package/esm/drivers/postgres/naming.mjs +1 -1
- package/esm/drivers/postgres/naming.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-blueprint.d.mts +1 -3
- package/esm/drivers/postgres/postgres-blueprint.d.mts.map +1 -1
- package/esm/drivers/postgres/postgres-dialect.d.mts +4 -6
- package/esm/drivers/postgres/postgres-dialect.d.mts.map +1 -1
- package/esm/drivers/postgres/postgres-dialect.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-driver.d.mts +4 -5
- package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
- package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-migration-driver.d.mts +1 -3
- package/esm/drivers/postgres/postgres-migration-driver.d.mts.map +1 -1
- package/esm/drivers/postgres/postgres-migration-driver.mjs +13 -10
- package/esm/drivers/postgres/postgres-migration-driver.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-query-builder.d.mts +4 -4
- package/esm/drivers/postgres/postgres-query-builder.d.mts.map +1 -1
- package/esm/drivers/postgres/postgres-query-builder.mjs +14 -10
- package/esm/drivers/postgres/postgres-query-builder.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-query-parser.d.mts +16 -11
- package/esm/drivers/postgres/postgres-query-parser.d.mts.map +1 -1
- package/esm/drivers/postgres/postgres-query-parser.mjs +1 -2
- package/esm/drivers/postgres/postgres-query-parser.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-sql-serializer.mjs +13 -10
- package/esm/drivers/postgres/postgres-sql-serializer.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-ttl-purge.mjs +1 -1
- package/esm/drivers/postgres/postgres-ttl-purge.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-type-converters.mjs +2 -2
- package/esm/drivers/postgres/postgres-type-converters.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-update-validator.mjs +1 -1
- package/esm/drivers/postgres/postgres-update-validator.mjs.map +1 -1
- package/esm/drivers/postgres/types.d.mts +87 -45
- package/esm/drivers/postgres/types.d.mts.map +1 -1
- package/esm/drivers/sql/sql-dialect.contract.d.mts +1 -3
- package/esm/drivers/sql/sql-dialect.contract.d.mts.map +1 -1
- package/esm/drivers/sql/sql-types.d.mts +113 -59
- package/esm/drivers/sql/sql-types.d.mts.map +1 -1
- package/esm/errors/missing-data-source.error.d.mts +1 -2
- package/esm/errors/missing-data-source.error.d.mts.map +1 -1
- package/esm/errors/transaction-rollback.error.d.mts +1 -2
- package/esm/errors/transaction-rollback.error.d.mts.map +1 -1
- package/esm/errors/undefined-where-value.error.d.mts +1 -2
- package/esm/errors/undefined-where-value.error.d.mts.map +1 -1
- package/esm/errors/unsafe-filter.error.d.mts +1 -2
- package/esm/errors/unsafe-filter.error.d.mts.map +1 -1
- package/esm/errors/unsafe-raw-expression.error.d.mts +1 -2
- package/esm/errors/unsafe-raw-expression.error.d.mts.map +1 -1
- package/esm/errors/unsupported-lean-operation.error.d.mts +1 -2
- package/esm/errors/unsupported-lean-operation.error.d.mts.map +1 -1
- package/esm/errors/unsupported-lock-for-update.error.d.mts +1 -2
- package/esm/errors/unsupported-lock-for-update.error.d.mts.map +1 -1
- package/esm/errors/unsupported-query-operation.error.d.mts +1 -2
- package/esm/errors/unsupported-query-operation.error.d.mts.map +1 -1
- package/esm/errors/unsupported-query-operation.error.mjs.map +1 -1
- package/esm/errors/unsupported-update-operation.error.d.mts +1 -2
- package/esm/errors/unsupported-update-operation.error.d.mts.map +1 -1
- package/esm/events/model-events.d.mts +8 -6
- package/esm/events/model-events.d.mts.map +1 -1
- package/esm/expressions/aggregate-expressions.d.mts +7 -7
- package/esm/expressions/aggregate-expressions.d.mts.map +1 -1
- package/esm/expressions/column-expressions.d.mts +11 -11
- package/esm/expressions/column-expressions.d.mts.map +1 -1
- package/esm/expressions/column-expressions.mjs.map +1 -1
- package/esm/index.d.mts +7 -2
- package/esm/index.mjs +1 -1
- package/esm/migration/column-builder.d.mts +2 -3
- package/esm/migration/column-builder.d.mts.map +1 -1
- package/esm/migration/column-helpers.d.mts +49 -50
- package/esm/migration/column-helpers.d.mts.map +1 -1
- package/esm/migration/column-helpers.mjs +1 -1
- package/esm/migration/foreign-key-builder.d.mts +2 -3
- package/esm/migration/foreign-key-builder.d.mts.map +1 -1
- package/esm/migration/foreign-key-builder.mjs.map +1 -1
- package/esm/migration/index.d.mts +3 -2
- package/esm/migration/index.mjs +2 -2
- package/esm/migration/migration-runner.d.mts +7 -6
- package/esm/migration/migration-runner.d.mts.map +1 -1
- package/esm/migration/migration-runner.mjs +15 -11
- package/esm/migration/migration-runner.mjs.map +1 -1
- package/esm/migration/migration.d.mts +50 -32
- package/esm/migration/migration.d.mts.map +1 -1
- package/esm/migration/migration.mjs +1 -3
- package/esm/migration/migration.mjs.map +1 -1
- package/esm/migration/parse-created-at.mjs.map +1 -1
- package/esm/migration/sql-grammar.mjs.map +1 -1
- package/esm/migration/sql-serializer.d.mts +1 -3
- package/esm/migration/sql-serializer.d.mts.map +1 -1
- package/esm/migration/types.d.mts +7 -7
- package/esm/migration/types.d.mts.map +1 -1
- package/esm/model/generate-model-id.mjs.map +1 -1
- package/esm/model/methods/accessor-methods.mjs +1 -1
- package/esm/model/methods/accessor-methods.mjs.map +1 -1
- package/esm/model/methods/dirty-methods.mjs.map +1 -1
- package/esm/model/methods/hydration-methods.mjs.map +1 -1
- package/esm/model/methods/meta-methods.mjs +4 -2
- package/esm/model/methods/meta-methods.mjs.map +1 -1
- package/esm/model/methods/query-methods.mjs +1 -1
- package/esm/model/methods/query-methods.mjs.map +1 -1
- package/esm/model/methods/scope-methods.mjs.map +1 -1
- package/esm/model/methods/static-event-methods.mjs.map +1 -1
- package/esm/model/methods/write-methods.d.mts +3 -2
- package/esm/model/methods/write-methods.d.mts.map +1 -1
- package/esm/model/methods/write-methods.mjs +5 -2
- package/esm/model/methods/write-methods.mjs.map +1 -1
- package/esm/model/model.d.mts +9 -9
- package/esm/model/model.d.mts.map +1 -1
- package/esm/model/model.mjs.map +1 -1
- package/esm/model/model.types.d.mts +7 -7
- package/esm/model/model.types.d.mts.map +1 -1
- package/esm/model/register-model.d.mts +13 -15
- package/esm/model/register-model.d.mts.map +1 -1
- package/esm/model/register-model.mjs.map +1 -1
- package/esm/model/relation-decorators.d.mts +6 -8
- package/esm/model/relation-decorators.d.mts.map +1 -1
- package/esm/operations/database.d.mts +13 -8
- package/esm/operations/database.d.mts.map +1 -1
- package/esm/operations/index.d.mts +2 -1
- package/esm/operations/index.mjs +1 -1
- package/esm/operations/migrations.d.mts +18 -14
- package/esm/operations/migrations.d.mts.map +1 -1
- package/esm/operations/migrations.mjs.map +1 -1
- package/esm/query-builder/query-builder.d.mts +2 -4
- package/esm/query-builder/query-builder.d.mts.map +1 -1
- package/esm/query-builder/query-builder.mjs +12 -11
- package/esm/query-builder/query-builder.mjs.map +1 -1
- package/esm/relations/index.d.mts +2 -1
- package/esm/relations/index.mjs +1 -1
- package/esm/relations/key-conventions.mjs +2 -1
- package/esm/relations/key-conventions.mjs.map +1 -1
- package/esm/relations/pivot-operations.d.mts +3 -5
- package/esm/relations/pivot-operations.d.mts.map +1 -1
- package/esm/relations/pivot-operations.mjs.map +1 -1
- package/esm/relations/relation-hydrator.d.mts +3 -5
- package/esm/relations/relation-hydrator.d.mts.map +1 -1
- package/esm/relations/relation-loader.d.mts +2 -4
- package/esm/relations/relation-loader.d.mts.map +1 -1
- package/esm/relations/relation-loader.mjs +4 -5
- package/esm/relations/relation-loader.mjs.map +1 -1
- package/esm/relations/types.d.mts +14 -15
- package/esm/relations/types.d.mts.map +1 -1
- package/esm/remover/database-remover.d.mts +1 -3
- package/esm/remover/database-remover.d.mts.map +1 -1
- package/esm/remover/database-remover.mjs.map +1 -1
- package/esm/restorer/database-restorer.d.mts +1 -3
- package/esm/restorer/database-restorer.d.mts.map +1 -1
- package/esm/restorer/database-restorer.mjs +2 -1
- package/esm/restorer/database-restorer.mjs.map +1 -1
- package/esm/sql-database-dirty-tracker.d.mts +1 -3
- package/esm/sql-database-dirty-tracker.d.mts.map +1 -1
- package/esm/sync/model-events.mjs +1 -1
- package/esm/sync/model-sync-operation.d.mts +2 -4
- package/esm/sync/model-sync-operation.d.mts.map +1 -1
- package/esm/sync/model-sync.d.mts +3 -4
- package/esm/sync/model-sync.d.mts.map +1 -1
- package/esm/sync/sync-context.d.mts +2 -4
- package/esm/sync/sync-context.d.mts.map +1 -1
- package/esm/sync/sync-context.mjs.map +1 -1
- package/esm/sync/sync-fanout.mjs +1 -1
- package/esm/sync/sync-fanout.mjs.map +1 -1
- package/esm/sync/sync-manager.d.mts +2 -4
- package/esm/sync/sync-manager.d.mts.map +1 -1
- package/esm/sync/sync-manager.mjs.map +1 -1
- package/esm/sync/types.d.mts +121 -64
- package/esm/sync/types.d.mts.map +1 -1
- package/esm/transactions/after-commit.d.mts +2 -2
- package/esm/transactions/after-commit.d.mts.map +1 -1
- package/esm/types.d.mts +7 -8
- package/esm/types.d.mts.map +1 -1
- package/esm/utils/connect-to-database.d.mts +7 -8
- package/esm/utils/connect-to-database.d.mts.map +1 -1
- package/esm/utils/database-writer.utils.d.mts +3 -4
- package/esm/utils/database-writer.utils.d.mts.map +1 -1
- package/esm/utils/database-writer.utils.mjs.map +1 -1
- package/esm/utils/define-model.d.mts +3 -5
- package/esm/utils/define-model.d.mts.map +1 -1
- package/esm/utils/escape-regex.d.mts +3 -4
- package/esm/utils/escape-regex.d.mts.map +1 -1
- package/esm/utils/is-valid-date-value.mjs.map +1 -1
- package/esm/utils/once-connected.d.mts +2 -4
- package/esm/utils/once-connected.d.mts.map +1 -1
- package/esm/utils/sanitize-filter.d.mts +3 -4
- package/esm/utils/sanitize-filter.d.mts.map +1 -1
- package/esm/validation/database-seal-plugins.d.mts +2 -1
- package/esm/validation/database-writer-validation-error.d.mts +1 -3
- package/esm/validation/database-writer-validation-error.d.mts.map +1 -1
- package/esm/validation/index.d.mts +3 -1
- package/esm/validation/index.mjs +1 -1
- package/esm/validation/plugins/database-rules-plugin.d.mts +2 -3
- package/esm/validation/plugins/database-rules-plugin.d.mts.map +1 -1
- package/esm/validation/plugins/embed-validator-plugin.d.mts +4 -3
- package/esm/validation/plugins/embed-validator-plugin.d.mts.map +1 -1
- package/esm/validation/types.d.mts +14 -10
- package/esm/validation/types.d.mts.map +1 -1
- package/esm/validation/validators/embed-validator.d.mts +2 -3
- package/esm/validation/validators/embed-validator.d.mts.map +1 -1
- package/esm/writer/database-writer.d.mts +1 -3
- package/esm/writer/database-writer.d.mts.map +1 -1
- package/esm/writer/database-writer.mjs +1 -1
- package/esm/writer/database-writer.mjs.map +1 -1
- package/llms-full.txt +541 -434
- package/package.json +5 -5
- package/skills/aggregate-data/SKILL.md +28 -17
- package/skills/alter-migration/SKILL.md +325 -233
- package/skills/cascade-basics/SKILL.md +19 -19
- package/skills/configure-delete-strategy/SKILL.md +11 -11
- package/skills/define-model/SKILL.md +19 -17
- package/skills/define-relations/SKILL.md +13 -18
- package/skills/index.md +3 -0
- package/skills/manage-transactions/SKILL.md +18 -12
- package/skills/paginate-results/SKILL.md +6 -8
- package/skills/perform-atomic-ops/SKILL.md +26 -21
- package/skills/query-data/SKILL.md +27 -33
- package/skills/run-cascade-cli/SKILL.md +11 -10
- package/skills/search-by-vector/SKILL.md +8 -5
- package/skills/subscribe-to-model-events/SKILL.md +17 -17
- package/skills/track-changes/SKILL.md +9 -9
- package/skills/write-migration/SKILL.md +4 -4
package/llms-full.txt
CHANGED
|
@@ -18,23 +18,21 @@ Cascade's query builder isn't only for finding records — it crunches numbers t
|
|
|
18
18
|
## Scalar aggregates
|
|
19
19
|
|
|
20
20
|
```ts
|
|
21
|
-
const total
|
|
22
|
-
const revenue
|
|
23
|
-
const avgTicket
|
|
24
|
-
const cheapest
|
|
25
|
-
const priciest
|
|
21
|
+
const total = await Order.count(); // count is static on the model
|
|
22
|
+
const revenue = await Order.query().sum("total");
|
|
23
|
+
const avgTicket = await Order.query().avg("total");
|
|
24
|
+
const cheapest = await Order.query().min("total");
|
|
25
|
+
const priciest = await Order.query().max("total");
|
|
26
26
|
|
|
27
27
|
// Filtered
|
|
28
|
-
const monthRevenue = await Order.query()
|
|
29
|
-
.whereDateAfter("created_at", startOfMonth)
|
|
30
|
-
.sum("total");
|
|
28
|
+
const monthRevenue = await Order.query().whereDateAfter("created_at", startOfMonth).sum("total");
|
|
31
29
|
```
|
|
32
30
|
|
|
33
31
|
Each returns a single number (`Promise<number>`). Only `count` is a static shortcut on the model; `sum` / `avg` / `min` / `max` (and the date helpers) are query-builder methods — reach them via `Order.query()` or chain off `Order.where(...)`. Date filters take a single date: `whereDate(field, value)` (exact day, time ignored), `whereDateAfter` / `whereDateBefore` (one-sided), `whereDateBetween(field, [a, b])` (range) — there is no 3-arg `whereDate(field, op, value)`. By Cascade convention the scalar terminators return **`0` on zero rows — not `null`** — so you can use the result directly without a null guard.
|
|
34
32
|
|
|
35
33
|
## Grouped rollups — two-arg `groupBy(fields, aggregates)`
|
|
36
34
|
|
|
37
|
-
To compute aggregates
|
|
35
|
+
To compute aggregates _per group_, pass a second argument to `groupBy`: an object mapping output aliases to aggregate expressions. Build the expressions with the `$agg` helpers:
|
|
38
36
|
|
|
39
37
|
```ts
|
|
40
38
|
import { $agg } from "@warlock.js/cascade";
|
|
@@ -67,8 +65,8 @@ import { $agg, $expr } from "@warlock.js/cascade";
|
|
|
67
65
|
|
|
68
66
|
const revenue = await OrderItem.query()
|
|
69
67
|
.groupBy("product_id", {
|
|
70
|
-
revenue: $agg.sum($expr.mul("price", "quantity")),
|
|
71
|
-
net:
|
|
68
|
+
revenue: $agg.sum($expr.mul("price", "quantity")), // SUM(price * quantity)
|
|
69
|
+
net: $agg.sum($expr.mul($expr.sub($expr.lit(1), "discount"), "price")), // SUM((1 - discount) * price)
|
|
72
70
|
})
|
|
73
71
|
.get();
|
|
74
72
|
```
|
|
@@ -116,7 +114,7 @@ const monthly = await Order.query()
|
|
|
116
114
|
|
|
117
115
|
## `.having(...)` — filter groups by a computed aggregate
|
|
118
116
|
|
|
119
|
-
`.where()` filters rows
|
|
117
|
+
`.where()` filters rows _before_ grouping (cheap, uses indexes). `.having()` filters _after_ aggregation, by the alias you defined:
|
|
120
118
|
|
|
121
119
|
```ts
|
|
122
120
|
const big = await Order.query()
|
|
@@ -145,14 +143,27 @@ The `orderBy` reference matches the alias from the aggregates object.
|
|
|
145
143
|
const rows = await Post.query().unwind("tags").where("tags", "news").lean().get();
|
|
146
144
|
|
|
147
145
|
// Keep posts with no tags; record each tag's position
|
|
148
|
-
await Post.query()
|
|
146
|
+
await Post.query()
|
|
147
|
+
.unwind("tags", { preserveNullAndEmptyArrays: true, includeArrayIndex: "position" })
|
|
148
|
+
.get();
|
|
149
149
|
|
|
150
150
|
// Computed field, existing fields kept
|
|
151
|
-
await Post.query()
|
|
151
|
+
await Post.query()
|
|
152
|
+
.addFields({ score: { $add: ["$likes", "$shares"] } })
|
|
153
|
+
.orderBy("score", "desc")
|
|
154
|
+
.get();
|
|
152
155
|
|
|
153
156
|
// Pipeline $lookup
|
|
154
|
-
await Post.query()
|
|
155
|
-
|
|
157
|
+
await Post.query()
|
|
158
|
+
.join({
|
|
159
|
+
table: "comments",
|
|
160
|
+
localField: "id",
|
|
161
|
+
foreignField: "postId",
|
|
162
|
+
alias: "approved",
|
|
163
|
+
pipeline: [{ $match: { approved: true } }],
|
|
164
|
+
})
|
|
165
|
+
.lean()
|
|
166
|
+
.get();
|
|
156
167
|
```
|
|
157
168
|
|
|
158
169
|
`unwind()` returns several rows for one stored document. Use `.lean()` when you only read them. `addFields` expressions are raw aggregation expressions: write them in code, never from request data. Computed columns in the output also work with `selectRaw({ alias: expr })` (`$project`). There is no `$facet` method yet.
|
|
@@ -164,7 +175,7 @@ Raw stages: `joinRaw({ $lookup: { from, let, pipeline, as } })` (or an array of
|
|
|
164
175
|
## Gotchas
|
|
165
176
|
|
|
166
177
|
- **`where` vs `having`.** Row filters go in `.where()` (before grouping, index-friendly); aggregate filters go in `.having()`.
|
|
167
|
-
- **Empty sets.** Scalar terminators return `0` on zero rows (above). For
|
|
178
|
+
- **Empty sets.** Scalar terminators return `0` on zero rows (above). For _grouped_ reports, a group with no rows simply doesn't appear — a report over an empty range returns `[]`, not a row of zeros.
|
|
168
179
|
- **Don't `.all()` then `array.reduce`.** Always push aggregates to the database.
|
|
169
180
|
|
|
170
181
|
## See also
|
|
@@ -194,15 +205,19 @@ Use `Migration.alter` to evolve an existing table — adding or dropping columns
|
|
|
194
205
|
import { Migration, text } from "@warlock.js/cascade";
|
|
195
206
|
import { User } from "../user.model";
|
|
196
207
|
|
|
197
|
-
export default Migration.alter(
|
|
198
|
-
|
|
199
|
-
|
|
208
|
+
export default Migration.alter(
|
|
209
|
+
User,
|
|
210
|
+
{
|
|
211
|
+
add: {
|
|
212
|
+
phone: text().nullable(),
|
|
213
|
+
},
|
|
200
214
|
},
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
215
|
+
{
|
|
216
|
+
down() {
|
|
217
|
+
this.dropColumn("phone");
|
|
218
|
+
},
|
|
204
219
|
},
|
|
205
|
-
|
|
220
|
+
);
|
|
206
221
|
```
|
|
207
222
|
|
|
208
223
|
---
|
|
@@ -232,21 +247,25 @@ import { Migration, text, integer, bool, uuid } from "@warlock.js/cascade";
|
|
|
232
247
|
import { Organization } from "app/organizations/models/organization";
|
|
233
248
|
import { User } from "../user.model";
|
|
234
249
|
|
|
235
|
-
export default Migration.alter(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
250
|
+
export default Migration.alter(
|
|
251
|
+
User,
|
|
252
|
+
{
|
|
253
|
+
add: {
|
|
254
|
+
phone: text().nullable(),
|
|
255
|
+
login_attempts: integer().notNullable().default(0),
|
|
256
|
+
is_verified: bool().notNullable().default(false),
|
|
257
|
+
organization_id: uuid().references(Organization.table).onDelete("cascade").notNullable(),
|
|
258
|
+
},
|
|
241
259
|
},
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
260
|
+
{
|
|
261
|
+
down() {
|
|
262
|
+
this.dropColumn("phone");
|
|
263
|
+
this.dropColumn("login_attempts");
|
|
264
|
+
this.dropColumn("is_verified");
|
|
265
|
+
this.dropColumn("organization_id");
|
|
266
|
+
},
|
|
248
267
|
},
|
|
249
|
-
|
|
268
|
+
);
|
|
250
269
|
```
|
|
251
270
|
|
|
252
271
|
Column helpers (`text`, `integer`, `bool`, `uuid`, `double`, `json`, `timestamp`, `date`, …) are imported from `@warlock.js/cascade` and support the same modifier chain as in `Migration.create`.
|
|
@@ -254,31 +273,39 @@ Column helpers (`text`, `integer`, `bool`, `uuid`, `double`, `json`, `timestamp`
|
|
|
254
273
|
### `drop` — remove columns
|
|
255
274
|
|
|
256
275
|
```ts
|
|
257
|
-
export default Migration.alter(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
// re-add with the original definition if rollback is meaningful
|
|
262
|
-
this.text("legacy_token").nullable();
|
|
263
|
-
this.text("old_avatar_url").nullable();
|
|
276
|
+
export default Migration.alter(
|
|
277
|
+
User,
|
|
278
|
+
{
|
|
279
|
+
drop: ["legacy_token", "old_avatar_url"],
|
|
264
280
|
},
|
|
265
|
-
|
|
281
|
+
{
|
|
282
|
+
down() {
|
|
283
|
+
// re-add with the original definition if rollback is meaningful
|
|
284
|
+
this.text("legacy_token").nullable();
|
|
285
|
+
this.text("old_avatar_url").nullable();
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
);
|
|
266
289
|
```
|
|
267
290
|
|
|
268
291
|
### `rename` — rename columns
|
|
269
292
|
|
|
270
293
|
```ts
|
|
271
|
-
export default Migration.alter(
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
294
|
+
export default Migration.alter(
|
|
295
|
+
Post,
|
|
296
|
+
{
|
|
297
|
+
rename: {
|
|
298
|
+
body: "content",
|
|
299
|
+
thumb_url: "thumbnail_url",
|
|
300
|
+
},
|
|
275
301
|
},
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
302
|
+
{
|
|
303
|
+
down() {
|
|
304
|
+
this.renameColumn("content", "body");
|
|
305
|
+
this.renameColumn("thumbnail_url", "thumb_url");
|
|
306
|
+
},
|
|
280
307
|
},
|
|
281
|
-
|
|
308
|
+
);
|
|
282
309
|
```
|
|
283
310
|
|
|
284
311
|
### `modify` — change column type or nullability
|
|
@@ -287,17 +314,21 @@ export default Migration.alter(Post, {
|
|
|
287
314
|
import { Migration, string } from "@warlock.js/cascade";
|
|
288
315
|
import { User } from "../user.model";
|
|
289
316
|
|
|
290
|
-
export default Migration.alter(
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
317
|
+
export default Migration.alter(
|
|
318
|
+
User,
|
|
319
|
+
{
|
|
320
|
+
modify: {
|
|
321
|
+
// widen email from text to varchar(320) and keep not-null
|
|
322
|
+
email: string(320).notNullable(),
|
|
323
|
+
},
|
|
294
324
|
},
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
325
|
+
{
|
|
326
|
+
down() {
|
|
327
|
+
// revert to unbounded text
|
|
328
|
+
this.text("email").notNullable();
|
|
329
|
+
},
|
|
299
330
|
},
|
|
300
|
-
|
|
331
|
+
);
|
|
301
332
|
```
|
|
302
333
|
|
|
303
334
|
> `modify` re-declares the column. Include every modifier you want to keep (`.notNullable()`, `.default(…)`, etc.) — the declaration replaces the old one entirely.
|
|
@@ -309,29 +340,37 @@ export default Migration.alter(User, {
|
|
|
309
340
|
### Regular indexes — `addIndex` / `dropIndex`
|
|
310
341
|
|
|
311
342
|
```ts
|
|
312
|
-
export default Migration.alter(
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
this.dropIndex("status");
|
|
343
|
+
export default Migration.alter(
|
|
344
|
+
Order,
|
|
345
|
+
{
|
|
346
|
+
// single column
|
|
347
|
+
addIndex: [{ columns: "status" }],
|
|
318
348
|
},
|
|
319
|
-
|
|
349
|
+
{
|
|
350
|
+
down() {
|
|
351
|
+
this.dropIndex("status");
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
);
|
|
320
355
|
```
|
|
321
356
|
|
|
322
357
|
```ts
|
|
323
358
|
// composite index with an explicit name
|
|
324
|
-
export default Migration.alter(
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
},
|
|
330
|
-
|
|
331
|
-
this.dropIndex("idx_org_status");
|
|
332
|
-
this.dropIndex("created_at");
|
|
359
|
+
export default Migration.alter(
|
|
360
|
+
Order,
|
|
361
|
+
{
|
|
362
|
+
addIndex: [
|
|
363
|
+
{ columns: ["organization_id", "status"], name: "idx_org_status" },
|
|
364
|
+
{ columns: "created_at" },
|
|
365
|
+
],
|
|
333
366
|
},
|
|
334
|
-
|
|
367
|
+
{
|
|
368
|
+
down() {
|
|
369
|
+
this.dropIndex("idx_org_status");
|
|
370
|
+
this.dropIndex("created_at");
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
);
|
|
335
374
|
```
|
|
336
375
|
|
|
337
376
|
`dropIndex` accepts a name string or a columns array:
|
|
@@ -345,63 +384,79 @@ export default Migration.alter(Order, {
|
|
|
345
384
|
`options.concurrently: true` builds the index without locking the table (Postgres):
|
|
346
385
|
|
|
347
386
|
```ts
|
|
348
|
-
addIndex: [{ columns: "email", options: { concurrently: true } }]
|
|
387
|
+
addIndex: [{ columns: "email", options: { concurrently: true } }];
|
|
349
388
|
```
|
|
350
389
|
|
|
351
390
|
### Unique constraints — `addUnique` / `dropUnique`
|
|
352
391
|
|
|
353
392
|
```ts
|
|
354
393
|
// promote an indexed column to unique
|
|
355
|
-
export default Migration.alter(
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
this.dropUnique("email");
|
|
394
|
+
export default Migration.alter(
|
|
395
|
+
User,
|
|
396
|
+
{
|
|
397
|
+
addUnique: [{ columns: "email" }],
|
|
360
398
|
},
|
|
361
|
-
|
|
399
|
+
{
|
|
400
|
+
down() {
|
|
401
|
+
this.dropUnique("email");
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
);
|
|
362
405
|
```
|
|
363
406
|
|
|
364
407
|
Composite unique constraint:
|
|
365
408
|
|
|
366
409
|
```ts
|
|
367
|
-
export default Migration.alter(
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
this.dropUnique(["organization_id", "user_id"]);
|
|
410
|
+
export default Migration.alter(
|
|
411
|
+
Membership,
|
|
412
|
+
{
|
|
413
|
+
addUnique: [{ columns: ["organization_id", "user_id"], name: "uq_org_member" }],
|
|
372
414
|
},
|
|
373
|
-
|
|
415
|
+
{
|
|
416
|
+
down() {
|
|
417
|
+
this.dropUnique(["organization_id", "user_id"]);
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
);
|
|
374
421
|
```
|
|
375
422
|
|
|
376
423
|
Downgrade a unique to a plain index (the pattern used in this session):
|
|
377
424
|
|
|
378
425
|
```ts
|
|
379
|
-
export default Migration.alter(
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
this.dropIndex("users_email_index");
|
|
385
|
-
this.unique("email");
|
|
426
|
+
export default Migration.alter(
|
|
427
|
+
User,
|
|
428
|
+
{
|
|
429
|
+
dropUnique: [["email"]],
|
|
430
|
+
addIndex: [{ columns: "email", name: "users_email_index" }],
|
|
386
431
|
},
|
|
387
|
-
|
|
432
|
+
{
|
|
433
|
+
down() {
|
|
434
|
+
this.dropIndex("users_email_index");
|
|
435
|
+
this.unique("email");
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
);
|
|
388
439
|
```
|
|
389
440
|
|
|
390
441
|
### Expression indexes — `addExpressionIndex` (Postgres)
|
|
391
442
|
|
|
392
443
|
```ts
|
|
393
|
-
export default Migration.alter(
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
},
|
|
400
|
-
|
|
401
|
-
this.dropIndex("idx_email_ci");
|
|
402
|
-
this.dropIndex("idx_name_ci");
|
|
444
|
+
export default Migration.alter(
|
|
445
|
+
User,
|
|
446
|
+
{
|
|
447
|
+
addExpressionIndex: [
|
|
448
|
+
{ expressions: "lower(email)", name: "idx_email_ci" },
|
|
449
|
+
{ expressions: "lower(name)", name: "idx_name_ci" },
|
|
450
|
+
{ expressions: ["year(created_at)", "status"] },
|
|
451
|
+
],
|
|
403
452
|
},
|
|
404
|
-
|
|
453
|
+
{
|
|
454
|
+
down() {
|
|
455
|
+
this.dropIndex("idx_email_ci");
|
|
456
|
+
this.dropIndex("idx_name_ci");
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
);
|
|
405
460
|
```
|
|
406
461
|
|
|
407
462
|
---
|
|
@@ -411,30 +466,38 @@ export default Migration.alter(User, {
|
|
|
411
466
|
### Full-text — `addFullText` / `dropFullText`
|
|
412
467
|
|
|
413
468
|
```ts
|
|
414
|
-
export default Migration.alter(
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
469
|
+
export default Migration.alter(
|
|
470
|
+
Article,
|
|
471
|
+
{
|
|
472
|
+
addFullText: [
|
|
473
|
+
{
|
|
474
|
+
columns: ["title", "body"],
|
|
475
|
+
options: { name: "ft_article_search", language: "english" },
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
down() {
|
|
481
|
+
this.dropFullText("ft_article_search");
|
|
419
482
|
},
|
|
420
|
-
],
|
|
421
|
-
}, {
|
|
422
|
-
down() {
|
|
423
|
-
this.dropFullText("ft_article_search");
|
|
424
483
|
},
|
|
425
|
-
|
|
484
|
+
);
|
|
426
485
|
```
|
|
427
486
|
|
|
428
487
|
### Geo-spatial — `addGeoIndex` / `dropGeoIndex`
|
|
429
488
|
|
|
430
489
|
```ts
|
|
431
|
-
export default Migration.alter(
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
this.dropGeoIndex("location");
|
|
490
|
+
export default Migration.alter(
|
|
491
|
+
Store,
|
|
492
|
+
{
|
|
493
|
+
addGeoIndex: [{ column: "location" }],
|
|
436
494
|
},
|
|
437
|
-
|
|
495
|
+
{
|
|
496
|
+
down() {
|
|
497
|
+
this.dropGeoIndex("location");
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
);
|
|
438
501
|
```
|
|
439
502
|
|
|
440
503
|
Options: `{ name?, type?: "2dsphere" | "2d", min?, max? }`
|
|
@@ -442,16 +505,22 @@ Options: `{ name?, type?: "2dsphere" | "2d", min?, max? }`
|
|
|
442
505
|
### Vector (AI embeddings) — `addVectorIndex` / `dropVectorIndex`
|
|
443
506
|
|
|
444
507
|
```ts
|
|
445
|
-
export default Migration.alter(
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
508
|
+
export default Migration.alter(
|
|
509
|
+
Document,
|
|
510
|
+
{
|
|
511
|
+
addVectorIndex: [
|
|
512
|
+
{
|
|
513
|
+
column: "embedding",
|
|
514
|
+
options: { dimensions: 1536, similarity: "cosine", name: "idx_doc_embedding" },
|
|
515
|
+
},
|
|
516
|
+
],
|
|
453
517
|
},
|
|
454
|
-
|
|
518
|
+
{
|
|
519
|
+
down() {
|
|
520
|
+
this.dropVectorIndex("embedding");
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
);
|
|
455
524
|
```
|
|
456
525
|
|
|
457
526
|
Similarity options: `"cosine"` | `"euclidean"` | `"dotProduct"`.
|
|
@@ -461,13 +530,17 @@ Similarity options: `"cosine"` | `"euclidean"` | `"dotProduct"`.
|
|
|
461
530
|
Expires documents automatically after a fixed duration:
|
|
462
531
|
|
|
463
532
|
```ts
|
|
464
|
-
export default Migration.alter(
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
this.dropTTLIndex("created_at");
|
|
533
|
+
export default Migration.alter(
|
|
534
|
+
Session,
|
|
535
|
+
{
|
|
536
|
+
addTTLIndex: [{ column: "created_at", expireAfterSeconds: 86400 }], // 24 h
|
|
469
537
|
},
|
|
470
|
-
|
|
538
|
+
{
|
|
539
|
+
down() {
|
|
540
|
+
this.dropTTLIndex("created_at");
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
);
|
|
471
544
|
```
|
|
472
545
|
|
|
473
546
|
---
|
|
@@ -483,17 +556,21 @@ import { Migration } from "@warlock.js/cascade";
|
|
|
483
556
|
import { Team } from "app/teams/models/team";
|
|
484
557
|
import { User } from "../user.model";
|
|
485
558
|
|
|
486
|
-
export default Migration.alter(
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
},
|
|
492
|
-
|
|
493
|
-
this.dropForeign("team_id", Team.table);
|
|
494
|
-
this.dropForeign("owner_id", "users");
|
|
559
|
+
export default Migration.alter(
|
|
560
|
+
User,
|
|
561
|
+
{
|
|
562
|
+
addForeign: [
|
|
563
|
+
{ column: "team_id", references: Team, onDelete: "cascade" },
|
|
564
|
+
{ column: "owner_id", references: "users", on: "id", onDelete: "setNull" },
|
|
565
|
+
],
|
|
495
566
|
},
|
|
496
|
-
|
|
567
|
+
{
|
|
568
|
+
down() {
|
|
569
|
+
this.dropForeign("team_id", Team.table);
|
|
570
|
+
this.dropForeign("owner_id", "users");
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
);
|
|
497
574
|
```
|
|
498
575
|
|
|
499
576
|
`onDelete` / `onUpdate` values: `"cascade"` | `"restrict"` | `"setNull"` | `"noAction"`. Both default to `"restrict"` when omitted.
|
|
@@ -501,23 +578,27 @@ export default Migration.alter(User, {
|
|
|
501
578
|
Drop by constraint name when you named it manually:
|
|
502
579
|
|
|
503
580
|
```ts
|
|
504
|
-
dropForeign: [{ columnOrConstraint: "fk_my_custom_name" }]
|
|
581
|
+
dropForeign: [{ columnOrConstraint: "fk_my_custom_name" }];
|
|
505
582
|
```
|
|
506
583
|
|
|
507
584
|
### CHECK constraints — `addCheck` / `dropCheck`
|
|
508
585
|
|
|
509
586
|
```ts
|
|
510
|
-
export default Migration.alter(
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
},
|
|
516
|
-
|
|
517
|
-
this.dropCheck("price_positive");
|
|
518
|
-
this.dropCheck("stock_non_negative");
|
|
587
|
+
export default Migration.alter(
|
|
588
|
+
Product,
|
|
589
|
+
{
|
|
590
|
+
addCheck: [
|
|
591
|
+
{ name: "price_positive", expression: "price_cents >= 0" },
|
|
592
|
+
{ name: "stock_non_negative", expression: "stock >= 0" },
|
|
593
|
+
],
|
|
519
594
|
},
|
|
520
|
-
|
|
595
|
+
{
|
|
596
|
+
down() {
|
|
597
|
+
this.dropCheck("price_positive");
|
|
598
|
+
this.dropCheck("stock_non_negative");
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
);
|
|
521
602
|
```
|
|
522
603
|
|
|
523
604
|
---
|
|
@@ -527,16 +608,20 @@ export default Migration.alter(Product, {
|
|
|
527
608
|
Use `raw` when you need a one-off SQL statement alongside declarative changes (e.g. backfilling a new column in the same migration when the table is small):
|
|
528
609
|
|
|
529
610
|
```ts
|
|
530
|
-
export default Migration.alter(
|
|
531
|
-
|
|
532
|
-
|
|
611
|
+
export default Migration.alter(
|
|
612
|
+
User,
|
|
613
|
+
{
|
|
614
|
+
add: {
|
|
615
|
+
full_name: text().nullable(),
|
|
616
|
+
},
|
|
617
|
+
raw: `UPDATE users SET full_name = first_name || ' ' || last_name`,
|
|
533
618
|
},
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
619
|
+
{
|
|
620
|
+
down() {
|
|
621
|
+
this.dropColumn("full_name");
|
|
622
|
+
},
|
|
538
623
|
},
|
|
539
|
-
|
|
624
|
+
);
|
|
540
625
|
```
|
|
541
626
|
|
|
542
627
|
Pass an array for multiple statements:
|
|
@@ -545,7 +630,7 @@ Pass an array for multiple statements:
|
|
|
545
630
|
raw: [
|
|
546
631
|
`UPDATE users SET status = 'active' WHERE status IS NULL`,
|
|
547
632
|
`UPDATE users SET role = 'member' WHERE role IS NULL`,
|
|
548
|
-
]
|
|
633
|
+
];
|
|
549
634
|
```
|
|
550
635
|
|
|
551
636
|
---
|
|
@@ -554,24 +639,24 @@ raw: [
|
|
|
554
639
|
|
|
555
640
|
`Migration.alter` **never** auto-infers rollback. Supply `down()` in options whenever you want `cascade migrate:rollback` to work. Inside `down()`, `this` is a full `Migration` instance — every class-form builder is available:
|
|
556
641
|
|
|
557
|
-
| Operation
|
|
558
|
-
|
|
559
|
-
| Drop an added column
|
|
560
|
-
| Re-add a dropped column
|
|
561
|
-
| Undo a rename
|
|
562
|
-
| Undo a column modify
|
|
563
|
-
| Drop a plain index (by name)
|
|
564
|
-
| Drop a plain index (by columns) | `this.dropIndex(["col_a", "col_b"])`
|
|
565
|
-
| Drop a unique constraint
|
|
566
|
-
| Add a unique constraint back
|
|
567
|
-
| Add a plain index back
|
|
568
|
-
| Drop a foreign key
|
|
569
|
-
| Drop a CHECK constraint
|
|
570
|
-
| Drop full-text index
|
|
571
|
-
| Drop geo index
|
|
572
|
-
| Drop vector index
|
|
573
|
-
| Drop TTL index
|
|
574
|
-
| Raw SQL
|
|
642
|
+
| Operation | Class-form call in `down()` |
|
|
643
|
+
| ------------------------------- | ----------------------------------------------------------- |
|
|
644
|
+
| Drop an added column | `this.dropColumn("name")` |
|
|
645
|
+
| Re-add a dropped column | `this.text("name").nullable()` |
|
|
646
|
+
| Undo a rename | `this.renameColumn("new", "old")` |
|
|
647
|
+
| Undo a column modify | `this.text("col").notNullable()` |
|
|
648
|
+
| Drop a plain index (by name) | `this.dropIndex("idx_name")` |
|
|
649
|
+
| Drop a plain index (by columns) | `this.dropIndex(["col_a", "col_b"])` |
|
|
650
|
+
| Drop a unique constraint | `this.dropUnique("email")` or `this.dropUnique(["a", "b"])` |
|
|
651
|
+
| Add a unique constraint back | `this.unique("email")` |
|
|
652
|
+
| Add a plain index back | `this.index("email", "idx_name")` |
|
|
653
|
+
| Drop a foreign key | `this.dropForeign("col", "ref_table")` |
|
|
654
|
+
| Drop a CHECK constraint | `this.dropCheck("constraint_name")` |
|
|
655
|
+
| Drop full-text index | `this.dropFullText("ft_name")` |
|
|
656
|
+
| Drop geo index | `this.dropGeoIndex("column")` |
|
|
657
|
+
| Drop vector index | `this.dropVectorIndex("column")` |
|
|
658
|
+
| Drop TTL index | `this.dropTTLIndex("column")` |
|
|
659
|
+
| Raw SQL | `this.raw("SQL statement")` |
|
|
575
660
|
|
|
576
661
|
If a migration is genuinely irreversible, throw inside `down()` so accidental rollbacks fail loudly:
|
|
577
662
|
|
|
@@ -587,28 +672,32 @@ If a migration is genuinely irreversible, throw inside `down()` so accidental ro
|
|
|
587
672
|
|
|
588
673
|
## Options (`MigrationAlterOptions`)
|
|
589
674
|
|
|
590
|
-
| Key
|
|
591
|
-
|
|
592
|
-
| `down`
|
|
593
|
-
| `up`
|
|
594
|
-
| `order`
|
|
595
|
-
| `createdAt`
|
|
596
|
-
| `transactional` | `boolean`
|
|
675
|
+
| Key | Type | Purpose |
|
|
676
|
+
| --------------- | -------------------------------------------- | ------------------------------------------------------- |
|
|
677
|
+
| `down` | `(this: Migration) => void \| Promise<void>` | Rollback logic (not auto-inferred) |
|
|
678
|
+
| `up` | `(this: Migration) => void \| Promise<void>` | Extra imperative logic to run after declarative changes |
|
|
679
|
+
| `order` | `number` | Execution order override (default `0`) |
|
|
680
|
+
| `createdAt` | `string` | ISO timestamp override for migration name ordering |
|
|
681
|
+
| `transactional` | `boolean` | Wrap the migration in a DB transaction |
|
|
597
682
|
|
|
598
683
|
The `up` hook runs **after** the declarative schema changes, making it useful for data backfills or conditional DDL that depends on the added schema:
|
|
599
684
|
|
|
600
685
|
```ts
|
|
601
|
-
export default Migration.alter(
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
// backfill: mark existing confirmed users as verified
|
|
606
|
-
this.raw(`UPDATE users SET verified_at = updated_at WHERE status = 'confirmed'`);
|
|
686
|
+
export default Migration.alter(
|
|
687
|
+
User,
|
|
688
|
+
{
|
|
689
|
+
add: { verified_at: timestamp().nullable() },
|
|
607
690
|
},
|
|
608
|
-
|
|
609
|
-
|
|
691
|
+
{
|
|
692
|
+
up() {
|
|
693
|
+
// backfill: mark existing confirmed users as verified
|
|
694
|
+
this.raw(`UPDATE users SET verified_at = updated_at WHERE status = 'confirmed'`);
|
|
695
|
+
},
|
|
696
|
+
down() {
|
|
697
|
+
this.dropColumn("verified_at");
|
|
698
|
+
},
|
|
610
699
|
},
|
|
611
|
-
|
|
700
|
+
);
|
|
612
701
|
```
|
|
613
702
|
|
|
614
703
|
---
|
|
@@ -622,48 +711,58 @@ import { Migration, uuid } from "@warlock.js/cascade";
|
|
|
622
711
|
import { Team } from "app/teams/models/team";
|
|
623
712
|
import { User } from "../user.model";
|
|
624
713
|
|
|
625
|
-
export default Migration.alter(
|
|
626
|
-
|
|
627
|
-
|
|
714
|
+
export default Migration.alter(
|
|
715
|
+
User,
|
|
716
|
+
{
|
|
717
|
+
add: {
|
|
718
|
+
team_id: uuid().nullable(),
|
|
719
|
+
},
|
|
720
|
+
addForeign: [{ column: "team_id", references: Team, onDelete: "setNull" }],
|
|
721
|
+
addIndex: [{ columns: "team_id" }],
|
|
628
722
|
},
|
|
629
|
-
|
|
630
|
-
{
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
this.dropForeign("team_id", Team.table);
|
|
636
|
-
this.dropIndex("team_id");
|
|
637
|
-
this.dropColumn("team_id");
|
|
723
|
+
{
|
|
724
|
+
down() {
|
|
725
|
+
this.dropForeign("team_id", Team.table);
|
|
726
|
+
this.dropIndex("team_id");
|
|
727
|
+
this.dropColumn("team_id");
|
|
728
|
+
},
|
|
638
729
|
},
|
|
639
|
-
|
|
730
|
+
);
|
|
640
731
|
```
|
|
641
732
|
|
|
642
733
|
### Swap unique → plain index
|
|
643
734
|
|
|
644
735
|
```ts
|
|
645
|
-
export default Migration.alter(
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
this.dropIndex("users_email_index");
|
|
651
|
-
this.unique("email");
|
|
736
|
+
export default Migration.alter(
|
|
737
|
+
User,
|
|
738
|
+
{
|
|
739
|
+
dropUnique: [["email"]],
|
|
740
|
+
addIndex: [{ columns: "email", name: "users_email_index" }],
|
|
652
741
|
},
|
|
653
|
-
|
|
742
|
+
{
|
|
743
|
+
down() {
|
|
744
|
+
this.dropIndex("users_email_index");
|
|
745
|
+
this.unique("email");
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
);
|
|
654
749
|
```
|
|
655
750
|
|
|
656
751
|
### Rename + widen a column
|
|
657
752
|
|
|
658
753
|
```ts
|
|
659
|
-
export default Migration.alter(
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
},
|
|
663
|
-
|
|
664
|
-
this.renameColumn("content", "body");
|
|
754
|
+
export default Migration.alter(
|
|
755
|
+
Post,
|
|
756
|
+
{
|
|
757
|
+
rename: { body: "content" },
|
|
758
|
+
modify: { content: text().notNullable() },
|
|
665
759
|
},
|
|
666
|
-
|
|
760
|
+
{
|
|
761
|
+
down() {
|
|
762
|
+
this.renameColumn("content", "body");
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
);
|
|
667
766
|
```
|
|
668
767
|
|
|
669
768
|
### Multiple operations in one migration
|
|
@@ -671,22 +770,26 @@ export default Migration.alter(Post, {
|
|
|
671
770
|
Combine freely — all operations run in a single `up()` call:
|
|
672
771
|
|
|
673
772
|
```ts
|
|
674
|
-
export default Migration.alter(
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
},
|
|
682
|
-
|
|
683
|
-
this.dropCheck("price_non_negative");
|
|
684
|
-
this.dropIndex("idx_cat_status");
|
|
685
|
-
this.renameColumn("price_cents", "price");
|
|
686
|
-
this.text("old_code").nullable();
|
|
687
|
-
this.dropColumn("sku");
|
|
773
|
+
export default Migration.alter(
|
|
774
|
+
Product,
|
|
775
|
+
{
|
|
776
|
+
add: { sku: text().unique().notNullable() },
|
|
777
|
+
drop: ["old_code"],
|
|
778
|
+
rename: { price: "price_cents" },
|
|
779
|
+
modify: { price_cents: integer().notNullable() },
|
|
780
|
+
addIndex: [{ columns: ["category_id", "status"], name: "idx_cat_status" }],
|
|
781
|
+
addCheck: [{ name: "price_non_negative", expression: "price_cents >= 0" }],
|
|
688
782
|
},
|
|
689
|
-
|
|
783
|
+
{
|
|
784
|
+
down() {
|
|
785
|
+
this.dropCheck("price_non_negative");
|
|
786
|
+
this.dropIndex("idx_cat_status");
|
|
787
|
+
this.renameColumn("price_cents", "price");
|
|
788
|
+
this.text("old_code").nullable();
|
|
789
|
+
this.dropColumn("sku");
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
);
|
|
690
793
|
```
|
|
691
794
|
|
|
692
795
|
---
|
|
@@ -768,12 +871,12 @@ export class User extends Model<UserSchema> {
|
|
|
768
871
|
|
|
769
872
|
// Write
|
|
770
873
|
const user = await User.create({ name: "Ada Lovelace", email: "ada@example.com" });
|
|
771
|
-
user.id;
|
|
772
|
-
user.get("status");
|
|
874
|
+
user.id; // generated ID — direct property
|
|
875
|
+
user.get("status"); // "active" — default fired
|
|
773
876
|
|
|
774
877
|
// Read
|
|
775
878
|
const found = await User.find(user.id);
|
|
776
|
-
found?.get("email");
|
|
879
|
+
found?.get("email"); // "ada@example.com"
|
|
777
880
|
|
|
778
881
|
// Filter
|
|
779
882
|
const active = await User.where("status", "active").get();
|
|
@@ -782,22 +885,22 @@ const page = await User.paginate({ page: 1, limit: 20 });
|
|
|
782
885
|
|
|
783
886
|
## Pick a skill
|
|
784
887
|
|
|
785
|
-
| If the task is about…
|
|
786
|
-
|
|
|
787
|
-
| Defining a model class — schema, decorators, `Model<TSchema>`, accessors
|
|
788
|
-
| Querying — `.where`, `.find`, `.first`, `.all`, `.count`, `.exists`, ordering
|
|
789
|
-
| Pagination — `.paginate`, `cursorPaginate`, `chunk`
|
|
790
|
-
| Relations — `belongsTo` / `hasMany` / `belongsToMany`, eager loading
|
|
791
|
-
| Migrations — `migration` definition, `up`/`down`, CLI
|
|
792
|
-
| Transactions — `transaction(fn)`, rollback, isolation
|
|
793
|
-
| Dirty tracking — `hasChanges`, `isDirty`, `getDirtyColumns`, `getDirtyColumnsWithValues` | The `track-changes` topic
|
|
794
|
-
| Lifecycle hooks — `saving`, `saved`, `deleting`, `deleted`
|
|
795
|
-
| Soft / hard / trash deletes + restore
|
|
796
|
-
| Atomic ops — `Model.increase`, `decrease`, `atomic`, `Model.delete(filter)`
|
|
797
|
-
| Aggregates — `.sum`, `.avg`, `.count`, `.groupBy`, `.having`
|
|
798
|
-
| Vector search — `similarTo`, pgvector / Atlas vector index
|
|
799
|
-
| Multiple databases — `connectToDatabase`, per-model `static dataSource`
|
|
800
|
-
| CLI + Operations API — `cascade migrate`, `migrate:rollback`, programmatic
|
|
888
|
+
| If the task is about… | Load |
|
|
889
|
+
| ---------------------------------------------------------------------------------------- | ------------------------------------- |
|
|
890
|
+
| Defining a model class — schema, decorators, `Model<TSchema>`, accessors | The `define-model` topic |
|
|
891
|
+
| Querying — `.where`, `.find`, `.first`, `.all`, `.count`, `.exists`, ordering | The `query-data` topic |
|
|
892
|
+
| Pagination — `.paginate`, `cursorPaginate`, `chunk` | The `paginate-results` topic |
|
|
893
|
+
| Relations — `belongsTo` / `hasMany` / `belongsToMany`, eager loading | The `define-relations` topic |
|
|
894
|
+
| Migrations — `migration` definition, `up`/`down`, CLI | The `write-migration` topic |
|
|
895
|
+
| Transactions — `transaction(fn)`, rollback, isolation | The `manage-transactions` topic |
|
|
896
|
+
| Dirty tracking — `hasChanges`, `isDirty`, `getDirtyColumns`, `getDirtyColumnsWithValues` | The `track-changes` topic |
|
|
897
|
+
| Lifecycle hooks — `saving`, `saved`, `deleting`, `deleted` | The `subscribe-to-model-events` topic |
|
|
898
|
+
| Soft / hard / trash deletes + restore | The `configure-delete-strategy` topic |
|
|
899
|
+
| Atomic ops — `Model.increase`, `decrease`, `atomic`, `Model.delete(filter)` | The `perform-atomic-ops` topic |
|
|
900
|
+
| Aggregates — `.sum`, `.avg`, `.count`, `.groupBy`, `.having` | The `aggregate-data` topic |
|
|
901
|
+
| Vector search — `similarTo`, pgvector / Atlas vector index | The `search-by-vector` topic |
|
|
902
|
+
| Multiple databases — `connectToDatabase`, per-model `static dataSource` | The `manage-data-sources` topic |
|
|
903
|
+
| CLI + Operations API — `cascade migrate`, `migrate:rollback`, programmatic | The `run-cascade-cli` topic |
|
|
801
904
|
|
|
802
905
|
## Things NOT to do
|
|
803
906
|
|
|
@@ -824,11 +927,11 @@ description: 'Pick the delete behavior — `permanent` (hard delete), `soft` (se
|
|
|
824
927
|
|
|
825
928
|
`DeleteStrategy` is `"permanent" | "soft" | "trash"`.
|
|
826
929
|
|
|
827
|
-
| Strategy
|
|
828
|
-
|
|
|
829
|
-
| `permanent` | DELETE the row / document (the default fallback)
|
|
830
|
-
| `soft`
|
|
831
|
-
| `trash`
|
|
930
|
+
| Strategy | Behavior | Reversible? |
|
|
931
|
+
| ----------- | -------------------------------------------------------------------------- | ----------------------------- |
|
|
932
|
+
| `permanent` | DELETE the row / document (the default fallback) | No |
|
|
933
|
+
| `soft` | Set the `deletedAt` column; the row stays in the table | Yes — via `Model.restore(id)` |
|
|
934
|
+
| `trash` | Move the record to a separate table / collection, then delete the original | Yes — via `Model.restore(id)` |
|
|
832
935
|
|
|
833
936
|
## Set the default per-model
|
|
834
937
|
|
|
@@ -849,9 +952,9 @@ When the strategy resolves to `"soft"`, `Migration.create(Model, { … })` adds
|
|
|
849
952
|
## Override per-call
|
|
850
953
|
|
|
851
954
|
```ts
|
|
852
|
-
await user.destroy();
|
|
853
|
-
await user.destroy({ strategy: "permanent" });
|
|
854
|
-
await user.destroy({ strategy: "trash" });
|
|
955
|
+
await user.destroy(); // uses the model's strategy
|
|
956
|
+
await user.destroy({ strategy: "permanent" }); // GDPR-compliant hard delete
|
|
957
|
+
await user.destroy({ strategy: "trash" }); // move to the trash table
|
|
855
958
|
```
|
|
856
959
|
|
|
857
960
|
Resolution order: `destroy({ strategy })` → `static deleteStrategy` → data source `defaultDeleteStrategy` → `"permanent"`.
|
|
@@ -863,10 +966,10 @@ After a `"soft"` destroy the in-memory instance reflects the change — `model.g
|
|
|
863
966
|
Restore is a **static** operation keyed by the record's id. It auto-detects whether the record was soft-deleted or trashed:
|
|
864
967
|
|
|
865
968
|
```ts
|
|
866
|
-
const user = await User.restore(123);
|
|
969
|
+
const user = await User.restore(123); // clears deletedAt or pulls from trash; fires "restored"
|
|
867
970
|
const user2 = await User.restore(123, { onIdConflict: "fail" });
|
|
868
971
|
|
|
869
|
-
await User.restoreAll();
|
|
972
|
+
await User.restoreAll(); // restore every deleted record for this model
|
|
870
973
|
```
|
|
871
974
|
|
|
872
975
|
There is no instance `user.restore()` — call `User.restore(id)`. `restoreAll(options?)` restores all soft-deleted (or all trashed) records for the model's table.
|
|
@@ -904,7 +1007,7 @@ export class User extends Model<UserSchema> {
|
|
|
904
1007
|
With that scope in place:
|
|
905
1008
|
|
|
906
1009
|
```ts
|
|
907
|
-
await User.all();
|
|
1010
|
+
await User.all(); // active only (scope applied)
|
|
908
1011
|
await User.query().withoutGlobalScope("notDeleted").get(); // active + soft-deleted
|
|
909
1012
|
await User.query().withoutGlobalScope("notDeleted").whereNotNull("deletedAt").get(); // only deleted
|
|
910
1013
|
```
|
|
@@ -995,10 +1098,10 @@ export class User extends Model<UserSchema> {
|
|
|
995
1098
|
## Read state — `.id`, `.uuid`, and `.get(field)`
|
|
996
1099
|
|
|
997
1100
|
```ts
|
|
998
|
-
user.id;
|
|
999
|
-
user.uuid;
|
|
1000
|
-
user.get("status");
|
|
1001
|
-
user.get<number>("age");
|
|
1101
|
+
user.id; // direct property — ID is so common cascade exposes it directly
|
|
1102
|
+
user.uuid; // the same id, narrowed to `string` (see below)
|
|
1103
|
+
user.get("status"); // canonical reader for every other column
|
|
1104
|
+
user.get<number>("age"); // TypeScript generic for typed reads
|
|
1002
1105
|
```
|
|
1003
1106
|
|
|
1004
1107
|
Use the direct `.id` property; use `.get("field")` for everything else. Add a typed getter on the model class when the same field is read in many places — turns N typed-cast call sites into one named accessor.
|
|
@@ -1007,18 +1110,20 @@ Use the direct `.id` property; use `.get("field")` for everything else. Add a ty
|
|
|
1007
1110
|
|
|
1008
1111
|
Both getters return the model's primary id (`this.get("id")`); the only difference is the **static type**:
|
|
1009
1112
|
|
|
1010
|
-
| Accessor
|
|
1011
|
-
|
|
1012
|
-
| `model.id`
|
|
1013
|
-
| `model.uuid` | `string`
|
|
1113
|
+
| Accessor | Return type | Use when |
|
|
1114
|
+
| ------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
1115
|
+
| `model.id` | `string \| number` | You want the real id type — SQL auto-increment is `number`, MongoDB's ObjectId is `string`. |
|
|
1116
|
+
| `model.uuid` | `string` | You want to pass the id to code typed for a single `string` id, without leaking the engine's `string \| number` union everywhere. |
|
|
1014
1117
|
|
|
1015
1118
|
`uuid` does **not** validate or coerce the value to a UUID — the name is historical. It simply returns the same id, typed as `string`, so a helper typed for a string id accepts it on either engine:
|
|
1016
1119
|
|
|
1017
1120
|
```ts
|
|
1018
|
-
function shareLink(modelId: string) {
|
|
1121
|
+
function shareLink(modelId: string) {
|
|
1122
|
+
/* ... */
|
|
1123
|
+
}
|
|
1019
1124
|
|
|
1020
|
-
shareLink(user.uuid);
|
|
1021
|
-
shareLink(user.id);
|
|
1125
|
+
shareLink(user.uuid); // ✓ typechecks on both Postgres and MongoDB
|
|
1126
|
+
shareLink(user.id); // ✗ TS error on SQL: `number` is not assignable to `string`
|
|
1022
1127
|
```
|
|
1023
1128
|
|
|
1024
1129
|
## Write — three update idioms
|
|
@@ -1041,7 +1146,7 @@ await user.merge({ name: "Augusta Ada King", status: "active" }).save();
|
|
|
1041
1146
|
|
|
1042
1147
|
The everyday case. Service takes `Partial<UserSchema>` from a request body, merges it into the instance, saves. Existing fields not in the object are untouched.
|
|
1043
1148
|
|
|
1044
|
-
**Safe against id-retargeting by design.** `model.merge(req.body); await model.save();` is the canonical update-my-profile shape — and `req.body` is attacker-controlled. On an already-persisted model, `merge()` drops identity columns (`id`, `_id`, the configured primary key) instead of applying them, so a body carrying `{ id: "<victim-id>", role: "admin" }` cannot redirect the write onto someone else's row: `update()`/`replace()`/`destroy()` build their filter from `model.trustedPrimaryKey` — the id captured when the instance became persisted (hydration, or right after an insert), never the current in-memory value. Identity columns are also excluded from the `$set`/`$unset` payload, so an explicit `.set("id", …)` on a loaded record doesn't rewrite the key of the row it's pinned to either. This only applies to an
|
|
1149
|
+
**Safe against id-retargeting by design.** `model.merge(req.body); await model.save();` is the canonical update-my-profile shape — and `req.body` is attacker-controlled. On an already-persisted model, `merge()` drops identity columns (`id`, `_id`, the configured primary key) instead of applying them, so a body carrying `{ id: "<victim-id>", role: "admin" }` cannot redirect the write onto someone else's row: `update()`/`replace()`/`destroy()` build their filter from `model.trustedPrimaryKey` — the id captured when the instance became persisted (hydration, or right after an insert), never the current in-memory value. Identity columns are also excluded from the `$set`/`$unset` payload, so an explicit `.set("id", …)` on a loaded record doesn't rewrite the key of the row it's pinned to either. This only applies to an _existing_ record — `User.create({ id, ... })` with an explicit id is unchanged, since there's no row to retarget yet. If you deliberately need to change a primary key, do it through the atomic/raw APIs (see the `perform-atomic-ops` topic), not `merge()`.
|
|
1045
1150
|
|
|
1046
1151
|
### `.save()` after manual mutation — when changes are spread
|
|
1047
1152
|
|
|
@@ -1057,11 +1162,11 @@ await user.save();
|
|
|
1057
1162
|
|
|
1058
1163
|
Quick reference:
|
|
1059
1164
|
|
|
1060
|
-
| Situation
|
|
1061
|
-
|
|
|
1062
|
-
| 1–2 specific fields
|
|
1063
|
-
| Bulk from an object
|
|
1064
|
-
| After spread mutations | `user.save()`
|
|
1165
|
+
| Situation | Pattern |
|
|
1166
|
+
| ---------------------- | ------------------------- |
|
|
1167
|
+
| 1–2 specific fields | `user.set(k, v).save()` |
|
|
1168
|
+
| Bulk from an object | `user.merge(data).save()` |
|
|
1169
|
+
| After spread mutations | `user.save()` |
|
|
1065
1170
|
|
|
1066
1171
|
## Remove a field — `.unset(field)`
|
|
1067
1172
|
|
|
@@ -1206,7 +1311,7 @@ export class User extends Model<UserSchema> {
|
|
|
1206
1311
|
|
|
1207
1312
|
@BelongsToMany("Role", {
|
|
1208
1313
|
pivot: "user_roles",
|
|
1209
|
-
localKey: "user_id",
|
|
1314
|
+
localKey: "user_id", // pivot column → this model (User)
|
|
1210
1315
|
foreignKey: "role_id", // pivot column → the related model (Role)
|
|
1211
1316
|
})
|
|
1212
1317
|
public roles!: Role[];
|
|
@@ -1220,12 +1325,12 @@ export class User extends Model<UserSchema> {
|
|
|
1220
1325
|
Manage pivot rows through `model.pivot(relation)`, which returns the relation's `PivotOperations` handle:
|
|
1221
1326
|
|
|
1222
1327
|
```ts
|
|
1223
|
-
await user.pivot("roles").attach([adminId, editorId]);
|
|
1328
|
+
await user.pivot("roles").attach([adminId, editorId]); // add (skips existing)
|
|
1224
1329
|
await user.pivot("roles").attach([adminId], { addedBy: actorId }); // + extra pivot columns
|
|
1225
|
-
await user.pivot("roles").detach([editorId]);
|
|
1226
|
-
await user.pivot("roles").detach();
|
|
1227
|
-
await user.pivot("roles").sync([adminId]);
|
|
1228
|
-
await user.pivot("roles").toggle([adminId]);
|
|
1330
|
+
await user.pivot("roles").detach([editorId]); // remove a subset
|
|
1331
|
+
await user.pivot("roles").detach(); // remove all
|
|
1332
|
+
await user.pivot("roles").sync([adminId]); // replace the whole set
|
|
1333
|
+
await user.pivot("roles").toggle([adminId]); // flip each id
|
|
1229
1334
|
```
|
|
1230
1335
|
|
|
1231
1336
|
`model.attach(relation, ids, pivotData?)` and `model.detach(relation, ids?)` are thin shortcuts for the two most common ops; `.sync()` / `.toggle()` live only on the `pivot(relation)` handle (or the standalone `createPivotOperations(model, relation)`). Passing a non-`belongsToMany` relation throws. Routing through `model.pivot(relation)` keeps the join-table `.sync()` distinct from `Model.sync(Target, field)` (the denormalization-embed feature). Pivot ops run direct driver writes — no model lifecycle events fire on the related model.
|
|
@@ -1238,7 +1343,7 @@ Load related models in the same query, avoiding N+1:
|
|
|
1238
1343
|
const posts = await Post.with("author").get();
|
|
1239
1344
|
|
|
1240
1345
|
for (const post of posts) {
|
|
1241
|
-
post.getRelation("author");
|
|
1346
|
+
post.getRelation("author"); // already loaded — no second query
|
|
1242
1347
|
}
|
|
1243
1348
|
```
|
|
1244
1349
|
|
|
@@ -1260,11 +1365,9 @@ await User.with("posts.comments").get();
|
|
|
1260
1365
|
Filter parents based on conditions on children:
|
|
1261
1366
|
|
|
1262
1367
|
```ts
|
|
1263
|
-
const usersWithPublishedPosts = await User
|
|
1264
|
-
.
|
|
1265
|
-
|
|
1266
|
-
})
|
|
1267
|
-
.get();
|
|
1368
|
+
const usersWithPublishedPosts = await User.whereHas("posts", (query) => {
|
|
1369
|
+
query.where("status", "published");
|
|
1370
|
+
}).get();
|
|
1268
1371
|
```
|
|
1269
1372
|
|
|
1270
1373
|
`.whereHas(relation, callback)` runs the callback on a query builder scoped to the related model. Use when "find Xs that have at least one Y matching Z."
|
|
@@ -1287,8 +1390,8 @@ Why not `post.set("author", currentUser)`? `set` treats the value as a column wr
|
|
|
1287
1390
|
```ts
|
|
1288
1391
|
const post = await Post.with("author").first();
|
|
1289
1392
|
|
|
1290
|
-
post.getRelation("author");
|
|
1291
|
-
post.author;
|
|
1393
|
+
post.getRelation("author"); // typed access to the loaded User
|
|
1394
|
+
post.author; // direct field — only if you declared it as a typed field
|
|
1292
1395
|
|
|
1293
1396
|
// Without eager loading — load on demand, then read:
|
|
1294
1397
|
await post.load("author");
|
|
@@ -1302,10 +1405,7 @@ const author = post.getRelation("author");
|
|
|
1302
1405
|
`.with()` runs separate queries and hydrates relations. `.joinWith()` joins at the SQL/aggregation level:
|
|
1303
1406
|
|
|
1304
1407
|
```ts
|
|
1305
|
-
const result = await User
|
|
1306
|
-
.joinWith("posts")
|
|
1307
|
-
.where("posts.status", "published")
|
|
1308
|
-
.get();
|
|
1408
|
+
const result = await User.joinWith("posts").where("posts.status", "published").get();
|
|
1309
1409
|
```
|
|
1310
1410
|
|
|
1311
1411
|
Use `.joinWith` when you need conditions across both tables in a single query. Use `.with` when you want hydrated relation models without joining.
|
|
@@ -1604,9 +1704,12 @@ Throwing works too (and re-throws to the caller); `ctx.rollback()` is the non-th
|
|
|
1604
1704
|
Default is the driver's default (typically `READ COMMITTED`). Request a different level via `isolationLevel`:
|
|
1605
1705
|
|
|
1606
1706
|
```ts
|
|
1607
|
-
await transaction(
|
|
1608
|
-
|
|
1609
|
-
|
|
1707
|
+
await transaction(
|
|
1708
|
+
async () => {
|
|
1709
|
+
/* ... */
|
|
1710
|
+
},
|
|
1711
|
+
{ isolationLevel: "SERIALIZABLE" },
|
|
1712
|
+
);
|
|
1610
1713
|
```
|
|
1611
1714
|
|
|
1612
1715
|
On `SERIALIZABLE`, Postgres may abort with a serialization failure when concurrent transactions conflict — wrap in retry logic at the caller.
|
|
@@ -1616,21 +1719,24 @@ On `SERIALIZABLE`, Postgres may abort with a serialization failure when concurre
|
|
|
1616
1719
|
Pass `settings` to configure PostgreSQL values only for the current transaction. This is useful for row-level security context, timeouts, and schema selection; Cascade applies each value with parameterized `set_config(..., true)`, so it cannot leak back into the pool:
|
|
1617
1720
|
|
|
1618
1721
|
```ts
|
|
1619
|
-
await transaction(
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
settings: {
|
|
1623
|
-
"app.tenant_id": tenantId,
|
|
1624
|
-
statement_timeout: 5_000,
|
|
1722
|
+
await transaction(
|
|
1723
|
+
async () => {
|
|
1724
|
+
await Invoice.query().get();
|
|
1625
1725
|
},
|
|
1626
|
-
|
|
1726
|
+
{
|
|
1727
|
+
settings: {
|
|
1728
|
+
"app.tenant_id": tenantId,
|
|
1729
|
+
statement_timeout: 5_000,
|
|
1730
|
+
},
|
|
1731
|
+
},
|
|
1732
|
+
);
|
|
1627
1733
|
```
|
|
1628
1734
|
|
|
1629
1735
|
## Row locking — `lockForUpdate({ skipLocked })` (Postgres)
|
|
1630
1736
|
|
|
1631
1737
|
Chain `lockForUpdate()` on any query to emit `SELECT ... FOR UPDATE`: the matched rows are locked until the transaction ends, so no other transaction can modify or lock them. Only meaningful **inside** a transaction — outside one the lock releases immediately.
|
|
1632
1738
|
|
|
1633
|
-
`{ skipLocked: true }` adds `SKIP LOCKED` — rows another transaction already holds are silently skipped instead of waited on. That is the standard concurrent queue-claim shape: N workers each grab
|
|
1739
|
+
`{ skipLocked: true }` adds `SKIP LOCKED` — rows another transaction already holds are silently skipped instead of waited on. That is the standard concurrent queue-claim shape: N workers each grab _different_ pending rows with no double-processing and no blocking.
|
|
1634
1740
|
|
|
1635
1741
|
```ts
|
|
1636
1742
|
import { transaction } from "@warlock.js/cascade";
|
|
@@ -1696,7 +1802,7 @@ const { rows: counts } = await dataSource.raw<{ count: number }>(
|
|
|
1696
1802
|
|
|
1697
1803
|
### Native-array columns (`JSONB[]` / `TEXT[]` / `INTEGER[]`)
|
|
1698
1804
|
|
|
1699
|
-
From a value alone the serializer can't tell a `json` / `jsonb` column (which needs JSON text) from a genuine **native-array** column (`arrayJson()` → `JSONB[]`, `arrayText()` → `TEXT[]`, …, which needs the raw JS array so node-pg emits a `{...}` literal). Guess wrong and Postgres rejects the insert with
|
|
1805
|
+
From a value alone the serializer can't tell a `json` / `jsonb` column (which needs JSON text) from a genuine **native-array** column (`arrayJson()` → `JSONB[]`, `arrayText()` → `TEXT[]`, …, which needs the raw JS array so node-pg emits a `{...}` literal). Guess wrong and Postgres rejects the insert with _"malformed array literal"_.
|
|
1700
1806
|
|
|
1701
1807
|
**This is handled automatically.** On connect the driver introspects `information_schema` once and remembers which columns are native arrays **per table**, so `arrayText()` / `arrayJson()` columns just work with **no configuration**. Because it's per-table, the same column name being `TEXT[]` in one table and `jsonb` in another is encoded correctly for each; the pgvector all-number array form is still preserved.
|
|
1702
1808
|
|
|
@@ -1763,8 +1869,8 @@ The everyday case for listings with page numbers:
|
|
|
1763
1869
|
```ts
|
|
1764
1870
|
const page = await User.paginate({ page: 1, limit: 20 });
|
|
1765
1871
|
|
|
1766
|
-
page.data;
|
|
1767
|
-
page.pagination;
|
|
1872
|
+
page.data; // User[]
|
|
1873
|
+
page.pagination; // { total, page, limit, pages }
|
|
1768
1874
|
```
|
|
1769
1875
|
|
|
1770
1876
|
`PaginationOptions` is `{ page?, limit? }` — there is no `filter` field; filter by chaining `.where()` before `.paginate()` (below).
|
|
@@ -1772,9 +1878,7 @@ page.pagination; // { total, page, limit, pages }
|
|
|
1772
1878
|
Chain off `.where()` for filtered pagination:
|
|
1773
1879
|
|
|
1774
1880
|
```ts
|
|
1775
|
-
const activePage = await User
|
|
1776
|
-
.where("status", "active")
|
|
1777
|
-
.paginate({ page: 2, limit: 20 });
|
|
1881
|
+
const activePage = await User.where("status", "active").paginate({ page: 2, limit: 20 });
|
|
1778
1882
|
```
|
|
1779
1883
|
|
|
1780
1884
|
**Cost characteristic.** Offset pagination scans `offset + limit` rows on every page — page 100 with limit 20 scans 2020 rows just to skip 2000. Fine for the first few pages; not great deep in the result set.
|
|
@@ -1786,9 +1890,9 @@ For very large datasets where deep pagination matters:
|
|
|
1786
1890
|
```ts
|
|
1787
1891
|
const first = await User.query().orderBy("created_at", "desc").cursorPaginate({ limit: 20 });
|
|
1788
1892
|
|
|
1789
|
-
first.data;
|
|
1790
|
-
first.pagination.nextCursor;
|
|
1791
|
-
first.pagination.hasMore;
|
|
1893
|
+
first.data; // User[]
|
|
1894
|
+
first.pagination.nextCursor; // opaque value — pass to the next call
|
|
1895
|
+
first.pagination.hasMore; // boolean
|
|
1792
1896
|
|
|
1793
1897
|
const next = await User.query()
|
|
1794
1898
|
.orderBy("created_at", "desc")
|
|
@@ -1894,10 +1998,13 @@ Signature: `Model.increase(filter, field, amount)` / `Model.decrease(filter, fie
|
|
|
1894
1998
|
## Arbitrary atomic mutations — `Model.atomic`
|
|
1895
1999
|
|
|
1896
2000
|
```ts
|
|
1897
|
-
await User.atomic(
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
}
|
|
2001
|
+
await User.atomic(
|
|
2002
|
+
{ id: userId },
|
|
2003
|
+
{
|
|
2004
|
+
$set: { last_seen: new Date() },
|
|
2005
|
+
$inc: { login_count: 1 },
|
|
2006
|
+
},
|
|
2007
|
+
);
|
|
1901
2008
|
```
|
|
1902
2009
|
|
|
1903
2010
|
`Model.atomic(filter, operations)` → `Promise<number>`. Driver-flavored atomic mutation — MongoDB has `$set` / `$inc` / `$push` / `$pull` natively; the Postgres driver supports `$set` / `$unset` / `$inc` / `$dec` (and `$setOnInsert` on upsert) and throws `UnsupportedUpdateOperationError` for the rest. Use when you need to combine multiple field changes atomically without loading the model first.
|
|
@@ -1923,7 +2030,11 @@ const granted = await Quota.atomic(
|
|
|
1923
2030
|
|
|
1924
2031
|
// MongoDB only: pipeline update, $addToSet, arrayFilters
|
|
1925
2032
|
await Post.findOneAndUpdate({ slug }, [{ $set: { score: { $add: ["$likes", "$shares"] } } }]);
|
|
1926
|
-
await Post.atomic(
|
|
2033
|
+
await Post.atomic(
|
|
2034
|
+
{ slug },
|
|
2035
|
+
{ $set: { "grades.$[low].score": 50 } },
|
|
2036
|
+
{ arrayFilters: [{ "low.score": { $lt: 50 } }] },
|
|
2037
|
+
);
|
|
1927
2038
|
```
|
|
1928
2039
|
|
|
1929
2040
|
- `atomic(filter, update, { upsert?, arrayFilters?, trustedFilter? })` → modified + upserted count.
|
|
@@ -1931,6 +2042,7 @@ await Post.atomic({ slug }, { $set: { "grades.$[low].score": 50 } }, { arrayFilt
|
|
|
1931
2042
|
- `findAndUpdate(filter, update, { upsert?, arrayFilters? })`. No `trustedFilter`, because it reads the rows back with `where(filter)`.
|
|
1932
2043
|
|
|
1933
2044
|
**Postgres.** An upsert runs as `INSERT … ON CONFLICT (target) DO UPDATE … RETURNING *`. The target is the primary key, or a unique index, whose columns are ALL equality keys of the filter. Other filter conditions become `DO UPDATE … WHERE`. If the row exists and fails them, nothing is written and `findOneAndUpdate` returns `null`. The driver throws `UnsupportedUpdateOperationError` (`.operation`, `.driver`) for:
|
|
2045
|
+
|
|
1934
2046
|
- pipeline updates
|
|
1935
2047
|
- `arrayFilters`
|
|
1936
2048
|
- `$push`, `$pull`, `$addToSet` and unknown operators
|
|
@@ -1973,10 +2085,7 @@ const rows = await OrderItem.createMany(millionRows, { bulk: true, batchSize: 10
|
|
|
1973
2085
|
## Bulk update — `Model.findAndUpdate(filter, operations)`
|
|
1974
2086
|
|
|
1975
2087
|
```ts
|
|
1976
|
-
const updated = await User.findAndUpdate(
|
|
1977
|
-
{ status: "pending" },
|
|
1978
|
-
{ $set: { status: "active" } },
|
|
1979
|
-
);
|
|
2088
|
+
const updated = await User.findAndUpdate({ status: "pending" }, { $set: { status: "active" } });
|
|
1980
2089
|
// updated: User[] — the matched-and-updated models
|
|
1981
2090
|
```
|
|
1982
2091
|
|
|
@@ -1987,8 +2096,8 @@ const updated = await User.findAndUpdate(
|
|
|
1987
2096
|
## Bulk delete — `Model.delete(filter)`
|
|
1988
2097
|
|
|
1989
2098
|
```ts
|
|
1990
|
-
await User.delete({ status: "spam" });
|
|
1991
|
-
await User.deleteOne({ status: "spam" });
|
|
2099
|
+
await User.delete({ status: "spam" }); // delete all matching → count
|
|
2100
|
+
await User.deleteOne({ status: "spam" }); // delete the first match → count
|
|
1992
2101
|
```
|
|
1993
2102
|
|
|
1994
2103
|
`Model.delete(filter?)` and `Model.deleteOne(filter?)` both return `Promise<number>`. These bypass the per-instance delete strategy and `deleted` events — they are raw driver deletes.
|
|
@@ -2004,16 +2113,16 @@ for (const user of targets) {
|
|
|
2004
2113
|
|
|
2005
2114
|
## When to reach for what
|
|
2006
2115
|
|
|
2007
|
-
| Task
|
|
2008
|
-
|
|
|
2009
|
-
| Increment a counter
|
|
2010
|
-
| Atomically change multiple fields on one record | `Model.atomic(filter, ops)`
|
|
2011
|
-
| Insert N records
|
|
2012
|
-
| Insert a large batch fast (no per-row events)
|
|
2013
|
-
| Update many rows with operators
|
|
2014
|
-
| Update one record by id
|
|
2015
|
-
| Delete many rows (raw)
|
|
2016
|
-
| Multi-row read-modify-write
|
|
2116
|
+
| Task | Reach for |
|
|
2117
|
+
| ----------------------------------------------- | ------------------------------------------------------------- |
|
|
2118
|
+
| Increment a counter | `Model.increase(filter, field, n)` |
|
|
2119
|
+
| Atomically change multiple fields on one record | `Model.atomic(filter, ops)` |
|
|
2120
|
+
| Insert N records | `Model.createMany(rows)` |
|
|
2121
|
+
| Insert a large batch fast (no per-row events) | `Model.createMany(rows, { bulk: true, batchSize })` |
|
|
2122
|
+
| Update many rows with operators | `Model.findAndUpdate(filter, { $set: {...} })` |
|
|
2123
|
+
| Update one record by id | `Model.update(id, data)` |
|
|
2124
|
+
| Delete many rows (raw) | `Model.delete(filter)` |
|
|
2125
|
+
| Multi-row read-modify-write | Wrap in a transaction (see the `manage-transactions` topic) |
|
|
2017
2126
|
| Need lifecycle events / delete strategy per row | `Model.where(...).get()` + iterate + `.save()` / `.destroy()` |
|
|
2018
2127
|
|
|
2019
2128
|
## Things NOT to do
|
|
@@ -2055,9 +2164,9 @@ const activeUsers = await User.where("status", "active").get();
|
|
|
2055
2164
|
### Operators
|
|
2056
2165
|
|
|
2057
2166
|
```ts
|
|
2058
|
-
const adults
|
|
2059
|
-
const recent
|
|
2060
|
-
const nonAdmins
|
|
2167
|
+
const adults = await User.where("age", ">", 18).get();
|
|
2168
|
+
const recent = await User.where("created_at", ">=", lastWeek).get();
|
|
2169
|
+
const nonAdmins = await User.where("role", "!=", "admin").get();
|
|
2061
2170
|
```
|
|
2062
2171
|
|
|
2063
2172
|
3-argument form. Common operators: `=`, `!=`, `<`, `<=`, `>`, `>=`, `in`, `notIn`, `like`, `between`. Same syntax across MongoDB and Postgres.
|
|
@@ -2065,10 +2174,7 @@ const nonAdmins = await User.where("role", "!=", "admin").get();
|
|
|
2065
2174
|
### Compound conditions
|
|
2066
2175
|
|
|
2067
2176
|
```ts
|
|
2068
|
-
const activeAdmins = await User
|
|
2069
|
-
.where("status", "active")
|
|
2070
|
-
.where("role", "admin")
|
|
2071
|
-
.get();
|
|
2177
|
+
const activeAdmins = await User.where("status", "active").where("role", "admin").get();
|
|
2072
2178
|
```
|
|
2073
2179
|
|
|
2074
2180
|
Chained `.where()` calls combine with `AND`.
|
|
@@ -2093,7 +2199,7 @@ await User.first({ email, password }); // throws UnsafeFilterError if password i
|
|
|
2093
2199
|
|
|
2094
2200
|
```ts
|
|
2095
2201
|
query.whereRaw({ $expr: { $gt: ["$stock", "$reserved"] } }); // object form — MongoDB
|
|
2096
|
-
query.whereRaw("age > ?", [30]);
|
|
2202
|
+
query.whereRaw("age > ?", [30]); // string form — SQL, real bindings
|
|
2097
2203
|
```
|
|
2098
2204
|
|
|
2099
2205
|
The **object form** is checked like any other filter value, plus it rejects the server-side-JavaScript operators `$where`, `$function` and `$accumulator` anywhere in the expression (throws `UnsafeRawExpressionError`); `$expr` and the other aggregation operators keep working. The **string form on the MongoDB driver throws `UnsafeRawExpressionError`** — a string used to compile to `{ $where: "<js>" }`, executing arbitrary JavaScript inside `mongod` per scanned document; there is no safe way to parameterize that, so it's rejected outright. Use the object form instead. SQL drivers keep string mode with real `?` bindings (unchanged).
|
|
@@ -2103,15 +2209,15 @@ The **object form** is checked like any other filter value, plus it rejects the
|
|
|
2103
2209
|
### By ID
|
|
2104
2210
|
|
|
2105
2211
|
```ts
|
|
2106
|
-
const user = await User.find(id);
|
|
2212
|
+
const user = await User.find(id); // User | null
|
|
2107
2213
|
```
|
|
2108
2214
|
|
|
2109
2215
|
### First match
|
|
2110
2216
|
|
|
2111
2217
|
```ts
|
|
2112
|
-
const anyUser
|
|
2113
|
-
const firstAdmin = await User.first({ role: "admin" });
|
|
2114
|
-
const filtered
|
|
2218
|
+
const anyUser = await User.first(); // first user, any
|
|
2219
|
+
const firstAdmin = await User.first({ role: "admin" }); // first admin
|
|
2220
|
+
const filtered = await User.where("status", "active").first(); // chain into .first()
|
|
2115
2221
|
```
|
|
2116
2222
|
|
|
2117
2223
|
`.first()` with no args returns the very first record (driver-dependent default order). With a filter object, the first match by equality. Chain off `.where()` when you need operators.
|
|
@@ -2129,10 +2235,7 @@ Throws when nothing matches — useful when you KNOW it should exist and want th
|
|
|
2129
2235
|
## Order and paginate
|
|
2130
2236
|
|
|
2131
2237
|
```ts
|
|
2132
|
-
const newest = await User
|
|
2133
|
-
.where("status", "active")
|
|
2134
|
-
.orderBy("created_at", "desc")
|
|
2135
|
-
.get();
|
|
2238
|
+
const newest = await User.where("status", "active").orderBy("created_at", "desc").get();
|
|
2136
2239
|
```
|
|
2137
2240
|
|
|
2138
2241
|
`.orderBy(field, "asc" | "desc")` sorts. Default direction is `"asc"`. Chain multiple `.orderBy()` for tiebreakers.
|
|
@@ -2151,11 +2254,11 @@ rows[0].name; // typed as UserSchema, not a User instance
|
|
|
2151
2254
|
## Count and existence
|
|
2152
2255
|
|
|
2153
2256
|
```ts
|
|
2154
|
-
const total
|
|
2257
|
+
const total = await User.count();
|
|
2155
2258
|
const activeCount = await User.count({ status: "active" });
|
|
2156
|
-
const adminCount
|
|
2259
|
+
const adminCount = await User.where("role", "admin").count();
|
|
2157
2260
|
|
|
2158
|
-
const hasAdmin
|
|
2261
|
+
const hasAdmin = await User.where("role", "admin").exists(); // boolean, short-circuits
|
|
2159
2262
|
const noneBlocked = await User.where("status", "blocked").notExists();
|
|
2160
2263
|
```
|
|
2161
2264
|
|
|
@@ -2164,7 +2267,7 @@ const noneBlocked = await User.where("status", "blocked").notExists();
|
|
|
2164
2267
|
## Get many — `.all(filter?)`
|
|
2165
2268
|
|
|
2166
2269
|
```ts
|
|
2167
|
-
const allUsers
|
|
2270
|
+
const allUsers = await User.all();
|
|
2168
2271
|
const activeUsers = await User.all({ status: "active" });
|
|
2169
2272
|
```
|
|
2170
2273
|
|
|
@@ -2176,19 +2279,19 @@ const activeUsers = await User.all({ status: "active" });
|
|
|
2176
2279
|
|
|
2177
2280
|
Cascade's query builder has around 60 methods. Reach for these as the need arises:
|
|
2178
2281
|
|
|
2179
|
-
| Reach for
|
|
2180
|
-
|
|
|
2181
|
-
| `.whereIn(field, values)` / `.whereNotIn(field, values)`
|
|
2182
|
-
| `.whereNull(field)` / `.whereNotNull(field)`
|
|
2183
|
-
| `.whereBetween(field, [a, b])`
|
|
2184
|
-
| `.whereDate(field, value)`, `.whereDateBetween`, `.whereDateBefore`, `.whereDateAfter` | Date helpers
|
|
2185
|
-
| `.whereLike(field, pattern)` / `.whereStartsWith` / `.whereEndsWith` / `.whereSearch`
|
|
2186
|
-
| `.whereHas(relation, callback)`
|
|
2187
|
-
| `.sum(field)` / `.avg(field)` / `.min(field)` / `.max(field)`
|
|
2188
|
-
| `.distinct(field)` / `.pluck(field)`
|
|
2189
|
-
| `.chunk(size, callback)`
|
|
2190
|
-
| `.cursorPaginate({ limit, cursor })`
|
|
2191
|
-
| `.similarTo(column, embedding)`
|
|
2282
|
+
| Reach for | When |
|
|
2283
|
+
| -------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
2284
|
+
| `.whereIn(field, values)` / `.whereNotIn(field, values)` | Match against / exclude a list |
|
|
2285
|
+
| `.whereNull(field)` / `.whereNotNull(field)` | Nullability checks |
|
|
2286
|
+
| `.whereBetween(field, [a, b])` | Inclusive range |
|
|
2287
|
+
| `.whereDate(field, value)`, `.whereDateBetween`, `.whereDateBefore`, `.whereDateAfter` | Date helpers |
|
|
2288
|
+
| `.whereLike(field, pattern)` / `.whereStartsWith` / `.whereEndsWith` / `.whereSearch` | Pattern matching — see below for literal-vs-regex semantics |
|
|
2289
|
+
| `.whereHas(relation, callback)` | Filter by conditions on a related model |
|
|
2290
|
+
| `.sum(field)` / `.avg(field)` / `.min(field)` / `.max(field)` | Aggregates — the `aggregate-data` topic |
|
|
2291
|
+
| `.distinct(field)` / `.pluck(field)` | Single-field reads (distinct values, flat list) |
|
|
2292
|
+
| `.chunk(size, callback)` | Stream a large table in batches |
|
|
2293
|
+
| `.cursorPaginate({ limit, cursor })` | Cursor pagination — the `paginate-results` topic |
|
|
2294
|
+
| `.similarTo(column, embedding)` | Vector similarity — the `search-by-vector` topic |
|
|
2192
2295
|
|
|
2193
2296
|
Each chains off `User.where(...)` or `User.query()` and ends with the appropriate terminator. (`where`, `with`, `joinWith`, `first`, `count`, `find`, `all`, `paginate` are static shortcuts on the model; the rest live on the query builder, so reach them via `User.query()` or by chaining off a static `where`.)
|
|
2194
2297
|
|
|
@@ -2271,6 +2374,7 @@ cascade migrate:export-sql # write .up.sql / .down.sql instead of executing
|
|
|
2271
2374
|
```
|
|
2272
2375
|
|
|
2273
2376
|
Flags:
|
|
2377
|
+
|
|
2274
2378
|
- `migrate` — `-f/--fresh` (drop everything and re-run), `-s/--sql` (export SQL instead of executing), `--pending-only`, `-c/--compact`, `-p/--path <glob>`
|
|
2275
2379
|
- `migrate:rollback` — `-a/--all` (roll back everything), `--batches N`, `-p/--path <glob>`
|
|
2276
2380
|
- `migrate:export-sql` — `--pending-only`, `-c/--compact`, `-p/--path <glob>`
|
|
@@ -2354,16 +2458,16 @@ Reach for these when you need migrations inside test setup (`beforeAll(async ()
|
|
|
2354
2458
|
|
|
2355
2459
|
## Common task → command
|
|
2356
2460
|
|
|
2357
|
-
| You want to…
|
|
2358
|
-
|
|
2359
|
-
| Run all pending migrations
|
|
2360
|
-
| Drop everything and re-run
|
|
2361
|
-
| Roll back the last batch
|
|
2362
|
-
| Roll back everything
|
|
2363
|
-
| Generate SQL without executing | `cascade migrate --sql`
|
|
2364
|
-
| Generate SQL for pending only
|
|
2365
|
-
| See what's been executed
|
|
2366
|
-
| Run from a non-default folder
|
|
2461
|
+
| You want to… | Command |
|
|
2462
|
+
| ------------------------------ | -------------------------------------- |
|
|
2463
|
+
| Run all pending migrations | `cascade migrate` |
|
|
2464
|
+
| Drop everything and re-run | `cascade migrate -f` |
|
|
2465
|
+
| Roll back the last batch | `cascade migrate:rollback` |
|
|
2466
|
+
| Roll back everything | `cascade migrate:rollback --all` |
|
|
2467
|
+
| Generate SQL without executing | `cascade migrate --sql` |
|
|
2468
|
+
| Generate SQL for pending only | `cascade migrate --sql --pending-only` |
|
|
2469
|
+
| See what's been executed | `cascade migrate:list` |
|
|
2470
|
+
| Run from a non-default folder | `cascade migrate -p "<glob>"` |
|
|
2367
2471
|
|
|
2368
2472
|
## Things NOT to do
|
|
2369
2473
|
|
|
@@ -2477,7 +2581,10 @@ async function answer(question: string) {
|
|
|
2477
2581
|
// optional: drop low-similarity hits in code
|
|
2478
2582
|
const relevant = context.filter((document) => document.score >= 0.75);
|
|
2479
2583
|
|
|
2480
|
-
const prompt = buildPrompt(
|
|
2584
|
+
const prompt = buildPrompt(
|
|
2585
|
+
question,
|
|
2586
|
+
relevant.map((document) => document.body),
|
|
2587
|
+
);
|
|
2481
2588
|
return ai.complete(prompt);
|
|
2482
2589
|
}
|
|
2483
2590
|
```
|
|
@@ -2486,11 +2593,11 @@ A score threshold isn't a query option — filter on the `score` column in code
|
|
|
2486
2593
|
|
|
2487
2594
|
## Driver support
|
|
2488
2595
|
|
|
2489
|
-
| Driver
|
|
2490
|
-
|
|
|
2491
|
-
| Postgres (with `CREATE EXTENSION vector`)
|
|
2596
|
+
| Driver | Vector |
|
|
2597
|
+
| ---------------------------------------------- | ------------------------------------ |
|
|
2598
|
+
| Postgres (with `CREATE EXTENSION vector`) | ✅ pgvector + IVFFlat index |
|
|
2492
2599
|
| MongoDB Atlas (paid tier + Atlas Search index) | ✅ `$vectorSearch` aggregation stage |
|
|
2493
|
-
| MongoDB community / self-hosted / local
|
|
2600
|
+
| MongoDB community / self-hosted / local | ❌ Atlas-only |
|
|
2494
2601
|
|
|
2495
2602
|
For local dev with MongoDB, develop the vector path against Postgres + pgvector.
|
|
2496
2603
|
|
|
@@ -2537,18 +2644,18 @@ Listeners are async-aware — the model's persistence awaits the listener before
|
|
|
2537
2644
|
|
|
2538
2645
|
The events fire in this order. The **gerund** form (`saving`, `creating`) is the "before" hook — there is no `beforeSave` alias.
|
|
2539
2646
|
|
|
2540
|
-
| Event
|
|
2541
|
-
|
|
|
2542
|
-
| `saving`
|
|
2543
|
-
| `validating` / `validated`
|
|
2544
|
-
| `creating`
|
|
2545
|
-
| `updating`
|
|
2546
|
-
| `saved`
|
|
2547
|
-
| `created`
|
|
2548
|
-
| `updated`
|
|
2549
|
-
| `deleting` / `deleted`
|
|
2550
|
-
| `restoring` / `restored`
|
|
2551
|
-
| `fetching` / `fetched` / `hydrating` | Around reads / instance hydration
|
|
2647
|
+
| Event | Fires |
|
|
2648
|
+
| ------------------------------------ | ------------------------------------------------------------------- |
|
|
2649
|
+
| `saving` | Before validation, on every `save()` (both insert and update paths) |
|
|
2650
|
+
| `validating` / `validated` | Around schema validation |
|
|
2651
|
+
| `creating` | Before a new record is inserted (first save) |
|
|
2652
|
+
| `updating` | Before an existing record's update is written |
|
|
2653
|
+
| `saved` | After a successful write (any branch — insert or update) |
|
|
2654
|
+
| `created` | After a new record is inserted |
|
|
2655
|
+
| `updated` | After an existing record's update is written |
|
|
2656
|
+
| `deleting` / `deleted` | Before / after the delete strategy runs |
|
|
2657
|
+
| `restoring` / `restored` | Before / after a soft-deleted or trashed record is restored |
|
|
2658
|
+
| `fetching` / `fetched` / `hydrating` | Around reads / instance hydration |
|
|
2552
2659
|
|
|
2553
2660
|
`saving` fires for both inserts and updates; `creating` / `updating` narrow to the branch. So `saved` always fires; exactly one of `created` / `updated` follows.
|
|
2554
2661
|
|
|
@@ -2579,17 +2686,17 @@ Pair with `isDirty()` from the `track-changes` topic so the listener only runs i
|
|
|
2579
2686
|
|
|
2580
2687
|
## Global listeners — across all models
|
|
2581
2688
|
|
|
2582
|
-
For framework-level concerns (audit log, observability, cache invalidation), subscribe
|
|
2689
|
+
For framework-level concerns (audit log, observability, cache invalidation), subscribe through `Model.globalEvents()` — the listener runs for every model:
|
|
2583
2690
|
|
|
2584
2691
|
```ts
|
|
2585
2692
|
import { Model } from "@warlock.js/cascade";
|
|
2586
2693
|
|
|
2587
|
-
Model.on("saved",
|
|
2588
|
-
|
|
2694
|
+
Model.globalEvents().on("saved", (instance) => {
|
|
2695
|
+
console.info("save", instance.constructor.name, instance.id, instance.getDirtyColumns());
|
|
2589
2696
|
});
|
|
2590
2697
|
```
|
|
2591
2698
|
|
|
2592
|
-
Filter by `instance.constructor.name === "User"` when you only care about specific models, or register per-model handlers when the scope is narrow.
|
|
2699
|
+
Filter by `instance.constructor.name === "User"` when you only care about specific models, or register per-model handlers when the scope is narrow. The base `Model` is abstract; its per-model `on()` shortcut expects a concrete subclass.
|
|
2593
2700
|
|
|
2594
2701
|
## `off()` to unsubscribe
|
|
2595
2702
|
|
|
@@ -2627,7 +2734,7 @@ Model.on("updated", async (instance) => {
|
|
|
2627
2734
|
});
|
|
2628
2735
|
```
|
|
2629
2736
|
|
|
2630
|
-
Use `updated` (not `saved`) when you only want change diffs — `getDirtyColumnsWithValues()` is still populated in the post-write events because the tracker resets
|
|
2737
|
+
Use `updated` (not `saved`) when you only want change diffs — `getDirtyColumnsWithValues()` is still populated in the post-write events because the tracker resets _after_ they fire.
|
|
2631
2738
|
|
|
2632
2739
|
### Cache invalidation
|
|
2633
2740
|
|
|
@@ -2674,11 +2781,11 @@ Every Cascade model carries a dirty tracker that records which fields you've cha
|
|
|
2674
2781
|
## The main reads
|
|
2675
2782
|
|
|
2676
2783
|
```ts
|
|
2677
|
-
user.hasChanges();
|
|
2678
|
-
user.isDirty("email");
|
|
2784
|
+
user.hasChanges(); // boolean — any field changed (or removed)?
|
|
2785
|
+
user.isDirty("email"); // boolean — specifically this column?
|
|
2679
2786
|
|
|
2680
|
-
user.getDirtyColumns();
|
|
2681
|
-
user.getRemovedColumns();
|
|
2787
|
+
user.getDirtyColumns(); // string[] — names of changed columns
|
|
2788
|
+
user.getRemovedColumns(); // string[] — columns explicitly unset since load
|
|
2682
2789
|
|
|
2683
2790
|
user.getDirtyColumnsWithValues();
|
|
2684
2791
|
// Record<string, { oldValue, newValue }> — the full diff, old + new per column
|
|
@@ -2723,9 +2830,9 @@ await AuditLog.create({
|
|
|
2723
2830
|
|
|
2724
2831
|
```ts
|
|
2725
2832
|
user.set("name", "Augusta");
|
|
2726
|
-
user.isDirty("name");
|
|
2833
|
+
user.isDirty("name"); // true
|
|
2727
2834
|
await user.save();
|
|
2728
|
-
user.isDirty("name");
|
|
2835
|
+
user.isDirty("name"); // false — tracker reset to the just-saved state
|
|
2729
2836
|
```
|
|
2730
2837
|
|
|
2731
2838
|
`save()` resets the tracker's baseline to the persisted state. Subsequent `isDirty()` checks measure changes since the last `save()`, not since the original load.
|
|
@@ -2767,12 +2874,12 @@ for (const [field, { oldValue, newValue }] of Object.entries(dirty)) {
|
|
|
2767
2874
|
|
|
2768
2875
|
```ts
|
|
2769
2876
|
// plain object → merged key-by-key, siblings survive
|
|
2770
|
-
user.merge({ profile: { age: 26 } });
|
|
2771
|
-
user.getDirtyColumns();
|
|
2877
|
+
user.merge({ profile: { age: 26 } }); // profile.city untouched
|
|
2878
|
+
user.getDirtyColumns(); // ["profile.age"]
|
|
2772
2879
|
|
|
2773
2880
|
// Date → replaced, not merged
|
|
2774
2881
|
conversation.merge({ lastOutboundAt: new Date() });
|
|
2775
|
-
conversation.getDirtyColumns();
|
|
2882
|
+
conversation.getDirtyColumns(); // ["lastOutboundAt"]
|
|
2776
2883
|
```
|
|
2777
2884
|
|
|
2778
2885
|
:::note[Fixed in 4.9.1]
|
|
@@ -2924,10 +3031,10 @@ Class-form builders include `createTable()` / `createTableIfNotExists()`, `dropT
|
|
|
2924
3031
|
`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:
|
|
2925
3032
|
|
|
2926
3033
|
| `migrationDefaults.primaryKey` | `foreignId` produces |
|
|
2927
|
-
|
|
|
2928
|
-
| `uuid`
|
|
2929
|
-
| `bigInt`
|
|
2930
|
-
| anything else (default)
|
|
3034
|
+
| ------------------------------ | -------------------- |
|
|
3035
|
+
| `uuid` | `uuid` column |
|
|
3036
|
+
| `bigInt` | `bigInteger` column |
|
|
3037
|
+
| anything else (default) | `integer` column |
|
|
2931
3038
|
|
|
2932
3039
|
```ts
|
|
2933
3040
|
import { Migration } from "@warlock.js/cascade";
|