@snolla/data 0.0.0-stage → 1.0.7

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.
Files changed (67) hide show
  1. package/dist/actor.d.ts +28 -0
  2. package/dist/actor.js +63 -0
  3. package/dist/audit.d.ts +36 -0
  4. package/dist/audit.js +198 -0
  5. package/dist/dataProvider.d.ts +41 -0
  6. package/dist/dataProvider.js +148 -0
  7. package/dist/dataSource.d.ts +4 -0
  8. package/dist/dataSource.js +65 -0
  9. package/dist/dbProvider.d.ts +1261 -0
  10. package/dist/dbProvider.js +1898 -0
  11. package/dist/freeze.d.ts +29 -0
  12. package/dist/freeze.js +69 -0
  13. package/dist/guard.d.ts +53 -0
  14. package/dist/guard.js +397 -0
  15. package/dist/insert.d.ts +9 -0
  16. package/dist/insert.js +44 -0
  17. package/dist/migrations/siteid.d.mts +738 -0
  18. package/dist/migrations/siteid.mjs +512 -0
  19. package/dist/publish.d.ts +1 -0
  20. package/dist/publish.js +1 -0
  21. package/dist/schema/dsl.d.ts +64 -0
  22. package/dist/schema/dsl.js +28 -0
  23. package/dist/schema/generate.d.mts +14 -0
  24. package/dist/schema/generate.mjs +282 -0
  25. package/dist/schema/generated/ddl.d.ts +7 -0
  26. package/dist/schema/generated/ddl.js +11 -0
  27. package/dist/schema/generated/kysely-types.d.ts +1988 -0
  28. package/dist/schema/generated/kysely-types.js +5 -0
  29. package/dist/schema/index.d.ts +2 -0
  30. package/dist/schema/index.js +7 -0
  31. package/dist/schema/provision.d.mts +11 -0
  32. package/dist/schema/provision.mjs +111 -0
  33. package/dist/schema/tables.d.ts +2 -0
  34. package/dist/schema/tables.js +3301 -0
  35. package/dist/scripts/canon-audit.d.mts +25 -0
  36. package/dist/scripts/canon-audit.mjs +213 -0
  37. package/dist/scripts/canon-smoke.d.mts +1 -0
  38. package/dist/scripts/canon-smoke.mjs +44 -0
  39. package/dist/scripts/dialect-ddl-stand.d.mts +1 -0
  40. package/dist/scripts/dialect-ddl-stand.mjs +136 -0
  41. package/dist/scripts/owner-spec-check.d.mts +1 -0
  42. package/dist/scripts/owner-spec-check.mjs +166 -0
  43. package/dist/scripts/owners/owner-resolve-spec.json +540 -0
  44. package/dist/scripts/owners-audit.d.mts +60 -0
  45. package/dist/scripts/owners-audit.mjs +443 -0
  46. package/dist/scripts/owners-fixture.d.mts +20 -0
  47. package/dist/scripts/owners-fixture.mjs +192 -0
  48. package/dist/scripts/schema-lengths-audit.d.mts +22 -0
  49. package/dist/scripts/schema-lengths-audit.mjs +146 -0
  50. package/dist/scripts/siteid-migrate.d.mts +7 -0
  51. package/dist/scripts/siteid-migrate.mjs +260 -0
  52. package/dist/scripts/tenant-invariant-audit.d.mts +1 -0
  53. package/dist/scripts/tenant-invariant-audit.mjs +34 -0
  54. package/dist/singleMode.d.ts +5 -0
  55. package/dist/singleMode.js +23 -0
  56. package/dist/sqliteDialect.d.ts +6 -0
  57. package/dist/sqliteDialect.js +92 -0
  58. package/dist/templateDefs.d.ts +38 -0
  59. package/dist/templateDefs.js +163 -0
  60. package/dist/test/fixtures/broken-driver/index.d.ts +3 -0
  61. package/dist/test/fixtures/broken-driver/index.js +5 -0
  62. package/dist/test/support/dialect-stand.d.ts +37 -0
  63. package/dist/test/support/dialect-stand.js +162 -0
  64. package/dist/test/support/render-parity.d.ts +44 -0
  65. package/dist/test/support/render-parity.js +262 -0
  66. package/package.json +50 -5
  67. package/README.md +0 -3
@@ -0,0 +1,29 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { Database } from './schema/generated/kysely-types.ts';
3
+ type Db = Kysely<Database>;
4
+ /**
5
+ * The freeze flag of one site. `null` means the site is not registered in *this*
6
+ * database — a different answer from "false", and one a cutover has to see.
7
+ */
8
+ export declare const readWritable: (db: Db, siteId: string) => Promise<boolean | null>;
9
+ /**
10
+ * Freezes or unfreezes one site and reports what it was, so a runbook can log a
11
+ * transition instead of an assumption. Idempotent: freezing an already frozen
12
+ * site is a no-op that still tells the truth.
13
+ */
14
+ export declare const setWritable: (db: Db, siteId: string, writable: boolean) => Promise<{
15
+ ref: string;
16
+ previous: boolean;
17
+ writable: boolean;
18
+ }>;
19
+ /**
20
+ * A guarded handle for one site whose freeze state comes from the database rather
21
+ * than from the caller (the cell's path: authenticate, read the flag, send the
22
+ * handle up). The write grant is the caller's, and deny-by-default: without
23
+ * `{ writable: true }` the handle reads and cannot write (task:3865).
24
+ */
25
+ export declare const siteGuard: (db: Db, siteId: string, { writable, frozen }?: {
26
+ writable?: boolean;
27
+ frozen?: boolean;
28
+ }) => Promise<Kysely<Database>>;
29
+ export {};
package/dist/freeze.js ADDED
@@ -0,0 +1,69 @@
1
+ import { tenantGuard } from "./guard.js";
2
+ const REGISTRY = 'Sites';
3
+ const asBool = (value) => {
4
+ if (value === true || value === 1 || value === '1' || value === 1n)
5
+ return true;
6
+ if (value === false || value === 0 || value === '0' || value === 0n)
7
+ return false;
8
+ // neither driver form: guessing here would either freeze a site nobody froze or
9
+ // unfreeze one that was, and both are worse than a refused read
10
+ throw new Error(`freeze: unexpected Writable value ${JSON.stringify(value)} — the canon declares a boolean`);
11
+ };
12
+ const requireSiteId = (siteId) => {
13
+ if (typeof siteId !== 'string' || !siteId)
14
+ throw new Error('freeze: a site id is required');
15
+ return siteId;
16
+ };
17
+ /**
18
+ * The freeze flag of one site. `null` means the site is not registered in *this*
19
+ * database — a different answer from "false", and one a cutover has to see.
20
+ */
21
+ export const readWritable = async (db, siteId) => {
22
+ const row = await db
23
+ .selectFrom(REGISTRY)
24
+ .select('Writable')
25
+ .where('SiteId', '=', requireSiteId(siteId))
26
+ .executeTakeFirst();
27
+ if (!row)
28
+ return null;
29
+ return asBool(row.Writable);
30
+ };
31
+ /**
32
+ * Freezes or unfreezes one site and reports what it was, so a runbook can log a
33
+ * transition instead of an assumption. Idempotent: freezing an already frozen
34
+ * site is a no-op that still tells the truth.
35
+ */
36
+ export const setWritable = async (db, siteId, writable) => {
37
+ const ref = requireSiteId(siteId);
38
+ if (typeof writable !== 'boolean')
39
+ throw new Error('freeze: writable must be a boolean');
40
+ const previous = await readWritable(db, ref);
41
+ if (previous === null)
42
+ throw new Error(`freeze: site ${ref} is not registered in this database`);
43
+ if (previous !== writable) {
44
+ await db.updateTable(REGISTRY).set({ Writable: writable }).where('SiteId', '=', ref).execute();
45
+ }
46
+ // the report is read back, not assumed: if something else changed the flag under
47
+ // us, the caller sees the database's answer, not the intention
48
+ const actual = await readWritable(db, ref);
49
+ return { ref, previous, writable: actual === true };
50
+ };
51
+ /**
52
+ * A guarded handle for one site whose freeze state comes from the database rather
53
+ * than from the caller (the cell's path: authenticate, read the flag, send the
54
+ * handle up). The write grant is the caller's, and deny-by-default: without
55
+ * `{ writable: true }` the handle reads and cannot write (task:3865).
56
+ */
57
+ export const siteGuard = async (db, siteId, { writable = false, frozen } = {}) => {
58
+ // `db` is meant to be the cell's own unguarded handle: a guard stacked on a guard
59
+ // is not additive — the inner one sees the statement first and its refusals stand —
60
+ // so passing an already-guarded handle here buys nothing but confusion.
61
+ const ref = requireSiteId(siteId);
62
+ // An explicit `frozen` skips the read: the caller asks the flag per request instead
63
+ // (admin-api's cell, task:3913) and uses this handle for the write grant only — a
64
+ // boot-time flag inside a long-lived handle goes stale in both directions.
65
+ const flag = frozen === undefined ? await readWritable(db, ref) : !frozen;
66
+ if (flag === null)
67
+ throw new Error(`freeze: site ${ref} is not registered in this database`);
68
+ return db.withPlugin(tenantGuard({ kind: 'site', siteId: ref, frozen: !flag, writable }));
69
+ };
@@ -0,0 +1,53 @@
1
+ import type { KyselyPlugin } from 'kysely';
2
+ /** Where the tenant key comes from: the session/cell config, never the request. */
3
+ export type GuardMode = {
4
+ kind: 'site';
5
+ siteId: string;
6
+ /**
7
+ * Cutover freeze (task:3828, plan:50 Q6): a frozen site accepts reads and
8
+ * refuses every write. Required, not optional, on purpose — a guard that
9
+ * silently defaulted to "writable" would make the freeze a promise instead
10
+ * of a mechanism. Whoever builds a site handle states the flag, and the one
11
+ * path that reads it from the database is `siteGuard` (freeze.ts), which the
12
+ * cell is meant to use: `Sites.Writable` in, guard mode out.
13
+ */
14
+ frozen: boolean;
15
+ /**
16
+ * The write grant (task:3865): `frozen` says what the site is
17
+ * (`Sites.Writable`), `writable` says what this handle may do. Optional and
18
+ * deny-by-default — a reader does not state it, a writer must: writes go
19
+ * through only when `writable === true && !frozen`, and a missing grant is
20
+ * `SITE_READ_ONLY` rather than a silent write.
21
+ */
22
+ writable?: boolean;
23
+ } | {
24
+ kind: 'control';
25
+ }
26
+ /**
27
+ * Declared transfer (task:3816): the only way a row changes tenant. Both
28
+ * ends are pinned by the context — the source predicate and the target key
29
+ * — so the payload still cannot name a tenant.
30
+ *
31
+ * A transfer writes (it rewrites the tenant key on the source's rows), so the
32
+ * write grant and the freeze answer for it exactly as they do for a site
33
+ * handle (task:3868) — EXCEPT on the system path: the janitor draining the
34
+ * system-owned bucket moves rows that never belonged to a customer and has no
35
+ * cutover window to respect. That is stated (`system: true`), never inferred.
36
+ */
37
+ | {
38
+ kind: 'transfer';
39
+ fromSiteId: string;
40
+ toSiteId: string;
41
+ /** The system path (janitor out of the sentinel bucket): not gated. */
42
+ system?: boolean;
43
+ /** The write grant, as on a site handle: a user-facing transfer needs it. */
44
+ writable?: boolean;
45
+ /** The SOURCE's state (`Sites.Writable`): a frozen source refuses a user transfer. */
46
+ frozen?: boolean;
47
+ };
48
+ export type TenantViolationCode = 'NO_SITE_CONTEXT' | 'FOREIGN_SITE_ID' | 'UNSAFE_RAW_SQL' | 'UNSUPPORTED_STATEMENT' | 'SITE_READ_ONLY' | 'SITE_FROZEN' | 'TABLE_OUTSIDE_CANON';
49
+ export declare class TenantViolation extends Error {
50
+ readonly code: TenantViolationCode;
51
+ constructor(code: TenantViolationCode, message: string);
52
+ }
53
+ export declare const tenantGuard: (mode: GuardMode) => KyselyPlugin;
package/dist/guard.js ADDED
@@ -0,0 +1,397 @@
1
+ // Tenant guard — the access-layer mechanism that keeps tenants apart
2
+ // (task:3813; decisions: plan:48 R1-Q1/R1-Q2/R1-Q4, plan:49 Q4).
3
+ //
4
+ // Why a plugin and not RLS: row-level security exists only in Postgres, so
5
+ // building isolation on it would make correctness dialect-specific and break
6
+ // R2 ([[requirements:65]]). One Kysely plugin behaves the same on every
7
+ // dialect; on Postgres a policy may be added later as a second belt.
8
+ //
9
+ // The tenant key never comes from the query. A `site`-mode guard carries the
10
+ // site id it was built with, a `control`-mode guard carries none — and then it
11
+ // may not touch site-scoped tables at all (deny-by-default).
12
+ //
13
+ // Deny-by-default reaches the write path too (task:3865): a site handle is
14
+ // read-only unless its builder grants writes explicitly (`writable: true`). A
15
+ // forgotten grant costs a refused write, never a write nobody intended — and the
16
+ // freeze (`frozen`, task:3828) remains a second, independent stop.
17
+ import { canonicalSchema, isSiteScoped } from "./schema/index.js";
18
+ /** Tables that live in a site database, i.e. that must never be read unscoped. */
19
+ const SITE_TABLES = new Set(canonicalSchema.tables.filter(isSiteScoped).map((t) => t.name));
20
+ /**
21
+ * Every table the canon knows, site-scoped or not. The canon is the contract: a table it does
22
+ * not describe has no tenant key, no owner and no invariants — so the guard cannot scope it and
23
+ * must not pretend it did (task:3993: `LogEvents` fell out of the canon and its route kept
24
+ * reading every tenant, silently, because "not a site table" used to mean "not my business").
25
+ */
26
+ const CANON_TABLES = new Set(canonicalSchema.tables.map((t) => t.name));
27
+ export class TenantViolation extends Error {
28
+ code;
29
+ constructor(code, message) {
30
+ super(message);
31
+ this.name = 'TenantViolation';
32
+ this.code = code;
33
+ }
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // minimal operation-node builders (Kysely does not expose parseExpression)
37
+ // ---------------------------------------------------------------------------
38
+ const ident = (name) => ({ kind: 'IdentifierNode', name });
39
+ const tableRef = (name) => ({ kind: 'TableNode', table: ident(name) });
40
+ const columnRef = (qualifier, column) => ({
41
+ kind: 'ReferenceNode',
42
+ column: { kind: 'ColumnNode', column: ident(column) },
43
+ table: tableRef(qualifier),
44
+ });
45
+ const operator = (op) => ({ kind: 'OperatorNode', operator: op, op });
46
+ const value = (v) => ({ kind: 'ValueNode', value: v });
47
+ const equals = (left, right) => ({
48
+ kind: 'BinaryOperationNode',
49
+ leftOperand: left,
50
+ operator: operator('='),
51
+ rightOperand: right,
52
+ });
53
+ const and = (left, right) => ({
54
+ kind: 'BinaryOperationNode',
55
+ leftOperand: left,
56
+ operator: operator('and'),
57
+ rightOperand: right,
58
+ });
59
+ const parens = (node) => ({ kind: 'ParensNode', node });
60
+ const isNode = (x) => typeof x === 'object' && x !== null && typeof x.kind === 'string';
61
+ const tableNameOf = (node) => {
62
+ if (!isNode(node))
63
+ return null;
64
+ if (node.kind === 'AliasNode') {
65
+ // AliasNode: { node: TableNode, alias: IdentifierNode }
66
+ const inner = tableNameOf(node.node);
67
+ const alias = node.alias?.name;
68
+ if (inner && alias)
69
+ return { table: inner.table, qualifier: alias };
70
+ return inner;
71
+ }
72
+ if (node.kind === 'TableNode') {
73
+ const table = node.table;
74
+ const identNode = (table?.kind === 'SchemableIdentifierNode' ? table.identifier : table);
75
+ const name = identNode?.name;
76
+ return name ? { table: name, qualifier: name } : null;
77
+ }
78
+ return null;
79
+ };
80
+ const columnNameOf = (node) => {
81
+ if (!isNode(node))
82
+ return null;
83
+ if (node.kind === 'ColumnNode')
84
+ return (node.column?.name) ?? null;
85
+ if (node.kind === 'ReferenceNode')
86
+ return columnNameOf(node.column);
87
+ return null;
88
+ };
89
+ /** Tables referenced by a statement's own FROM/JOIN/target clause — every one of them. */
90
+ const directTableRefs = (node) => {
91
+ const refs = [];
92
+ const push = (n) => {
93
+ const t = tableNameOf(n);
94
+ if (t)
95
+ refs.push(t);
96
+ };
97
+ const from = node.from;
98
+ for (const f of from?.froms ?? [])
99
+ push(f);
100
+ if (from?.from)
101
+ push(from.from);
102
+ for (const join of node.joins ?? [])
103
+ push(join.table);
104
+ if (node.into)
105
+ push(node.into);
106
+ if ((node.kind === 'UpdateQueryNode' || node.kind === 'DeleteQueryNode') && node.table)
107
+ push(node.table);
108
+ return refs;
109
+ };
110
+ /** Site tables referenced by a statement's own FROM/JOIN/target clause. */
111
+ const directSiteRefs = (node) => directTableRefs(node)
112
+ .filter((ref) => SITE_TABLES.has(ref.table))
113
+ .map((ref) => ({ qualifier: ref.qualifier }));
114
+ /**
115
+ * Identifiers inside a raw fragment. Raw SQL cannot be rewritten, so the scan
116
+ * has to answer one question without guessing: does this fragment name a
117
+ * site-scoped table? A substring test answers it wrongly in both directions —
118
+ * `where "Title" = 'Pages'` is not a table reference, and a name inside a
119
+ * comment is not code — so the fragment is tokenised: quoted identifiers
120
+ * (`"Pages"`, `` `Pages` ``) and bare words are identifiers, string literals,
121
+ * dollar-quoted bodies (`$$…$$`, `$tag$…$tag$`) and comments are not. Doubled
122
+ * quotes are the standard escape; a backslash-escaped literal (MySQL) should be
123
+ * parameterised instead of written raw.
124
+ */
125
+ const sqlIdentifiers = (sql) => {
126
+ const tokens = [];
127
+ let i = 0;
128
+ while (i < sql.length) {
129
+ const ch = sql[i];
130
+ // the loop condition already bounded `i`; narrowing it once keeps the tokenizer
131
+ // readable under `noUncheckedIndexedAccess`
132
+ if (ch === undefined)
133
+ break;
134
+ if (ch === "'") {
135
+ i += 1;
136
+ while (i < sql.length) {
137
+ if (sql[i] !== "'") {
138
+ i += 1;
139
+ }
140
+ else if (sql[i + 1] === "'") {
141
+ i += 2;
142
+ }
143
+ else {
144
+ i += 1;
145
+ break;
146
+ }
147
+ }
148
+ continue;
149
+ }
150
+ if (ch === '-' && sql[i + 1] === '-') {
151
+ const end = sql.indexOf('\n', i + 2);
152
+ i = end === -1 ? sql.length : end + 1;
153
+ continue;
154
+ }
155
+ if (ch === '/' && sql[i + 1] === '*') {
156
+ const end = sql.indexOf('*/', i + 2);
157
+ i = end === -1 ? sql.length : end + 2;
158
+ continue;
159
+ }
160
+ if (ch === '"' || ch === '`') {
161
+ const quote = ch;
162
+ let name = '';
163
+ i += 1;
164
+ while (i < sql.length) {
165
+ if (sql[i] !== quote) {
166
+ name += sql[i];
167
+ i += 1;
168
+ }
169
+ else if (sql[i + 1] === quote) {
170
+ name += quote;
171
+ i += 2;
172
+ }
173
+ else {
174
+ i += 1;
175
+ break;
176
+ }
177
+ }
178
+ tokens.push(name);
179
+ continue;
180
+ }
181
+ if (ch === '$') {
182
+ // Postgres dollar-quoting: `$$…$$` or `$tag$…$tag$` is one literal;
183
+ // a lone `$1` parameter is not a quote and falls through to a word.
184
+ const opening = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i));
185
+ if (opening) {
186
+ const closeAt = sql.indexOf(opening[0], i + opening[0].length);
187
+ i = closeAt === -1 ? sql.length : closeAt + opening[0].length;
188
+ continue;
189
+ }
190
+ i += 1;
191
+ continue;
192
+ }
193
+ if (/[A-Za-z_]/.test(ch)) {
194
+ const start = i;
195
+ while (i < sql.length && /[A-Za-z0-9_$]/.test(sql[i] ?? ''))
196
+ i += 1;
197
+ tokens.push(sql.slice(start, i));
198
+ continue;
199
+ }
200
+ i += 1;
201
+ }
202
+ return tokens;
203
+ };
204
+ /** Any site-scoped table name used as an identifier in a raw fragment. */
205
+ const rawMentionsSiteTable = (fragments) => {
206
+ for (const fragment of fragments) {
207
+ if (typeof fragment !== 'string')
208
+ continue;
209
+ for (const token of sqlIdentifiers(fragment)) {
210
+ if (SITE_TABLES.has(token))
211
+ return token;
212
+ }
213
+ }
214
+ return null;
215
+ };
216
+ /** Statements that change rows — what a freeze has to stop. */
217
+ const MUTATION_KINDS = new Set(['InsertQueryNode', 'UpdateQueryNode', 'DeleteQueryNode']);
218
+ // What the grant and the freeze cover, stated so it is not assumed (review of
219
+ // task:3865): the three typed DML builders above, and raw SQL that names a
220
+ // site-scoped table (refused by the token scan, whichever statement it is).
221
+ // Kysely has no builder for TRUNCATE, MERGE or DDL, so a site handle cannot reach
222
+ // them through the typed API — through `sql` they are refused by name.
223
+ // The declared transfer mode (`kind: 'transfer'`) used to be exempt from both;
224
+ // task:3868 closed that: it is a write to the source, so it takes the same grant
225
+ // and the same freeze — the system path says so explicitly with `system: true`.
226
+ // ---------------------------------------------------------------------------
227
+ // rewrite
228
+ // ---------------------------------------------------------------------------
229
+ const rewrite = (node, mode, seenSiteRefs) => {
230
+ if (Array.isArray(node))
231
+ return node.map((n) => rewrite(n, mode, seenSiteRefs));
232
+ if (!isNode(node))
233
+ return node;
234
+ if (node.kind === 'RawNode') {
235
+ const mentioned = rawMentionsSiteTable(node.sqlFragments ?? []);
236
+ if (mentioned) {
237
+ throw new TenantViolation('UNSAFE_RAW_SQL', `raw SQL mentions the site-scoped table "${mentioned}" and cannot be rewritten safely`);
238
+ }
239
+ return node;
240
+ }
241
+ // children first: subqueries are guarded on their own level
242
+ const next = { ...node };
243
+ for (const [key, child] of Object.entries(node)) {
244
+ if (isNode(child) || Array.isArray(child))
245
+ next[key] = rewrite(child, mode, seenSiteRefs);
246
+ }
247
+ const refs = directSiteRefs(next);
248
+ if (refs.length)
249
+ seenSiteRefs.push(...refs);
250
+ // before anything else asks what a table means, ask whether the canon knows it at all: an
251
+ // unknown table has no tenant key to force and no scope to respect, so the honest answer is
252
+ // a refusal naming the table, not a query that reads every tenant (task:3993)
253
+ const outside = directTableRefs(next).find((ref) => !CANON_TABLES.has(ref.table));
254
+ if (outside) {
255
+ throw new TenantViolation('TABLE_OUTSIDE_CANON', `the query reads "${outside.table}", which the canon does not describe — bring it into the canon ` +
256
+ '(schema/index.ts) if it belongs to the product, or read it through a handle without the tenant guard ' +
257
+ 'if it does not: a table outside the canon has no tenant key, so no scoped read of it can be honest');
258
+ }
259
+ // the write grant is checked before the statement is rewritten, and without
260
+ // asking which table it happens to touch: a read-only handle has no legitimate
261
+ // write shape, and a granted one is still stopped by the freeze. The grant is
262
+ // the handle's authority (task:3865), the freeze the site's state (task:3828).
263
+ // The transfer answers to both (task:3868): it rewrites the tenant key of the
264
+ // source's rows — unless it declares itself the system path.
265
+ const gated = mode.kind === 'site' || (mode.kind === 'transfer' && mode.system !== true);
266
+ if (gated && MUTATION_KINDS.has(String(next.kind))) {
267
+ const who = mode.kind === 'site'
268
+ ? `site ${mode.siteId}`
269
+ : `transfer ${mode.fromSiteId} → ${mode.toSiteId}`;
270
+ if (mode.writable !== true) {
271
+ throw new TenantViolation('SITE_READ_ONLY', `${who}: this handle was not granted writes — build it with { writable: true } ` +
272
+ '(the writer path does; a reader needs no grant). Deny-by-default: a forgotten grant must not write');
273
+ }
274
+ if (mode.frozen === true) {
275
+ throw new TenantViolation('SITE_FROZEN', `${who} is frozen (Sites.Writable = false): writes are refused until the cutover window ends (plan:50 Q6)`);
276
+ }
277
+ }
278
+ switch (next.kind) {
279
+ case 'SelectQueryNode':
280
+ case 'DeleteQueryNode':
281
+ return applyWhere(next, refs, mode);
282
+ case 'UpdateQueryNode': {
283
+ const siteIdWrites = (next.updates ?? []).filter((u) => columnNameOf(u.column) === 'SiteId');
284
+ if (mode.kind === 'transfer') {
285
+ for (const write of siteIdWrites) {
286
+ if (write.value?.kind !== 'ValueNode' || write.value.value !== mode.toSiteId) {
287
+ throw new TenantViolation('FOREIGN_SITE_ID', `a transfer writes only its declared target (${mode.toSiteId}); the payload named another tenant`);
288
+ }
289
+ }
290
+ if (!siteIdWrites.length) {
291
+ next.updates = [
292
+ ...(next.updates ?? []),
293
+ {
294
+ kind: 'ColumnUpdateNode',
295
+ column: { kind: 'ColumnNode', column: ident('SiteId') },
296
+ value: value(mode.toSiteId),
297
+ },
298
+ ];
299
+ }
300
+ return applyWhere(next, refs, mode);
301
+ }
302
+ if (siteIdWrites.length) {
303
+ throw new TenantViolation('FOREIGN_SITE_ID', 'the payload moves the row between tenants; SiteId is owned by the access layer');
304
+ }
305
+ return applyWhere(next, refs, mode);
306
+ }
307
+ case 'InsertQueryNode':
308
+ return applyInsert(next, refs, mode);
309
+ default:
310
+ return next;
311
+ }
312
+ };
313
+ const requireSiteId = (mode, table) => {
314
+ if (mode.kind === 'site')
315
+ return mode.siteId;
316
+ if (mode.kind === 'transfer')
317
+ return mode.fromSiteId;
318
+ throw new TenantViolation('NO_SITE_CONTEXT', `the query touches "${table ?? 'an unnamed table'}" but no site context is present (deny-by-default)`);
319
+ };
320
+ const applyWhere = (node, refs, mode) => {
321
+ if (!refs.length)
322
+ return node;
323
+ let predicate = null;
324
+ for (const ref of refs) {
325
+ const siteId = requireSiteId(mode, ref.qualifier);
326
+ const term = equals(columnRef(ref.qualifier, 'SiteId'), value(siteId));
327
+ predicate = predicate ? and(predicate, term) : term;
328
+ }
329
+ const existing = node.where;
330
+ const where = existing?.where
331
+ ? { kind: 'WhereNode', where: and(parens(existing.where), predicate) }
332
+ : { kind: 'WhereNode', where: predicate };
333
+ return { ...node, where };
334
+ };
335
+ const applyInsert = (node, refs, mode) => {
336
+ if (!refs.length)
337
+ return node;
338
+ if (mode.kind === 'transfer') {
339
+ throw new TenantViolation('UNSUPPORTED_STATEMENT', 'a transfer moves existing rows; it never inserts a tenant key');
340
+ }
341
+ const siteId = requireSiteId(mode, refs[0]?.qualifier);
342
+ const columns = node.columns ?? [];
343
+ if (columns.some((c) => columnNameOf(c) === 'SiteId')) {
344
+ throw new TenantViolation('FOREIGN_SITE_ID', 'the payload sets SiteId explicitly; the tenant key comes from the session context');
345
+ }
346
+ // `onConflict().doUpdateSet()` is a second write path inside one statement: the
347
+ // insert's column list says nothing about what the SET clause carries, so the
348
+ // tenant key is checked there too (review of task:3865)
349
+ const conflictUpdates = ((node.onConflict?.updates) ?? []);
350
+ if (conflictUpdates.some((update) => columnNameOf(update.column) === 'SiteId')) {
351
+ throw new TenantViolation('FOREIGN_SITE_ID', 'an upsert writes SiteId in its conflict payload; the tenant key is owned by the access layer');
352
+ }
353
+ const values = node.values;
354
+ if (!values || values.kind !== 'ValuesNode') {
355
+ throw new TenantViolation('UNSUPPORTED_STATEMENT', 'insert without a literal VALUES list cannot be scoped automatically');
356
+ }
357
+ const rows = (values.values ?? []).map((row) => {
358
+ if (!isNode(row))
359
+ throw new TenantViolation('UNSUPPORTED_STATEMENT', 'unsupported VALUES row');
360
+ if (row.kind === 'PrimitiveValueListNode')
361
+ return { ...row, values: [...row.values, siteId] };
362
+ if (row.kind === 'ValueListNode')
363
+ return { ...row, values: [...row.values, value(siteId)] };
364
+ throw new TenantViolation('UNSUPPORTED_STATEMENT', `unsupported VALUES row ${String(row.kind)}`);
365
+ });
366
+ return {
367
+ ...node,
368
+ columns: [...columns, { kind: 'ColumnNode', column: ident('SiteId') }],
369
+ values: { ...values, values: rows },
370
+ };
371
+ };
372
+ // ---------------------------------------------------------------------------
373
+ // plugin
374
+ // ---------------------------------------------------------------------------
375
+ export const tenantGuard = (mode) => {
376
+ // the freeze is only as good as the handle that states it: a site guard built
377
+ // without an answer (a forgotten field in JS) is refused here, not at the first
378
+ // write, so a read-only handle cannot pass for a freeze-aware one
379
+ if (mode.kind === 'site' && typeof mode.frozen !== 'boolean') {
380
+ throw new Error('tenant-guard: a site guard must state `frozen` — read Sites.Writable (freeze.ts `readWritable`/`siteGuard`) instead of leaving it out');
381
+ }
382
+ // `writable` is deliberately NOT required: deny-by-default means a forgotten
383
+ // grant is read-only, so nothing has to be remembered to stay safe (task:3865)
384
+ return {
385
+ transformQuery(args) {
386
+ const seen = [];
387
+ const rewritten = rewrite(args.node, mode, seen);
388
+ if (mode.kind === 'control' && seen.length) {
389
+ throw new TenantViolation('NO_SITE_CONTEXT', `a control-plane instance may not touch site-scoped tables (${seen.map((r) => r.qualifier).join(', ')})`);
390
+ }
391
+ return rewritten;
392
+ },
393
+ async transformResult(_args) {
394
+ return _args.result;
395
+ },
396
+ };
397
+ };
@@ -0,0 +1,9 @@
1
+ import type { Kysely } from 'kysely';
2
+ /**
3
+ * Insert `values` into `table` and answer with the written row.
4
+ *
5
+ * `key` is the primary-key column: the dialect that cannot return the row reads it back by
6
+ * that key, so a caller on such a dialect must supply it — the canon's MariaDB DDL has no
7
+ * server-side id default, and inventing one here would hide that.
8
+ */
9
+ export declare const insertAndReturn: (db: Kysely<any>, table: string, key: string, values: Record<string, unknown>) => Promise<any>;
package/dist/insert.js ADDED
@@ -0,0 +1,44 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ /** The rows a driver answered with, whichever envelope it used (`output inserted` nests them). */
3
+ const rowsOf = (answer) => Array.isArray(answer) ? answer : Array.isArray(answer?.rows) ? answer.rows : [];
4
+ const readBack = async (db, table, key, id) => {
5
+ const row = await db.selectFrom(table).selectAll().where(key, '=', id).executeTakeFirst();
6
+ if (!row)
7
+ throw new Error(`${table}: the inserted row could not be read back`);
8
+ return row;
9
+ };
10
+ /**
11
+ * Insert `values` into `table` and answer with the written row.
12
+ *
13
+ * `key` is the primary-key column: the dialect that cannot return the row reads it back by
14
+ * that key, so a caller on such a dialect must supply it — the canon's MariaDB DDL has no
15
+ * server-side id default, and inventing one here would hide that.
16
+ */
17
+ export const insertAndReturn = async (db, table, key, values) => {
18
+ const adapter = db.getExecutor?.().adapter;
19
+ const keyValue = values[key];
20
+ const noKey = () => new Error(`${table}: this dialect cannot return an inserted row — supply "${key}"`);
21
+ const readBackByKey = async () => {
22
+ if (keyValue === undefined)
23
+ throw noKey();
24
+ return readBack(db, table, key, keyValue);
25
+ };
26
+ let query = db.insertInto(table).values(values);
27
+ if (adapter?.supportsReturning)
28
+ query = query.returningAll();
29
+ else if (adapter?.supportsOutput)
30
+ query = query.outputAll('inserted');
31
+ else {
32
+ // Refuse before writing anything: an insert nobody can name is worse than no insert.
33
+ if (keyValue === undefined)
34
+ throw noKey();
35
+ await query.execute();
36
+ return readBackByKey();
37
+ }
38
+ // MariaDB declares `supportsReturning` and still answers `InsertResult` to
39
+ // `INSERT … RETURNING` through the mysql2 driver: the statement is portable, the row is
40
+ // not delivered. Read it back by the caller's key instead of answering a row-shaped
41
+ // hole (task:3913 — MariaDB is a cell target, not a curiosity).
42
+ const row = rowsOf(await query.execute())[0];
43
+ return row ?? (await readBackByKey());
44
+ };