@sequenceholdings/orm 2.0.0 → 2.1.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.
Files changed (69) hide show
  1. package/README.md +94 -2
  2. package/dist/authoring.cjs +1474 -54
  3. package/dist/authoring.cjs.map +1 -1
  4. package/dist/authoring.d.cts +242 -4
  5. package/dist/authoring.d.ts +242 -4
  6. package/dist/authoring.js +1465 -56
  7. package/dist/authoring.js.map +1 -1
  8. package/dist/canonical.cjs +740 -42
  9. package/dist/canonical.cjs.map +1 -1
  10. package/dist/canonical.d.cts +742 -53
  11. package/dist/canonical.d.ts +742 -53
  12. package/dist/canonical.js +724 -43
  13. package/dist/canonical.js.map +1 -1
  14. package/dist/cli.cjs +2478 -328
  15. package/dist/cli.cjs.map +1 -1
  16. package/dist/cli.js +2480 -330
  17. package/dist/cli.js.map +1 -1
  18. package/dist/client.cjs +90 -25
  19. package/dist/client.cjs.map +1 -1
  20. package/dist/client.d.cts +39 -22
  21. package/dist/client.d.ts +39 -22
  22. package/dist/client.js +87 -26
  23. package/dist/client.js.map +1 -1
  24. package/dist/compiler.cjs +1755 -329
  25. package/dist/compiler.cjs.map +1 -1
  26. package/dist/compiler.d.cts +56 -3
  27. package/dist/compiler.d.ts +56 -3
  28. package/dist/compiler.js +1756 -330
  29. package/dist/compiler.js.map +1 -1
  30. package/dist/constants-YoFGjIBM.d.cts +99 -0
  31. package/dist/constants-YoFGjIBM.d.ts +99 -0
  32. package/dist/dynamic-input.d.cts +1 -0
  33. package/dist/dynamic-input.d.ts +1 -0
  34. package/dist/index.cjs +1973 -164
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.d.cts +474 -100
  37. package/dist/index.d.ts +474 -100
  38. package/dist/index.js +1940 -167
  39. package/dist/index.js.map +1 -1
  40. package/dist/ir.cjs +869 -46
  41. package/dist/ir.cjs.map +1 -1
  42. package/dist/ir.d.cts +699 -25
  43. package/dist/ir.d.ts +699 -25
  44. package/dist/ir.js +861 -47
  45. package/dist/ir.js.map +1 -1
  46. package/dist/{naming-CTvWSRTf.d.ts → naming-CPfx7EfF.d.ts} +28 -1
  47. package/dist/{naming-DRyinU4u.d.cts → naming-CU9ThP_E.d.cts} +28 -1
  48. package/dist/policy-1iZbGnZZ.d.cts +24 -0
  49. package/dist/policy-1iZbGnZZ.d.ts +24 -0
  50. package/dist/predicate-v2.cjs +852 -49
  51. package/dist/predicate-v2.cjs.map +1 -1
  52. package/dist/predicate-v2.d.cts +2 -1
  53. package/dist/predicate-v2.d.ts +2 -1
  54. package/dist/predicate-v2.js +852 -49
  55. package/dist/predicate-v2.js.map +1 -1
  56. package/dist/sdl.cjs +1309 -58
  57. package/dist/sdl.cjs.map +1 -1
  58. package/dist/sdl.d.cts +7 -3
  59. package/dist/sdl.d.ts +7 -3
  60. package/dist/sdl.js +1305 -59
  61. package/dist/sdl.js.map +1 -1
  62. package/package.json +11 -6
  63. package/templates/orm-namespace/CLAUDE.md +6 -4
  64. package/templates/orm-namespace/codegen.preset.d.cts +7 -0
  65. package/templates/orm-namespace/codegen.ts +2 -1
  66. package/templates/orm-namespace/graphql/example/queries.ts +4 -3
  67. package/templates/orm-namespace/package.json +2 -1
  68. package/templates/orm-namespace/schema/tables.ts +3 -2
  69. package/templates/orm-namespace/sequence.config.ts +7 -11
@@ -29,10 +29,478 @@ var PolicyCompileError = class extends Error {
29
29
  };
30
30
 
31
31
  // src/constants.ts
32
- var CAPABILITY_KEY_PATTERN = /^(?:org\/)?[a-z0-9-]{1,64}$/;
32
+ var COLUMN_IDENTIFIER_PATTERN = /^_?[a-z][a-z0-9_]*$/;
33
+ var MAX_IDENTIFIER_LENGTH = 63;
34
+ var CAPABILITY_KEY_PATTERN = /^(?:org\/|app\/[a-z][a-z0-9-]{0,63}\/)?[a-z0-9-]{1,64}$/;
35
+ var CELL_PARTS = ["at", "by", "rev", "source", "conflicts"];
36
+ var CELL_PART_SUFFIX = {
37
+ at: "__at",
38
+ by: "__by",
39
+ rev: "__rev",
40
+ source: "__src",
41
+ conflicts: "__lost"
42
+ };
43
+ var MAX_TRACKED_COLUMN_NAME = MAX_IDENTIFIER_LENGTH - 6;
44
+ function cellColumnName(column, part) {
45
+ return `${column}${CELL_PART_SUFFIX[part]}`;
46
+ }
47
+ var MATERIALIZE_ACTOR = "orm:materialize";
48
+ JSON.stringify({ sub: MATERIALIZE_ACTOR, org: null, roles: [], capabilities: [] });
49
+ var quoteIdentifier = (name) => `"${name.replaceAll('"', '""')}"`;
50
+ var quoteLiteral = (value) => `'${value.replaceAll("'", "''")}'`;
51
+
52
+ // src/checks/compile.ts
53
+ function tokenize(source) {
54
+ const tokens = [];
55
+ for (let position = 0; position < source.length; ) {
56
+ const char = source[position];
57
+ if (/\s/.test(char)) {
58
+ position++;
59
+ continue;
60
+ }
61
+ if (char === "(" || char === ")" || char === ",") {
62
+ tokens.push({ kind: char === "(" ? "lparen" : char === ")" ? "rparen" : "comma", value: char, position });
63
+ position++;
64
+ continue;
65
+ }
66
+ if (char === "'") {
67
+ const start = position++;
68
+ let value = "";
69
+ let closed = false;
70
+ while (position < source.length) {
71
+ if (source[position] !== "'") {
72
+ value += source[position++];
73
+ continue;
74
+ }
75
+ if (source[position + 1] === "'") {
76
+ value += "'";
77
+ position += 2;
78
+ continue;
79
+ }
80
+ position++;
81
+ closed = true;
82
+ break;
83
+ }
84
+ if (!closed) throw new Error(`unterminated string at position ${start}`);
85
+ if (/[\x00-\x1f\x7f]/.test(value)) throw new Error(`control character in string at position ${start}`);
86
+ tokens.push({ kind: "string", value, position: start });
87
+ continue;
88
+ }
89
+ const operator = /^(<=|>=|<>|!=|!~|=|<|>|~)/.exec(source.slice(position));
90
+ if (operator) {
91
+ tokens.push({ kind: "operator", value: operator[0] === "<>" ? "!=" : operator[0], position });
92
+ position += operator[0].length;
93
+ continue;
94
+ }
95
+ const number = /^-?\d+(?:\.\d+)?/.exec(source.slice(position));
96
+ if (number) {
97
+ tokens.push({ kind: "number", value: number[0], position });
98
+ position += number[0].length;
99
+ continue;
100
+ }
101
+ const identifier3 = /^[a-zA-Z_][a-zA-Z_0-9]*/.exec(source.slice(position));
102
+ if (identifier3) {
103
+ tokens.push({ kind: "identifier", value: identifier3[0], position });
104
+ position += identifier3[0].length;
105
+ continue;
106
+ }
107
+ throw new Error(`unexpected character '${char}' at position ${position}`);
108
+ }
109
+ tokens.push({ kind: "end", value: "", position: source.length });
110
+ if (tokens.length > 256) throw new Error("check expression exceeds 256 tokens");
111
+ return tokens;
112
+ }
113
+ var Parser = class {
114
+ constructor(tokens, columns) {
115
+ this.tokens = tokens;
116
+ this.columns = /* @__PURE__ */ new Map();
117
+ for (const column of columns) {
118
+ for (const alias of [column.name, column.columnName]) {
119
+ const previous = this.columns.get(alias);
120
+ if (previous && previous.columnName !== column.columnName) {
121
+ throw new Error(`ambiguous check column '${alias}'`);
122
+ }
123
+ this.columns.set(alias, column);
124
+ }
125
+ }
126
+ }
127
+ index = 0;
128
+ depth = 0;
129
+ columns;
130
+ parse() {
131
+ const expression = this.parseOr();
132
+ this.expect("end", "end of expression");
133
+ return expression;
134
+ }
135
+ parseOr() {
136
+ let left = this.parseAnd();
137
+ while (this.matchKeyword("or")) left = `(${left} OR ${this.parseAnd()})`;
138
+ return left;
139
+ }
140
+ parseAnd() {
141
+ let left = this.parseNot();
142
+ while (this.matchKeyword("and")) left = `(${left} AND ${this.parseNot()})`;
143
+ return left;
144
+ }
145
+ parseNot() {
146
+ if (this.matchKeyword("not")) return `(NOT ${this.parseNot()})`;
147
+ return this.parsePrimary();
148
+ }
149
+ parsePrimary() {
150
+ if (this.peek().kind === "lparen") {
151
+ if (++this.depth > 32) throw new Error("check expression nesting exceeds 32 levels");
152
+ this.advance();
153
+ const expression = this.parseOr();
154
+ this.expect("rparen", "')'");
155
+ this.depth--;
156
+ return `(${expression})`;
157
+ }
158
+ return this.parseComparison();
159
+ }
160
+ parseComparison() {
161
+ const left = this.parseValue();
162
+ if (this.matchKeyword("is")) {
163
+ const negate = this.matchKeyword("not");
164
+ this.expectKeyword("null");
165
+ return `${left.sql} IS ${negate ? "NOT " : ""}NULL`;
166
+ }
167
+ if (this.matchKeyword("between")) {
168
+ const lower = this.parseValue();
169
+ this.expectKeyword("and");
170
+ const upper = this.parseValue();
171
+ return `${left.sql} BETWEEN ${lower.sql} AND ${upper.sql}`;
172
+ }
173
+ if (this.matchKeyword("in")) {
174
+ this.expect("lparen", "'(' after IN");
175
+ const values = [this.parseValue()];
176
+ while (this.peek().kind === "comma") {
177
+ this.advance();
178
+ values.push(this.parseValue());
179
+ }
180
+ this.expect("rparen", "')' after IN values");
181
+ if (values.some((value) => !value.literal)) throw new Error("IN values must be literals");
182
+ return `${left.sql} IN (${values.map((value) => value.sql).join(", ")})`;
183
+ }
184
+ const operator = this.expect("operator", "a comparison operator");
185
+ const right = this.parseValue();
186
+ if (operator.value === "~" || operator.value === "!~") {
187
+ if (left.column && left.column.type !== "text") throw new Error("regex checks require a text column");
188
+ if (!right.literal) throw new Error("regex pattern must be a literal");
189
+ }
190
+ return `${left.sql} ${operator.value} ${right.sql}`;
191
+ }
192
+ parseValue() {
193
+ const token = this.advance();
194
+ if (token.kind === "string") return { sql: quoteLiteral(token.value), literal: true };
195
+ if (token.kind === "number") {
196
+ const number = Number(token.value);
197
+ if (!Number.isFinite(number) || Number.isInteger(number) && !Number.isSafeInteger(number)) {
198
+ throw new Error(`unsafe numeric literal at position ${token.position}`);
199
+ }
200
+ return { sql: token.value, literal: true };
201
+ }
202
+ if (token.kind !== "identifier") throw new Error(`expected a column or literal at position ${token.position}`);
203
+ const keyword = token.value.toLowerCase();
204
+ if (keyword === "true" || keyword === "false" || keyword === "null") return { sql: keyword.toUpperCase(), literal: true };
205
+ if (keyword === "btrim") {
206
+ this.expect("lparen", "'(' after btrim");
207
+ const column2 = this.parseColumn();
208
+ this.expect("rparen", "')' after btrim column");
209
+ if (column2.type !== "text") throw new Error("btrim requires a text column");
210
+ return { sql: `btrim(${quoteIdentifier(column2.columnName)})`, column: column2 };
211
+ }
212
+ const column = this.columns.get(token.value);
213
+ if (!column) throw new Error(`unknown check column '${token.value}'`);
214
+ return { sql: quoteIdentifier(column.columnName), column };
215
+ }
216
+ parseColumn() {
217
+ const token = this.expect("identifier", "a column name");
218
+ const column = this.columns.get(token.value);
219
+ if (!column) throw new Error(`unknown check column '${token.value}'`);
220
+ return column;
221
+ }
222
+ peek() {
223
+ return this.tokens[this.index];
224
+ }
225
+ advance() {
226
+ const token = this.peek();
227
+ if (token.kind !== "end") this.index++;
228
+ return token;
229
+ }
230
+ matchKeyword(keyword) {
231
+ if (this.peek().kind !== "identifier" || this.peek().value.toLowerCase() !== keyword) return false;
232
+ this.advance();
233
+ return true;
234
+ }
235
+ expectKeyword(keyword) {
236
+ if (!this.matchKeyword(keyword)) throw new Error(`expected '${keyword}' at position ${this.peek().position}`);
237
+ }
238
+ expect(kind, description) {
239
+ const token = this.peek();
240
+ if (token.kind !== kind) throw new Error(`expected ${description} at position ${token.position}`);
241
+ return this.advance();
242
+ }
243
+ };
244
+ function compileCheckExpression(source, columns) {
245
+ return new Parser(tokenize(source), columns).parse();
246
+ }
247
+ var policyVariantSchema = z.object({
248
+ /** Claims-only guard (capability()/role()/claim() comparisons — no columns). */
249
+ when: z.string().min(1).max(1e3),
250
+ /** Exactly one of: allow-all bypass, or a row predicate. */
251
+ allow: z.literal(true).optional(),
252
+ policy: z.string().min(1).max(2e3).optional()
253
+ }).refine((v) => v.allow === true !== (typeof v.policy === "string"), {
254
+ message: "A variant declares exactly one of allow: true or policy"
255
+ });
256
+ var policyExpressionSchema = z.union([
257
+ z.string().min(1).max(2e3),
258
+ z.object({
259
+ default: z.string().min(1).max(2e3),
260
+ variants: z.array(policyVariantSchema).max(10).default([])
261
+ })
262
+ ]);
263
+ var identifier = z.string().min(1).max(63).regex(/^[a-z][a-z0-9_]*$/);
264
+ var durationMs = z.number().int().positive().max(24 * 60 * 60 * 1e3);
265
+ var RESOLVED_VIEW_SYSTEM_COLUMNS = {
266
+ writebackStatus: { name: "writebackStatus", columnName: "_writeback_status", type: "text" },
267
+ writebackError: { name: "writebackError", columnName: "_writeback_error", type: "text" }
268
+ };
269
+ var RESOLVED_SOURCE_SYSTEM_COLUMNS = {
270
+ tombstoned: { name: "sysTombstoned", columnName: "_tombstoned" },
271
+ fetchedAt: { name: "sysFetchedAt", columnName: "_fetched_at" },
272
+ writebackStatus: { name: "sysWritebackStatus", columnName: "_writeback_status" },
273
+ writebackError: { name: "sysWritebackError", columnName: "_writeback_error" },
274
+ writebackAt: { name: "sysWritebackAt", columnName: "_writeback_at" }
275
+ };
276
+ var RESOLVED_VIEW_CELL_PARTS = ["source", "at", "by", "conflicts"];
277
+ /* @__PURE__ */ new Set([
278
+ ...Object.values(RESOLVED_VIEW_SYSTEM_COLUMNS).flatMap((c) => [c.name, c.columnName]),
279
+ ...Object.values(RESOLVED_SOURCE_SYSTEM_COLUMNS).flatMap((c) => [c.name, c.columnName])
280
+ ]);
281
+ var COMPARATORS = ["latest", "highest", "lowest", "priority", "nonNullFirst", "custom"];
282
+ var canonicalFunctionRefSchema = z.object({
283
+ /** `managed` = a Managed Function id/slug; `native` = an in-process Atlas function (reserved). */
284
+ kind: z.enum(["managed", "native"]),
285
+ id: z.string().min(1).max(200)
286
+ }).strict();
287
+ var canonicalFetchShapeSchema = z.union([
288
+ z.object({ shape: z.literal("single") }).strict(),
289
+ z.object({ shape: z.literal("batch"), maxKeys: z.number().int().min(2).max(500) }).strict()
290
+ ]);
291
+ var canonicalResolvedSourceSchema = z.object({
292
+ name: identifier,
293
+ kind: z.enum(["manual", "etl", "prefetch"]),
294
+ /** Backing relation in this namespace: an internal table (manual/prefetch) or a synced table (etl). */
295
+ table: identifier,
296
+ /** Resolved columns (logical names) this source supplies — the primary key is implicit. */
297
+ columns: z.array(z.string().min(1).max(63)).min(1).max(500),
298
+ /** etl: logical timestamptz/date column on the synced table used as every cell's updated_at. */
299
+ updatedAt: z.string().min(1).max(63).optional(),
300
+ /** prefetch: resolved column holding the source system's key (may be the primary key). */
301
+ key: z.string().min(1).max(63).optional(),
302
+ fetch: canonicalFetchShapeSchema.optional(),
303
+ function: canonicalFunctionRefSchema.optional(),
304
+ prefetchDefaults: z.object({
305
+ maxStalenessMs: durationMs,
306
+ timeoutMs: durationMs,
307
+ onError: z.enum(["fallback", "fail"])
308
+ }).strict().optional(),
309
+ writeback: z.object({
310
+ columns: z.array(z.string().min(1).max(63)).min(1).max(500),
311
+ defaultMode: z.enum(["sync", "async"]),
312
+ timeoutMs: durationMs,
313
+ /** Remove confirmed cells from the manual source so a stale edit cannot shadow the source. */
314
+ clearManualOnConfirm: z.boolean()
315
+ }).strict().optional()
316
+ }).strict();
317
+ var canonicalResolveRuleSchema = z.object({
318
+ column: z.string().min(1).max(63),
319
+ comparator: z.enum(COMPARATORS),
320
+ /** Source names, in tie-break order. */
321
+ candidates: z.array(identifier).min(1).max(16),
322
+ /** custom only: SQL ORDER BY key over value / updated_at / source / actor / ord. */
323
+ orderBy: z.string().min(1).max(2e3).optional()
324
+ }).strict();
325
+ var canonicalResolvedSchema = z.object({
326
+ sources: z.array(canonicalResolvedSourceSchema).min(1).max(16),
327
+ /** One rule per resolved (non-pk) column; documents the generated view for tooling. */
328
+ resolve: z.array(canonicalResolveRuleSchema).max(500),
329
+ /** Sources whose tombstone hides the resolved row (v1: the manual source only). */
330
+ deletedBy: z.array(identifier).max(1).default([]),
331
+ /**
332
+ * Materialized: the view reads an indexed internal table (`table`) that the
333
+ * data plane keeps in step with `compute`, the internal view holding the
334
+ * resolution query. Omitted when the view computes on read.
335
+ */
336
+ materialized: z.object({ table: identifier, compute: identifier }).strict().optional(),
337
+ /** Write policies evaluated against the RESOLVED row (reads use the view's read policy). */
338
+ policies: z.object({
339
+ write: policyExpressionSchema.optional(),
340
+ insert: policyExpressionSchema.optional(),
341
+ update: policyExpressionSchema.optional()
342
+ }).strict().default({})
343
+ }).strict();
344
+ function validateResolvedView({ view, tables, views = [] }) {
345
+ const resolved = view.resolved;
346
+ if (!resolved) return [];
347
+ const errors = [];
348
+ const fail = (message) => errors.push(`resolved table '${view.view}': ${message}`);
349
+ const pkColumn = view.primaryKey?.length === 1 ? view.columns.find((c) => c.columnName === view.primaryKey?.[0]) : void 0;
350
+ if (!pkColumn) {
351
+ fail("requires exactly one primary-key column");
352
+ return errors;
353
+ }
354
+ const systemNames = new Set(Object.values(RESOLVED_VIEW_SYSTEM_COLUMNS).map((c) => c.name));
355
+ const scope = {
356
+ view,
357
+ resolved,
358
+ pkColumn,
359
+ dataColumns: new Map(view.columns.filter((c) => !systemNames.has(c.name) && c.cell === void 0).map((c) => [c.name, c])),
360
+ sourceByName: /* @__PURE__ */ new Map(),
361
+ fail
362
+ };
363
+ if (resolved.sources.filter((source) => source.kind === "manual").length !== 1) fail("requires exactly one manual source");
364
+ const tableByName = new Map(tables.map((table) => [table.table, table]));
365
+ for (const source of resolved.sources) {
366
+ if (scope.sourceByName.has(source.name)) fail(`duplicate source '${source.name}'`);
367
+ scope.sourceByName.set(source.name, source);
368
+ const table = tableByName.get(source.table);
369
+ if (!table) fail(`source '${source.name}' references unknown table '${source.table}'`);
370
+ else validateSource({ scope, source, table });
371
+ }
372
+ validateRules(scope);
373
+ validateViewCells(scope);
374
+ if (resolved.materialized) validateMaterialized({ scope, tableByName, views });
375
+ for (const name of resolved.deletedBy) {
376
+ if (scope.sourceByName.get(name)?.kind !== "manual") fail(`deletedBy '${name}' must be the manual source`);
377
+ }
378
+ return errors;
379
+ }
380
+ function validateSource({ scope, source, table }) {
381
+ const { fail } = scope;
382
+ if (source.kind === "etl" && table.source === void 0) fail(`etl source '${source.name}' must reference a synced table`);
383
+ if (source.kind !== "etl" && (table.internal !== true || table.cellMetadata !== true)) {
384
+ fail(`${source.kind} source '${source.name}' must reference an internal table with cell metadata`);
385
+ }
386
+ const hasRev = table.columns.some((column) => column.cell?.part === "rev");
387
+ if (source.kind === "manual" && !hasRev) fail(`manual source '${source.name}' must carry '__rev' cell columns`);
388
+ if (source.kind === "prefetch" && hasRev) fail(`prefetch source '${source.name}' must not carry '__rev' cell columns`);
389
+ const byName = new Map(table.columns.map((c) => [c.name, c]));
390
+ validateSourceColumns({ scope, source, byName });
391
+ if (source.kind === "etl") {
392
+ const updatedAt = source.updatedAt === void 0 ? void 0 : byName.get(source.updatedAt);
393
+ if (source.updatedAt !== void 0 && updatedAt?.type !== "timestamptz" && updatedAt?.type !== "date") {
394
+ fail(`etl source '${source.name}' updatedAt must name a timestamptz/date column on '${source.table}'`);
395
+ }
396
+ }
397
+ validatePrefetchFields({ scope, source });
398
+ }
399
+ function validatePrefetchFields({ scope, source }) {
400
+ const { fail, pkColumn } = scope;
401
+ if (source.kind !== "prefetch") {
402
+ if (source.key || source.fetch || source.function || source.prefetchDefaults || source.writeback) {
403
+ fail(`only prefetch sources may declare key/fetch/function/prefetchDefaults/writeback ('${source.name}')`);
404
+ }
405
+ return;
406
+ }
407
+ if (!source.key || !source.fetch || !source.function || !source.prefetchDefaults) {
408
+ fail(`prefetch source '${source.name}' requires key, fetch, function and prefetchDefaults`);
409
+ } else if (source.key !== pkColumn.name && !source.columns.includes(source.key)) {
410
+ fail(`prefetch source '${source.name}' key '${source.key}' must be the primary key or one of its columns`);
411
+ }
412
+ for (const column of source.writeback?.columns ?? []) {
413
+ if (!source.columns.includes(column)) fail(`prefetch source '${source.name}' writes back '${column}', which it does not supply`);
414
+ }
415
+ }
416
+ function validateSourceColumns({ scope, source, byName }) {
417
+ const { fail, pkColumn, dataColumns } = scope;
418
+ for (const column of [pkColumn.name, ...source.columns]) {
419
+ const own = column === pkColumn.name ? pkColumn : dataColumns.get(column);
420
+ const theirs = byName.get(column);
421
+ if (!own) fail(`source '${source.name}' supplies unknown column '${column}'`);
422
+ else if (!theirs) fail(`source '${source.name}' table '${source.table}' has no column '${column}'`);
423
+ else if (JSON.stringify(theirs.type) !== JSON.stringify(own.type)) fail(`column '${column}' has a different type on source '${source.name}'`);
424
+ }
425
+ }
426
+ function validateRules({ resolved, pkColumn, dataColumns, sourceByName, fail }) {
427
+ const ruled = /* @__PURE__ */ new Set();
428
+ for (const rule of resolved.resolve) {
429
+ if (!dataColumns.has(rule.column) || rule.column === pkColumn.name) {
430
+ fail(`resolve rule targets unknown column '${rule.column}'`);
431
+ continue;
432
+ }
433
+ if (ruled.has(rule.column)) fail(`column '${rule.column}' has more than one resolve rule`);
434
+ ruled.add(rule.column);
435
+ if (rule.comparator === "custom" !== (rule.orderBy !== void 0)) {
436
+ fail(`column '${rule.column}': orderBy is required for custom comparators and only allowed there`);
437
+ }
438
+ if (new Set(rule.candidates).size !== rule.candidates.length) fail(`column '${rule.column}' repeats a candidate`);
439
+ for (const candidate of rule.candidates) {
440
+ const source = sourceByName.get(candidate);
441
+ if (!source) fail(`column '${rule.column}' names unknown candidate source '${candidate}'`);
442
+ else if (!source.columns.includes(rule.column)) fail(`column '${rule.column}' candidate '${candidate}' does not supply it`);
443
+ }
444
+ }
445
+ for (const column of dataColumns.keys()) {
446
+ if (column !== pkColumn.name && !ruled.has(column)) fail(`column '${column}' has no resolve rule`);
447
+ }
448
+ }
449
+ var VIEW_CELL_TYPES = {
450
+ source: "text",
451
+ at: "timestamptz",
452
+ by: "text",
453
+ conflicts: "jsonb"
454
+ };
455
+ function validateViewCells({ view, dataColumns, pkColumn, fail }) {
456
+ const expected = /* @__PURE__ */ new Map();
457
+ for (const column of dataColumns.values()) {
458
+ if (column.name === pkColumn.name) continue;
459
+ for (const part of RESOLVED_VIEW_CELL_PARTS) {
460
+ expected.set(cellColumnName(column.name, part), { part, columnName: cellColumnName(column.columnName, part) });
461
+ }
462
+ }
463
+ for (const column of view.columns) {
464
+ if (!column.cell) continue;
465
+ const want = expected.get(column.name);
466
+ if (!want || want.part !== column.cell.part || want.columnName !== column.columnName || cellColumnName(column.cell.of, column.cell.part) !== column.name) {
467
+ fail(`unexpected cell column '${column.name}'`);
468
+ continue;
469
+ }
470
+ if (column.type !== VIEW_CELL_TYPES[column.cell.part]) fail(`cell column '${column.name}' must be ${VIEW_CELL_TYPES[column.cell.part]}`);
471
+ expected.delete(column.name);
472
+ }
473
+ for (const name of expected.keys()) fail(`missing cell column '${name}'`);
474
+ }
475
+ var columnSignature = (columns) => JSON.stringify(columns.map((column) => [column.name, column.columnName, column.type]));
476
+ function validateMaterialized({ scope, tableByName, views }) {
477
+ const { view, resolved, fail } = scope;
478
+ const { table: tableName, compute: computeName } = resolved.materialized;
479
+ const table = tableByName.get(tableName);
480
+ if (!table || table.internal !== true || table.materializes !== view.view) {
481
+ fail(`materialized table '${tableName}' must be an internal table that materializes '${view.view}'`);
482
+ } else {
483
+ if (columnSignature(table.columns) !== columnSignature(view.columns)) fail(`materialized table '${tableName}' must have exactly the view's columns`);
484
+ if (JSON.stringify(table.primaryKey ?? []) !== JSON.stringify(view.primaryKey ?? [])) fail(`materialized table '${tableName}' must share the view's primary key`);
485
+ }
486
+ const compute = views.find((candidate) => candidate.view === computeName);
487
+ if (!compute || compute.internal !== true) fail(`compute view '${computeName}' must be an internal view`);
488
+ else if (columnSignature(compute.columns) !== columnSignature(view.columns)) fail(`compute view '${computeName}' must have exactly the view's columns`);
489
+ for (const source of resolved.sources) {
490
+ if (source.kind !== "etl") continue;
491
+ const read = tableByName.get(source.table)?.policies?.read;
492
+ const unconditional = typeof read === "string" ? read.trim() === "true" : read !== void 0 && read.default.trim() === "true" && read.variants.length === 0;
493
+ if (!unconditional) {
494
+ fail(`etl source '${source.name}' must have read policy 'true' to be materialized (the copy is shared by every caller)`);
495
+ }
496
+ }
497
+ }
498
+
499
+ // src/canonical/v2.ts
33
500
  var CANONICAL_FORMAT = 2;
34
501
  var IDENTIFIER_RE = /^[a-z][a-z0-9_]*$/;
35
- var identifier = z.string().min(1).max(63).regex(IDENTIFIER_RE);
502
+ var identifier2 = z.string().min(1).max(63).regex(IDENTIFIER_RE);
503
+ var columnIdentifier = z.string().min(1).max(63).regex(COLUMN_IDENTIFIER_PATTERN);
36
504
  var COLUMN_TYPES = [
37
505
  "uuid",
38
506
  "text",
@@ -47,59 +515,71 @@ var COLUMN_TYPES = [
47
515
  ];
48
516
  var columnTypeSchema = z.enum(COLUMN_TYPES);
49
517
  var canonicalEnumSchema = z.object({
50
- name: identifier,
518
+ name: identifier2,
51
519
  values: z.array(z.string().min(1).max(63)).min(1).max(100)
52
520
  });
53
521
  var canonicalColumnSchema = z.object({
54
522
  /** Property name in TS (camelCase); columnName is the Postgres name. */
55
523
  name: z.string().min(1).max(63),
56
- columnName: identifier,
57
- type: z.union([columnTypeSchema, z.object({ enum: identifier })]),
524
+ columnName: columnIdentifier,
525
+ type: z.union([columnTypeSchema, z.object({ enum: identifier2 })]),
58
526
  nullable: z.boolean().default(true),
59
527
  unique: z.boolean().default(false),
60
528
  /** Literal default, or one of the allowlisted SQL functions. */
61
529
  default: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
62
530
  defaultFn: z.enum(["gen_random_uuid()", "now()"]).optional(),
531
+ /**
532
+ * Stamp this column from the database clock on UPDATE (`now()`). The
533
+ * column stays insertable (defaults fill gaps) but is omitted from
534
+ * SetInput / settable so callers cannot backdate it.
535
+ */
536
+ onUpdateFn: z.enum(["now()"]).optional(),
63
537
  description: z.string().max(500).optional(),
64
538
  /** Migration hints — same semantics as v1. */
65
- renamedFrom: identifier.optional(),
539
+ renamedFrom: columnIdentifier.optional(),
66
540
  cast: z.string().max(200).optional(),
67
- deprecated: z.boolean().default(false)
541
+ deprecated: z.boolean().default(false),
542
+ /**
543
+ * Marks a system cell-metadata column (see CELL_PARTS in constants): part
544
+ * `part` of the cell of logical column `of`. Synthesized by authoring,
545
+ * never client-writable, absent from GraphQL scalar fields. Omitted on
546
+ * ordinary columns so their hashes are unchanged.
547
+ */
548
+ cell: z.object({ of: z.string().min(1).max(63), part: z.enum(CELL_PARTS) }).strict().optional()
549
+ }).superRefine((column, ctx) => {
550
+ if (column.onUpdateFn === void 0) return;
551
+ if (typeof column.type !== "string" || column.type !== "timestamptz" && column.type !== "date") {
552
+ ctx.addIssue({
553
+ code: "custom",
554
+ path: ["onUpdateFn"],
555
+ message: `onUpdateFn '${column.onUpdateFn}' is only valid on timestamptz/date columns`
556
+ });
557
+ }
68
558
  });
69
559
  var canonicalLinkSchema = z.object({
70
560
  name: z.string().min(1).max(63),
71
561
  kind: z.enum(["toOne", "toMany", "toPlatformUser"]),
72
562
  /** Target table name (namespace-local). Absent for toPlatformUser. */
73
- target: identifier.optional(),
563
+ target: identifier2.optional(),
74
564
  /** FK column on the owning side (toOne/toPlatformUser: on this table; toMany: on the target). */
75
- on: identifier,
565
+ on: columnIdentifier,
76
566
  /** Referenced column on the target (default: target's PK / platform user `sub`). */
77
- references: identifier.optional(),
567
+ references: columnIdentifier.optional(),
78
568
  /** enforce: true emits a real FK constraint (same-namespace toOne only). */
79
569
  enforce: z.boolean().default(false),
570
+ onDelete: z.enum(["cascade", "restrict", "no action", "set null", "set default"]).optional(),
571
+ onUpdate: z.enum(["cascade", "restrict", "no action", "set null", "set default"]).optional(),
80
572
  description: z.string().max(500).optional()
81
573
  });
82
574
  var canonicalIndexSchema = z.object({
83
- columns: z.array(identifier).min(1),
575
+ columns: z.array(columnIdentifier).min(1),
84
576
  unique: z.boolean().default(false),
85
- name: identifier.optional()
86
- });
87
- var policyVariantSchema = z.object({
88
- /** Claims-only guard (capability()/role()/claim() comparisons — no columns). */
89
- when: z.string().min(1).max(1e3),
90
- /** Exactly one of: allow-all bypass, or a row predicate. */
91
- allow: z.literal(true).optional(),
92
- policy: z.string().min(1).max(2e3).optional()
93
- }).refine((v) => v.allow === true !== (typeof v.policy === "string"), {
94
- message: "A variant declares exactly one of allow: true or policy"
577
+ name: identifier2.optional()
95
578
  });
96
- var policyExpressionSchema = z.union([
97
- z.string().min(1).max(2e3),
98
- z.object({
99
- default: z.string().min(1).max(2e3),
100
- variants: z.array(policyVariantSchema).max(10).default([])
101
- })
102
- ]);
579
+ var canonicalCheckSchema = z.object({
580
+ name: identifier2,
581
+ expression: z.string().min(1).max(2e3)
582
+ }).strict();
103
583
  var canonicalPoliciesSchema = z.object({
104
584
  read: policyExpressionSchema.optional(),
105
585
  write: policyExpressionSchema.optional(),
@@ -130,7 +610,7 @@ function isSingleViewQuerySql(value) {
130
610
  return state === "normal" && !/\$[a-z_0-9]*\$/i.test(query);
131
611
  }
132
612
  var canonicalActionSchema = z.object({
133
- name: identifier,
613
+ name: identifier2,
134
614
  requires: z.string().max(1e3).optional(),
135
615
  params: z.record(z.string(), z.object({
136
616
  type: columnTypeSchema,
@@ -142,9 +622,9 @@ var canonicalActionSchema = z.object({
142
622
  });
143
623
  var canonicalCowMutationSchema = z.object({
144
624
  kind: z.enum(["override", "revert"]),
145
- name: identifier,
625
+ name: identifier2,
146
626
  /** Synced table (same namespace) the overlay copies from / falls back to. */
147
- synced: identifier,
627
+ synced: identifier2,
148
628
  /** Single-column primary key PROPERTY (TS camelCase, like column `name`) shared by overlay and synced. */
149
629
  pk: z.string().min(1).max(63),
150
630
  /** Override: contract properties copied INSERT…SELECT from synced (pk included). Empty for revert. */
@@ -171,17 +651,28 @@ var canonicalCowMutationSchema = z.object({
171
651
  }
172
652
  }
173
653
  });
654
+ var canonicalForeignKeySchema = z.object({
655
+ name: identifier2,
656
+ columns: z.array(columnIdentifier).min(1).max(8),
657
+ target: identifier2,
658
+ references: z.array(columnIdentifier).min(1).max(8),
659
+ deferrable: z.enum(["initially-immediate", "initially-deferred"]).optional(),
660
+ onDelete: z.enum(["cascade", "restrict", "no action", "set null", "set default"]).optional(),
661
+ onUpdate: z.enum(["cascade", "restrict", "no action", "set null", "set default"]).optional()
662
+ }).strict();
174
663
  var canonicalTableSchema = z.object({
175
- table: identifier,
664
+ table: identifier2,
176
665
  description: z.string().max(2e3).optional(),
177
666
  /** Table-level rename hint (new in v2). */
178
- renamedFrom: identifier.optional(),
667
+ renamedFrom: identifier2.optional(),
179
668
  /** Synced/reflected read-only tables (`source: gold.*`) — v1 semantics. */
180
669
  source: z.string().regex(/^gold\.[a-z][a-z0-9_]*$/).optional(),
181
670
  columns: z.array(canonicalColumnSchema).default([]),
182
- primaryKey: z.array(identifier).min(1).max(8).optional(),
671
+ primaryKey: z.array(columnIdentifier).min(1).max(8).optional(),
183
672
  links: z.array(canonicalLinkSchema).default([]),
184
673
  indexes: z.array(canonicalIndexSchema).default([]),
674
+ /** Omitted by old definitions, preserving their existing content hashes. */
675
+ checks: z.array(canonicalCheckSchema).max(100).optional(),
185
676
  policies: canonicalPoliciesSchema.default({}),
186
677
  /**
187
678
  * v1-conversion carry-over only. v2 has no action surface — the runtime
@@ -190,26 +681,64 @@ var canonicalTableSchema = z.object({
190
681
  */
191
682
  actions: z.array(canonicalActionSchema).default([]),
192
683
  /** Optional (not defaulted) so pre-CoW canonical documents keep byte-identical hashes. */
193
- cowMutations: z.array(canonicalCowMutationSchema).optional()
684
+ cowMutations: z.array(canonicalCowMutationSchema).optional(),
685
+ /** Omitted by old definitions, preserving their existing content hashes. */
686
+ foreignKeys: z.array(canonicalForeignKeySchema).max(100).optional(),
687
+ /**
688
+ * Per-cell edit metadata (`cellMetadata: true` in authoring): every tracked
689
+ * column `c` carries typed `c__at` / `c__by` system columns (plus `c__rev`
690
+ * on a resolved table's internal sources), listed explicitly in `columns`
691
+ * with a `cell` marker so DDL and migrations treat them as ordinary columns.
692
+ * Omitted when off so existing definitions hash unchanged.
693
+ */
694
+ cellMetadata: z.literal(true).optional(),
695
+ /**
696
+ * System-owned backing table of a resolved table (its manual or prefetch
697
+ * source). Internal tables get DDL and IR metadata but no GraphQL surface;
698
+ * only the data plane writes them. Omitted when false.
699
+ */
700
+ internal: z.literal(true).optional(),
701
+ /**
702
+ * The materialized copy of resolved table `materializes` (internal): the
703
+ * same columns as that view, including its cell parts, maintained by the
704
+ * data plane. Omitted otherwise.
705
+ */
706
+ materializes: identifier2.optional()
194
707
  });
708
+ var MAX_VIEW_QUERY = 1e5;
709
+ var MAX_RESOLVED_VIEW_QUERY = 5e6;
195
710
  var canonicalViewSchema = z.object({
196
- view: identifier,
711
+ view: identifier2,
197
712
  description: z.string().max(2e3).optional(),
198
713
  columns: z.array(canonicalColumnSchema).min(1),
199
714
  /** Logical identity for GraphQL only; PostgreSQL views have no PK constraint. */
200
- primaryKey: z.array(identifier).min(1).max(8).optional(),
715
+ primaryKey: z.array(columnIdentifier).min(1).max(8).optional(),
201
716
  links: z.array(canonicalLinkSchema).default([]),
202
717
  policies: z.object({ read: policyExpressionSchema }).strict(),
203
- query: z.string().min(1).max(1e5).refine(isSingleViewQuerySql, {
718
+ query: z.string().min(1).max(MAX_RESOLVED_VIEW_QUERY).refine(isSingleViewQuerySql, {
204
719
  message: "view query must be one SELECT/WITH statement without comments or dollar-quoted bodies"
205
720
  }),
206
721
  /** Namespace-local logical relation names, sorted for deterministic hashing. */
207
- dependencies: z.array(identifier).max(500)
722
+ dependencies: z.array(identifier2).max(500),
723
+ /**
724
+ * Present when this view is a resolved table (defineResolvedTable): the
725
+ * multi-source metadata behind its generated query. Omitted otherwise so
726
+ * existing views hash unchanged.
727
+ */
728
+ resolved: canonicalResolvedSchema.optional(),
729
+ /** System-owned view (a materialized resolved table's compute view): no GraphQL surface. */
730
+ internal: z.literal(true).optional()
208
731
  }).superRefine((view, ctx) => {
732
+ if (view.resolved === void 0 && view.query.length > MAX_VIEW_QUERY) {
733
+ ctx.addIssue({ code: "custom", path: ["query"], message: `view query must be at most ${MAX_VIEW_QUERY} characters` });
734
+ }
209
735
  for (const [index, column] of view.columns.entries()) {
210
736
  if (column.default !== void 0 || column.defaultFn !== void 0) {
211
737
  ctx.addIssue({ code: "custom", path: ["columns", index], message: `view column '${column.name}' cannot declare a default` });
212
738
  }
739
+ if (column.onUpdateFn !== void 0) {
740
+ ctx.addIssue({ code: "custom", path: ["columns", index], message: `view column '${column.name}' cannot declare onUpdateFn` });
741
+ }
213
742
  if (column.unique) {
214
743
  ctx.addIssue({ code: "custom", path: ["columns", index], message: `view column '${column.name}' cannot declare unique` });
215
744
  }
@@ -217,15 +746,82 @@ var canonicalViewSchema = z.object({
217
746
  ctx.addIssue({ code: "custom", path: ["columns", index], message: `view column '${column.name}' cannot declare migration hints` });
218
747
  }
219
748
  }
749
+ if (view.resolved === void 0 && view.internal !== true) {
750
+ for (const [index, column] of view.columns.entries()) {
751
+ if (column.cell) ctx.addIssue({ code: "custom", path: ["columns", index], message: `only resolved tables carry cell columns ('${column.name}')` });
752
+ }
753
+ }
220
754
  for (const [index, link] of view.links.entries()) {
221
755
  if (link.enforce) {
222
756
  ctx.addIssue({ code: "custom", path: ["links", index, "enforce"], message: `view link '${link.name}' cannot enforce a foreign key` });
223
757
  }
758
+ if (link.onDelete !== void 0 || link.onUpdate !== void 0) {
759
+ ctx.addIssue({ code: "custom", path: ["links", index], message: `view link '${link.name}' cannot declare referential actions` });
760
+ }
224
761
  }
225
762
  });
763
+ var CELL_PART_TYPES = {
764
+ at: "timestamptz",
765
+ by: "text",
766
+ rev: "uuid",
767
+ source: "text",
768
+ conflicts: "jsonb"
769
+ };
770
+ function trackedCellColumns(table) {
771
+ const pk = new Set(table.primaryKey ?? []);
772
+ return table.columns.filter((column) => column.cell === void 0 && !pk.has(column.columnName) && column.onUpdateFn === void 0);
773
+ }
774
+ var SUFFIXES = Object.values(CELL_PART_SUFFIX);
775
+ function validateCellMetadata(table, index, ctx) {
776
+ const fail = (message, path = ["tables", index]) => ctx.addIssue({ code: "custom", path, message });
777
+ const cells = table.columns.flatMap((column, position) => column.cell ? [{ column, position, cell: column.cell }] : []);
778
+ if (table.materializes !== void 0) return;
779
+ if (table.cellMetadata !== true) {
780
+ for (const { position } of cells) fail(`cell-metadata columns require cellMetadata on table '${table.table}'`, ["tables", index, "columns", position]);
781
+ return;
782
+ }
783
+ if (table.source !== void 0) {
784
+ fail(`synced table '${table.table}' cannot track cell metadata`);
785
+ return;
786
+ }
787
+ const tracked = trackedCellColumns(table);
788
+ const byName = new Map(tracked.map((column) => [column.name, column]));
789
+ const withRev = cells.some(({ cell }) => cell.part === "rev");
790
+ if (withRev && table.internal !== true) fail(`only a resolved table's internal sources carry '__rev' cell columns ('${table.table}')`);
791
+ const seen = /* @__PURE__ */ new Set();
792
+ for (const { column, position, cell } of cells) {
793
+ const path = ["tables", index, "columns", position];
794
+ const owner = byName.get(cell.of);
795
+ if (!owner) {
796
+ fail(`cell column '${column.name}' belongs to '${cell.of}', which is not a tracked column`, path);
797
+ continue;
798
+ }
799
+ if (cell.part !== "at" && cell.part !== "by" && cell.part !== "rev") fail(`a table cell column must be part at/by/rev, not '${cell.part}'`, path);
800
+ if (column.name !== cellColumnName(owner.name, cell.part) || column.columnName !== cellColumnName(owner.columnName, cell.part)) {
801
+ fail(`cell column for '${owner.name}' ${cell.part} must be named '${cellColumnName(owner.name, cell.part)}' / '${cellColumnName(owner.columnName, cell.part)}'`, path);
802
+ }
803
+ if (column.type !== CELL_PART_TYPES[cell.part] || !column.nullable || column.unique || column.default !== void 0 || column.defaultFn !== void 0) {
804
+ fail(`cell column '${column.name}' must be a nullable, default-less ${CELL_PART_TYPES[cell.part]}`, path);
805
+ }
806
+ const key = `${cell.of}:${cell.part}`;
807
+ if (seen.has(key)) fail(`duplicate cell column '${column.name}'`, path);
808
+ seen.add(key);
809
+ }
810
+ for (const column of tracked) {
811
+ if (column.columnName.length > MAX_TRACKED_COLUMN_NAME) {
812
+ fail(`column '${column.columnName}' is too long to track cell metadata (max ${MAX_TRACKED_COLUMN_NAME} characters)`);
813
+ }
814
+ if (SUFFIXES.some((suffix) => column.columnName.endsWith(suffix))) {
815
+ fail(`column '${column.columnName}' ends in a reserved cell-metadata suffix (${SUFFIXES.join(", ")})`);
816
+ }
817
+ for (const part of withRev ? ["at", "by", "rev"] : ["at", "by"]) {
818
+ if (!seen.has(`${column.name}:${part}`)) fail(`tracked column '${column.name}' is missing its '${cellColumnName(column.columnName, part)}' cell column`);
819
+ }
820
+ }
821
+ }
226
822
  z.object({
227
823
  definition_format: z.literal(CANONICAL_FORMAT),
228
- namespace: identifier.max(41),
824
+ namespace: identifier2.max(41),
229
825
  description: z.string().max(2e3).optional(),
230
826
  enums: z.array(canonicalEnumSchema).default([]),
231
827
  tables: z.array(canonicalTableSchema).min(0).max(500),
@@ -243,6 +839,74 @@ z.object({
243
839
  ctx.addIssue({ code: "custom", path: ["tables", index, "table"], message: `duplicate relation '${table.table}'` });
244
840
  }
245
841
  seen.add(table.table);
842
+ validateCellMetadata(table, index, ctx);
843
+ if (table.source !== void 0) {
844
+ for (const [columnIndex, column] of table.columns.entries()) {
845
+ if (column.onUpdateFn !== void 0) {
846
+ ctx.addIssue({
847
+ code: "custom",
848
+ path: ["tables", index, "columns", columnIndex],
849
+ message: `synced table '${table.table}' column '${column.name}' cannot declare onUpdateFn`
850
+ });
851
+ }
852
+ }
853
+ }
854
+ }
855
+ const fkTables = new Map(definition.tables.map((table) => [table.table, table]));
856
+ for (const [tableIndex, table] of definition.tables.entries()) {
857
+ const names = /* @__PURE__ */ new Set([
858
+ ...table.primaryKey?.length ? [`${table.table}_pkey`] : [],
859
+ ...table.columns.filter((column) => column.unique && !(table.primaryKey?.length === 1 && table.primaryKey[0] === column.columnName)).map((column) => `${table.table}_${column.columnName}_unique`),
860
+ ...table.links.filter((link) => link.enforce).map((link) => `fk_${table.table}_${link.name}`)
861
+ ]);
862
+ for (const [index, link] of table.links.entries()) {
863
+ if ((link.onDelete !== void 0 || link.onUpdate !== void 0) && (!link.enforce || link.kind !== "toOne")) {
864
+ ctx.addIssue({ code: "custom", path: ["tables", tableIndex, "links", index], message: "referential actions require an enforced toOne link" });
865
+ }
866
+ if (link.onDelete === "set null" || link.onUpdate === "set null") {
867
+ const source = table.columns.find((column) => column.columnName === link.on);
868
+ if (source?.nullable === false || table.primaryKey?.includes(link.on)) ctx.addIssue({ code: "custom", path: ["tables", tableIndex, "links", index], message: "SET NULL requires a nullable source column" });
869
+ }
870
+ }
871
+ for (const [index, check] of (table.checks ?? []).entries()) {
872
+ const fail = (message) => ctx.addIssue({ code: "custom", path: ["tables", tableIndex, "checks", index], message });
873
+ if (table.source) fail("synced tables cannot declare checks");
874
+ if (names.has(check.name)) fail(`duplicate constraint '${check.name}'`);
875
+ names.add(check.name);
876
+ try {
877
+ compileCheckExpression(check.expression, table.columns);
878
+ } catch (error) {
879
+ fail(error instanceof Error ? error.message : String(error));
880
+ }
881
+ }
882
+ for (const [index, fk] of (table.foreignKeys ?? []).entries()) {
883
+ const fail = (message) => ctx.addIssue({ code: "custom", path: ["tables", tableIndex, "foreignKeys", index], message });
884
+ if (names.has(fk.name)) fail(`duplicate foreign key '${fk.name}'`);
885
+ names.add(fk.name);
886
+ const target = fkTables.get(fk.target);
887
+ if (table.source || !target || target.source) {
888
+ fail("foreign keys must connect owned tables in the same namespace");
889
+ continue;
890
+ }
891
+ if (fk.columns.length !== fk.references.length) fail("foreign key columns and references must have the same number of columns");
892
+ if (new Set(fk.columns).size !== fk.columns.length || new Set(fk.references).size !== fk.references.length) fail("foreign key columns must not repeat");
893
+ for (const [position, name] of fk.columns.entries()) {
894
+ const sourceColumn = table.columns.find((column) => column.columnName === name);
895
+ const targetColumn = target.columns.find((column) => column.columnName === fk.references[position]);
896
+ if (!sourceColumn || !targetColumn) fail("foreign key references an unknown column");
897
+ else if (JSON.stringify(sourceColumn.type) !== JSON.stringify(targetColumn.type)) fail("foreign key column types must match");
898
+ }
899
+ if (fk.onDelete === "set null" || fk.onUpdate === "set null") {
900
+ if (fk.columns.some((name) => table.columns.find((column) => column.columnName === name)?.nullable === false || table.primaryKey?.includes(name))) fail("SET NULL requires nullable source columns");
901
+ }
902
+ const uniqueKeys = [target.primaryKey ?? [], ...target.indexes.filter((index2) => index2.unique).map((index2) => index2.columns), ...target.columns.filter((column) => column.unique).map((column) => [column.columnName])];
903
+ if (!uniqueKeys.some((key) => key.length === fk.references.length && key.every((column) => fk.references.includes(column)))) fail("foreign key target columns must form a unique key");
904
+ }
905
+ }
906
+ for (const [viewIndex, view] of (definition.views ?? []).entries()) {
907
+ for (const message of validateResolvedView({ view, tables: definition.tables, views: definition.views ?? [] })) {
908
+ ctx.addIssue({ code: "custom", path: ["views", viewIndex, "resolved"], message });
909
+ }
246
910
  }
247
911
  const tableByName = new Map(definition.tables.map((table) => [table.table, table]));
248
912
  for (const [tableIndex, table] of definition.tables.entries()) {
@@ -361,7 +1025,10 @@ z.object({
361
1025
  });
362
1026
 
363
1027
  // src/ir/index.ts
364
- var IR_VERSION = 1;
1028
+ var IR_VERSION = 2;
1029
+ var LEGACY_IR_VERSION = 1;
1030
+ var irVersionSchema = z.union([z.literal(LEGACY_IR_VERSION), z.literal(IR_VERSION)]);
1031
+ z.object({ irVersion: irVersionSchema });
365
1032
  var CLAIMS_CONTRACT = {
366
1033
  sub: "string",
367
1034
  org: "string?",
@@ -433,6 +1100,21 @@ var selectionIrSchema = z.lazy(
433
1100
  () => z.object({
434
1101
  typename: z.string(),
435
1102
  columns: z.array(z.object({ alias: z.string(), column: z.string() })),
1103
+ cells: z.object({
1104
+ alias: z.string(),
1105
+ typename: z.string(),
1106
+ fields: z.array(z.object({
1107
+ alias: z.string(),
1108
+ column: z.string(),
1109
+ typename: z.string(),
1110
+ parts: z.array(z.object({
1111
+ alias: z.string(),
1112
+ part: z.enum(["source", "at", "by", "conflicts"]),
1113
+ fields: z.array(z.object({ alias: z.string(), field: z.enum(["source", "at", "value"]) })).optional(),
1114
+ typename: z.string().optional()
1115
+ }))
1116
+ }))
1117
+ }).optional(),
436
1118
  relations: z.array(z.object({
437
1119
  alias: z.string(),
438
1120
  link: z.string(),
@@ -454,6 +1136,11 @@ var selectionIrSchema = z.lazy(
454
1136
  })
455
1137
  );
456
1138
  var includeIfSchema = z.object({ variable: z.string(), negated: z.boolean() });
1139
+ var prefetchPlanIrSchema = z.object({
1140
+ source: z.string(),
1141
+ maxStalenessMs: z.number().int().positive().optional(),
1142
+ key: z.union([z.object({ one: valueIrSchema }), z.object({ many: valueIrSchema })])
1143
+ });
457
1144
  var selectRootSchema = z.object({
458
1145
  kind: z.literal("select"),
459
1146
  alias: z.string(),
@@ -465,7 +1152,8 @@ var selectRootSchema = z.object({
465
1152
  distinctOn: z.union([z.array(z.string()), z.object({ slot: z.string() })]).optional(),
466
1153
  limit: limitIrSchema,
467
1154
  offset: z.object({ variable: z.string().optional(), value: z.number().int().optional() }).optional(),
468
- includeIf: includeIfSchema.optional()
1155
+ includeIf: includeIfSchema.optional(),
1156
+ prefetch: z.array(prefetchPlanIrSchema).optional()
469
1157
  });
470
1158
  var byPkRootSchema = z.object({
471
1159
  kind: z.literal("byPk"),
@@ -475,7 +1163,8 @@ var byPkRootSchema = z.object({
475
1163
  pk: z.array(z.object({ column: z.string(), value: valueIrSchema })).min(1),
476
1164
  /** Policy predicate — ANDed with the PK match. */
477
1165
  where: predIrSchema,
478
- includeIf: includeIfSchema.optional()
1166
+ includeIf: includeIfSchema.optional(),
1167
+ prefetch: z.array(prefetchPlanIrSchema).optional()
479
1168
  });
480
1169
  var aggregateRootSchema = z.object({
481
1170
  kind: z.literal("aggregate"),
@@ -488,6 +1177,33 @@ var aggregateRootSchema = z.object({
488
1177
  nodes: selectionIrSchema.optional(),
489
1178
  includeIf: includeIfSchema.optional()
490
1179
  });
1180
+ var DATE_BUCKETS = ["year", "quarter", "month", "week", "day", "hour"];
1181
+ var groupByKeyIrSchema = z.object({
1182
+ column: z.string(),
1183
+ bucket: z.enum(DATE_BUCKETS).optional()
1184
+ });
1185
+ var aggregateGroupsRootSchema = z.object({
1186
+ kind: z.literal("aggregateGroups"),
1187
+ alias: z.string(),
1188
+ table: z.string(),
1189
+ /** Composed at compile time: AND(policy, documentWhere?, dynamicFilter?). */
1190
+ where: predIrSchema,
1191
+ groupBy: z.array(groupByKeyIrSchema).min(1),
1192
+ /**
1193
+ * The `key { … }` projection: which group columns appear in each entry's key
1194
+ * object, in selection order. A subset of `groupBy` (the compiler rejects a
1195
+ * key field that is not a group key — it would be indeterminate within the
1196
+ * group). Empty when the document selects only `aggregate`.
1197
+ */
1198
+ key: z.array(z.object({ alias: z.string(), column: z.string() })).default([]),
1199
+ count: z.object({ columns: z.array(z.string()).optional(), distinct: z.boolean().optional() }).optional(),
1200
+ sum: z.array(z.string()).optional(),
1201
+ avg: z.array(z.string()).optional(),
1202
+ /** Bounds the GROUP count, not the scanned rows — see MAX_GROUPS. */
1203
+ limit: limitIrSchema,
1204
+ offset: z.object({ variable: z.string().optional(), value: z.number().int().optional() }).optional(),
1205
+ includeIf: includeIfSchema.optional()
1206
+ });
491
1207
  var writeValuesSchema = z.union([
492
1208
  z.object({ slot: z.string() }),
493
1209
  z.object({ entries: z.array(z.object({ column: z.string(), value: valueIrSchema })) })
@@ -506,7 +1222,20 @@ var mutationRootSchema = z.object({
506
1222
  * coalesce view falls back to synced. Both are gated on a matching synced
507
1223
  * row and carry the write policy in `where` like update/delete.
508
1224
  */
509
- op: z.enum(["insert", "insertOne", "update", "updateByPk", "delete", "deleteByPk", "cowOverride", "cowRevert"]),
1225
+ op: z.enum(["insert", "insertOne", "update", "updateByPk", "delete", "deleteByPk", "cowOverride", "cowRevert", "resolvedWrite"]),
1226
+ /**
1227
+ * resolvedWrite: a write to a resolved table (`table` is its view). The
1228
+ * executor gates on the resolved row (write policy in `where`), writes only
1229
+ * the touched cells into the manual source, then re-checks `insertCheck`
1230
+ * against the resolved row. `revert` clears `revertColumns` from the manual
1231
+ * source; `delete` tombstones the row there.
1232
+ */
1233
+ resolvedAction: z.enum(["insert", "update", "delete", "revert"]).optional(),
1234
+ revertColumns: z.array(z.string()).optional(),
1235
+ /** `@writeback(source:, mode:)` directives on this root. */
1236
+ writeback: z.array(z.object({ source: z.string(), mode: z.enum(["sync", "async"]) })).optional(),
1237
+ /** `@localOnly(source:)` — writeback columns deliberately kept local for these sources. */
1238
+ localOnly: z.array(z.string()).optional(),
510
1239
  alias: z.string(),
511
1240
  table: z.string(),
512
1241
  /** CoW ops: logical name of the synced twin (read through its policy-filtered view). */
@@ -533,12 +1262,15 @@ var mutationRootSchema = z.object({
533
1262
  insertCheck: predIrSchema.optional(),
534
1263
  returning: selectionIrSchema.nullable(),
535
1264
  affectedRows: z.boolean().default(false),
1265
+ /** v2: abort the transaction when the actual write count differs, regardless of selection. */
1266
+ assertAffectedRows: z.number().int().min(0).max(2147483647).optional(),
536
1267
  includeIf: includeIfSchema.optional()
537
1268
  });
538
1269
  var rootIrSchema = z.discriminatedUnion("kind", [
539
1270
  selectRootSchema,
540
1271
  byPkRootSchema,
541
1272
  aggregateRootSchema,
1273
+ aggregateGroupsRootSchema,
542
1274
  mutationRootSchema
543
1275
  ]);
544
1276
  var INPUT_REF_KINDS = [
@@ -568,7 +1300,7 @@ var variableIrSchema = z.object({
568
1300
  defaultValue: variableDefaultIrSchema.optional()
569
1301
  });
570
1302
  var compiledOperationSchema = z.object({
571
- irVersion: z.literal(IR_VERSION),
1303
+ irVersion: irVersionSchema,
572
1304
  name: z.string(),
573
1305
  opHash: z.string().regex(/^sha256:[0-9a-f]{64}$/),
574
1306
  kind: z.enum(["query", "mutation"]),
@@ -580,6 +1312,50 @@ var compiledOperationSchema = z.object({
580
1312
  /** Every table the op touches — data-token scoping + audit. */
581
1313
  tableSet: z.array(z.string()).min(1),
582
1314
  roots: z.array(rootIrSchema).min(1)
1315
+ }).superRefine((operation, ctx) => {
1316
+ for (const [index, root] of operation.roots.entries()) {
1317
+ if (root.kind !== "mutation" || root.assertAffectedRows === void 0) continue;
1318
+ if (operation.irVersion < 2 || operation.kind !== "mutation" || root.includeIf !== void 0) {
1319
+ ctx.addIssue({ code: "custom", path: ["roots", index], message: "affected-row assertions require unconditional mutation roots and IR v2" });
1320
+ }
1321
+ }
1322
+ });
1323
+ var resolvedSourceIrSchema = z.object({
1324
+ name: z.string(),
1325
+ kind: z.enum(["manual", "etl", "prefetch"]),
1326
+ /** Logical table key of the backing relation. */
1327
+ table: z.string(),
1328
+ columns: z.array(z.string()),
1329
+ key: z.string().optional(),
1330
+ fetch: z.union([
1331
+ z.object({ shape: z.literal("single") }),
1332
+ z.object({ shape: z.literal("batch"), maxKeys: z.number().int().positive() })
1333
+ ]).optional(),
1334
+ function: z.object({ kind: z.enum(["managed", "native"]), id: z.string() }).optional(),
1335
+ prefetchDefaults: z.object({
1336
+ maxStalenessMs: z.number().int().positive(),
1337
+ timeoutMs: z.number().int().positive(),
1338
+ onError: z.enum(["fallback", "fail"])
1339
+ }).optional(),
1340
+ writeback: z.object({
1341
+ columns: z.array(z.string()),
1342
+ defaultMode: z.enum(["sync", "async"]),
1343
+ timeoutMs: z.number().int().positive(),
1344
+ clearManualOnConfirm: z.boolean()
1345
+ }).optional()
1346
+ });
1347
+ var resolvedMetaIrSchema = z.object({
1348
+ /** Logical pk column of the resolved view (single-column). */
1349
+ pk: z.string(),
1350
+ /** Source name of the manual source. */
1351
+ manual: z.string(),
1352
+ sources: z.array(resolvedSourceIrSchema),
1353
+ deletedBy: z.array(z.string()),
1354
+ /**
1355
+ * Materialized: the view reads `table`, which the data plane refreshes from
1356
+ * the `compute` view (claims-gated) whenever it writes a source row.
1357
+ */
1358
+ materialized: z.object({ table: z.string(), compute: z.string() }).optional()
583
1359
  });
584
1360
  var tableMetaIrSchema = z.object({
585
1361
  table: z.string(),
@@ -616,18 +1392,45 @@ var tableMetaIrSchema = z.object({
616
1392
  insertable: z.array(z.string()),
617
1393
  settable: z.array(z.string()),
618
1394
  appendable: z.array(z.string()),
1395
+ /**
1396
+ * Logical columns stamped with Postgres `now()` on UPDATE (canonical
1397
+ * `onUpdateFn`). Omitted when empty so pre-onUpdate artifacts still parse.
1398
+ */
1399
+ touchOnUpdate: z.array(z.string()).optional(),
1400
+ /**
1401
+ * Per-cell metadata: logical column → the logical names of its cell-part
1402
+ * columns (which are listed in `columns`, but never filterable, orderable or
1403
+ * writable). Tables stamp `at`/`by` (and a manual source `rev`) on every
1404
+ * write; resolved views expose `source`/`at`/`by`/`conflicts`. Omitted when
1405
+ * the relation tracks no cells.
1406
+ */
1407
+ cells: z.record(z.string(), z.object({
1408
+ at: z.string().optional(),
1409
+ by: z.string().optional(),
1410
+ rev: z.string().optional(),
1411
+ source: z.string().optional(),
1412
+ conflicts: z.string().optional()
1413
+ }).strict()).optional(),
1414
+ /** Resolved-table view metadata (sources, prefetch/writeback config) for the data plane. */
1415
+ resolved: resolvedMetaIrSchema.optional(),
619
1416
  constraints: z.array(z.object({ name: z.string(), columns: z.array(z.string()) })),
620
1417
  /** Depth/node caps for dynamic filter trees on this table. */
621
1418
  dynamicFilterMaxDepth: z.number().int().positive().default(6),
622
1419
  dynamicFilterMaxNodes: z.number().int().positive().default(64)
623
1420
  });
624
1421
  z.object({
625
- irVersion: z.literal(IR_VERSION),
1422
+ irVersion: irVersionSchema,
626
1423
  namespace: z.string(),
627
1424
  schemaHash: z.string(),
628
1425
  opsHash: z.string(),
629
1426
  tables: z.record(z.string(), tableMetaIrSchema),
630
1427
  operations: z.record(z.string(), compiledOperationSchema)
1428
+ }).superRefine((artifact, ctx) => {
1429
+ for (const [name, operation] of Object.entries(artifact.operations)) {
1430
+ if (operation.irVersion > artifact.irVersion) {
1431
+ ctx.addIssue({ code: "custom", path: ["operations", name, "irVersion"], message: "operation requires a newer IR version than its artifact" });
1432
+ }
1433
+ }
631
1434
  });
632
1435
 
633
1436
  // src/predicate-v2/parse.ts
@@ -638,7 +1441,7 @@ var IDENTIFIER_CHAR = /[a-zA-Z0-9_]/;
638
1441
  var NUMBER_PATTERN = /^-?\d+(\.\d+)?/;
639
1442
  var SCALAR_CLAIM_KEYS = Object.entries(CLAIMS_CONTRACT).filter(([, type]) => !type.endsWith("[]")).map(([key]) => key);
640
1443
  var ARRAY_CLAIM_KEYS = Object.entries(CLAIMS_CONTRACT).filter(([, type]) => type.endsWith("[]")).map(([key]) => key);
641
- function tokenize(source) {
1444
+ function tokenize2(source) {
642
1445
  const tokens = [];
643
1446
  let position = 0;
644
1447
  while (position < source.length) {
@@ -717,7 +1520,7 @@ function readString(source, start) {
717
1520
  }
718
1521
  throw new PolicyParseError("unterminated string literal", start);
719
1522
  }
720
- var Parser = class {
1523
+ var Parser2 = class {
721
1524
  constructor(tokens) {
722
1525
  this.tokens = tokens;
723
1526
  }
@@ -891,7 +1694,7 @@ var Parser = class {
891
1694
  function parsePolicy(source) {
892
1695
  const trimmed = source.trim();
893
1696
  if (trimmed.length === 0) throw new PolicyParseError("empty policy expression", 0);
894
- return { source: trimmed, ast: new Parser(tokenize(trimmed)).parse() };
1697
+ return { source: trimmed, ast: new Parser2(tokenize2(trimmed)).parse() };
895
1698
  }
896
1699
 
897
1700
  // src/predicate-v2/compile.ts