@warlock.js/cascade 5.19.1 → 5.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/cjs/index.cjs +78 -7
- package/cjs/index.cjs.map +1 -1
- package/esm/context/database-transaction-context.d.mts +10 -0
- package/esm/context/database-transaction-context.d.mts.map +1 -1
- package/esm/context/database-transaction-context.mjs +17 -0
- package/esm/context/database-transaction-context.mjs.map +1 -1
- package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-driver.mjs +13 -5
- package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
- package/esm/drivers/postgres/postgres-driver.mjs +8 -3
- package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
- package/esm/index.d.mts +2 -1
- package/esm/index.mjs +2 -1
- package/esm/migration/migration-runner.mjs +1 -1
- package/esm/transactions/after-commit.d.mts +24 -0
- package/esm/transactions/after-commit.d.mts.map +1 -0
- package/esm/transactions/after-commit.mjs +47 -0
- package/esm/transactions/after-commit.mjs.map +1 -0
- package/llms-full.txt +2783 -2766
- package/llms.txt +15 -15
- package/package.json +6 -6
- package/skills/aggregate-data/SKILL.md +165 -165
- package/skills/alter-migration/SKILL.md +531 -531
- package/skills/configure-delete-strategy/SKILL.md +130 -130
- package/skills/define-model/SKILL.md +205 -205
- package/skills/define-relations/SKILL.md +171 -171
- package/skills/index.md +23 -0
- package/skills/manage-data-sources/SKILL.md +140 -140
- package/skills/manage-transactions/SKILL.md +208 -191
- package/skills/paginate-results/SKILL.md +122 -122
- package/skills/perform-atomic-ops/SKILL.md +156 -156
- package/skills/query-data/SKILL.md +213 -213
- package/skills/run-cascade-cli/SKILL.md +125 -125
- package/skills/search-by-vector/SKILL.md +127 -127
- package/skills/subscribe-to-model-events/SKILL.md +148 -148
- package/skills/track-changes/SKILL.md +127 -127
- package/skills/write-migration/SKILL.md +206 -206
|
@@ -1,171 +1,171 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: define-relations
|
|
3
|
-
description:
|
|
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
|
package/skills/index.md
ADDED
|
@@ -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: "
|
|
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
|