@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/llms-full.txt
CHANGED
|
@@ -4437,6 +4437,227 @@ router.get("/reports", handler).use(guards);
|
|
|
4437
4437
|
|
|
4438
4438
|
|
|
4439
4439
|
|
|
4440
|
+
---
|
|
4441
|
+
|
|
4442
|
+
<!-- source: docs/accounts.md -->
|
|
4443
|
+
|
|
4444
|
+
# Accounts
|
|
4445
|
+
|
|
4446
|
+
Password reset, email verification, and two-factor authentication — the flows every
|
|
4447
|
+
app with a login needs, built on primitives already in core (`hash`, `encryption`,
|
|
4448
|
+
`mail`, `rate-limit`).
|
|
4449
|
+
|
|
4450
|
+
They live in the framework, tested once, rather than being copy-pasted into each new
|
|
4451
|
+
app. A password-reset flow written five times is four copies that quietly rot.
|
|
4452
|
+
|
|
4453
|
+
```bash
|
|
4454
|
+
npm install @shaferllc/keel
|
|
4455
|
+
```
|
|
4456
|
+
|
|
4457
|
+
```ts
|
|
4458
|
+
// bootstrap/providers.ts
|
|
4459
|
+
import { AccountsServiceProvider } from "@shaferllc/keel/accounts";
|
|
4460
|
+
|
|
4461
|
+
app.register(AccountsServiceProvider);
|
|
4462
|
+
```
|
|
4463
|
+
|
|
4464
|
+
That merges config, adds four columns to your `users` table via a migration, and
|
|
4465
|
+
mounts the JSON endpoints. **Views stay yours** — these are functions and JSON
|
|
4466
|
+
endpoints; your controllers render the forms.
|
|
4467
|
+
|
|
4468
|
+
## Login
|
|
4469
|
+
|
|
4470
|
+
`attempt()` checks a password. What comes back depends on whether the user has 2FA.
|
|
4471
|
+
|
|
4472
|
+
```ts
|
|
4473
|
+
const result = await attempt(email, password);
|
|
4474
|
+
|
|
4475
|
+
if (result.status === "failed") {
|
|
4476
|
+
return { error: "Those credentials don't match." };
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4479
|
+
if (result.status === "two-factor") {
|
|
4480
|
+
// Nothing is logged in yet. Hold the challenge, ask for a code.
|
|
4481
|
+
return { twoFactor: true, challenge: result.challenge };
|
|
4482
|
+
}
|
|
4483
|
+
|
|
4484
|
+
auth().login(result.user.id);
|
|
4485
|
+
```
|
|
4486
|
+
|
|
4487
|
+
A wrong email and a wrong password give the same answer, and take the same time —
|
|
4488
|
+
`attempt()` hashes against `hash.dummy` when no user is found, because a fast "no
|
|
4489
|
+
such user" tells an attacker which addresses are registered.
|
|
4490
|
+
|
|
4491
|
+
## Two-factor
|
|
4492
|
+
|
|
4493
|
+
### The challenge is not a session
|
|
4494
|
+
|
|
4495
|
+
When 2FA is on, a correct password yields a **challenge**, not a login. Nothing is
|
|
4496
|
+
authenticated until the code verifies.
|
|
4497
|
+
|
|
4498
|
+
This matters more than it looks. The usual implementation logs the user in and sets a
|
|
4499
|
+
`needs_2fa` flag for middleware to check — which means they are holding a real
|
|
4500
|
+
authenticated session *before* the second factor. Every route that forgets the
|
|
4501
|
+
middleware, and every `auth()` call that only asks "is anyone logged in?", is then
|
|
4502
|
+
bypassable with just a password. The second factor becomes advisory.
|
|
4503
|
+
|
|
4504
|
+
Here there is no half-authenticated state to forget about, because there is no
|
|
4505
|
+
session. The challenge is a short-lived token bound to a single purpose, so it cannot
|
|
4506
|
+
be swapped for a session cookie or spent as a password-reset link.
|
|
4507
|
+
|
|
4508
|
+
```ts
|
|
4509
|
+
const user = await completeTwoFactor(challenge, code);
|
|
4510
|
+
if (!user) return { error: "That code isn't valid." };
|
|
4511
|
+
|
|
4512
|
+
auth().login(user.id);
|
|
4513
|
+
```
|
|
4514
|
+
|
|
4515
|
+
`completeTwoFactor()` accepts an authenticator code **or** a recovery code.
|
|
4516
|
+
|
|
4517
|
+
### Turning it on takes two steps
|
|
4518
|
+
|
|
4519
|
+
```ts
|
|
4520
|
+
// Step one: a secret and recovery codes. 2FA is NOT on yet.
|
|
4521
|
+
const setup = await enableTwoFactor(user, { issuer: "Acme" });
|
|
4522
|
+
```
|
|
4523
|
+
|
|
4524
|
+
`setup.uri` is an `otpauth://` URI to render as a QR code. `setup.secret` is for
|
|
4525
|
+
manual entry. `setup.recoveryCodes` are shown **once**.
|
|
4526
|
+
|
|
4527
|
+
> Render the QR **locally**. The URI contains the shared secret, so posting it to a
|
|
4528
|
+
> QR-image service hands your users' second factor to a third party.
|
|
4529
|
+
|
|
4530
|
+
```ts
|
|
4531
|
+
// Step two: a working code turns it on.
|
|
4532
|
+
const ok = await confirmTwoFactor(user, code);
|
|
4533
|
+
```
|
|
4534
|
+
|
|
4535
|
+
The two-step dance is deliberate. A one-step "enable" locks out every user who scans
|
|
4536
|
+
the QR wrong or whose phone clock is off — and what's broken is the very thing they'd
|
|
4537
|
+
need to get back in. Until `confirmTwoFactor()` succeeds, `hasTwoFactor()` is false
|
|
4538
|
+
and login works as before.
|
|
4539
|
+
|
|
4540
|
+
### Recovery codes
|
|
4541
|
+
|
|
4542
|
+
Eight by default, hashed at rest, single-use — redeeming one burns it, so a code read
|
|
4543
|
+
over your shoulder is one you have already spent.
|
|
4544
|
+
|
|
4545
|
+
```ts
|
|
4546
|
+
await recoveryCodesRemaining(user); // worth surfacing when it gets low
|
|
4547
|
+
await regenerateRecoveryCodes(user); // invalidates the old set
|
|
4548
|
+
await disableTwoFactor(user); // destroys the secret and the codes
|
|
4549
|
+
```
|
|
4550
|
+
|
|
4551
|
+
### What's stored
|
|
4552
|
+
|
|
4553
|
+
The TOTP secret is **encrypted** at rest and the recovery codes are **hashed**, so a
|
|
4554
|
+
leaked database hands over neither the second factor nor the backdoor.
|
|
4555
|
+
|
|
4556
|
+
TOTP itself is RFC 6238, verified against the RFC's published test vectors, and built
|
|
4557
|
+
on WebCrypto with no dependencies — so it runs unchanged on the edge.
|
|
4558
|
+
|
|
4559
|
+
## Password reset
|
|
4560
|
+
|
|
4561
|
+
```ts
|
|
4562
|
+
await requestPasswordReset(email); // emails a link, or quietly does nothing
|
|
4563
|
+
```
|
|
4564
|
+
|
|
4565
|
+
The answer is the same whether or not that address has an account. "No account with
|
|
4566
|
+
that address" is a free enumeration oracle on an unauthenticated endpoint that anyone
|
|
4567
|
+
can ask about anyone.
|
|
4568
|
+
|
|
4569
|
+
```ts
|
|
4570
|
+
const ok = await resetPassword(token, password);
|
|
4571
|
+
```
|
|
4572
|
+
|
|
4573
|
+
**A reset link works exactly once**, and there is no `password_resets` table. The
|
|
4574
|
+
token carries its own purpose and expiry inside the ciphertext, and it is bound to a
|
|
4575
|
+
fingerprint of the current password hash — so the moment the password changes, every
|
|
4576
|
+
token minted against the old one is dead. Nothing to store, nothing to clean up, and
|
|
4577
|
+
no window where a stale row is still redeemable because a cron job didn't run.
|
|
4578
|
+
|
|
4579
|
+
## Email verification
|
|
4580
|
+
|
|
4581
|
+
```ts
|
|
4582
|
+
await sendVerificationEmail(user);
|
|
4583
|
+
const user = await verifyEmail(token); // idempotent
|
|
4584
|
+
```
|
|
4585
|
+
|
|
4586
|
+
The token is bound to the address it was sent to. A link mailed to an old address
|
|
4587
|
+
cannot verify a new one — otherwise changing your email to someone else's and clicking
|
|
4588
|
+
an older link would mark *their* address as proven.
|
|
4589
|
+
|
|
4590
|
+
## The endpoints
|
|
4591
|
+
|
|
4592
|
+
Mounted at `auth` unless you turn them off (`routes.enabled: false`) and call the
|
|
4593
|
+
functions from your own controllers instead.
|
|
4594
|
+
|
|
4595
|
+
| Method | Path | Notes |
|
|
4596
|
+
| --- | --- | --- |
|
|
4597
|
+
| POST | `/auth/login` | `{ email, password }` → a user, or `{ twoFactor, challenge }` |
|
|
4598
|
+
| POST | `/auth/two-factor` | `{ challenge, code }` — code or recovery code |
|
|
4599
|
+
| POST | `/auth/password/forgot` | Always `202`. Never says who exists. |
|
|
4600
|
+
| POST | `/auth/password/reset` | `{ token, password }` |
|
|
4601
|
+
| POST | `/auth/email/verify` | `{ token }` |
|
|
4602
|
+
| POST | `/auth/email/resend` | Always `202` |
|
|
4603
|
+
|
|
4604
|
+
Every one is unauthenticated and touches credentials, so the group is rate-limited
|
|
4605
|
+
(5 per minute by default). Without a throttle, a six-digit code inside a 30-second
|
|
4606
|
+
window is guessable, and forgot-password is an email cannon pointed at whoever the
|
|
4607
|
+
caller names.
|
|
4608
|
+
|
|
4609
|
+
## Configuration
|
|
4610
|
+
|
|
4611
|
+
```bash
|
|
4612
|
+
keel vendor:publish --tag accounts-config
|
|
4613
|
+
```
|
|
4614
|
+
|
|
4615
|
+
```ts
|
|
4616
|
+
export default {
|
|
4617
|
+
userTable: "users",
|
|
4618
|
+
routes: { enabled: true, prefix: "auth" },
|
|
4619
|
+
passwordReset: { expiresIn: "60m", url: "/reset-password?token=:token" },
|
|
4620
|
+
verification: { expiresIn: "24h", url: "/verify-email?token=:token" },
|
|
4621
|
+
twoFactor: {
|
|
4622
|
+
issuer: env("APP_NAME", "Keel"),
|
|
4623
|
+
window: 1, // ±30s of clock drift
|
|
4624
|
+
challengeExpiresIn: "5m",
|
|
4625
|
+
recoveryCodes: 8,
|
|
4626
|
+
},
|
|
4627
|
+
rateLimit: { max: 5, window: 60 },
|
|
4628
|
+
};
|
|
4629
|
+
```
|
|
4630
|
+
|
|
4631
|
+
`twoFactor.challengeExpiresIn` is the window in which a stolen password alone is
|
|
4632
|
+
enough. Keep it short.
|
|
4633
|
+
|
|
4634
|
+
## A different users table
|
|
4635
|
+
|
|
4636
|
+
Accounts talks to a table through the query builder rather than assuming a `Model`.
|
|
4637
|
+
If your users live somewhere else — an auth service, a legacy schema — replace the
|
|
4638
|
+
store:
|
|
4639
|
+
|
|
4640
|
+
```ts
|
|
4641
|
+
setAccountStore({
|
|
4642
|
+
async findById(id) { /* … */ },
|
|
4643
|
+
async findByEmail(email) { /* … */ },
|
|
4644
|
+
async update(id, values) { /* … */ },
|
|
4645
|
+
});
|
|
4646
|
+
```
|
|
4647
|
+
|
|
4648
|
+
## The schema
|
|
4649
|
+
|
|
4650
|
+
Four columns on your users table, and no tokens table:
|
|
4651
|
+
|
|
4652
|
+
| Column | |
|
|
4653
|
+
| --- | --- |
|
|
4654
|
+
| `email_verified_at` | null until proven |
|
|
4655
|
+
| `two_factor_secret` | encrypted at rest |
|
|
4656
|
+
| `two_factor_recovery_codes` | hashed, then encrypted |
|
|
4657
|
+
| `two_factor_confirmed_at` | null until a working code proves it |
|
|
4658
|
+
|
|
4659
|
+
|
|
4660
|
+
|
|
4440
4661
|
---
|
|
4441
4662
|
|
|
4442
4663
|
<!-- source: docs/ai.md -->
|
|
@@ -7573,6 +7794,23 @@ await db("posts").latest().get(); // ORDER BY created_at DESC
|
|
|
7573
7794
|
await db("posts").oldest("published_at").get();
|
|
7574
7795
|
```
|
|
7575
7796
|
|
|
7797
|
+
Joins, grouping, and conditional/raw clauses:
|
|
7798
|
+
|
|
7799
|
+
```ts
|
|
7800
|
+
await db("posts")
|
|
7801
|
+
.join("users", "posts.user_id", "users.id") // also leftJoin(...)
|
|
7802
|
+
.select("posts.title", "users.name")
|
|
7803
|
+
.get();
|
|
7804
|
+
|
|
7805
|
+
await db("posts").select("user_id").groupBy("user_id").having("COUNT(*)", ">", 5).get();
|
|
7806
|
+
await db("users").distinct().select("country").pluck("country");
|
|
7807
|
+
|
|
7808
|
+
await db("events").whereColumn("updated_at", ">", "created_at").get();
|
|
7809
|
+
await db("users").whereRaw("score >= ?", [10]).orderByRaw("LENGTH(name) DESC").get();
|
|
7810
|
+
|
|
7811
|
+
await db("users").when(search, (q, term) => q.whereLike("name", `%${term}%`)).get();
|
|
7812
|
+
```
|
|
7813
|
+
|
|
7576
7814
|
## Aggregates, single values, and pagination
|
|
7577
7815
|
|
|
7578
7816
|
```ts
|
|
@@ -7602,6 +7840,22 @@ Everything is parameterized — values become bindings, never string-interpolate
|
|
|
7602
7840
|
SQL — so it's injection-safe by construction. Writes return a `WriteResult`;
|
|
7603
7841
|
`insertGetId` unwraps it to just the new id.
|
|
7604
7842
|
|
|
7843
|
+
Counters, bulk upserts, and paged iteration:
|
|
7844
|
+
|
|
7845
|
+
```ts
|
|
7846
|
+
await db("posts").where("id", id).increment("views"); // += 1
|
|
7847
|
+
await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
|
|
7848
|
+
|
|
7849
|
+
// Insert, updating the listed columns on a unique-key conflict (dialect-aware).
|
|
7850
|
+
await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]);
|
|
7851
|
+
await db("logs").insertOrIgnore({ key, value }); // skip duplicates
|
|
7852
|
+
|
|
7853
|
+
// Process a large table a page at a time (return false to stop early).
|
|
7854
|
+
await db("users").orderBy("id").chunk(500, async (rows) => {
|
|
7855
|
+
for (const row of rows) await process(row);
|
|
7856
|
+
});
|
|
7857
|
+
```
|
|
7858
|
+
|
|
7605
7859
|
> **Guard your writes.** `update()` and `delete()` apply to every row that
|
|
7606
7860
|
> matches the current `where` clause — with no `where`, that's the whole table.
|
|
7607
7861
|
> Always scope a write with `where` unless you truly mean to touch every row.
|
|
@@ -8004,6 +8258,53 @@ await db("sessions").where("expires_at", "<", now).delete();
|
|
|
8004
8258
|
**Notes:** with no `where`, empties the table. There's no soft-delete here — pair
|
|
8005
8259
|
with a `deleted_at` column and `whereNull` if you want one.
|
|
8006
8260
|
|
|
8261
|
+
#### `whereColumn(first, operator?, second)` · `whereRaw(sql, bindings?)`
|
|
8262
|
+
|
|
8263
|
+
Compare two columns (no binding) or add a raw WHERE fragment with its own
|
|
8264
|
+
bindings. `whereColumn("updated_at", ">", "created_at")`; `whereRaw("score >= ?", [10])`.
|
|
8265
|
+
|
|
8266
|
+
#### `join(table, first, operator?, second)` · `leftJoin(...)`
|
|
8267
|
+
|
|
8268
|
+
Add an `INNER JOIN` / `LEFT JOIN` on an equality (or the given operator).
|
|
8269
|
+
Included in `get`, `count`, and aggregates. Qualify ambiguous columns
|
|
8270
|
+
(`"posts.user_id"`).
|
|
8271
|
+
|
|
8272
|
+
#### `groupBy(...columns)` · `having(column, operator?, value)` · `distinct()`
|
|
8273
|
+
|
|
8274
|
+
`GROUP BY`, a bound `HAVING` predicate, and `SELECT DISTINCT`.
|
|
8275
|
+
|
|
8276
|
+
#### `orderByRaw(sql)` · `when(condition, then, otherwise?)`
|
|
8277
|
+
|
|
8278
|
+
A raw `ORDER BY` fragment; and conditional building — `then(query, value)` runs
|
|
8279
|
+
only when `condition` is truthy, else `otherwise`.
|
|
8280
|
+
|
|
8281
|
+
#### `increment(column, amount?, extra?)` · `decrement(column, amount?, extra?)`
|
|
8282
|
+
|
|
8283
|
+
`increment(column: string, amount = 1, extra: Row = {}): Promise<WriteResult>`
|
|
8284
|
+
|
|
8285
|
+
Atomically `column = column ± amount` on matching rows, optionally setting other
|
|
8286
|
+
columns in the same statement. Scope with `where`.
|
|
8287
|
+
|
|
8288
|
+
#### `upsert(rows, uniqueBy, update?)`
|
|
8289
|
+
|
|
8290
|
+
`upsert(rows: Row | Row[], uniqueBy: string[], update?: string[]): Promise<WriteResult>`
|
|
8291
|
+
|
|
8292
|
+
Insert rows, updating `update` columns (default: all non-unique) on a conflict
|
|
8293
|
+
against `uniqueBy`. Dialect-aware: `ON CONFLICT … DO UPDATE` (sqlite/postgres) or
|
|
8294
|
+
`ON DUPLICATE KEY UPDATE` (mysql).
|
|
8295
|
+
|
|
8296
|
+
#### `insertOrIgnore(rows)`
|
|
8297
|
+
|
|
8298
|
+
Insert one or more rows, skipping any that violate a unique constraint
|
|
8299
|
+
(`INSERT OR IGNORE` / `INSERT IGNORE` / `ON CONFLICT DO NOTHING`).
|
|
8300
|
+
|
|
8301
|
+
#### `chunk(size, callback)`
|
|
8302
|
+
|
|
8303
|
+
`chunk(size: number, callback: (rows: T[]) => void | boolean | Promise<void | boolean>): Promise<void>`
|
|
8304
|
+
|
|
8305
|
+
Process results a page at a time so a large table never loads at once. Return
|
|
8306
|
+
`false` from the callback to stop early. Pair with `orderBy` for a stable order.
|
|
8307
|
+
|
|
8007
8308
|
### Interfaces & types
|
|
8008
8309
|
|
|
8009
8310
|
#### `Connection`
|
|
@@ -12745,12 +13046,41 @@ t.boolean("active").default(true); // sqlite: DEFAULT 1, else DEFAULT true
|
|
|
12745
13046
|
t.integer("retries").default(0); // ... DEFAULT 0
|
|
12746
13047
|
```
|
|
12747
13048
|
|
|
12748
|
-
|
|
12749
|
-
|
|
13049
|
+
### Indexes and foreign keys
|
|
13050
|
+
|
|
13051
|
+
`createTable` builds indexes and foreign keys alongside the columns:
|
|
13052
|
+
|
|
13053
|
+
```ts
|
|
13054
|
+
schema.createTable("members", (t) => {
|
|
13055
|
+
t.id();
|
|
13056
|
+
t.integer("team_id");
|
|
13057
|
+
t.string("email");
|
|
13058
|
+
t.uniqueIndex("email"); // or t.index(["a", "b"]) for composite
|
|
13059
|
+
t.foreign("team_id").references("id").on("teams").onDelete("cascade");
|
|
13060
|
+
});
|
|
13061
|
+
```
|
|
13062
|
+
|
|
13063
|
+
### Altering a table
|
|
13064
|
+
|
|
13065
|
+
`schema.alterTable(name, build)` adds, renames, and drops columns and indexes on
|
|
13066
|
+
an existing table (dialect-aware SQL). Drop an index before the column it covers:
|
|
12750
13067
|
|
|
12751
13068
|
```ts
|
|
12752
13069
|
up: (schema) =>
|
|
12753
|
-
schema.
|
|
13070
|
+
schema.alterTable("users", (t) => {
|
|
13071
|
+
t.string("phone").nullable(); // ADD COLUMN
|
|
13072
|
+
t.renameColumn("name", "full_name");
|
|
13073
|
+
t.index("phone");
|
|
13074
|
+
t.dropIndex("users_legacy_index");
|
|
13075
|
+
t.dropColumn("legacy");
|
|
13076
|
+
}),
|
|
13077
|
+
```
|
|
13078
|
+
|
|
13079
|
+
For anything the builder still doesn't cover, `schema.raw(sql, bindings?)` runs
|
|
13080
|
+
arbitrary SQL:
|
|
13081
|
+
|
|
13082
|
+
```ts
|
|
13083
|
+
up: (schema) => schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)"),
|
|
12754
13084
|
```
|
|
12755
13085
|
|
|
12756
13086
|
> `raw()` writes through the connection **without** placeholder conversion, so
|
|
@@ -12928,11 +13258,10 @@ already gone — the typical `down()` for a `createTable`.
|
|
|
12928
13258
|
|
|
12929
13259
|
`raw(sql: string, bindings?: unknown[]): Promise<void>`
|
|
12930
13260
|
|
|
12931
|
-
Runs arbitrary SQL through the connection — the escape hatch for
|
|
12932
|
-
|
|
13261
|
+
Runs arbitrary SQL through the connection — the escape hatch for anything the
|
|
13262
|
+
builders don't cover.
|
|
12933
13263
|
|
|
12934
13264
|
```ts
|
|
12935
|
-
await schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)");
|
|
12936
13265
|
await schema.raw("UPDATE users SET active = ? WHERE active IS NULL", [true]);
|
|
12937
13266
|
```
|
|
12938
13267
|
|
|
@@ -12940,6 +13269,22 @@ await schema.raw("UPDATE users SET active = ? WHERE active IS NULL", [true]);
|
|
|
12940
13269
|
`raw()` does **not** rewrite `?` to `$n`, so pass `$1, $2, …` yourself on the
|
|
12941
13270
|
`postgres` dialect.
|
|
12942
13271
|
|
|
13272
|
+
#### `alterTable(name, build)`
|
|
13273
|
+
|
|
13274
|
+
`alterTable(name: string, build: (table: AlterTableBuilder) => void): Promise<void>`
|
|
13275
|
+
|
|
13276
|
+
Alter an existing table — the callback gets an [`AlterTableBuilder`](#altertablebuilder)
|
|
13277
|
+
for adding, renaming, and dropping columns and indexes. Emits one dialect-aware
|
|
13278
|
+
statement per operation, ordered so a dropped index precedes its column.
|
|
13279
|
+
|
|
13280
|
+
```ts
|
|
13281
|
+
await schema.alterTable("users", (t) => {
|
|
13282
|
+
t.string("phone").nullable();
|
|
13283
|
+
t.renameColumn("name", "full_name");
|
|
13284
|
+
t.dropColumn("legacy");
|
|
13285
|
+
});
|
|
13286
|
+
```
|
|
13287
|
+
|
|
12943
13288
|
### `TableBuilder`
|
|
12944
13289
|
|
|
12945
13290
|
Describes a table's columns. **You get one from the `createTable` callback** — it
|
|
@@ -13056,6 +13401,27 @@ t.toCreateSql("users", "postgres");
|
|
|
13056
13401
|
// CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL)
|
|
13057
13402
|
```
|
|
13058
13403
|
|
|
13404
|
+
#### `index(columns, name?)` / `uniqueIndex(columns, name?)`
|
|
13405
|
+
|
|
13406
|
+
Add a (possibly composite) index or unique index; `columns` is a name or array.
|
|
13407
|
+
Emitted as `CREATE [UNIQUE] INDEX` after the table. Auto-named unless `name` is
|
|
13408
|
+
given.
|
|
13409
|
+
|
|
13410
|
+
```ts
|
|
13411
|
+
t.index("email");
|
|
13412
|
+
t.uniqueIndex(["team_id", "slug"]);
|
|
13413
|
+
```
|
|
13414
|
+
|
|
13415
|
+
#### `foreign(column)`
|
|
13416
|
+
|
|
13417
|
+
`foreign(column: string): ForeignKeyBuilder`
|
|
13418
|
+
|
|
13419
|
+
Add a foreign key, built fluently and emitted inline in the `CREATE TABLE`.
|
|
13420
|
+
|
|
13421
|
+
```ts
|
|
13422
|
+
t.foreign("team_id").references("id").on("teams").onDelete("cascade");
|
|
13423
|
+
```
|
|
13424
|
+
|
|
13059
13425
|
#### `columns`
|
|
13060
13426
|
|
|
13061
13427
|
`readonly columns: Column[]`
|
|
@@ -13063,6 +13429,21 @@ t.toCreateSql("users", "postgres");
|
|
|
13063
13429
|
The `Column` instances added so far, in declaration order. Read-only inspection
|
|
13064
13430
|
seam; you rarely touch it.
|
|
13065
13431
|
|
|
13432
|
+
### `AlterTableBuilder`
|
|
13433
|
+
|
|
13434
|
+
From the `alterTable` callback. Column methods (`string`, `integer`, …) **add**
|
|
13435
|
+
columns; plus:
|
|
13436
|
+
|
|
13437
|
+
- `dropColumn(name)` — drop a column.
|
|
13438
|
+
- `renameColumn(from, to)` — rename a column.
|
|
13439
|
+
- `index(columns, name?)` / `uniqueIndex(columns, name?)` — add an index.
|
|
13440
|
+
- `dropIndex(name)` — drop an index (runs before column drops).
|
|
13441
|
+
|
|
13442
|
+
### `ForeignKeyBuilder`
|
|
13443
|
+
|
|
13444
|
+
From `TableBuilder.foreign(column)`. Chainable: `references(column)`, `on(table)`,
|
|
13445
|
+
`onDelete(action)`, `onUpdate(action)`.
|
|
13446
|
+
|
|
13066
13447
|
### `Column`
|
|
13067
13448
|
|
|
13068
13449
|
A single column definition. **You get one from a `TableBuilder` method** (`t.string(...)` etc.) — you don't construct it in migrations. Modifier methods
|
|
@@ -13328,6 +13709,110 @@ return json(user); // works directly — json() serializes it
|
|
|
13328
13709
|
user.fill({ name: "X" }); // merge mass-assignable attributes without saving
|
|
13329
13710
|
```
|
|
13330
13711
|
|
|
13712
|
+
Control what `toJSON()` exposes with three statics. `hidden` strips columns;
|
|
13713
|
+
`visible` is an allowlist that wins over everything; `appends` adds computed
|
|
13714
|
+
attributes — a getter or a zero-arg method on the model:
|
|
13715
|
+
|
|
13716
|
+
```ts
|
|
13717
|
+
class User extends Model {
|
|
13718
|
+
static table = "users";
|
|
13719
|
+
static hidden = ["password"]; // never serialized
|
|
13720
|
+
static appends = ["fullName"]; // added to the output
|
|
13721
|
+
get fullName() { return `${this.first} ${this.last}`; }
|
|
13722
|
+
}
|
|
13723
|
+
```
|
|
13724
|
+
|
|
13725
|
+
## Lifecycle events
|
|
13726
|
+
|
|
13727
|
+
A model fires events as it is retrieved, saved, and deleted. Hook onto them to
|
|
13728
|
+
slug a title, bust a cache, or cascade — without touching every call site. The
|
|
13729
|
+
`*ing` events are **cancelable**: a hook returning `false` aborts the write.
|
|
13730
|
+
|
|
13731
|
+
```ts
|
|
13732
|
+
User.creating((user) => { user.uuid = crypto.randomUUID(); });
|
|
13733
|
+
User.saved((user) => cache().forget(`user:${user.id}`));
|
|
13734
|
+
User.deleting((user) => (user.isRoot ? false : undefined)); // veto
|
|
13735
|
+
|
|
13736
|
+
// Or group them in an observer:
|
|
13737
|
+
User.observe({
|
|
13738
|
+
creating: (u) => { u.uuid = crypto.randomUUID(); },
|
|
13739
|
+
deleted: (u) => audit(`deleted ${u.id}`),
|
|
13740
|
+
});
|
|
13741
|
+
```
|
|
13742
|
+
|
|
13743
|
+
Events: `retrieved`, `creating`/`created`, `updating`/`updated`,
|
|
13744
|
+
`saving`/`saved`, `deleting`/`deleted`, `restoring`/`restored`. They're keyed by
|
|
13745
|
+
the exact class (subclasses don't inherit a parent's hooks).
|
|
13746
|
+
|
|
13747
|
+
## Query scopes
|
|
13748
|
+
|
|
13749
|
+
A **global scope** constrains every query a model builds — the base for
|
|
13750
|
+
multi-tenancy, published-only reads, and soft deletes:
|
|
13751
|
+
|
|
13752
|
+
```ts
|
|
13753
|
+
Post.addGlobalScope("published", (q) => q.where("published", true));
|
|
13754
|
+
await Post.all(); // only published
|
|
13755
|
+
await Post.query().where("author_id", 1).get(); // still only published
|
|
13756
|
+
```
|
|
13757
|
+
|
|
13758
|
+
Scopes **inherit**. A scope declared on a base class constrains every model that
|
|
13759
|
+
extends it — which is what makes a single tenant-scoped base class possible:
|
|
13760
|
+
|
|
13761
|
+
```ts
|
|
13762
|
+
class TenantModel extends Model {}
|
|
13763
|
+
TenantModel.addGlobalScope("tenant", (q) => q.where("teamId", currentTeamId()));
|
|
13764
|
+
|
|
13765
|
+
class Post extends TenantModel {} // scoped, without repeating yourself
|
|
13766
|
+
```
|
|
13767
|
+
|
|
13768
|
+
Scopes from several levels all apply, and a subclass overrides an ancestor's scope
|
|
13769
|
+
by reusing its name — the nearest declaration wins.
|
|
13770
|
+
|
|
13771
|
+
### Escaping a scope
|
|
13772
|
+
|
|
13773
|
+
```ts
|
|
13774
|
+
await Post.withoutGlobalScope("tenant").get(); // one named scope
|
|
13775
|
+
await Post.withoutGlobalScopes().get(); // all of them
|
|
13776
|
+
```
|
|
13777
|
+
|
|
13778
|
+
Escaping is deliberately explicit, and worth keeping that way. A query that steps
|
|
13779
|
+
outside a tenancy scope is exactly the thing you want to be able to *find* — so it
|
|
13780
|
+
should be typed out and greppable, never something you arrive at by forgetting a
|
|
13781
|
+
`where`.
|
|
13782
|
+
|
|
13783
|
+
A **local scope** is just a static method returning a query — no framework
|
|
13784
|
+
feature needed:
|
|
13785
|
+
|
|
13786
|
+
```ts
|
|
13787
|
+
class Post extends Model {
|
|
13788
|
+
static popular() { return this.query().where("views", ">", 1000); }
|
|
13789
|
+
}
|
|
13790
|
+
await Post.popular().orderBy("views", "desc").get();
|
|
13791
|
+
```
|
|
13792
|
+
|
|
13793
|
+
## Soft deletes
|
|
13794
|
+
|
|
13795
|
+
Opt in with `static softDeletes = true` and a `deleted_at` column. `delete()`
|
|
13796
|
+
then sets the timestamp instead of removing the row, and a global scope hides
|
|
13797
|
+
soft-deleted rows from every query.
|
|
13798
|
+
|
|
13799
|
+
```ts
|
|
13800
|
+
class User extends Model {
|
|
13801
|
+
static table = "users";
|
|
13802
|
+
static softDeletes = true;
|
|
13803
|
+
static casts = { deleted_at: "date" };
|
|
13804
|
+
}
|
|
13805
|
+
|
|
13806
|
+
await user.delete(); // sets deleted_at; row stays in the table
|
|
13807
|
+
user.trashed(); // true
|
|
13808
|
+
await User.find(user.id); // null — hidden by the scope
|
|
13809
|
+
|
|
13810
|
+
await User.withTrashed().get(); // include soft-deleted
|
|
13811
|
+
await User.onlyTrashed().get(); // only soft-deleted
|
|
13812
|
+
await user.restore(); // clear deleted_at
|
|
13813
|
+
await user.forceDelete(); // remove the row for good
|
|
13814
|
+
```
|
|
13815
|
+
|
|
13331
13816
|
## Relationships
|
|
13332
13817
|
|
|
13333
13818
|
Define a relationship as a method that returns one of `hasMany` / `hasOne` /
|
|
@@ -13377,6 +13862,29 @@ users[0].toJSON(); // includes `posts` and `roles`
|
|
|
13377
13862
|
Loaded relations are stored off the model, so they never leak into `save()`,
|
|
13378
13863
|
and `toJSON()` serializes them (nested models included).
|
|
13379
13864
|
|
|
13865
|
+
### Querying relationships (`with`, `withCount`, `whereHas`)
|
|
13866
|
+
|
|
13867
|
+
`Model.query()` returns a model-aware builder with the relationship operations a
|
|
13868
|
+
raw query can't express. `with()` eager-loads (dotted paths nest), `withCount()`
|
|
13869
|
+
adds a `<relation>_count`, and `has`/`whereHas`/`doesntHave` filter by whether a
|
|
13870
|
+
related row exists:
|
|
13871
|
+
|
|
13872
|
+
```ts
|
|
13873
|
+
const users = await User.query()
|
|
13874
|
+
.where("active", true)
|
|
13875
|
+
.with("posts.comments") // nested eager load
|
|
13876
|
+
.withCount("posts") // users[i].posts_count
|
|
13877
|
+
.whereHas("posts", (q) => q.where("published", true))
|
|
13878
|
+
.get();
|
|
13879
|
+
|
|
13880
|
+
await User.has("posts").get(); // users with at least one post
|
|
13881
|
+
await User.doesntHave("posts").get(); // users with none
|
|
13882
|
+
```
|
|
13883
|
+
|
|
13884
|
+
`with`/`withCount`/`whereHas`/`has`/`doesntHave` are also static shortcuts
|
|
13885
|
+
(`User.with(...)`, `User.whereHas(...)`). Existence filters use the same
|
|
13886
|
+
driver-agnostic two-query strategy as the relations themselves — no JOIN.
|
|
13887
|
+
|
|
13380
13888
|
### Many-to-many
|
|
13381
13889
|
|
|
13382
13890
|
`belongsToMany` reads through a pivot table (default name: the two table names
|
|
@@ -13397,11 +13905,39 @@ this.belongsTo(User, "owner_id", "id");
|
|
|
13397
13905
|
this.belongsToMany(Role, "user_roles", "user_id", "role_id");
|
|
13398
13906
|
```
|
|
13399
13907
|
|
|
13908
|
+
### Polymorphic
|
|
13909
|
+
|
|
13910
|
+
A polymorphic relation lets one model belong to more than one type. The related
|
|
13911
|
+
rows carry `<name>_id` + `<name>_type`; register each owner type so `morphTo`
|
|
13912
|
+
can resolve it:
|
|
13913
|
+
|
|
13914
|
+
```ts
|
|
13915
|
+
class Post extends Model {
|
|
13916
|
+
comments() { return this.morphMany(Comment, "commentable"); }
|
|
13917
|
+
}
|
|
13918
|
+
class Video extends Model {
|
|
13919
|
+
comments() { return this.morphMany(Comment, "commentable"); }
|
|
13920
|
+
}
|
|
13921
|
+
class Comment extends Model {
|
|
13922
|
+
commentable() { return this.morphTo("commentable"); } // resolves back to Post or Video
|
|
13923
|
+
}
|
|
13924
|
+
|
|
13925
|
+
registerMorphType("Post", Post);
|
|
13926
|
+
registerMorphType("Video", Video);
|
|
13927
|
+
|
|
13928
|
+
await post.comments().create({ body: "nice" }); // sets commentable_id/_type
|
|
13929
|
+
const owner = await comment.commentable(); // Post | Video | null
|
|
13930
|
+
```
|
|
13931
|
+
|
|
13932
|
+
`morphOne` is the one-to-one variant. Eager loading (`Model.load` / `with`) works
|
|
13933
|
+
across mixed types.
|
|
13934
|
+
|
|
13400
13935
|
## What this is (and isn't)
|
|
13401
13936
|
|
|
13402
|
-
This is a
|
|
13403
|
-
|
|
13404
|
-
(`
|
|
13937
|
+
This is a compact active-record — CRUD, lifecycle events, scopes, soft deletes,
|
|
13938
|
+
serialization control, eager loading (including nested `with("posts.comments")`),
|
|
13939
|
+
relationship queries (`whereHas`/`withCount`), and polymorphic relations — all on
|
|
13940
|
+
a driver-agnostic query builder, no ORM dependency. For complex one-off queries
|
|
13405
13941
|
you can always drop to `db()` or your driver directly.
|
|
13406
13942
|
|
|
13407
13943
|
---
|
|
@@ -13557,6 +14093,55 @@ Rarely called directly — `create`/`save` use it internally.
|
|
|
13557
14093
|
const storable = Post.toDatabase({ published: true }); // { published: 1 }
|
|
13558
14094
|
```
|
|
13559
14095
|
|
|
14096
|
+
#### `Model.with(...names)` · `Model.withCount(...names)`
|
|
14097
|
+
|
|
14098
|
+
Start a [`ModelQuery`](#modelquery) that eager-loads the named relations (dotted
|
|
14099
|
+
paths nest: `"posts.comments"`) or counts them into `<relation>_count`.
|
|
14100
|
+
|
|
14101
|
+
#### `Model.has(name)` · `Model.whereHas(name, constrain?)` · `Model.doesntHave(name, constrain?)`
|
|
14102
|
+
|
|
14103
|
+
Start a `ModelQuery` filtered by relationship existence — has at least one
|
|
14104
|
+
related row, has one matching `constrain(query)`, or has none. `constrain`
|
|
14105
|
+
receives the related-table query builder.
|
|
14106
|
+
|
|
14107
|
+
#### `Model.newQuery()`
|
|
14108
|
+
|
|
14109
|
+
`static newQuery(): ModelQuery<T>`
|
|
14110
|
+
|
|
14111
|
+
The model-aware query behind the sugar above — hydrates rows to models and adds
|
|
14112
|
+
`with`/`withCount`/`whereHas`.
|
|
14113
|
+
|
|
14114
|
+
#### `Model.addGlobalScope(name, scope)`
|
|
14115
|
+
|
|
14116
|
+
`static addGlobalScope(name: string, scope: (query: QueryBuilder) => void): void`
|
|
14117
|
+
|
|
14118
|
+
Register a constraint applied to every query the model builds. Inherited by
|
|
14119
|
+
subclasses; a subclass re-using a name overrides it.
|
|
14120
|
+
|
|
14121
|
+
#### `Model.withTrashed()` · `Model.onlyTrashed()` · `Model.withoutGlobalScope(...names)` · `Model.withoutGlobalScopes()`
|
|
14122
|
+
|
|
14123
|
+
Escape hatches returning a `QueryBuilder`: include (or only) soft-deleted rows,
|
|
14124
|
+
or drop named / all global scopes. Deliberately explicit so an unscoped query is
|
|
14125
|
+
greppable at audit time.
|
|
14126
|
+
|
|
14127
|
+
### `Model` — lifecycle events
|
|
14128
|
+
|
|
14129
|
+
Register per-class hooks (keyed by the exact class). The `*ing` events are
|
|
14130
|
+
cancelable — a hook returning `false` aborts the operation.
|
|
14131
|
+
|
|
14132
|
+
#### `Model.creating` · `created` · `updating` · `updated` · `saving` · `saved` · `deleting` · `deleted` · `restoring` · `restored` · `retrieved`
|
|
14133
|
+
|
|
14134
|
+
`static <event>(hook: (model: T) => void | boolean | Promise<void | boolean>): void`
|
|
14135
|
+
|
|
14136
|
+
Add a hook for that lifecycle event. `create()` fires `saving`→`creating`→write→
|
|
14137
|
+
`created`→`saved`; a save that updates fires the `updating`/`updated` pair.
|
|
14138
|
+
|
|
14139
|
+
#### `Model.observe(observer)`
|
|
14140
|
+
|
|
14141
|
+
`static observe(observer: Partial<Record<ModelEvent, ModelHook<T>>>): void`
|
|
14142
|
+
|
|
14143
|
+
Attach an observer object — each method named after an event becomes a hook.
|
|
14144
|
+
|
|
13560
14145
|
### `Model` — configuration statics
|
|
13561
14146
|
|
|
13562
14147
|
Set these on the subclass to configure it. All have defaults.
|
|
@@ -13617,6 +14202,21 @@ class Post extends Model {
|
|
|
13617
14202
|
}
|
|
13618
14203
|
```
|
|
13619
14204
|
|
|
14205
|
+
#### `static hidden` / `static visible` / `static appends`
|
|
14206
|
+
|
|
14207
|
+
`static hidden: string[]` · `static visible: string[]` · `static appends: string[]`
|
|
14208
|
+
|
|
14209
|
+
Shape `toJSON()`: `hidden` strips columns, `visible` is an allowlist that wins,
|
|
14210
|
+
`appends` adds computed attributes (a getter or zero-arg method). All default `[]`.
|
|
14211
|
+
|
|
14212
|
+
#### `static softDeletes` / `static deletedAtColumn`
|
|
14213
|
+
|
|
14214
|
+
`static softDeletes: boolean` (default `false`) · `static deletedAtColumn: string`
|
|
14215
|
+
(default `"deleted_at"`)
|
|
14216
|
+
|
|
14217
|
+
Turn on soft deletes: `delete()` sets the timestamp and a global scope hides
|
|
14218
|
+
trashed rows.
|
|
14219
|
+
|
|
13620
14220
|
### `Model` — instance methods
|
|
13621
14221
|
|
|
13622
14222
|
#### `new Model(attributes?)`
|
|
@@ -13655,14 +14255,22 @@ still issues the query.
|
|
|
13655
14255
|
|
|
13656
14256
|
`delete(): Promise<void>`
|
|
13657
14257
|
|
|
13658
|
-
Deletes the row matching this model's primary key
|
|
14258
|
+
Deletes the row matching this model's primary key — or, with `static softDeletes`
|
|
14259
|
+
on, sets `deleted_at` instead. Fires `deleting`/`deleted`.
|
|
13659
14260
|
|
|
13660
14261
|
```ts
|
|
13661
14262
|
await user.delete();
|
|
13662
14263
|
```
|
|
13663
14264
|
|
|
13664
|
-
**Notes:** keys off the current `primaryKey` value
|
|
13665
|
-
|
|
14265
|
+
**Notes:** keys off the current `primaryKey` value. See `forceDelete`/`restore`
|
|
14266
|
+
for the soft-delete variants.
|
|
14267
|
+
|
|
14268
|
+
#### `forceDelete()` · `restore()` · `trashed()`
|
|
14269
|
+
|
|
14270
|
+
`forceDelete(): Promise<void>` · `restore(): Promise<this>` · `trashed(): boolean`
|
|
14271
|
+
|
|
14272
|
+
For soft-deletable models: permanently remove the row, clear `deleted_at`
|
|
14273
|
+
(fires `restoring`/`restored`), or test whether it's currently trashed.
|
|
13666
14274
|
|
|
13667
14275
|
#### `fill(attributes)`
|
|
13668
14276
|
|
|
@@ -13792,6 +14400,52 @@ joined with `_` (User + Role → `role_user`). The pivot keys default to
|
|
|
13792
14400
|
`<model>_<primaryKey>`. Reads as two `whereIn` queries (no JOIN), so it stays
|
|
13793
14401
|
edge-safe.
|
|
13794
14402
|
|
|
14403
|
+
#### `morphMany(related, name, localKey?)` · `morphOne(related, name, localKey?)`
|
|
14404
|
+
|
|
14405
|
+
`morphMany<T>(related: ModelClass<T>, name: string, localKey?: string): MorphMany<T>`
|
|
14406
|
+
|
|
14407
|
+
The parent side of a polymorphic relation. Related rows carry `<name>_id` +
|
|
14408
|
+
`<name>_type` (the type stored is this model's class name). `MorphMany` also has
|
|
14409
|
+
`.create(attributes)`, which fills the morph keys.
|
|
14410
|
+
|
|
14411
|
+
```ts
|
|
14412
|
+
comments() { return this.morphMany(Comment, "commentable"); }
|
|
14413
|
+
```
|
|
14414
|
+
|
|
14415
|
+
#### `morphTo(name, idColumn?, typeColumn?)`
|
|
14416
|
+
|
|
14417
|
+
`morphTo(name: string, idColumn?: string, typeColumn?: string): MorphTo`
|
|
14418
|
+
|
|
14419
|
+
The owning side — resolves the parent from the stored `<name>_type` (via
|
|
14420
|
+
[`registerMorphType`](#registermorphtypetype-model)) and `<name>_id`. Awaitable;
|
|
14421
|
+
returns the parent model or `null`.
|
|
14422
|
+
|
|
14423
|
+
```ts
|
|
14424
|
+
commentable() { return this.morphTo("commentable"); }
|
|
14425
|
+
```
|
|
14426
|
+
|
|
14427
|
+
#### `registerMorphType(type, model)`
|
|
14428
|
+
|
|
14429
|
+
`registerMorphType(type: string, related: ModelClass<Model>): void`
|
|
14430
|
+
|
|
14431
|
+
Register a model under a morph-type string (usually its class name) so `morphTo`
|
|
14432
|
+
can resolve it. Call once at boot for each owner type.
|
|
14433
|
+
|
|
14434
|
+
### `ModelQuery`
|
|
14435
|
+
|
|
14436
|
+
The model-aware builder returned by `Model.query()`, `Model.newQuery()`, and the
|
|
14437
|
+
`with`/`whereHas`/`withCount` shortcuts. It proxies the query-builder constraint
|
|
14438
|
+
methods (`where`, `orderBy`, `limit`, …) and hydrates results to models, adding:
|
|
14439
|
+
|
|
14440
|
+
- `with(...names)` — eager-load relations; dotted paths nest (`"posts.comments"`).
|
|
14441
|
+
- `withCount(...names)` — add `<relation>_count` to each result.
|
|
14442
|
+
- `has(name)` / `whereHas(name, constrain?)` / `doesntHave(name, constrain?)` —
|
|
14443
|
+
filter by relationship existence.
|
|
14444
|
+
- Terminals `get()`, `first()`, `count()`, `exists()`, `paginate(page?, perPage?)`.
|
|
14445
|
+
|
|
14446
|
+
Existence filters and counts use the same driver-agnostic two-query strategy as
|
|
14447
|
+
the relations (no JOIN). `toBase()` returns the underlying `QueryBuilder`.
|
|
14448
|
+
|
|
13795
14449
|
### Relations
|
|
13796
14450
|
|
|
13797
14451
|
You never `new` these — a relationship method (`user.posts()`) returns one. Each
|
|
@@ -17377,6 +18031,189 @@ Optional capabilities: `metadata` / `copy` / `move` / `signedUrl` /
|
|
|
17377
18031
|
|
|
17378
18032
|
|
|
17379
18033
|
|
|
18034
|
+
---
|
|
18035
|
+
|
|
18036
|
+
<!-- source: docs/teams.md -->
|
|
18037
|
+
|
|
18038
|
+
# Teams
|
|
18039
|
+
|
|
18040
|
+
Multi-tenancy, membership, roles, and invitations — where a row belongs to a team,
|
|
18041
|
+
and one team can never see another's.
|
|
18042
|
+
|
|
18043
|
+
```ts
|
|
18044
|
+
// bootstrap/providers.ts
|
|
18045
|
+
import { TeamsServiceProvider } from "@shaferllc/keel/teams";
|
|
18046
|
+
|
|
18047
|
+
app.register(TeamsServiceProvider);
|
|
18048
|
+
```
|
|
18049
|
+
|
|
18050
|
+
```ts
|
|
18051
|
+
// app/Http/Kernel.ts
|
|
18052
|
+
import { teamContext } from "@shaferllc/keel/teams";
|
|
18053
|
+
|
|
18054
|
+
protected middleware = [sessionMiddleware(), teamContext()];
|
|
18055
|
+
```
|
|
18056
|
+
|
|
18057
|
+
Then a tenant-owned model is one word:
|
|
18058
|
+
|
|
18059
|
+
```ts
|
|
18060
|
+
import { TenantModel } from "@shaferllc/keel/teams";
|
|
18061
|
+
|
|
18062
|
+
class Post extends TenantModel {
|
|
18063
|
+
static table = "posts";
|
|
18064
|
+
}
|
|
18065
|
+
|
|
18066
|
+
await Post.all(); // only the current team's posts
|
|
18067
|
+
await Post.create({ title: "Hi" }); // stamped with the current team
|
|
18068
|
+
```
|
|
18069
|
+
|
|
18070
|
+
## Isolation is the default, not a habit
|
|
18071
|
+
|
|
18072
|
+
Two halves, and both matter.
|
|
18073
|
+
|
|
18074
|
+
**Reads** are constrained by a global scope on `TenantModel`, so every query the
|
|
18075
|
+
model builds carries the team — including `find()`. Naming another team's row by its
|
|
18076
|
+
id returns `null`, not that row. This is the difference between tenancy and a list
|
|
18077
|
+
filter: a filter you forget on one endpoint is a leak; a scope you never write can't
|
|
18078
|
+
be forgotten.
|
|
18079
|
+
|
|
18080
|
+
**Writes** are stamped by a `creating` hook, so a row cannot be born ownerless and
|
|
18081
|
+
end up visible to everyone (or to no one).
|
|
18082
|
+
|
|
18083
|
+
## No team means an error, not "everything"
|
|
18084
|
+
|
|
18085
|
+
A queued job, a console command, a webhook, a seeder — none of them run inside a
|
|
18086
|
+
request, so none of them have a team. **A tenant query there throws.**
|
|
18087
|
+
|
|
18088
|
+
```ts
|
|
18089
|
+
await Post.all();
|
|
18090
|
+
// Error: No team in context, so a tenant-scoped query can't be built safely.
|
|
18091
|
+
// Inside a request, add teamContext() to your middleware.
|
|
18092
|
+
// In a job, command, or seeder, wrap the work: runForTeam(team, () => …).
|
|
18093
|
+
// If it genuinely spans every team, say so: withoutTenant(() => …).
|
|
18094
|
+
```
|
|
18095
|
+
|
|
18096
|
+
This is the security model, and the alternatives are worse:
|
|
18097
|
+
|
|
18098
|
+
| If no team meant… | Then |
|
|
18099
|
+
| --- | --- |
|
|
18100
|
+
| *unscoped* | every background job sees every tenant's rows — this is how customer A's invoice reaches customer B |
|
|
18101
|
+
| `teamId = NULL` | jobs match nothing, "work" fine, and quietly do nothing for a month |
|
|
18102
|
+
| **an error** | a job that forgot **crashes in development** instead of leaking in production |
|
|
18103
|
+
|
|
18104
|
+
So a job says which team it's for:
|
|
18105
|
+
|
|
18106
|
+
```ts
|
|
18107
|
+
await runForTeam(team, () => sendInvoices());
|
|
18108
|
+
```
|
|
18109
|
+
|
|
18110
|
+
...or says, out loud, that it isn't for one:
|
|
18111
|
+
|
|
18112
|
+
```ts
|
|
18113
|
+
await withoutTenant(() => Post.withoutGlobalScope(TENANT_SCOPE).get());
|
|
18114
|
+
```
|
|
18115
|
+
|
|
18116
|
+
Both are named calls you can **grep for at audit time**. That's the point: crossing a
|
|
18117
|
+
tenant boundary should be something you typed, never something you arrived at by
|
|
18118
|
+
forgetting a `where`.
|
|
18119
|
+
|
|
18120
|
+
> Your jobs will crash until each one is wrapped. That friction is the feature — it
|
|
18121
|
+
> is a loud failure in development in exchange for not having a silent one in
|
|
18122
|
+
> production.
|
|
18123
|
+
|
|
18124
|
+
The context lives in `AsyncLocalStorage`, not a module global, so two concurrent
|
|
18125
|
+
requests can't see each other's team.
|
|
18126
|
+
|
|
18127
|
+
## Teams and membership
|
|
18128
|
+
|
|
18129
|
+
```ts
|
|
18130
|
+
const team = await createTeam("Acme", user.id); // creator becomes the owner
|
|
18131
|
+
|
|
18132
|
+
await teamsFor(user.id); // the teams a user is in
|
|
18133
|
+
await roleOf(user.id, team.id); // "owner" | "admin" | "member" | null
|
|
18134
|
+
await memberOf(user.id, team.id, "admin");
|
|
18135
|
+
await switchTeam(user.id, team.id); // false if they aren't a member
|
|
18136
|
+
```
|
|
18137
|
+
|
|
18138
|
+
A user is in a team **if and only if a membership row says so**. `teams.owner_id` is a
|
|
18139
|
+
convenience, not an authorization source.
|
|
18140
|
+
|
|
18141
|
+
`switchTeam()` verifies membership, and so does `teamContext()` on every request —
|
|
18142
|
+
`users.current_team_id` is just a number on a row the user can influence, so it is
|
|
18143
|
+
checked, never trusted. Without that, switching teams would be a matter of writing
|
|
18144
|
+
someone else's id onto your own row.
|
|
18145
|
+
|
|
18146
|
+
`Team` and `Membership` are deliberately **not** tenant-scoped: "which teams am I in?"
|
|
18147
|
+
is a question you have to answer *before* you know which team you're in.
|
|
18148
|
+
|
|
18149
|
+
## Roles
|
|
18150
|
+
|
|
18151
|
+
`owner` > `admin` > `member`, ordered — an owner can do anything an admin can.
|
|
18152
|
+
|
|
18153
|
+
```ts
|
|
18154
|
+
router.delete("/posts/:post", …).middleware(requireRole("admin"));
|
|
18155
|
+
```
|
|
18156
|
+
|
|
18157
|
+
```ts
|
|
18158
|
+
roleAtLeast("owner", "admin"); // true
|
|
18159
|
+
roleAtLeast("member", "admin"); // false
|
|
18160
|
+
```
|
|
18161
|
+
|
|
18162
|
+
## Invitations
|
|
18163
|
+
|
|
18164
|
+
```ts
|
|
18165
|
+
const { token } = await invite(team.id, "grace@example.com", "admin");
|
|
18166
|
+
await acceptInvitation(token, user.id, user.email);
|
|
18167
|
+
|
|
18168
|
+
await pendingInvitations(team.id);
|
|
18169
|
+
await revokeInvitation(id);
|
|
18170
|
+
```
|
|
18171
|
+
|
|
18172
|
+
Unlike a password-reset link, an invitation **is** a database row — it has to be
|
|
18173
|
+
listable ("3 pending") and revocable, and you can't revoke a stateless token. Only the
|
|
18174
|
+
token's **hash** is stored, so a database leak doesn't open every pending team.
|
|
18175
|
+
|
|
18176
|
+
The invited address is re-checked on accept, so a **forwarded link doesn't let someone
|
|
18177
|
+
else join** in the invitee's place — which is the interesting attack on an invitation
|
|
18178
|
+
system. Invitations are single-use, expire (72h by default), and re-inviting the same
|
|
18179
|
+
address replaces the outstanding invitation rather than stacking duplicates.
|
|
18180
|
+
|
|
18181
|
+
## Personal teams
|
|
18182
|
+
|
|
18183
|
+
On by default: every new user gets a team of their own, and a solo user is simply a
|
|
18184
|
+
team of one.
|
|
18185
|
+
|
|
18186
|
+
Worth leaving on even for an app that feels single-user. **Tenancy is not a feature
|
|
18187
|
+
you can add later** — bolting a `team_id` onto a schema that already has customer data
|
|
18188
|
+
means a backfill, a migration on every table, and rewriting every query. Ignoring a
|
|
18189
|
+
team you have costs one unused row. Needing a team you don't have costs a weekend.
|
|
18190
|
+
|
|
18191
|
+
## Configuration
|
|
18192
|
+
|
|
18193
|
+
```bash
|
|
18194
|
+
keel vendor:publish --tag teams-config
|
|
18195
|
+
```
|
|
18196
|
+
|
|
18197
|
+
```ts
|
|
18198
|
+
export default {
|
|
18199
|
+
userTable: "users",
|
|
18200
|
+
personalTeams: true,
|
|
18201
|
+
invitations: { expiresInHours: 72, url: "/invitations/:token" },
|
|
18202
|
+
};
|
|
18203
|
+
```
|
|
18204
|
+
|
|
18205
|
+
## The schema
|
|
18206
|
+
|
|
18207
|
+
| Table | |
|
|
18208
|
+
| --- | --- |
|
|
18209
|
+
| `teams` | name, slug, owner_id |
|
|
18210
|
+
| `team_memberships` | team_id, user_id, role — **unique per (team, user)**, enforced by the database |
|
|
18211
|
+
| `team_invitations` | team_id, email, role, token **hash**, expires_at |
|
|
18212
|
+
|
|
18213
|
+
Plus `current_team_id` on your users table.
|
|
18214
|
+
|
|
18215
|
+
|
|
18216
|
+
|
|
17380
18217
|
---
|
|
17381
18218
|
|
|
17382
18219
|
<!-- source: docs/telemetry.md -->
|