@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.
- package/dist/core/model-events.js +20 -2
- package/dist/core/model.d.ts +20 -2
- package/dist/core/model.js +50 -4
- 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/ai-manifest.json +8 -1
- package/docs/database.md +47 -0
- package/docs/examples/teams.ts +101 -0
- package/docs/migrations.md +54 -3
- package/docs/models.md +146 -3
- package/docs/teams.md +176 -0
- package/llms-full.txt +430 -6
- package/llms.txt +2 -0
- package/package.json +6 -2
|
@@ -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
|
+
});
|
package/docs/ai-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shaferllc/keel",
|
|
3
|
-
"version": "0.81.
|
|
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
|
+
};
|
package/docs/migrations.md
CHANGED
|
@@ -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
|
|
289
|
-
|
|
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
|