@shaferllc/keel 0.81.0 → 0.81.2

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/docs/database.md CHANGED
@@ -609,6 +609,53 @@ await db("sessions").where("expires_at", "<", now).delete();
609
609
  **Notes:** with no `where`, empties the table. There's no soft-delete here — pair
610
610
  with a `deleted_at` column and `whereNull` if you want one.
611
611
 
612
+ #### `whereColumn(first, operator?, second)` · `whereRaw(sql, bindings?)`
613
+
614
+ Compare two columns (no binding) or add a raw WHERE fragment with its own
615
+ bindings. `whereColumn("updated_at", ">", "created_at")`; `whereRaw("score >= ?", [10])`.
616
+
617
+ #### `join(table, first, operator?, second)` · `leftJoin(...)`
618
+
619
+ Add an `INNER JOIN` / `LEFT JOIN` on an equality (or the given operator).
620
+ Included in `get`, `count`, and aggregates. Qualify ambiguous columns
621
+ (`"posts.user_id"`).
622
+
623
+ #### `groupBy(...columns)` · `having(column, operator?, value)` · `distinct()`
624
+
625
+ `GROUP BY`, a bound `HAVING` predicate, and `SELECT DISTINCT`.
626
+
627
+ #### `orderByRaw(sql)` · `when(condition, then, otherwise?)`
628
+
629
+ A raw `ORDER BY` fragment; and conditional building — `then(query, value)` runs
630
+ only when `condition` is truthy, else `otherwise`.
631
+
632
+ #### `increment(column, amount?, extra?)` · `decrement(column, amount?, extra?)`
633
+
634
+ `increment(column: string, amount = 1, extra: Row = {}): Promise<WriteResult>`
635
+
636
+ Atomically `column = column ± amount` on matching rows, optionally setting other
637
+ columns in the same statement. Scope with `where`.
638
+
639
+ #### `upsert(rows, uniqueBy, update?)`
640
+
641
+ `upsert(rows: Row | Row[], uniqueBy: string[], update?: string[]): Promise<WriteResult>`
642
+
643
+ Insert rows, updating `update` columns (default: all non-unique) on a conflict
644
+ against `uniqueBy`. Dialect-aware: `ON CONFLICT … DO UPDATE` (sqlite/postgres) or
645
+ `ON DUPLICATE KEY UPDATE` (mysql).
646
+
647
+ #### `insertOrIgnore(rows)`
648
+
649
+ Insert one or more rows, skipping any that violate a unique constraint
650
+ (`INSERT OR IGNORE` / `INSERT IGNORE` / `ON CONFLICT DO NOTHING`).
651
+
652
+ #### `chunk(size, callback)`
653
+
654
+ `chunk(size: number, callback: (rows: T[]) => void | boolean | Promise<void | boolean>): Promise<void>`
655
+
656
+ Process results a page at a time so a large table never loads at once. Return
657
+ `false` from the callback to stop early. Pair with `orderBy` for a stable order.
658
+
612
659
  ### Interfaces & types
613
660
 
614
661
  #### `Connection`
@@ -0,0 +1,101 @@
1
+ // Typechecked example for docs/teams.md.
2
+ import { Application, sessionMiddleware } from "@shaferllc/keel/core";
3
+ import {
4
+ TeamsServiceProvider,
5
+ TenantModel,
6
+ TENANT_SCOPE,
7
+ acceptInvitation,
8
+ createTeam,
9
+ currentTeam,
10
+ invite,
11
+ memberOf,
12
+ pendingInvitations,
13
+ requireRole,
14
+ revokeInvitation,
15
+ roleAtLeast,
16
+ roleOf,
17
+ runForTeam,
18
+ switchTeam,
19
+ teamContext,
20
+ teamsFor,
21
+ withoutTenant,
22
+ } from "@shaferllc/keel/teams";
23
+
24
+ const app = new Application();
25
+ app.register(TeamsServiceProvider);
26
+
27
+ // Put every request inside a team; TenantModel queries are scoped from then on.
28
+ const middleware = [sessionMiddleware(), teamContext()];
29
+
30
+ /* -------------------------------- a tenant model -------------------------- */
31
+
32
+ class Post extends TenantModel {
33
+ static override table = "posts";
34
+ static override fillable = ["title"];
35
+
36
+ declare id: number;
37
+ declare title: string;
38
+ }
39
+
40
+ async function inAHandler() {
41
+ const mine = await Post.all(); // only the current team's
42
+ const one = await Post.find(1); // null if it belongs to another team
43
+ const made = await Post.create({ title: "Hi" }); // stamped with the current team
44
+ return { mine, one, made, team: currentTeam() };
45
+ }
46
+
47
+ /* ------------------------------ outside a request ------------------------- */
48
+
49
+ async function aJob(team: { id: number }) {
50
+ // Without this, the query throws — it does not quietly return every team's rows.
51
+ await runForTeam(team, async () => {
52
+ await Post.all();
53
+ });
54
+ }
55
+
56
+ async function anAdminReport() {
57
+ // Crossing tenants, said out loud so it can be found at audit time.
58
+ return withoutTenant(() => Post.withoutGlobalScope(TENANT_SCOPE).get());
59
+ }
60
+
61
+ /* --------------------------------- membership ----------------------------- */
62
+
63
+ async function onboarding(userId: number) {
64
+ const team = await createTeam("Acme", userId);
65
+
66
+ await teamsFor(userId);
67
+ await roleOf(userId, team.id);
68
+ await memberOf(userId, team.id, "admin");
69
+ await switchTeam(userId, team.id); // false if they aren't a member
70
+
71
+ return team;
72
+ }
73
+
74
+ const adminsOnly = requireRole("admin");
75
+ const ordered = roleAtLeast("owner", "admin"); // true
76
+
77
+ /* -------------------------------- invitations ----------------------------- */
78
+
79
+ async function inviteSomeone(teamId: number, userId: number) {
80
+ const { token, invitation } = await invite(teamId, "grace@example.com", "admin");
81
+
82
+ await pendingInvitations(teamId);
83
+ await revokeInvitation(invitation.id);
84
+
85
+ // The invited address is re-checked, so a forwarded link can't be redeemed by
86
+ // someone else.
87
+ return acceptInvitation(token, userId, "grace@example.com");
88
+ }
89
+
90
+ export {
91
+ app,
92
+ middleware,
93
+ Post,
94
+ inAHandler,
95
+ aJob,
96
+ anAdminReport,
97
+ onboarding,
98
+ adminsOnly,
99
+ ordered,
100
+ inviteSomeone,
101
+ };
@@ -285,11 +285,10 @@ already gone — the typical `down()` for a `createTable`.
285
285
 
286
286
  `raw(sql: string, bindings?: unknown[]): Promise<void>`
287
287
 
288
- Runs arbitrary SQL through the connection — the escape hatch for indexes, foreign
289
- keys, and `ALTER TABLE`.
288
+ Runs arbitrary SQL through the connection — the escape hatch for anything the
289
+ builders don't cover.
290
290
 
291
291
  ```ts
292
- await schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)");
293
292
  await schema.raw("UPDATE users SET active = ? WHERE active IS NULL", [true]);
294
293
  ```
295
294
 
@@ -297,6 +296,22 @@ await schema.raw("UPDATE users SET active = ? WHERE active IS NULL", [true]);
297
296
  `raw()` does **not** rewrite `?` to `$n`, so pass `$1, $2, …` yourself on the
298
297
  `postgres` dialect.
299
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
+
300
315
  ### `TableBuilder`
301
316
 
302
317
  Describes a table's columns. **You get one from the `createTable` callback** — it
@@ -413,6 +428,27 @@ t.toCreateSql("users", "postgres");
413
428
  // CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL)
414
429
  ```
415
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
+
416
452
  #### `columns`
417
453
 
418
454
  `readonly columns: Column[]`
@@ -420,6 +456,21 @@ t.toCreateSql("users", "postgres");
420
456
  The `Column` instances added so far, in declaration order. Read-only inspection
421
457
  seam; you rarely touch it.
422
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
+
423
474
  ### `Column`
424
475
 
425
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
@@ -219,6 +219,31 @@ await Post.all(); // only published
219
219
  await Post.query().where("author_id", 1).get(); // still only published
220
220
  ```
221
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
+
222
247
  A **local scope** is just a static method returning a query — no framework
223
248
  feature needed:
224
249
 
@@ -532,6 +557,55 @@ Rarely called directly — `create`/`save` use it internally.
532
557
  const storable = Post.toDatabase({ published: true }); // { published: 1 }
533
558
  ```
534
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
+
535
609
  ### `Model` — configuration statics
536
610
 
537
611
  Set these on the subclass to configure it. All have defaults.
@@ -592,6 +666,21 @@ class Post extends Model {
592
666
  }
593
667
  ```
594
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
+
595
684
  ### `Model` — instance methods
596
685
 
597
686
  #### `new Model(attributes?)`
@@ -630,14 +719,22 @@ still issues the query.
630
719
 
631
720
  `delete(): Promise<void>`
632
721
 
633
- 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`.
634
724
 
635
725
  ```ts
636
726
  await user.delete();
637
727
  ```
638
728
 
639
- **Notes:** keys off the current `primaryKey` value; on a model without one, the
640
- `where` binds `undefined`. Hard delete only — there's no soft-delete built in.
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.
641
738
 
642
739
  #### `fill(attributes)`
643
740
 
@@ -767,6 +864,52 @@ joined with `_` (User + Role → `role_user`). The pivot keys default to
767
864
  `<model>_<primaryKey>`. Reads as two `whereIn` queries (no JOIN), so it stays
768
865
  edge-safe.
769
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
+
770
913
  ### Relations
771
914
 
772
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.