@shaferllc/keel 0.80.0 → 0.81.1
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/dist/accounts/accounts.config.stub +50 -0
- package/dist/accounts/config.d.ts +46 -0
- package/dist/accounts/config.js +39 -0
- package/dist/accounts/flows.d.ts +50 -0
- package/dist/accounts/flows.js +133 -0
- package/dist/accounts/index.d.ts +28 -0
- package/dist/accounts/index.js +23 -0
- package/dist/accounts/migration.d.ts +14 -0
- package/dist/accounts/migration.js +39 -0
- package/dist/accounts/provider.d.ts +18 -0
- package/dist/accounts/provider.js +37 -0
- package/dist/accounts/routes.d.ts +15 -0
- package/dist/accounts/routes.js +116 -0
- package/dist/accounts/store.d.ts +33 -0
- package/dist/accounts/store.js +37 -0
- package/dist/accounts/tokens.d.ts +60 -0
- package/dist/accounts/tokens.js +116 -0
- package/dist/accounts/totp.d.ts +58 -0
- package/dist/accounts/totp.js +134 -0
- package/dist/accounts/two-factor.d.ts +56 -0
- package/dist/accounts/two-factor.js +146 -0
- package/dist/core/database.d.ts +36 -0
- package/dist/core/database.js +141 -4
- package/dist/core/index.d.ts +5 -2
- package/dist/core/index.js +3 -2
- package/dist/core/migrations.d.ts +52 -2
- package/dist/core/migrations.js +134 -3
- package/dist/core/model-events.d.ts +34 -0
- package/dist/core/model-events.js +89 -0
- package/dist/core/model-query.d.ts +68 -0
- package/dist/core/model-query.js +234 -0
- package/dist/core/model.d.ts +109 -4
- package/dist/core/model.js +263 -32
- package/dist/core/relations.d.ts +53 -0
- package/dist/core/relations.js +242 -0
- package/dist/teams/config.d.ts +27 -0
- package/dist/teams/config.js +23 -0
- package/dist/teams/context.d.ts +54 -0
- package/dist/teams/context.js +73 -0
- package/dist/teams/index.d.ts +25 -0
- package/dist/teams/index.js +20 -0
- package/dist/teams/invitations.d.ts +38 -0
- package/dist/teams/invitations.js +123 -0
- package/dist/teams/middleware.d.ts +30 -0
- package/dist/teams/middleware.js +92 -0
- package/dist/teams/migration.d.ts +9 -0
- package/dist/teams/migration.js +52 -0
- package/dist/teams/models.d.ts +54 -0
- package/dist/teams/models.js +85 -0
- package/dist/teams/provider.d.ts +17 -0
- package/dist/teams/provider.js +27 -0
- package/dist/teams/teams.config.stub +24 -0
- package/dist/teams/tenant.d.ts +25 -0
- package/dist/teams/tenant.js +45 -0
- package/docs/accounts.md +214 -0
- package/docs/ai-manifest.json +70 -1
- package/docs/database.md +80 -0
- package/docs/examples/accounts.ts +150 -0
- package/docs/examples/teams.ts +101 -0
- package/docs/migrations.md +86 -6
- package/docs/models.md +279 -6
- package/docs/teams.md +176 -0
- package/llms-full.txt +849 -12
- package/llms.txt +4 -0
- package/package.json +10 -2
package/docs/migrations.md
CHANGED
|
@@ -73,12 +73,41 @@ t.boolean("active").default(true); // sqlite: DEFAULT 1, else DEFAULT true
|
|
|
73
73
|
t.integer("retries").default(0); // ... DEFAULT 0
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
### Indexes and foreign keys
|
|
77
|
+
|
|
78
|
+
`createTable` builds indexes and foreign keys alongside the columns:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
schema.createTable("members", (t) => {
|
|
82
|
+
t.id();
|
|
83
|
+
t.integer("team_id");
|
|
84
|
+
t.string("email");
|
|
85
|
+
t.uniqueIndex("email"); // or t.index(["a", "b"]) for composite
|
|
86
|
+
t.foreign("team_id").references("id").on("teams").onDelete("cascade");
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Altering a table
|
|
91
|
+
|
|
92
|
+
`schema.alterTable(name, build)` adds, renames, and drops columns and indexes on
|
|
93
|
+
an existing table (dialect-aware SQL). Drop an index before the column it covers:
|
|
78
94
|
|
|
79
95
|
```ts
|
|
80
96
|
up: (schema) =>
|
|
81
|
-
schema.
|
|
97
|
+
schema.alterTable("users", (t) => {
|
|
98
|
+
t.string("phone").nullable(); // ADD COLUMN
|
|
99
|
+
t.renameColumn("name", "full_name");
|
|
100
|
+
t.index("phone");
|
|
101
|
+
t.dropIndex("users_legacy_index");
|
|
102
|
+
t.dropColumn("legacy");
|
|
103
|
+
}),
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
For anything the builder still doesn't cover, `schema.raw(sql, bindings?)` runs
|
|
107
|
+
arbitrary SQL:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
up: (schema) => schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)"),
|
|
82
111
|
```
|
|
83
112
|
|
|
84
113
|
> `raw()` writes through the connection **without** placeholder conversion, so
|
|
@@ -256,11 +285,10 @@ already gone — the typical `down()` for a `createTable`.
|
|
|
256
285
|
|
|
257
286
|
`raw(sql: string, bindings?: unknown[]): Promise<void>`
|
|
258
287
|
|
|
259
|
-
Runs arbitrary SQL through the connection — the escape hatch for
|
|
260
|
-
|
|
288
|
+
Runs arbitrary SQL through the connection — the escape hatch for anything the
|
|
289
|
+
builders don't cover.
|
|
261
290
|
|
|
262
291
|
```ts
|
|
263
|
-
await schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)");
|
|
264
292
|
await schema.raw("UPDATE users SET active = ? WHERE active IS NULL", [true]);
|
|
265
293
|
```
|
|
266
294
|
|
|
@@ -268,6 +296,22 @@ await schema.raw("UPDATE users SET active = ? WHERE active IS NULL", [true]);
|
|
|
268
296
|
`raw()` does **not** rewrite `?` to `$n`, so pass `$1, $2, …` yourself on the
|
|
269
297
|
`postgres` dialect.
|
|
270
298
|
|
|
299
|
+
#### `alterTable(name, build)`
|
|
300
|
+
|
|
301
|
+
`alterTable(name: string, build: (table: AlterTableBuilder) => void): Promise<void>`
|
|
302
|
+
|
|
303
|
+
Alter an existing table — the callback gets an [`AlterTableBuilder`](#altertablebuilder)
|
|
304
|
+
for adding, renaming, and dropping columns and indexes. Emits one dialect-aware
|
|
305
|
+
statement per operation, ordered so a dropped index precedes its column.
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
await schema.alterTable("users", (t) => {
|
|
309
|
+
t.string("phone").nullable();
|
|
310
|
+
t.renameColumn("name", "full_name");
|
|
311
|
+
t.dropColumn("legacy");
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
271
315
|
### `TableBuilder`
|
|
272
316
|
|
|
273
317
|
Describes a table's columns. **You get one from the `createTable` callback** — it
|
|
@@ -384,6 +428,27 @@ t.toCreateSql("users", "postgres");
|
|
|
384
428
|
// CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL)
|
|
385
429
|
```
|
|
386
430
|
|
|
431
|
+
#### `index(columns, name?)` / `uniqueIndex(columns, name?)`
|
|
432
|
+
|
|
433
|
+
Add a (possibly composite) index or unique index; `columns` is a name or array.
|
|
434
|
+
Emitted as `CREATE [UNIQUE] INDEX` after the table. Auto-named unless `name` is
|
|
435
|
+
given.
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
t.index("email");
|
|
439
|
+
t.uniqueIndex(["team_id", "slug"]);
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
#### `foreign(column)`
|
|
443
|
+
|
|
444
|
+
`foreign(column: string): ForeignKeyBuilder`
|
|
445
|
+
|
|
446
|
+
Add a foreign key, built fluently and emitted inline in the `CREATE TABLE`.
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
t.foreign("team_id").references("id").on("teams").onDelete("cascade");
|
|
450
|
+
```
|
|
451
|
+
|
|
387
452
|
#### `columns`
|
|
388
453
|
|
|
389
454
|
`readonly columns: Column[]`
|
|
@@ -391,6 +456,21 @@ t.toCreateSql("users", "postgres");
|
|
|
391
456
|
The `Column` instances added so far, in declaration order. Read-only inspection
|
|
392
457
|
seam; you rarely touch it.
|
|
393
458
|
|
|
459
|
+
### `AlterTableBuilder`
|
|
460
|
+
|
|
461
|
+
From the `alterTable` callback. Column methods (`string`, `integer`, …) **add**
|
|
462
|
+
columns; plus:
|
|
463
|
+
|
|
464
|
+
- `dropColumn(name)` — drop a column.
|
|
465
|
+
- `renameColumn(from, to)` — rename a column.
|
|
466
|
+
- `index(columns, name?)` / `uniqueIndex(columns, name?)` — add an index.
|
|
467
|
+
- `dropIndex(name)` — drop an index (runs before column drops).
|
|
468
|
+
|
|
469
|
+
### `ForeignKeyBuilder`
|
|
470
|
+
|
|
471
|
+
From `TableBuilder.foreign(column)`. Chainable: `references(column)`, `on(table)`,
|
|
472
|
+
`onDelete(action)`, `onUpdate(action)`.
|
|
473
|
+
|
|
394
474
|
### `Column`
|
|
395
475
|
|
|
396
476
|
A single column definition. **You get one from a `TableBuilder` method** (`t.string(...)` etc.) — you don't construct it in migrations. Modifier methods
|
package/docs/models.md
CHANGED
|
@@ -173,6 +173,110 @@ return json(user); // works directly — json() serializes it
|
|
|
173
173
|
user.fill({ name: "X" }); // merge mass-assignable attributes without saving
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
+
Control what `toJSON()` exposes with three statics. `hidden` strips columns;
|
|
177
|
+
`visible` is an allowlist that wins over everything; `appends` adds computed
|
|
178
|
+
attributes — a getter or a zero-arg method on the model:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
class User extends Model {
|
|
182
|
+
static table = "users";
|
|
183
|
+
static hidden = ["password"]; // never serialized
|
|
184
|
+
static appends = ["fullName"]; // added to the output
|
|
185
|
+
get fullName() { return `${this.first} ${this.last}`; }
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Lifecycle events
|
|
190
|
+
|
|
191
|
+
A model fires events as it is retrieved, saved, and deleted. Hook onto them to
|
|
192
|
+
slug a title, bust a cache, or cascade — without touching every call site. The
|
|
193
|
+
`*ing` events are **cancelable**: a hook returning `false` aborts the write.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
User.creating((user) => { user.uuid = crypto.randomUUID(); });
|
|
197
|
+
User.saved((user) => cache().forget(`user:${user.id}`));
|
|
198
|
+
User.deleting((user) => (user.isRoot ? false : undefined)); // veto
|
|
199
|
+
|
|
200
|
+
// Or group them in an observer:
|
|
201
|
+
User.observe({
|
|
202
|
+
creating: (u) => { u.uuid = crypto.randomUUID(); },
|
|
203
|
+
deleted: (u) => audit(`deleted ${u.id}`),
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Events: `retrieved`, `creating`/`created`, `updating`/`updated`,
|
|
208
|
+
`saving`/`saved`, `deleting`/`deleted`, `restoring`/`restored`. They're keyed by
|
|
209
|
+
the exact class (subclasses don't inherit a parent's hooks).
|
|
210
|
+
|
|
211
|
+
## Query scopes
|
|
212
|
+
|
|
213
|
+
A **global scope** constrains every query a model builds — the base for
|
|
214
|
+
multi-tenancy, published-only reads, and soft deletes:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
Post.addGlobalScope("published", (q) => q.where("published", true));
|
|
218
|
+
await Post.all(); // only published
|
|
219
|
+
await Post.query().where("author_id", 1).get(); // still only published
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Scopes **inherit**. A scope declared on a base class constrains every model that
|
|
223
|
+
extends it — which is what makes a single tenant-scoped base class possible:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
class TenantModel extends Model {}
|
|
227
|
+
TenantModel.addGlobalScope("tenant", (q) => q.where("teamId", currentTeamId()));
|
|
228
|
+
|
|
229
|
+
class Post extends TenantModel {} // scoped, without repeating yourself
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Scopes from several levels all apply, and a subclass overrides an ancestor's scope
|
|
233
|
+
by reusing its name — the nearest declaration wins.
|
|
234
|
+
|
|
235
|
+
### Escaping a scope
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
await Post.withoutGlobalScope("tenant").get(); // one named scope
|
|
239
|
+
await Post.withoutGlobalScopes().get(); // all of them
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Escaping is deliberately explicit, and worth keeping that way. A query that steps
|
|
243
|
+
outside a tenancy scope is exactly the thing you want to be able to *find* — so it
|
|
244
|
+
should be typed out and greppable, never something you arrive at by forgetting a
|
|
245
|
+
`where`.
|
|
246
|
+
|
|
247
|
+
A **local scope** is just a static method returning a query — no framework
|
|
248
|
+
feature needed:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
class Post extends Model {
|
|
252
|
+
static popular() { return this.query().where("views", ">", 1000); }
|
|
253
|
+
}
|
|
254
|
+
await Post.popular().orderBy("views", "desc").get();
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Soft deletes
|
|
258
|
+
|
|
259
|
+
Opt in with `static softDeletes = true` and a `deleted_at` column. `delete()`
|
|
260
|
+
then sets the timestamp instead of removing the row, and a global scope hides
|
|
261
|
+
soft-deleted rows from every query.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
class User extends Model {
|
|
265
|
+
static table = "users";
|
|
266
|
+
static softDeletes = true;
|
|
267
|
+
static casts = { deleted_at: "date" };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
await user.delete(); // sets deleted_at; row stays in the table
|
|
271
|
+
user.trashed(); // true
|
|
272
|
+
await User.find(user.id); // null — hidden by the scope
|
|
273
|
+
|
|
274
|
+
await User.withTrashed().get(); // include soft-deleted
|
|
275
|
+
await User.onlyTrashed().get(); // only soft-deleted
|
|
276
|
+
await user.restore(); // clear deleted_at
|
|
277
|
+
await user.forceDelete(); // remove the row for good
|
|
278
|
+
```
|
|
279
|
+
|
|
176
280
|
## Relationships
|
|
177
281
|
|
|
178
282
|
Define a relationship as a method that returns one of `hasMany` / `hasOne` /
|
|
@@ -222,6 +326,29 @@ users[0].toJSON(); // includes `posts` and `roles`
|
|
|
222
326
|
Loaded relations are stored off the model, so they never leak into `save()`,
|
|
223
327
|
and `toJSON()` serializes them (nested models included).
|
|
224
328
|
|
|
329
|
+
### Querying relationships (`with`, `withCount`, `whereHas`)
|
|
330
|
+
|
|
331
|
+
`Model.query()` returns a model-aware builder with the relationship operations a
|
|
332
|
+
raw query can't express. `with()` eager-loads (dotted paths nest), `withCount()`
|
|
333
|
+
adds a `<relation>_count`, and `has`/`whereHas`/`doesntHave` filter by whether a
|
|
334
|
+
related row exists:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
const users = await User.query()
|
|
338
|
+
.where("active", true)
|
|
339
|
+
.with("posts.comments") // nested eager load
|
|
340
|
+
.withCount("posts") // users[i].posts_count
|
|
341
|
+
.whereHas("posts", (q) => q.where("published", true))
|
|
342
|
+
.get();
|
|
343
|
+
|
|
344
|
+
await User.has("posts").get(); // users with at least one post
|
|
345
|
+
await User.doesntHave("posts").get(); // users with none
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
`with`/`withCount`/`whereHas`/`has`/`doesntHave` are also static shortcuts
|
|
349
|
+
(`User.with(...)`, `User.whereHas(...)`). Existence filters use the same
|
|
350
|
+
driver-agnostic two-query strategy as the relations themselves — no JOIN.
|
|
351
|
+
|
|
225
352
|
### Many-to-many
|
|
226
353
|
|
|
227
354
|
`belongsToMany` reads through a pivot table (default name: the two table names
|
|
@@ -242,11 +369,39 @@ this.belongsTo(User, "owner_id", "id");
|
|
|
242
369
|
this.belongsToMany(Role, "user_roles", "user_id", "role_id");
|
|
243
370
|
```
|
|
244
371
|
|
|
372
|
+
### Polymorphic
|
|
373
|
+
|
|
374
|
+
A polymorphic relation lets one model belong to more than one type. The related
|
|
375
|
+
rows carry `<name>_id` + `<name>_type`; register each owner type so `morphTo`
|
|
376
|
+
can resolve it:
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
class Post extends Model {
|
|
380
|
+
comments() { return this.morphMany(Comment, "commentable"); }
|
|
381
|
+
}
|
|
382
|
+
class Video extends Model {
|
|
383
|
+
comments() { return this.morphMany(Comment, "commentable"); }
|
|
384
|
+
}
|
|
385
|
+
class Comment extends Model {
|
|
386
|
+
commentable() { return this.morphTo("commentable"); } // resolves back to Post or Video
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
registerMorphType("Post", Post);
|
|
390
|
+
registerMorphType("Video", Video);
|
|
391
|
+
|
|
392
|
+
await post.comments().create({ body: "nice" }); // sets commentable_id/_type
|
|
393
|
+
const owner = await comment.commentable(); // Post | Video | null
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
`morphOne` is the one-to-one variant. Eager loading (`Model.load` / `with`) works
|
|
397
|
+
across mixed types.
|
|
398
|
+
|
|
245
399
|
## What this is (and isn't)
|
|
246
400
|
|
|
247
|
-
This is a
|
|
248
|
-
|
|
249
|
-
(`
|
|
401
|
+
This is a compact active-record — CRUD, lifecycle events, scopes, soft deletes,
|
|
402
|
+
serialization control, eager loading (including nested `with("posts.comments")`),
|
|
403
|
+
relationship queries (`whereHas`/`withCount`), and polymorphic relations — all on
|
|
404
|
+
a driver-agnostic query builder, no ORM dependency. For complex one-off queries
|
|
250
405
|
you can always drop to `db()` or your driver directly.
|
|
251
406
|
|
|
252
407
|
---
|
|
@@ -402,6 +557,55 @@ Rarely called directly — `create`/`save` use it internally.
|
|
|
402
557
|
const storable = Post.toDatabase({ published: true }); // { published: 1 }
|
|
403
558
|
```
|
|
404
559
|
|
|
560
|
+
#### `Model.with(...names)` · `Model.withCount(...names)`
|
|
561
|
+
|
|
562
|
+
Start a [`ModelQuery`](#modelquery) that eager-loads the named relations (dotted
|
|
563
|
+
paths nest: `"posts.comments"`) or counts them into `<relation>_count`.
|
|
564
|
+
|
|
565
|
+
#### `Model.has(name)` · `Model.whereHas(name, constrain?)` · `Model.doesntHave(name, constrain?)`
|
|
566
|
+
|
|
567
|
+
Start a `ModelQuery` filtered by relationship existence — has at least one
|
|
568
|
+
related row, has one matching `constrain(query)`, or has none. `constrain`
|
|
569
|
+
receives the related-table query builder.
|
|
570
|
+
|
|
571
|
+
#### `Model.newQuery()`
|
|
572
|
+
|
|
573
|
+
`static newQuery(): ModelQuery<T>`
|
|
574
|
+
|
|
575
|
+
The model-aware query behind the sugar above — hydrates rows to models and adds
|
|
576
|
+
`with`/`withCount`/`whereHas`.
|
|
577
|
+
|
|
578
|
+
#### `Model.addGlobalScope(name, scope)`
|
|
579
|
+
|
|
580
|
+
`static addGlobalScope(name: string, scope: (query: QueryBuilder) => void): void`
|
|
581
|
+
|
|
582
|
+
Register a constraint applied to every query the model builds. Inherited by
|
|
583
|
+
subclasses; a subclass re-using a name overrides it.
|
|
584
|
+
|
|
585
|
+
#### `Model.withTrashed()` · `Model.onlyTrashed()` · `Model.withoutGlobalScope(...names)` · `Model.withoutGlobalScopes()`
|
|
586
|
+
|
|
587
|
+
Escape hatches returning a `QueryBuilder`: include (or only) soft-deleted rows,
|
|
588
|
+
or drop named / all global scopes. Deliberately explicit so an unscoped query is
|
|
589
|
+
greppable at audit time.
|
|
590
|
+
|
|
591
|
+
### `Model` — lifecycle events
|
|
592
|
+
|
|
593
|
+
Register per-class hooks (keyed by the exact class). The `*ing` events are
|
|
594
|
+
cancelable — a hook returning `false` aborts the operation.
|
|
595
|
+
|
|
596
|
+
#### `Model.creating` · `created` · `updating` · `updated` · `saving` · `saved` · `deleting` · `deleted` · `restoring` · `restored` · `retrieved`
|
|
597
|
+
|
|
598
|
+
`static <event>(hook: (model: T) => void | boolean | Promise<void | boolean>): void`
|
|
599
|
+
|
|
600
|
+
Add a hook for that lifecycle event. `create()` fires `saving`→`creating`→write→
|
|
601
|
+
`created`→`saved`; a save that updates fires the `updating`/`updated` pair.
|
|
602
|
+
|
|
603
|
+
#### `Model.observe(observer)`
|
|
604
|
+
|
|
605
|
+
`static observe(observer: Partial<Record<ModelEvent, ModelHook<T>>>): void`
|
|
606
|
+
|
|
607
|
+
Attach an observer object — each method named after an event becomes a hook.
|
|
608
|
+
|
|
405
609
|
### `Model` — configuration statics
|
|
406
610
|
|
|
407
611
|
Set these on the subclass to configure it. All have defaults.
|
|
@@ -462,6 +666,21 @@ class Post extends Model {
|
|
|
462
666
|
}
|
|
463
667
|
```
|
|
464
668
|
|
|
669
|
+
#### `static hidden` / `static visible` / `static appends`
|
|
670
|
+
|
|
671
|
+
`static hidden: string[]` · `static visible: string[]` · `static appends: string[]`
|
|
672
|
+
|
|
673
|
+
Shape `toJSON()`: `hidden` strips columns, `visible` is an allowlist that wins,
|
|
674
|
+
`appends` adds computed attributes (a getter or zero-arg method). All default `[]`.
|
|
675
|
+
|
|
676
|
+
#### `static softDeletes` / `static deletedAtColumn`
|
|
677
|
+
|
|
678
|
+
`static softDeletes: boolean` (default `false`) · `static deletedAtColumn: string`
|
|
679
|
+
(default `"deleted_at"`)
|
|
680
|
+
|
|
681
|
+
Turn on soft deletes: `delete()` sets the timestamp and a global scope hides
|
|
682
|
+
trashed rows.
|
|
683
|
+
|
|
465
684
|
### `Model` — instance methods
|
|
466
685
|
|
|
467
686
|
#### `new Model(attributes?)`
|
|
@@ -500,14 +719,22 @@ still issues the query.
|
|
|
500
719
|
|
|
501
720
|
`delete(): Promise<void>`
|
|
502
721
|
|
|
503
|
-
Deletes the row matching this model's primary key
|
|
722
|
+
Deletes the row matching this model's primary key — or, with `static softDeletes`
|
|
723
|
+
on, sets `deleted_at` instead. Fires `deleting`/`deleted`.
|
|
504
724
|
|
|
505
725
|
```ts
|
|
506
726
|
await user.delete();
|
|
507
727
|
```
|
|
508
728
|
|
|
509
|
-
**Notes:** keys off the current `primaryKey` value
|
|
510
|
-
|
|
729
|
+
**Notes:** keys off the current `primaryKey` value. See `forceDelete`/`restore`
|
|
730
|
+
for the soft-delete variants.
|
|
731
|
+
|
|
732
|
+
#### `forceDelete()` · `restore()` · `trashed()`
|
|
733
|
+
|
|
734
|
+
`forceDelete(): Promise<void>` · `restore(): Promise<this>` · `trashed(): boolean`
|
|
735
|
+
|
|
736
|
+
For soft-deletable models: permanently remove the row, clear `deleted_at`
|
|
737
|
+
(fires `restoring`/`restored`), or test whether it's currently trashed.
|
|
511
738
|
|
|
512
739
|
#### `fill(attributes)`
|
|
513
740
|
|
|
@@ -637,6 +864,52 @@ joined with `_` (User + Role → `role_user`). The pivot keys default to
|
|
|
637
864
|
`<model>_<primaryKey>`. Reads as two `whereIn` queries (no JOIN), so it stays
|
|
638
865
|
edge-safe.
|
|
639
866
|
|
|
867
|
+
#### `morphMany(related, name, localKey?)` · `morphOne(related, name, localKey?)`
|
|
868
|
+
|
|
869
|
+
`morphMany<T>(related: ModelClass<T>, name: string, localKey?: string): MorphMany<T>`
|
|
870
|
+
|
|
871
|
+
The parent side of a polymorphic relation. Related rows carry `<name>_id` +
|
|
872
|
+
`<name>_type` (the type stored is this model's class name). `MorphMany` also has
|
|
873
|
+
`.create(attributes)`, which fills the morph keys.
|
|
874
|
+
|
|
875
|
+
```ts
|
|
876
|
+
comments() { return this.morphMany(Comment, "commentable"); }
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
#### `morphTo(name, idColumn?, typeColumn?)`
|
|
880
|
+
|
|
881
|
+
`morphTo(name: string, idColumn?: string, typeColumn?: string): MorphTo`
|
|
882
|
+
|
|
883
|
+
The owning side — resolves the parent from the stored `<name>_type` (via
|
|
884
|
+
[`registerMorphType`](#registermorphtypetype-model)) and `<name>_id`. Awaitable;
|
|
885
|
+
returns the parent model or `null`.
|
|
886
|
+
|
|
887
|
+
```ts
|
|
888
|
+
commentable() { return this.morphTo("commentable"); }
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
#### `registerMorphType(type, model)`
|
|
892
|
+
|
|
893
|
+
`registerMorphType(type: string, related: ModelClass<Model>): void`
|
|
894
|
+
|
|
895
|
+
Register a model under a morph-type string (usually its class name) so `morphTo`
|
|
896
|
+
can resolve it. Call once at boot for each owner type.
|
|
897
|
+
|
|
898
|
+
### `ModelQuery`
|
|
899
|
+
|
|
900
|
+
The model-aware builder returned by `Model.query()`, `Model.newQuery()`, and the
|
|
901
|
+
`with`/`whereHas`/`withCount` shortcuts. It proxies the query-builder constraint
|
|
902
|
+
methods (`where`, `orderBy`, `limit`, …) and hydrates results to models, adding:
|
|
903
|
+
|
|
904
|
+
- `with(...names)` — eager-load relations; dotted paths nest (`"posts.comments"`).
|
|
905
|
+
- `withCount(...names)` — add `<relation>_count` to each result.
|
|
906
|
+
- `has(name)` / `whereHas(name, constrain?)` / `doesntHave(name, constrain?)` —
|
|
907
|
+
filter by relationship existence.
|
|
908
|
+
- Terminals `get()`, `first()`, `count()`, `exists()`, `paginate(page?, perPage?)`.
|
|
909
|
+
|
|
910
|
+
Existence filters and counts use the same driver-agnostic two-query strategy as
|
|
911
|
+
the relations (no JOIN). `toBase()` returns the underlying `QueryBuilder`.
|
|
912
|
+
|
|
640
913
|
### Relations
|
|
641
914
|
|
|
642
915
|
You never `new` these — a relationship method (`user.posts()`) returns one. Each
|
package/docs/teams.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Teams
|
|
2
|
+
|
|
3
|
+
Multi-tenancy, membership, roles, and invitations — where a row belongs to a team,
|
|
4
|
+
and one team can never see another's.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
// bootstrap/providers.ts
|
|
8
|
+
import { TeamsServiceProvider } from "@shaferllc/keel/teams";
|
|
9
|
+
|
|
10
|
+
app.register(TeamsServiceProvider);
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// app/Http/Kernel.ts
|
|
15
|
+
import { teamContext } from "@shaferllc/keel/teams";
|
|
16
|
+
|
|
17
|
+
protected middleware = [sessionMiddleware(), teamContext()];
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then a tenant-owned model is one word:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { TenantModel } from "@shaferllc/keel/teams";
|
|
24
|
+
|
|
25
|
+
class Post extends TenantModel {
|
|
26
|
+
static table = "posts";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
await Post.all(); // only the current team's posts
|
|
30
|
+
await Post.create({ title: "Hi" }); // stamped with the current team
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Isolation is the default, not a habit
|
|
34
|
+
|
|
35
|
+
Two halves, and both matter.
|
|
36
|
+
|
|
37
|
+
**Reads** are constrained by a global scope on `TenantModel`, so every query the
|
|
38
|
+
model builds carries the team — including `find()`. Naming another team's row by its
|
|
39
|
+
id returns `null`, not that row. This is the difference between tenancy and a list
|
|
40
|
+
filter: a filter you forget on one endpoint is a leak; a scope you never write can't
|
|
41
|
+
be forgotten.
|
|
42
|
+
|
|
43
|
+
**Writes** are stamped by a `creating` hook, so a row cannot be born ownerless and
|
|
44
|
+
end up visible to everyone (or to no one).
|
|
45
|
+
|
|
46
|
+
## No team means an error, not "everything"
|
|
47
|
+
|
|
48
|
+
A queued job, a console command, a webhook, a seeder — none of them run inside a
|
|
49
|
+
request, so none of them have a team. **A tenant query there throws.**
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
await Post.all();
|
|
53
|
+
// Error: No team in context, so a tenant-scoped query can't be built safely.
|
|
54
|
+
// Inside a request, add teamContext() to your middleware.
|
|
55
|
+
// In a job, command, or seeder, wrap the work: runForTeam(team, () => …).
|
|
56
|
+
// If it genuinely spans every team, say so: withoutTenant(() => …).
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This is the security model, and the alternatives are worse:
|
|
60
|
+
|
|
61
|
+
| If no team meant… | Then |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| *unscoped* | every background job sees every tenant's rows — this is how customer A's invoice reaches customer B |
|
|
64
|
+
| `teamId = NULL` | jobs match nothing, "work" fine, and quietly do nothing for a month |
|
|
65
|
+
| **an error** | a job that forgot **crashes in development** instead of leaking in production |
|
|
66
|
+
|
|
67
|
+
So a job says which team it's for:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
await runForTeam(team, () => sendInvoices());
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
...or says, out loud, that it isn't for one:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
await withoutTenant(() => Post.withoutGlobalScope(TENANT_SCOPE).get());
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Both are named calls you can **grep for at audit time**. That's the point: crossing a
|
|
80
|
+
tenant boundary should be something you typed, never something you arrived at by
|
|
81
|
+
forgetting a `where`.
|
|
82
|
+
|
|
83
|
+
> Your jobs will crash until each one is wrapped. That friction is the feature — it
|
|
84
|
+
> is a loud failure in development in exchange for not having a silent one in
|
|
85
|
+
> production.
|
|
86
|
+
|
|
87
|
+
The context lives in `AsyncLocalStorage`, not a module global, so two concurrent
|
|
88
|
+
requests can't see each other's team.
|
|
89
|
+
|
|
90
|
+
## Teams and membership
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const team = await createTeam("Acme", user.id); // creator becomes the owner
|
|
94
|
+
|
|
95
|
+
await teamsFor(user.id); // the teams a user is in
|
|
96
|
+
await roleOf(user.id, team.id); // "owner" | "admin" | "member" | null
|
|
97
|
+
await memberOf(user.id, team.id, "admin");
|
|
98
|
+
await switchTeam(user.id, team.id); // false if they aren't a member
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
A user is in a team **if and only if a membership row says so**. `teams.owner_id` is a
|
|
102
|
+
convenience, not an authorization source.
|
|
103
|
+
|
|
104
|
+
`switchTeam()` verifies membership, and so does `teamContext()` on every request —
|
|
105
|
+
`users.current_team_id` is just a number on a row the user can influence, so it is
|
|
106
|
+
checked, never trusted. Without that, switching teams would be a matter of writing
|
|
107
|
+
someone else's id onto your own row.
|
|
108
|
+
|
|
109
|
+
`Team` and `Membership` are deliberately **not** tenant-scoped: "which teams am I in?"
|
|
110
|
+
is a question you have to answer *before* you know which team you're in.
|
|
111
|
+
|
|
112
|
+
## Roles
|
|
113
|
+
|
|
114
|
+
`owner` > `admin` > `member`, ordered — an owner can do anything an admin can.
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
router.delete("/posts/:post", …).middleware(requireRole("admin"));
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
roleAtLeast("owner", "admin"); // true
|
|
122
|
+
roleAtLeast("member", "admin"); // false
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Invitations
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const { token } = await invite(team.id, "grace@example.com", "admin");
|
|
129
|
+
await acceptInvitation(token, user.id, user.email);
|
|
130
|
+
|
|
131
|
+
await pendingInvitations(team.id);
|
|
132
|
+
await revokeInvitation(id);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Unlike a password-reset link, an invitation **is** a database row — it has to be
|
|
136
|
+
listable ("3 pending") and revocable, and you can't revoke a stateless token. Only the
|
|
137
|
+
token's **hash** is stored, so a database leak doesn't open every pending team.
|
|
138
|
+
|
|
139
|
+
The invited address is re-checked on accept, so a **forwarded link doesn't let someone
|
|
140
|
+
else join** in the invitee's place — which is the interesting attack on an invitation
|
|
141
|
+
system. Invitations are single-use, expire (72h by default), and re-inviting the same
|
|
142
|
+
address replaces the outstanding invitation rather than stacking duplicates.
|
|
143
|
+
|
|
144
|
+
## Personal teams
|
|
145
|
+
|
|
146
|
+
On by default: every new user gets a team of their own, and a solo user is simply a
|
|
147
|
+
team of one.
|
|
148
|
+
|
|
149
|
+
Worth leaving on even for an app that feels single-user. **Tenancy is not a feature
|
|
150
|
+
you can add later** — bolting a `team_id` onto a schema that already has customer data
|
|
151
|
+
means a backfill, a migration on every table, and rewriting every query. Ignoring a
|
|
152
|
+
team you have costs one unused row. Needing a team you don't have costs a weekend.
|
|
153
|
+
|
|
154
|
+
## Configuration
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
keel vendor:publish --tag teams-config
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
export default {
|
|
162
|
+
userTable: "users",
|
|
163
|
+
personalTeams: true,
|
|
164
|
+
invitations: { expiresInHours: 72, url: "/invitations/:token" },
|
|
165
|
+
};
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## The schema
|
|
169
|
+
|
|
170
|
+
| Table | |
|
|
171
|
+
| --- | --- |
|
|
172
|
+
| `teams` | name, slug, owner_id |
|
|
173
|
+
| `team_memberships` | team_id, user_id, role — **unique per (team, user)**, enforced by the database |
|
|
174
|
+
| `team_invitations` | team_id, email, role, token **hash**, expires_at |
|
|
175
|
+
|
|
176
|
+
Plus `current_team_id` on your users table.
|