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