@warlock.js/cascade 5.19.1 → 5.21.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.
Files changed (194) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/cjs/index.cjs +2356 -1039
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/context/database-transaction-context.d.mts +10 -0
  5. package/esm/context/database-transaction-context.d.mts.map +1 -1
  6. package/esm/context/database-transaction-context.mjs +17 -0
  7. package/esm/context/database-transaction-context.mjs.map +1 -1
  8. package/esm/contracts/query-builder.contract.d.mts +30 -3
  9. package/esm/contracts/query-builder.contract.d.mts.map +1 -1
  10. package/esm/data-source/data-source-registry.d.mts +33 -1
  11. package/esm/data-source/data-source-registry.d.mts.map +1 -1
  12. package/esm/data-source/data-source-registry.mjs +75 -5
  13. package/esm/data-source/data-source-registry.mjs.map +1 -1
  14. package/esm/database-dirty-tracker.d.mts +5 -0
  15. package/esm/database-dirty-tracker.d.mts.map +1 -1
  16. package/esm/database-dirty-tracker.mjs +16 -0
  17. package/esm/database-dirty-tracker.mjs.map +1 -1
  18. package/esm/drivers/mongodb/mongodb-driver.d.mts +12 -0
  19. package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
  20. package/esm/drivers/mongodb/mongodb-driver.mjs +58 -19
  21. package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
  22. package/esm/drivers/mongodb/mongodb-migration-driver.d.mts +1 -1
  23. package/esm/drivers/mongodb/mongodb-migration-driver.d.mts.map +1 -1
  24. package/esm/drivers/mongodb/mongodb-migration-driver.mjs +15 -18
  25. package/esm/drivers/mongodb/mongodb-migration-driver.mjs.map +1 -1
  26. package/esm/drivers/mongodb/mongodb-query-builder.d.mts +13 -3
  27. package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
  28. package/esm/drivers/mongodb/mongodb-query-builder.mjs +72 -19
  29. package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
  30. package/esm/drivers/mongodb/mongodb-query-parser.d.mts +6 -0
  31. package/esm/drivers/mongodb/mongodb-query-parser.d.mts.map +1 -1
  32. package/esm/drivers/mongodb/mongodb-query-parser.mjs +128 -30
  33. package/esm/drivers/mongodb/mongodb-query-parser.mjs.map +1 -1
  34. package/esm/drivers/postgres/index.d.mts +2 -3
  35. package/esm/drivers/postgres/index.mjs +1 -2
  36. package/esm/drivers/postgres/postgres-blueprint.mjs +4 -4
  37. package/esm/drivers/postgres/postgres-blueprint.mjs.map +1 -1
  38. package/esm/drivers/postgres/postgres-dialect.d.mts +5 -1
  39. package/esm/drivers/postgres/postgres-dialect.d.mts.map +1 -1
  40. package/esm/drivers/postgres/postgres-dialect.mjs +38 -8
  41. package/esm/drivers/postgres/postgres-dialect.mjs.map +1 -1
  42. package/esm/drivers/postgres/postgres-driver.d.mts +52 -29
  43. package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
  44. package/esm/drivers/postgres/postgres-driver.mjs +196 -58
  45. package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
  46. package/esm/drivers/postgres/postgres-migration-driver.d.mts +2 -1
  47. package/esm/drivers/postgres/postgres-migration-driver.d.mts.map +1 -1
  48. package/esm/drivers/postgres/postgres-migration-driver.mjs +8 -6
  49. package/esm/drivers/postgres/postgres-migration-driver.mjs.map +1 -1
  50. package/esm/drivers/postgres/postgres-query-builder.d.mts +18 -7
  51. package/esm/drivers/postgres/postgres-query-builder.d.mts.map +1 -1
  52. package/esm/drivers/postgres/postgres-query-builder.mjs +146 -31
  53. package/esm/drivers/postgres/postgres-query-builder.mjs.map +1 -1
  54. package/esm/drivers/postgres/postgres-query-parser.d.mts +23 -1
  55. package/esm/drivers/postgres/postgres-query-parser.d.mts.map +1 -1
  56. package/esm/drivers/postgres/postgres-query-parser.mjs +160 -43
  57. package/esm/drivers/postgres/postgres-query-parser.mjs.map +1 -1
  58. package/esm/drivers/postgres/postgres-sql-serializer.mjs +3 -2
  59. package/esm/drivers/postgres/postgres-sql-serializer.mjs.map +1 -1
  60. package/esm/drivers/postgres/postgres-type-converters.mjs +57 -0
  61. package/esm/drivers/postgres/postgres-type-converters.mjs.map +1 -0
  62. package/esm/events/model-events.d.mts +1 -1
  63. package/esm/events/model-events.d.mts.map +1 -1
  64. package/esm/events/model-events.mjs.map +1 -1
  65. package/esm/index.d.mts +6 -6
  66. package/esm/index.mjs +5 -5
  67. package/esm/migration/column-builder.d.mts.map +1 -1
  68. package/esm/migration/column-builder.mjs +2 -2
  69. package/esm/migration/column-builder.mjs.map +1 -1
  70. package/esm/migration/index.d.mts +2 -2
  71. package/esm/migration/index.mjs +1 -1
  72. package/esm/migration/migration-order.mjs +5 -2
  73. package/esm/migration/migration-order.mjs.map +1 -1
  74. package/esm/migration/migration-runner.d.mts +50 -5
  75. package/esm/migration/migration-runner.d.mts.map +1 -1
  76. package/esm/migration/migration-runner.mjs +370 -147
  77. package/esm/migration/migration-runner.mjs.map +1 -1
  78. package/esm/migration/migration.d.mts +36 -3
  79. package/esm/migration/migration.d.mts.map +1 -1
  80. package/esm/migration/migration.mjs +62 -5
  81. package/esm/migration/migration.mjs.map +1 -1
  82. package/esm/model/methods/accessor-methods.mjs +1 -3
  83. package/esm/model/methods/accessor-methods.mjs.map +1 -1
  84. package/esm/model/methods/delete-methods.mjs +6 -2
  85. package/esm/model/methods/delete-methods.mjs.map +1 -1
  86. package/esm/model/methods/hydration-methods.mjs +16 -2
  87. package/esm/model/methods/hydration-methods.mjs.map +1 -1
  88. package/esm/model/methods/instance-event-methods.mjs +2 -1
  89. package/esm/model/methods/instance-event-methods.mjs.map +1 -1
  90. package/esm/model/methods/meta-methods.mjs +53 -16
  91. package/esm/model/methods/meta-methods.mjs.map +1 -1
  92. package/esm/model/methods/query-methods.mjs +86 -15
  93. package/esm/model/methods/query-methods.mjs.map +1 -1
  94. package/esm/model/methods/static-event-methods.mjs +16 -1
  95. package/esm/model/methods/static-event-methods.mjs.map +1 -1
  96. package/esm/model/methods/write-methods.d.mts.map +1 -1
  97. package/esm/model/methods/write-methods.mjs +50 -50
  98. package/esm/model/methods/write-methods.mjs.map +1 -1
  99. package/esm/model/model.d.mts +16 -1
  100. package/esm/model/model.d.mts.map +1 -1
  101. package/esm/model/model.mjs +39 -5
  102. package/esm/model/model.mjs.map +1 -1
  103. package/esm/model/register-model.d.mts +6 -1
  104. package/esm/model/register-model.d.mts.map +1 -1
  105. package/esm/model/register-model.mjs +16 -1
  106. package/esm/model/register-model.mjs.map +1 -1
  107. package/esm/query-builder/query-builder.d.mts +21 -2
  108. package/esm/query-builder/query-builder.d.mts.map +1 -1
  109. package/esm/query-builder/query-builder.mjs +97 -20
  110. package/esm/query-builder/query-builder.mjs.map +1 -1
  111. package/esm/relations/key-conventions.mjs +13 -3
  112. package/esm/relations/key-conventions.mjs.map +1 -1
  113. package/esm/relations/pivot-operations.mjs +1 -1
  114. package/esm/relations/relation-loader.d.mts +15 -5
  115. package/esm/relations/relation-loader.d.mts.map +1 -1
  116. package/esm/relations/relation-loader.mjs +51 -16
  117. package/esm/relations/relation-loader.mjs.map +1 -1
  118. package/esm/remover/database-remover.d.mts.map +1 -1
  119. package/esm/remover/database-remover.mjs +21 -7
  120. package/esm/remover/database-remover.mjs.map +1 -1
  121. package/esm/restorer/database-restorer.d.mts +12 -2
  122. package/esm/restorer/database-restorer.d.mts.map +1 -1
  123. package/esm/restorer/database-restorer.mjs +43 -13
  124. package/esm/restorer/database-restorer.mjs.map +1 -1
  125. package/esm/sync/model-events.mjs +63 -3
  126. package/esm/sync/model-events.mjs.map +1 -1
  127. package/esm/sync/model-sync-operation.d.mts.map +1 -1
  128. package/esm/sync/model-sync-operation.mjs +3 -2
  129. package/esm/sync/model-sync-operation.mjs.map +1 -1
  130. package/esm/sync/sync-manager.d.mts +3 -42
  131. package/esm/sync/sync-manager.d.mts.map +1 -1
  132. package/esm/sync/sync-manager.mjs +37 -126
  133. package/esm/sync/sync-manager.mjs.map +1 -1
  134. package/esm/transactions/after-commit.d.mts +24 -0
  135. package/esm/transactions/after-commit.d.mts.map +1 -0
  136. package/esm/transactions/after-commit.mjs +47 -0
  137. package/esm/transactions/after-commit.mjs.map +1 -0
  138. package/esm/utils/connect-to-database.d.mts.map +1 -1
  139. package/esm/utils/connect-to-database.mjs +4 -4
  140. package/esm/utils/connect-to-database.mjs.map +1 -1
  141. package/esm/utils/database-writer.utils.d.mts +1 -1
  142. package/esm/utils/database-writer.utils.d.mts.map +1 -1
  143. package/esm/utils/database-writer.utils.mjs +1 -0
  144. package/esm/utils/database-writer.utils.mjs.map +1 -1
  145. package/esm/utils/define-model.d.mts.map +1 -1
  146. package/esm/utils/define-model.mjs +2 -10
  147. package/esm/utils/define-model.mjs.map +1 -1
  148. package/esm/utils/is-valid-date-value.mjs +3 -2
  149. package/esm/utils/is-valid-date-value.mjs.map +1 -1
  150. package/esm/utils/once-connected.d.mts.map +1 -1
  151. package/esm/utils/once-connected.mjs +12 -2
  152. package/esm/utils/once-connected.mjs.map +1 -1
  153. package/esm/validation/mutators/embed-mutator.mjs +19 -11
  154. package/esm/validation/mutators/embed-mutator.mjs.map +1 -1
  155. package/esm/validation/plugins/embed-validator-plugin.d.mts +2 -2
  156. package/esm/validation/plugins/embed-validator-plugin.d.mts.map +1 -1
  157. package/esm/validation/plugins/embed-validator-plugin.mjs +8 -2
  158. package/esm/validation/plugins/embed-validator-plugin.mjs.map +1 -1
  159. package/esm/validation/rules/database-model-rule.mjs +6 -5
  160. package/esm/validation/rules/database-model-rule.mjs.map +1 -1
  161. package/esm/validation/rules/exists-rule.mjs +6 -4
  162. package/esm/validation/rules/exists-rule.mjs.map +1 -1
  163. package/esm/validation/rules/unique-rule.mjs +5 -2
  164. package/esm/validation/rules/unique-rule.mjs.map +1 -1
  165. package/esm/validation/validators/embed-validator.d.mts.map +1 -1
  166. package/esm/validation/validators/embed-validator.mjs +1 -1
  167. package/esm/validation/validators/embed-validator.mjs.map +1 -1
  168. package/esm/writer/database-writer.d.mts +27 -0
  169. package/esm/writer/database-writer.d.mts.map +1 -1
  170. package/esm/writer/database-writer.mjs +84 -17
  171. package/esm/writer/database-writer.mjs.map +1 -1
  172. package/llms-full.txt +2783 -2766
  173. package/llms.txt +15 -15
  174. package/package.json +6 -6
  175. package/skills/aggregate-data/SKILL.md +165 -165
  176. package/skills/alter-migration/SKILL.md +531 -531
  177. package/skills/configure-delete-strategy/SKILL.md +130 -130
  178. package/skills/define-model/SKILL.md +205 -205
  179. package/skills/define-relations/SKILL.md +171 -171
  180. package/skills/index.md +23 -0
  181. package/skills/manage-data-sources/SKILL.md +140 -140
  182. package/skills/manage-transactions/SKILL.md +208 -191
  183. package/skills/paginate-results/SKILL.md +122 -122
  184. package/skills/perform-atomic-ops/SKILL.md +156 -156
  185. package/skills/query-data/SKILL.md +213 -213
  186. package/skills/run-cascade-cli/SKILL.md +125 -125
  187. package/skills/search-by-vector/SKILL.md +127 -127
  188. package/skills/subscribe-to-model-events/SKILL.md +148 -148
  189. package/skills/track-changes/SKILL.md +127 -127
  190. package/skills/write-migration/SKILL.md +206 -206
  191. package/esm/drivers/postgres/postgres-sync-adapter.d.mts +0 -79
  192. package/esm/drivers/postgres/postgres-sync-adapter.d.mts.map +0 -1
  193. package/esm/drivers/postgres/postgres-sync-adapter.mjs +0 -162
  194. package/esm/drivers/postgres/postgres-sync-adapter.mjs.map +0 -1
@@ -1,171 +1,171 @@
1
- ---
2
- name: define-relations
3
- description: "Define and query relations in @warlock.js/cascade; use when you need to define relations."
4
- ---
5
-
6
- # Define and query relations
7
-
8
- Relations are decorators on model fields. Cascade's global registry (populated by `@RegisterModel()`) lets each relation look up its peer by name — so `@BelongsTo("User")` finds the `User` class without an import (which avoids circular-dep hell when two models reference each other).
9
-
10
- ## Three core relation types
11
-
12
- ### `@BelongsTo(target)` — the foreign-key side
13
-
14
- ```ts
15
- import { BelongsTo, Model, RegisterModel } from "@warlock.js/cascade";
16
-
17
- @RegisterModel()
18
- export class Post extends Model<PostSchema> {
19
- public static table = "posts";
20
- public static schema = postSchema;
21
-
22
- @BelongsTo("User", { foreignKey: "author_id" })
23
- public author!: User;
24
- }
25
- ```
26
-
27
- `Post.belongs_to(User)`. The `author_id` column lives on `posts`. Pass the model name (string) for late-bound lookup, or a `lazy(() => User)` thunk if you need explicit type checking. `lazy` comes from `@mongez/reinforcements` (`import { lazy } from "@mongez/reinforcements"`), not from cascade.
28
-
29
- ### `@HasMany(target)` — the inverse, one-to-many
30
-
31
- ```ts
32
- @RegisterModel()
33
- export class User extends Model<UserSchema> {
34
- public static table = "users";
35
- public static schema = userSchema;
36
-
37
- @HasMany("Post", { foreignKey: "author_id" })
38
- public posts!: Post[];
39
- }
40
- ```
41
-
42
- `User.has_many(Post)`. Same `author_id` column on `posts` — the relation is described from both ends so eager loading and filtering work in both directions.
43
-
44
- ### `@BelongsToMany(target, options)` — many-to-many via a pivot
45
-
46
- ```ts
47
- @RegisterModel()
48
- export class User extends Model<UserSchema> {
49
- // ...
50
-
51
- @BelongsToMany("Role", {
52
- pivot: "user_roles",
53
- localKey: "user_id", // pivot column → this model (User)
54
- foreignKey: "role_id", // pivot column → the related model (Role)
55
- })
56
- public roles!: Role[];
57
- }
58
- ```
59
-
60
- `User.belongs_to_many(Role)` via the `user_roles` pivot table with `user_id` + `role_id`. The pivot's two columns are `localKey` (this model's FK) and `foreignKey` (the related model's FK) — there is no `relatedKey` option. Both sides of the many-to-many declare the relation (symmetric).
61
-
62
- #### Pivot operations
63
-
64
- Manage pivot rows through `model.pivot(relation)`, which returns the relation's `PivotOperations` handle:
65
-
66
- ```ts
67
- await user.pivot("roles").attach([adminId, editorId]); // add (skips existing)
68
- await user.pivot("roles").attach([adminId], { addedBy: actorId }); // + extra pivot columns
69
- await user.pivot("roles").detach([editorId]); // remove a subset
70
- await user.pivot("roles").detach(); // remove all
71
- await user.pivot("roles").sync([adminId]); // replace the whole set
72
- await user.pivot("roles").toggle([adminId]); // flip each id
73
- ```
74
-
75
- `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.
76
-
77
- ## Eager loading — `.with(relation)`
78
-
79
- Load related models in the same query, avoiding N+1:
80
-
81
- ```ts
82
- const posts = await Post.with("author").get();
83
-
84
- for (const post of posts) {
85
- post.getRelation("author"); // already loaded — no second query
86
- }
87
- ```
88
-
89
- Multiple relations:
90
-
91
- ```ts
92
- await User.with("posts", "roles").get();
93
- ```
94
-
95
- Nested relations via dot:
96
-
97
- ```ts
98
- await User.with("posts.comments").get();
99
- // Loads users → their posts → comments for each post — all in 3 queries, not N²
100
- ```
101
-
102
- ## Filtering by related conditions — `.whereHas`
103
-
104
- Filter parents based on conditions on children:
105
-
106
- ```ts
107
- const usersWithPublishedPosts = await User
108
- .whereHas("posts", (query) => {
109
- query.where("status", "published");
110
- })
111
- .get();
112
- ```
113
-
114
- `.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."
115
-
116
- ## Setting a relation on save — `setRelation`
117
-
118
- When you persist a model that ALSO needs to wire a relation slot at the same time, use `setRelation` — NOT `.set()`:
119
-
120
- ```ts
121
- const post = new Post();
122
- post.merge({ title: "Hello", body: "..." });
123
- post.setRelation("author", currentUser);
124
- await post.save();
125
- ```
126
-
127
- Why not `post.set("author", currentUser)`? `set` treats the value as a column write — for a relation slot, you'd be writing the User instance into a column. `setRelation` knows it's a relation, picks the right foreign-key column, and stores the ID.
128
-
129
- ## Reading loaded relations
130
-
131
- ```ts
132
- const post = await Post.with("author").first();
133
-
134
- post.getRelation("author"); // typed access to the loaded User
135
- post.author; // direct field — only if you declared it as a typed field
136
-
137
- // Without eager loading — load on demand, then read:
138
- await post.load("author");
139
- const author = post.getRelation("author");
140
- ```
141
-
142
- `getRelation("name")` returns the loaded relation (or null if not loaded). `post.load("relation", ...)` lazy-loads one or more relations onto the instance and returns the model (`this`) — read the value back with `getRelation` afterward. Useful when you didn't `.with()` upfront and need a relation conditionally.
143
-
144
- ## Joins — when eager loading isn't enough
145
-
146
- `.with()` runs separate queries and hydrates relations. `.joinWith()` joins at the SQL/aggregation level:
147
-
148
- ```ts
149
- const result = await User
150
- .joinWith("posts")
151
- .where("posts.status", "published")
152
- .get();
153
- ```
154
-
155
- Use `.joinWith` when you need conditions across both tables in a single query. Use `.with` when you want hydrated relation models without joining.
156
-
157
- ## Sync — embedded relations stay fresh
158
-
159
- For embedded/denormalized relations (cached `author_name` on posts, etc.), Cascade's sync system keeps the embedded copy in step with the source-of-truth via `Model.sync(Target, field)` and the `@warlock.js/cascade` sync helpers.
160
-
161
- ## Things NOT to do
162
-
163
- - Don't `post.set("author", user)`. Use `setRelation("author", user)`. `set` treats the value as a column write; `setRelation` picks the right foreign key.
164
- - Don't load all parents and iterate to fetch each child — that's the N+1 problem. Eager-load with `.with(...)` (or load specific children with `.load(...)` after the parent fetch).
165
- - Don't define a relation on only one side. Both ends of `BelongsTo`/`HasMany` and both sides of `BelongsToMany` need the decorator for queries to work bidirectionally.
166
- - Don't import the related class for `@BelongsTo` if it would create a circular dependency. Use the string form (`@BelongsTo("User")`) or `lazy(() => User)`.
167
-
168
- ## See also
169
-
170
- - [`@warlock.js/cascade/query-data/SKILL.md`](@warlock.js/cascade/query-data/SKILL.md) — `.whereHas` and other filters
171
- - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — `@RegisterModel`, the registry that makes name-based lookup work
1
+ ---
2
+ name: define-relations
3
+ description: 'Define and query relations — `@BelongsTo` / `@HasMany` / `@BelongsToMany`, `.with("relation")` eager loading, `.whereHas(relation, cb)` filter-by-related, `setRelation` on save, `.joinWith` for SQL joins, `loadRelation`, `lazy(() => Model)`. Triggers: `@BelongsTo`, `@HasMany`, `@BelongsToMany`, `.with`, `.whereHas`, `setRelation`, `.joinWith`, `lazy`; "define a relation", "avoid N+1", "eager load posts", "filter parents by child"; typical import `import { BelongsTo, HasMany, BelongsToMany } from "@warlock.js/cascade"`. Skip: model basics — `@warlock.js/cascade/define-model/SKILL.md`; competing libs `mongoose populate`, `prisma include`, `typeorm relations`.'
4
+ ---
5
+
6
+ # Define and query relations
7
+
8
+ Relations are decorators on model fields. Cascade's global registry (populated by `@RegisterModel()`) lets each relation look up its peer by name — so `@BelongsTo("User")` finds the `User` class without an import (which avoids circular-dep hell when two models reference each other).
9
+
10
+ ## Three core relation types
11
+
12
+ ### `@BelongsTo(target)` — the foreign-key side
13
+
14
+ ```ts
15
+ import { BelongsTo, Model, RegisterModel } from "@warlock.js/cascade";
16
+
17
+ @RegisterModel()
18
+ export class Post extends Model<PostSchema> {
19
+ public static table = "posts";
20
+ public static schema = postSchema;
21
+
22
+ @BelongsTo("User", { foreignKey: "author_id" })
23
+ public author!: User;
24
+ }
25
+ ```
26
+
27
+ `Post.belongs_to(User)`. The `author_id` column lives on `posts`. Pass the model name (string) for late-bound lookup, or a `lazy(() => User)` thunk if you need explicit type checking. `lazy` comes from `@mongez/reinforcements` (`import { lazy } from "@mongez/reinforcements"`), not from cascade.
28
+
29
+ ### `@HasMany(target)` — the inverse, one-to-many
30
+
31
+ ```ts
32
+ @RegisterModel()
33
+ export class User extends Model<UserSchema> {
34
+ public static table = "users";
35
+ public static schema = userSchema;
36
+
37
+ @HasMany("Post", { foreignKey: "author_id" })
38
+ public posts!: Post[];
39
+ }
40
+ ```
41
+
42
+ `User.has_many(Post)`. Same `author_id` column on `posts` — the relation is described from both ends so eager loading and filtering work in both directions.
43
+
44
+ ### `@BelongsToMany(target, options)` — many-to-many via a pivot
45
+
46
+ ```ts
47
+ @RegisterModel()
48
+ export class User extends Model<UserSchema> {
49
+ // ...
50
+
51
+ @BelongsToMany("Role", {
52
+ pivot: "user_roles",
53
+ localKey: "user_id", // pivot column → this model (User)
54
+ foreignKey: "role_id", // pivot column → the related model (Role)
55
+ })
56
+ public roles!: Role[];
57
+ }
58
+ ```
59
+
60
+ `User.belongs_to_many(Role)` via the `user_roles` pivot table with `user_id` + `role_id`. The pivot's two columns are `localKey` (this model's FK) and `foreignKey` (the related model's FK) — there is no `relatedKey` option. Both sides of the many-to-many declare the relation (symmetric).
61
+
62
+ #### Pivot operations
63
+
64
+ Manage pivot rows through `model.pivot(relation)`, which returns the relation's `PivotOperations` handle:
65
+
66
+ ```ts
67
+ await user.pivot("roles").attach([adminId, editorId]); // add (skips existing)
68
+ await user.pivot("roles").attach([adminId], { addedBy: actorId }); // + extra pivot columns
69
+ await user.pivot("roles").detach([editorId]); // remove a subset
70
+ await user.pivot("roles").detach(); // remove all
71
+ await user.pivot("roles").sync([adminId]); // replace the whole set
72
+ await user.pivot("roles").toggle([adminId]); // flip each id
73
+ ```
74
+
75
+ `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.
76
+
77
+ ## Eager loading — `.with(relation)`
78
+
79
+ Load related models in the same query, avoiding N+1:
80
+
81
+ ```ts
82
+ const posts = await Post.with("author").get();
83
+
84
+ for (const post of posts) {
85
+ post.getRelation("author"); // already loaded — no second query
86
+ }
87
+ ```
88
+
89
+ Multiple relations:
90
+
91
+ ```ts
92
+ await User.with("posts", "roles").get();
93
+ ```
94
+
95
+ Nested relations via dot:
96
+
97
+ ```ts
98
+ await User.with("posts.comments").get();
99
+ // Loads users → their posts → comments for each post — all in 3 queries, not N²
100
+ ```
101
+
102
+ ## Filtering by related conditions — `.whereHas`
103
+
104
+ Filter parents based on conditions on children:
105
+
106
+ ```ts
107
+ const usersWithPublishedPosts = await User
108
+ .whereHas("posts", (query) => {
109
+ query.where("status", "published");
110
+ })
111
+ .get();
112
+ ```
113
+
114
+ `.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."
115
+
116
+ ## Setting a relation on save — `setRelation`
117
+
118
+ When you persist a model that ALSO needs to wire a relation slot at the same time, use `setRelation` — NOT `.set()`:
119
+
120
+ ```ts
121
+ const post = new Post();
122
+ post.merge({ title: "Hello", body: "..." });
123
+ post.setRelation("author", currentUser);
124
+ await post.save();
125
+ ```
126
+
127
+ Why not `post.set("author", currentUser)`? `set` treats the value as a column write — for a relation slot, you'd be writing the User instance into a column. `setRelation` knows it's a relation, picks the right foreign-key column, and stores the ID.
128
+
129
+ ## Reading loaded relations
130
+
131
+ ```ts
132
+ const post = await Post.with("author").first();
133
+
134
+ post.getRelation("author"); // typed access to the loaded User
135
+ post.author; // direct field — only if you declared it as a typed field
136
+
137
+ // Without eager loading — load on demand, then read:
138
+ await post.load("author");
139
+ const author = post.getRelation("author");
140
+ ```
141
+
142
+ `getRelation("name")` returns the loaded relation (or null if not loaded). `post.load("relation", ...)` lazy-loads one or more relations onto the instance and returns the model (`this`) — read the value back with `getRelation` afterward. Useful when you didn't `.with()` upfront and need a relation conditionally.
143
+
144
+ ## Joins — when eager loading isn't enough
145
+
146
+ `.with()` runs separate queries and hydrates relations. `.joinWith()` joins at the SQL/aggregation level:
147
+
148
+ ```ts
149
+ const result = await User
150
+ .joinWith("posts")
151
+ .where("posts.status", "published")
152
+ .get();
153
+ ```
154
+
155
+ Use `.joinWith` when you need conditions across both tables in a single query. Use `.with` when you want hydrated relation models without joining.
156
+
157
+ ## Sync — embedded relations stay fresh
158
+
159
+ For embedded/denormalized relations (cached `author_name` on posts, etc.), Cascade's sync system keeps the embedded copy in step with the source-of-truth via `Model.sync(Target, field)` and the `@warlock.js/cascade` sync helpers.
160
+
161
+ ## Things NOT to do
162
+
163
+ - Don't `post.set("author", user)`. Use `setRelation("author", user)`. `set` treats the value as a column write; `setRelation` picks the right foreign key.
164
+ - Don't load all parents and iterate to fetch each child — that's the N+1 problem. Eager-load with `.with(...)` (or load specific children with `.load(...)` after the parent fetch).
165
+ - Don't define a relation on only one side. Both ends of `BelongsTo`/`HasMany` and both sides of `BelongsToMany` need the decorator for queries to work bidirectionally.
166
+ - Don't import the related class for `@BelongsTo` if it would create a circular dependency. Use the string form (`@BelongsTo("User")`) or `lazy(() => User)`.
167
+
168
+ ## See also
169
+
170
+ - [`@warlock.js/cascade/query-data/SKILL.md`](@warlock.js/cascade/query-data/SKILL.md) — `.whereHas` and other filters
171
+ - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — `@RegisterModel`, the registry that makes name-based lookup work
@@ -0,0 +1,23 @@
1
+ ---
2
+ description: "Model-first ORM for MongoDB and Postgres. Exports `Model`, `RegisterModel`, `Migration`, `defineModel`, `BelongsTo`, `HasMany`, `transaction`, `DataSource`, `dataSourceRegistry`, `connectToDatabase`, `runMigrations`. Use for: define a model, add a column or collection, write or alter a migration, query or filter records, paginate, aggregate or group, add a relation, run a transaction, soft delete, track changes, react to save or delete events, vector search. Not this package: request validation and schemas → @warlock.js/seal; routes, controllers, repositories → @warlock.js/core."
3
+ ---
4
+ # @warlock.js/cascade
5
+
6
+ Cascade is the data layer. The model is the query entry point (`User.find(id)`, `User.paginate(...)`); one seal schema gives the TypeScript type, the save-time validation, and the shape migrations write against. MongoDB and Postgres drivers ship with the same query API. Server-only.
7
+
8
+ ## The 80% path
9
+ 1. Read `cascade-basics.md` first: it is the map and the ten foundations.
10
+ 2. Define the model and register it (`define-model.md`), then relations (`define-relations.md`).
11
+ 3. Write the migration; models never create tables or collections on their own (`write-migration.md`, `alter-migration.md`, run with `run-cascade-cli.md`).
12
+ 4. Query, paginate, aggregate (`query-data.md`, `paginate-results.md`, `aggregate-data.md`, `perform-atomic-ops.md`).
13
+ 5. Wrap multi-step writes in a transaction (`manage-transactions.md`); pick a delete strategy (`configure-delete-strategy.md`).
14
+ 6. Connect and manage drivers (`manage-data-sources.md`); hook lifecycle and diffs (`subscribe-to-model-events.md`, `track-changes.md`); embeddings via `search-by-vector.md`.
15
+
16
+ ## Conventions and pitfalls
17
+ - `pg` and `mongodb` are optional peers; install only the driver you use.
18
+ - `.create()` validates against the schema, applies defaults, and throws on failure.
19
+ - Pick an update idiom by shape: `.set(k, v).save()` for one or two fields, `.merge(data).save()` for payloads.
20
+ - `.destroy()` follows the configured strategy (permanent, soft, or trash), not always a hard delete.
21
+ - Relations refer to models by registered name, so `@RegisterModel()` must run first.
22
+ - Run code needing a live connection through `onceConnected`, not at import time.
23
+ - Undefined `where` values and unsafe filters throw on purpose; sanitize user-supplied filters.
@@ -1,140 +1,140 @@
1
- ---
2
- name: manage-data-sources
3
- description: "Manage data sources in @warlock.js/cascade; use when you need to manage data sources."
4
- ---
5
-
6
- # Manage data sources
7
-
8
- Most apps run against a single database, and Cascade's defaults assume that. When you need multiple — analytics on a separate DB, per-tenant DBs — register each one and bind models to the right source.
9
-
10
- ## Register sources at boot
11
-
12
- `connectToDatabase()` builds the driver, registers the data source, and connects. Call it once per database:
13
-
14
- ```ts
15
- import { connectToDatabase } from "@warlock.js/cascade";
16
-
17
- await connectToDatabase({
18
- name: "primary",
19
- driver: "postgres",
20
- database: "app",
21
- host: "localhost",
22
- port: 5432,
23
- username: "app",
24
- password: "secret",
25
- isDefault: true, // the default source for models that don't pin one
26
- });
27
-
28
- await connectToDatabase({
29
- name: "analytics",
30
- driver: "postgres",
31
- database: "analytics",
32
- host: "analytics-host",
33
- });
34
-
35
- await connectToDatabase({
36
- name: "logs",
37
- driver: "mongodb",
38
- database: "logs",
39
- uri: "mongodb://localhost:27017",
40
- });
41
- ```
42
-
43
- The first source registered becomes the default unless another passes `isDefault: true`. (Lower-level: `dataSourceRegistry.register({ name, driver, isDefault })` if you build the driver yourself — `register` takes a `DataSourceOptions` object whose `driver` is a `DriverContract` instance, and constructs the `DataSource` for you.)
44
-
45
- ## Assign a model to a source
46
-
47
- ```ts
48
- @RegisterModel()
49
- export class AnalyticsEvent extends Model<AnalyticsEventSchema> {
50
- public static table = "analytics_events";
51
- public static schema = analyticsEventSchema;
52
- public static dataSource = "analytics"; // ← goes to the analytics DB
53
- }
54
-
55
- @RegisterModel()
56
- export class LogEntry extends Model<LogEntrySchema> {
57
- public static table = "logs";
58
- public static schema = logEntrySchema;
59
- public static dataSource = "logs"; // ← goes to MongoDB
60
- }
61
- ```
62
-
63
- `static dataSource` takes the registered name (or a `DataSource` instance). Models without it use the default. Same query API; different storage. There is no per-query `Model.using(name)` override — the binding lives on the class.
64
-
65
- ## Migrations per data source
66
-
67
- A migration class can pin its target source:
68
-
69
- ```ts
70
- import { Migration } from "@warlock.js/cascade";
71
-
72
- export default class CreateEventsTable extends Migration {
73
- public readonly table = "analytics_events";
74
- public readonly dataSource = "analytics";
75
-
76
- public up(): void {
77
- this.createTable();
78
- this.id();
79
- this.string("type");
80
- this.timestamps();
81
- }
82
-
83
- public down(): void {
84
- this.dropTable();
85
- }
86
- }
87
- ```
88
-
89
- See [`@warlock.js/cascade/write-migration/SKILL.md`](@warlock.js/cascade/write-migration/SKILL.md) for the migration shape.
90
-
91
- ## Transactions and data sources
92
-
93
- The top-level `transaction(fn)` helper runs on the **default** source's driver — it does not take a source name. To transact against a non-default source, reach for that source's driver directly (`dataSourceRegistry.get("analytics").driver.transaction(...)`). Transactions can't span two sources; coordinate at the application level (saga / outbox) if you need that.
94
-
95
- ```ts
96
- await transaction(async () => {
97
- // runs on the default source
98
- await Order.create({ ... });
99
- await OrderItem.createMany(items);
100
- });
101
- ```
102
-
103
- ## Multi-tenant per-tenant database
104
-
105
- For strict tenant isolation, register a source per tenant once, then bind at the model layer (or resolve the model class per tenant):
106
-
107
- ```ts
108
- async function ensureTenantSource(tenantId: string) {
109
- try {
110
- dataSourceRegistry.get(tenantId); // throws if not registered
111
- } catch {
112
- const config = await loadTenantConfig(tenantId);
113
- await connectToDatabase({ name: tenantId, ...config, isDefault: false });
114
- }
115
- }
116
- ```
117
-
118
- Register each tenant source once and reuse it — re-registering the same name re-creates the source.
119
-
120
- ## Inspection
121
-
122
- ```ts
123
- dataSourceRegistry.get("analytics"); // the DataSource instance (throws if missing)
124
- dataSourceRegistry.get(); // the default DataSource
125
- dataSourceRegistry.getAllDataSources(); // DataSource[] — every registered source
126
- ```
127
-
128
- There is no `has(name)` / `list()` / `setDefault()` — guard with a `try/catch` around `get(name)`, iterate `getAllDataSources()`, and set the default via `isDefault` at registration.
129
-
130
- ## Things NOT to do
131
-
132
- - Don't call `dataSourceRegistry.register("name", config)` — `register` takes a single `DataSourceOptions` object (`{ name, driver, isDefault }`, `driver` being a built `DriverContract`); for the common case use `connectToDatabase({ name, ... })`.
133
- - Don't reach for `Model.using(name)` / `setDefault` / `has` / `list` — they don't exist. Bind via `static dataSource`, inspect via `get` / `getAllDataSources`.
134
- - Don't span a transaction across two data sources. Use a saga / outbox pattern.
135
- - Don't write to a read replica. Most replicas reject writes; the data is overwritten on the next replication.
136
-
137
- ## See also
138
-
139
- - [`@warlock.js/cascade/write-migration/SKILL.md`](@warlock.js/cascade/write-migration/SKILL.md) — per-source migrations
140
- - [`@warlock.js/cascade/manage-transactions/SKILL.md`](@warlock.js/cascade/manage-transactions/SKILL.md) — transactions run on the default source
1
+ ---
2
+ name: manage-data-sources
3
+ description: 'Configure multiple databases — register each via `connectToDatabase({ name, driver, database, isDefault })`, assign a model with `static dataSource = "name"`, route a migration with `dataSource` on the migration class, inspect via `dataSourceRegistry.get(name)` / `getAllDataSources()`. The first (or `isDefault: true`) source is the default. Triggers: `connectToDatabase`, `dataSourceRegistry`, `dataSourceRegistry.get`, `getAllDataSources`, `static dataSource`; "multi-database app", "per-tenant DB", "analytics on separate DB"; typical import `import { connectToDatabase, dataSourceRegistry } from "@warlock.js/cascade"`. Skip: per-source migrations — `@warlock.js/cascade/write-migration/SKILL.md`; transaction scope — `@warlock.js/cascade/manage-transactions/SKILL.md`; competing patterns `mongoose.createConnection`, `typeorm` `DataSource`, `prisma` multi-schema.'
4
+ ---
5
+
6
+ # Manage data sources
7
+
8
+ Most apps run against a single database, and Cascade's defaults assume that. When you need multiple — analytics on a separate DB, per-tenant DBs — register each one and bind models to the right source.
9
+
10
+ ## Register sources at boot
11
+
12
+ `connectToDatabase()` builds the driver, registers the data source, and connects. Call it once per database:
13
+
14
+ ```ts
15
+ import { connectToDatabase } from "@warlock.js/cascade";
16
+
17
+ await connectToDatabase({
18
+ name: "primary",
19
+ driver: "postgres",
20
+ database: "app",
21
+ host: "localhost",
22
+ port: 5432,
23
+ username: "app",
24
+ password: "secret",
25
+ isDefault: true, // the default source for models that don't pin one
26
+ });
27
+
28
+ await connectToDatabase({
29
+ name: "analytics",
30
+ driver: "postgres",
31
+ database: "analytics",
32
+ host: "analytics-host",
33
+ });
34
+
35
+ await connectToDatabase({
36
+ name: "logs",
37
+ driver: "mongodb",
38
+ database: "logs",
39
+ uri: "mongodb://localhost:27017",
40
+ });
41
+ ```
42
+
43
+ The first source registered becomes the default unless another passes `isDefault: true`. (Lower-level: `dataSourceRegistry.register({ name, driver, isDefault })` if you build the driver yourself — `register` takes a `DataSourceOptions` object whose `driver` is a `DriverContract` instance, and constructs the `DataSource` for you.)
44
+
45
+ ## Assign a model to a source
46
+
47
+ ```ts
48
+ @RegisterModel()
49
+ export class AnalyticsEvent extends Model<AnalyticsEventSchema> {
50
+ public static table = "analytics_events";
51
+ public static schema = analyticsEventSchema;
52
+ public static dataSource = "analytics"; // ← goes to the analytics DB
53
+ }
54
+
55
+ @RegisterModel()
56
+ export class LogEntry extends Model<LogEntrySchema> {
57
+ public static table = "logs";
58
+ public static schema = logEntrySchema;
59
+ public static dataSource = "logs"; // ← goes to MongoDB
60
+ }
61
+ ```
62
+
63
+ `static dataSource` takes the registered name (or a `DataSource` instance). Models without it use the default. Same query API; different storage. There is no per-query `Model.using(name)` override — the binding lives on the class.
64
+
65
+ ## Migrations per data source
66
+
67
+ A migration class can pin its target source:
68
+
69
+ ```ts
70
+ import { Migration } from "@warlock.js/cascade";
71
+
72
+ export default class CreateEventsTable extends Migration {
73
+ public readonly table = "analytics_events";
74
+ public readonly dataSource = "analytics";
75
+
76
+ public up(): void {
77
+ this.createTable();
78
+ this.id();
79
+ this.string("type");
80
+ this.timestamps();
81
+ }
82
+
83
+ public down(): void {
84
+ this.dropTable();
85
+ }
86
+ }
87
+ ```
88
+
89
+ See [`@warlock.js/cascade/write-migration/SKILL.md`](@warlock.js/cascade/write-migration/SKILL.md) for the migration shape.
90
+
91
+ ## Transactions and data sources
92
+
93
+ The top-level `transaction(fn)` helper runs on the **default** source's driver — it does not take a source name. To transact against a non-default source, reach for that source's driver directly (`dataSourceRegistry.get("analytics").driver.transaction(...)`). Transactions can't span two sources; coordinate at the application level (saga / outbox) if you need that.
94
+
95
+ ```ts
96
+ await transaction(async () => {
97
+ // runs on the default source
98
+ await Order.create({ ... });
99
+ await OrderItem.createMany(items);
100
+ });
101
+ ```
102
+
103
+ ## Multi-tenant per-tenant database
104
+
105
+ For strict tenant isolation, register a source per tenant once, then bind at the model layer (or resolve the model class per tenant):
106
+
107
+ ```ts
108
+ async function ensureTenantSource(tenantId: string) {
109
+ try {
110
+ dataSourceRegistry.get(tenantId); // throws if not registered
111
+ } catch {
112
+ const config = await loadTenantConfig(tenantId);
113
+ await connectToDatabase({ name: tenantId, ...config, isDefault: false });
114
+ }
115
+ }
116
+ ```
117
+
118
+ Register each tenant source once and reuse it — re-registering the same name re-creates the source.
119
+
120
+ ## Inspection
121
+
122
+ ```ts
123
+ dataSourceRegistry.get("analytics"); // the DataSource instance (throws if missing)
124
+ dataSourceRegistry.get(); // the default DataSource
125
+ dataSourceRegistry.getAllDataSources(); // DataSource[] — every registered source
126
+ ```
127
+
128
+ There is no `has(name)` / `list()` / `setDefault()` — guard with a `try/catch` around `get(name)`, iterate `getAllDataSources()`, and set the default via `isDefault` at registration.
129
+
130
+ ## Things NOT to do
131
+
132
+ - Don't call `dataSourceRegistry.register("name", config)` — `register` takes a single `DataSourceOptions` object (`{ name, driver, isDefault }`, `driver` being a built `DriverContract`); for the common case use `connectToDatabase({ name, ... })`.
133
+ - Don't reach for `Model.using(name)` / `setDefault` / `has` / `list` — they don't exist. Bind via `static dataSource`, inspect via `get` / `getAllDataSources`.
134
+ - Don't span a transaction across two data sources. Use a saga / outbox pattern.
135
+ - Don't write to a read replica. Most replicas reject writes; the data is overwritten on the next replication.
136
+
137
+ ## See also
138
+
139
+ - [`@warlock.js/cascade/write-migration/SKILL.md`](@warlock.js/cascade/write-migration/SKILL.md) — per-source migrations
140
+ - [`@warlock.js/cascade/manage-transactions/SKILL.md`](@warlock.js/cascade/manage-transactions/SKILL.md) — transactions run on the default source