@shaferllc/keel 0.81.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.
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The teams schema: teams, memberships, invitations, and one column on users.
3
+ *
4
+ * `team_memberships` is what actually grants access — `teams.owner_id` is a
5
+ * convenience, not an authorization source. A user is in a team if and only if a
6
+ * membership row says so.
7
+ */
8
+ import type { Migration } from "../core/migrations.js";
9
+ export declare function teamsMigration(userTable?: string): Migration;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The teams schema: teams, memberships, invitations, and one column on users.
3
+ *
4
+ * `team_memberships` is what actually grants access — `teams.owner_id` is a
5
+ * convenience, not an authorization source. A user is in a team if and only if a
6
+ * membership row says so.
7
+ */
8
+ export function teamsMigration(userTable = "users") {
9
+ return {
10
+ name: "teams_00_create_teams",
11
+ async up(schema) {
12
+ await schema.createTable("teams", (t) => {
13
+ t.id();
14
+ t.string("name");
15
+ t.string("slug").unique();
16
+ t.integer("owner_id");
17
+ t.timestamps();
18
+ });
19
+ await schema.createTable("team_memberships", (t) => {
20
+ t.id();
21
+ t.integer("team_id");
22
+ t.integer("user_id");
23
+ // owner | admin | member
24
+ t.string("role", 32).default("member");
25
+ t.timestamps();
26
+ });
27
+ await schema.createTable("team_invitations", (t) => {
28
+ t.id();
29
+ t.integer("team_id");
30
+ t.string("email");
31
+ t.string("role", 32).default("member");
32
+ // The token's HASH. The token itself lives in the invitee's inbox and
33
+ // nowhere else, so a database leak doesn't open every pending team.
34
+ t.string("token");
35
+ t.timestamp("expires_at");
36
+ t.timestamps();
37
+ });
38
+ // One membership per user per team, enforced by the database rather than by
39
+ // remembering to check — accepting an invitation twice must not double up.
40
+ await schema.raw("CREATE UNIQUE INDEX IF NOT EXISTS team_memberships_unique ON team_memberships (team_id, user_id)");
41
+ await schema.raw("CREATE INDEX IF NOT EXISTS team_invitations_email ON team_invitations (email)");
42
+ // Which team the user is currently looking at.
43
+ await schema.raw(`ALTER TABLE ${userTable} ADD COLUMN current_team_id INTEGER`);
44
+ },
45
+ async down(schema) {
46
+ await schema.dropTable("team_invitations");
47
+ await schema.dropTable("team_memberships");
48
+ await schema.dropTable("teams");
49
+ await schema.raw(`ALTER TABLE ${userTable} DROP COLUMN current_team_id`).catch(() => { });
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Teams and membership.
3
+ *
4
+ * These are plain models, deliberately **not** `TenantModel` — the tenant scope
5
+ * constrains queries to the current team, and you have to be able to ask "which
6
+ * teams does this user belong to?" *before* you know which team you're in.
7
+ */
8
+ import { Model } from "../core/model.js";
9
+ /** Who can do what. Ordered: an owner can do anything an admin can, and so on. */
10
+ export declare const ROLES: readonly ["owner", "admin", "member"];
11
+ export type Role = (typeof ROLES)[number];
12
+ /** Does `role` carry at least the authority of `needed`? */
13
+ export declare function roleAtLeast(role: Role, needed: Role): boolean;
14
+ export declare class Team extends Model {
15
+ static table: string;
16
+ static fillable: string[];
17
+ static timestamps: boolean;
18
+ id: number;
19
+ name: string;
20
+ slug: string;
21
+ owner_id: number;
22
+ /** Everyone in this team, with their role. */
23
+ members(): Promise<Membership[]>;
24
+ }
25
+ export declare class Membership extends Model {
26
+ static table: string;
27
+ static fillable: string[];
28
+ static timestamps: boolean;
29
+ id: number;
30
+ team_id: number;
31
+ user_id: number;
32
+ role: Role;
33
+ }
34
+ /** The teams a user belongs to. */
35
+ export declare function teamsFor(userId: string | number): Promise<Team[]>;
36
+ /** A user's role in a team, or `null` if they aren't in it. */
37
+ export declare function roleOf(userId: string | number, teamId: string | number): Promise<Role | null>;
38
+ /** Is this user in this team, at least at this level? */
39
+ export declare function memberOf(userId: string | number, teamId: string | number, atLeast?: Role): Promise<boolean>;
40
+ /**
41
+ * Create a team and make its creator the owner.
42
+ *
43
+ * The membership row is what actually grants access — `teams.owner_id` alone would
44
+ * leave the owner out of every membership query.
45
+ */
46
+ export declare function createTeam(name: string, ownerId: string | number, slug?: string): Promise<Team>;
47
+ /**
48
+ * Switch which team a user is acting as — **only** to a team they're actually in.
49
+ *
50
+ * The check is the point. `current_team_id` is a number on the user's own row; if
51
+ * switching didn't verify membership, changing teams would just be a matter of
52
+ * writing someone else's id into it.
53
+ */
54
+ export declare function switchTeam(userId: string | number, teamId: string | number, userTable?: string): Promise<boolean>;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Teams and membership.
3
+ *
4
+ * These are plain models, deliberately **not** `TenantModel` — the tenant scope
5
+ * constrains queries to the current team, and you have to be able to ask "which
6
+ * teams does this user belong to?" *before* you know which team you're in.
7
+ */
8
+ import { Model } from "../core/model.js";
9
+ import { db } from "../core/database.js";
10
+ /** Who can do what. Ordered: an owner can do anything an admin can, and so on. */
11
+ export const ROLES = ["owner", "admin", "member"];
12
+ const RANK = { owner: 3, admin: 2, member: 1 };
13
+ /** Does `role` carry at least the authority of `needed`? */
14
+ export function roleAtLeast(role, needed) {
15
+ return RANK[role] >= RANK[needed];
16
+ }
17
+ export class Team extends Model {
18
+ static table = "teams";
19
+ static fillable = ["name", "slug", "owner_id"];
20
+ static timestamps = true;
21
+ /** Everyone in this team, with their role. */
22
+ async members() {
23
+ const rows = await db(Membership.table).where("team_id", this.id).get();
24
+ return rows.map((row) => new Membership(row));
25
+ }
26
+ }
27
+ export class Membership extends Model {
28
+ static table = "team_memberships";
29
+ static fillable = ["team_id", "user_id", "role"];
30
+ static timestamps = true;
31
+ }
32
+ /* --------------------------------- lookups -------------------------------- */
33
+ /** The teams a user belongs to. */
34
+ export async function teamsFor(userId) {
35
+ const memberships = await db(Membership.table).where("user_id", userId).get();
36
+ if (!memberships.length)
37
+ return [];
38
+ const ids = memberships.map((m) => m.team_id);
39
+ const rows = await db(Team.table).whereIn("id", ids).get();
40
+ return rows.map((row) => new Team(row));
41
+ }
42
+ /** A user's role in a team, or `null` if they aren't in it. */
43
+ export async function roleOf(userId, teamId) {
44
+ const row = await db(Membership.table).where("user_id", userId).where("team_id", teamId).first();
45
+ return row ? row.role : null;
46
+ }
47
+ /** Is this user in this team, at least at this level? */
48
+ export async function memberOf(userId, teamId, atLeast = "member") {
49
+ const role = await roleOf(userId, teamId);
50
+ return role ? roleAtLeast(role, atLeast) : false;
51
+ }
52
+ /**
53
+ * Create a team and make its creator the owner.
54
+ *
55
+ * The membership row is what actually grants access — `teams.owner_id` alone would
56
+ * leave the owner out of every membership query.
57
+ */
58
+ export async function createTeam(name, ownerId, slug) {
59
+ const team = await Team.create({
60
+ name,
61
+ slug: slug ?? slugify(name),
62
+ owner_id: ownerId,
63
+ });
64
+ await Membership.create({ team_id: team.id, user_id: ownerId, role: "owner" });
65
+ return team;
66
+ }
67
+ /**
68
+ * Switch which team a user is acting as — **only** to a team they're actually in.
69
+ *
70
+ * The check is the point. `current_team_id` is a number on the user's own row; if
71
+ * switching didn't verify membership, changing teams would just be a matter of
72
+ * writing someone else's id into it.
73
+ */
74
+ export async function switchTeam(userId, teamId, userTable = "users") {
75
+ if (!(await memberOf(userId, teamId)))
76
+ return false;
77
+ await db(userTable).where("id", userId).update({ current_team_id: teamId });
78
+ return true;
79
+ }
80
+ function slugify(name) {
81
+ return name
82
+ .toLowerCase()
83
+ .replace(/[^a-z0-9]+/g, "-")
84
+ .replace(/^-|-$/g, "");
85
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Teams — multi-tenancy, membership, roles, and invitations, shipped as a Keel
3
+ * package. One line in `bootstrap/providers.ts`:
4
+ *
5
+ * app.register(TeamsServiceProvider)
6
+ *
7
+ * Then put your requests inside a team, and every `TenantModel` is scoped:
8
+ *
9
+ * // app/Http/Kernel.ts
10
+ * protected middleware = [sessionMiddleware(), teamContext()];
11
+ */
12
+ import { PackageProvider } from "../core/package.js";
13
+ export declare class TeamsServiceProvider extends PackageProvider {
14
+ readonly name = "teams";
15
+ private config;
16
+ register(): void;
17
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Teams — multi-tenancy, membership, roles, and invitations, shipped as a Keel
3
+ * package. One line in `bootstrap/providers.ts`:
4
+ *
5
+ * app.register(TeamsServiceProvider)
6
+ *
7
+ * Then put your requests inside a team, and every `TenantModel` is scoped:
8
+ *
9
+ * // app/Http/Kernel.ts
10
+ * protected middleware = [sessionMiddleware(), teamContext()];
11
+ */
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, join } from "node:path";
14
+ import { PackageProvider } from "../core/package.js";
15
+ import { defaultConfig, resolveConfig } from "./config.js";
16
+ import { teamsMigration } from "./migration.js";
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ export class TeamsServiceProvider extends PackageProvider {
19
+ name = "teams";
20
+ config;
21
+ register() {
22
+ this.mergeConfig("teams", defaultConfig);
23
+ this.config = resolveConfig();
24
+ this.migrations([teamsMigration(this.config.userTable)]);
25
+ this.publishes({ [join(here, "teams.config.stub")]: "config/teams.ts" }, "teams-config");
26
+ }
27
+ }
@@ -0,0 +1,24 @@
1
+ import { env } from "@shaferllc/keel/core";
2
+
3
+ /**
4
+ * Teams configuration — multi-tenancy, membership, roles, invitations.
5
+ * Published with `keel vendor:publish --tag teams-config`.
6
+ */
7
+ export default {
8
+ // The users table. Teams adds `current_team_id` to it and touches nothing else.
9
+ userTable: "users",
10
+
11
+ // Give every new user a team of their own. Worth leaving on even if the app feels
12
+ // single-user: a personal workspace is a team of one, and adding tenancy LATER
13
+ // means backfilling every table and rewriting every query.
14
+ personalTeams: true,
15
+
16
+ invitations: {
17
+ expiresInHours: 72,
18
+ url: "/invitations/:token",
19
+ },
20
+
21
+ mail: {
22
+ from: env("MAIL_FROM", undefined),
23
+ },
24
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `TenantModel` — a model that belongs to a team, and can't be read or written
3
+ * outside one.
4
+ *
5
+ * class Post extends TenantModel {
6
+ * static table = "posts";
7
+ * }
8
+ *
9
+ * await Post.all(); // only the current team's posts
10
+ * await Post.create({ title: … }); // stamped with the current team
11
+ *
12
+ * Two halves, and both matter. The **read** side is a global scope, so every query
13
+ * the model builds is constrained — you cannot forget it, because you never write
14
+ * it. The **write** side is a `creating` hook that stamps the team id, so a row
15
+ * can't be born ownerless and then be visible to everyone (or to no one).
16
+ *
17
+ * Outside a team context both halves throw. See context.ts for why that's the point.
18
+ */
19
+ import { Model } from "../core/model.js";
20
+ /** The name of the scope, so it can be escaped by name: `Post.withoutGlobalScope(TENANT_SCOPE)`. */
21
+ export declare const TENANT_SCOPE = "tenant";
22
+ export declare class TenantModel extends Model {
23
+ /** The column holding the owning team. Override if your schema differs. */
24
+ static teamColumn: string;
25
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `TenantModel` — a model that belongs to a team, and can't be read or written
3
+ * outside one.
4
+ *
5
+ * class Post extends TenantModel {
6
+ * static table = "posts";
7
+ * }
8
+ *
9
+ * await Post.all(); // only the current team's posts
10
+ * await Post.create({ title: … }); // stamped with the current team
11
+ *
12
+ * Two halves, and both matter. The **read** side is a global scope, so every query
13
+ * the model builds is constrained — you cannot forget it, because you never write
14
+ * it. The **write** side is a `creating` hook that stamps the team id, so a row
15
+ * can't be born ownerless and then be visible to everyone (or to no one).
16
+ *
17
+ * Outside a team context both halves throw. See context.ts for why that's the point.
18
+ */
19
+ import { Model } from "../core/model.js";
20
+ import { addModelHook } from "../core/model-events.js";
21
+ import { currentTeamId } from "./context.js";
22
+ /** The name of the scope, so it can be escaped by name: `Post.withoutGlobalScope(TENANT_SCOPE)`. */
23
+ export const TENANT_SCOPE = "tenant";
24
+ export class TenantModel extends Model {
25
+ /** The column holding the owning team. Override if your schema differs. */
26
+ static teamColumn = "team_id";
27
+ }
28
+ // Registered on the base class, and inherited by every subclass — that inheritance
29
+ // is load-bearing. If it didn't hold, `Post.query()` would come back unconstrained
30
+ // and every tenant's rows would be readable, silently. (Both the scope and the hook
31
+ // walk the prototype chain; see model.ts / model-events.ts.)
32
+ TenantModel.addGlobalScope(TENANT_SCOPE, (query, model) => {
33
+ // `model` is the concrete subclass being queried, so each one gets its own
34
+ // teamColumn rather than the base class's.
35
+ query.where(model.teamColumn, currentTeamId());
36
+ });
37
+ addModelHook(TenantModel, "creating", (model) => {
38
+ const column = model.constructor.teamColumn;
39
+ const row = model;
40
+ // Don't overwrite an explicit team — a deliberate cross-team write (an admin tool,
41
+ // a transfer) already said what it meant. Everything else gets the current team.
42
+ if (row[column] === undefined || row[column] === null) {
43
+ row[column] = currentTeamId();
44
+ }
45
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.81.0",
3
+ "version": "0.81.1",
4
4
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
5
5
  "repo": "https://github.com/shaferllc/keel",
6
6
  "generated": "run `npm run build:ai` to regenerate",
@@ -362,6 +362,13 @@
362
362
  "path": "docs/storage.md",
363
363
  "example": "docs/examples/storage.ts"
364
364
  },
365
+ {
366
+ "slug": "teams",
367
+ "title": "Teams",
368
+ "summary": "Multi-tenancy, membership, roles, and invitations — where a row belongs to a team, and one team can never see another's.",
369
+ "path": "docs/teams.md",
370
+ "example": "docs/examples/teams.ts"
371
+ },
365
372
  {
366
373
  "slug": "telemetry",
367
374
  "title": "Telemetry",
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