@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.
- package/README.md +94 -2
- package/dist/authoring.cjs +1474 -54
- package/dist/authoring.cjs.map +1 -1
- package/dist/authoring.d.cts +242 -4
- package/dist/authoring.d.ts +242 -4
- package/dist/authoring.js +1465 -56
- package/dist/authoring.js.map +1 -1
- package/dist/canonical.cjs +740 -42
- package/dist/canonical.cjs.map +1 -1
- package/dist/canonical.d.cts +742 -53
- package/dist/canonical.d.ts +742 -53
- package/dist/canonical.js +724 -43
- package/dist/canonical.js.map +1 -1
- package/dist/cli.cjs +2478 -328
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +2480 -330
- package/dist/cli.js.map +1 -1
- package/dist/client.cjs +90 -25
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +39 -22
- package/dist/client.d.ts +39 -22
- package/dist/client.js +87 -26
- package/dist/client.js.map +1 -1
- package/dist/compiler.cjs +1755 -329
- package/dist/compiler.cjs.map +1 -1
- package/dist/compiler.d.cts +56 -3
- package/dist/compiler.d.ts +56 -3
- package/dist/compiler.js +1756 -330
- package/dist/compiler.js.map +1 -1
- package/dist/constants-YoFGjIBM.d.cts +99 -0
- package/dist/constants-YoFGjIBM.d.ts +99 -0
- package/dist/dynamic-input.d.cts +1 -0
- package/dist/dynamic-input.d.ts +1 -0
- package/dist/index.cjs +1973 -164
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +474 -100
- package/dist/index.d.ts +474 -100
- package/dist/index.js +1940 -167
- package/dist/index.js.map +1 -1
- package/dist/ir.cjs +869 -46
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +699 -25
- package/dist/ir.d.ts +699 -25
- package/dist/ir.js +861 -47
- package/dist/ir.js.map +1 -1
- package/dist/{naming-CTvWSRTf.d.ts → naming-CPfx7EfF.d.ts} +28 -1
- package/dist/{naming-DRyinU4u.d.cts → naming-CU9ThP_E.d.cts} +28 -1
- package/dist/policy-1iZbGnZZ.d.cts +24 -0
- package/dist/policy-1iZbGnZZ.d.ts +24 -0
- package/dist/predicate-v2.cjs +852 -49
- package/dist/predicate-v2.cjs.map +1 -1
- package/dist/predicate-v2.d.cts +2 -1
- package/dist/predicate-v2.d.ts +2 -1
- package/dist/predicate-v2.js +852 -49
- package/dist/predicate-v2.js.map +1 -1
- package/dist/sdl.cjs +1309 -58
- package/dist/sdl.cjs.map +1 -1
- package/dist/sdl.d.cts +7 -3
- package/dist/sdl.d.ts +7 -3
- package/dist/sdl.js +1305 -59
- package/dist/sdl.js.map +1 -1
- package/package.json +11 -6
- package/templates/orm-namespace/CLAUDE.md +6 -4
- package/templates/orm-namespace/codegen.preset.d.cts +7 -0
- package/templates/orm-namespace/codegen.ts +2 -1
- package/templates/orm-namespace/graphql/example/queries.ts +4 -3
- package/templates/orm-namespace/package.json +2 -1
- package/templates/orm-namespace/schema/tables.ts +3 -2
- package/templates/orm-namespace/sequence.config.ts +7 -11
package/dist/authoring.cjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
var drizzleOrm = require('drizzle-orm');
|
|
4
4
|
var pgCore = require('drizzle-orm/pg-core');
|
|
5
|
-
var crypto = require('crypto');
|
|
6
5
|
var zod = require('zod');
|
|
6
|
+
var crypto = require('crypto');
|
|
7
7
|
var jiti = require('jiti');
|
|
8
8
|
var promises = require('fs/promises');
|
|
9
9
|
var module$1 = require('module');
|
|
@@ -33,10 +33,12 @@ var ColumnBuilder = class {
|
|
|
33
33
|
hasDefault: false,
|
|
34
34
|
defaultValue: void 0,
|
|
35
35
|
defaultFn: void 0,
|
|
36
|
+
onUpdateFn: void 0,
|
|
36
37
|
description: void 0,
|
|
37
38
|
renamedFrom: void 0,
|
|
38
39
|
cast: void 0,
|
|
39
|
-
deprecated: false
|
|
40
|
+
deprecated: false,
|
|
41
|
+
cell: void 0
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
/** Marks this column as (part of) the primary key. Implies NOT NULL. */
|
|
@@ -68,6 +70,15 @@ var ColumnBuilder = class {
|
|
|
68
70
|
this.state.defaultFn = void 0;
|
|
69
71
|
return this;
|
|
70
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Stamp this column with Postgres `now()` on every UPDATE. Only valid on
|
|
75
|
+
* timestamptz/date; the compiler drops it from SetInput so callers cannot
|
|
76
|
+
* supply a value.
|
|
77
|
+
*/
|
|
78
|
+
onUpdate(value) {
|
|
79
|
+
this.state.onUpdateFn = value;
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
71
82
|
description(text2) {
|
|
72
83
|
this.state.description = text2;
|
|
73
84
|
return this;
|
|
@@ -87,6 +98,11 @@ var ColumnBuilder = class {
|
|
|
87
98
|
this.state.deprecated = true;
|
|
88
99
|
return this;
|
|
89
100
|
}
|
|
101
|
+
/** @internal System cell-metadata part of logical column `of` (generated resolved views only). */
|
|
102
|
+
cellPart(of, part) {
|
|
103
|
+
this.state.cell = { of, part };
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
90
106
|
/** Whether `.primaryKey()` was called — collected into the table-level PK. */
|
|
91
107
|
get isPrimaryKey() {
|
|
92
108
|
return this.state.primaryKey;
|
|
@@ -111,9 +127,11 @@ var ColumnBuilder = class {
|
|
|
111
127
|
};
|
|
112
128
|
if (this.state.hasDefault) column.default = this.state.defaultValue ?? null;
|
|
113
129
|
if (this.state.defaultFn !== void 0) column.defaultFn = this.state.defaultFn;
|
|
130
|
+
if (this.state.onUpdateFn !== void 0) column.onUpdateFn = this.state.onUpdateFn;
|
|
114
131
|
if (this.state.description !== void 0) column.description = this.state.description;
|
|
115
132
|
if (this.state.renamedFrom !== void 0) column.renamedFrom = this.state.renamedFrom;
|
|
116
133
|
if (this.state.cast !== void 0) column.cast = this.state.cast;
|
|
134
|
+
if (this.state.cell !== void 0) column.cell = { ...this.state.cell };
|
|
117
135
|
return column;
|
|
118
136
|
}
|
|
119
137
|
};
|
|
@@ -138,9 +156,25 @@ var t = {
|
|
|
138
156
|
// src/constants.ts
|
|
139
157
|
var NAMESPACE_PATTERN = /^[a-z][a-z0-9_]{0,40}$/;
|
|
140
158
|
var IDENTIFIER_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
159
|
+
var COLUMN_IDENTIFIER_PATTERN = /^_?[a-z][a-z0-9_]*$/;
|
|
141
160
|
var MAX_IDENTIFIER_LENGTH = 63;
|
|
142
161
|
var SYNCED_SOURCE_PATTERN = /^gold\.[a-z][a-z0-9_]*$/;
|
|
143
162
|
var HASH_DOMAIN = "sequence-orm-definitions\0";
|
|
163
|
+
var CELL_PARTS = ["at", "by", "rev", "source", "conflicts"];
|
|
164
|
+
var CELL_PART_SUFFIX = {
|
|
165
|
+
at: "__at",
|
|
166
|
+
by: "__by",
|
|
167
|
+
rev: "__rev",
|
|
168
|
+
source: "__src",
|
|
169
|
+
conflicts: "__lost"
|
|
170
|
+
};
|
|
171
|
+
var MAX_TRACKED_COLUMN_NAME = MAX_IDENTIFIER_LENGTH - 6;
|
|
172
|
+
var CELLS_FIELD = "cells";
|
|
173
|
+
function cellColumnName(column, part) {
|
|
174
|
+
return `${column}${CELL_PART_SUFFIX[part]}`;
|
|
175
|
+
}
|
|
176
|
+
var MATERIALIZE_ACTOR = "orm:materialize";
|
|
177
|
+
JSON.stringify({ sub: MATERIALIZE_ACTOR, org: null, roles: [], capabilities: [] });
|
|
144
178
|
|
|
145
179
|
// src/errors.ts
|
|
146
180
|
var OrmDefinitionError = class extends Error {
|
|
@@ -209,6 +243,8 @@ function toOne(target, options) {
|
|
|
209
243
|
on: options.on,
|
|
210
244
|
references: options.references,
|
|
211
245
|
enforce: options.enforce ?? false,
|
|
246
|
+
onDelete: options.onDelete,
|
|
247
|
+
onUpdate: options.onUpdate,
|
|
212
248
|
description: options.description
|
|
213
249
|
};
|
|
214
250
|
}
|
|
@@ -220,6 +256,8 @@ function toMany(target, options) {
|
|
|
220
256
|
on: options.on,
|
|
221
257
|
references: options.references,
|
|
222
258
|
enforce: false,
|
|
259
|
+
onDelete: void 0,
|
|
260
|
+
onUpdate: void 0,
|
|
223
261
|
description: options.description
|
|
224
262
|
};
|
|
225
263
|
}
|
|
@@ -231,6 +269,8 @@ function toPlatformUser(options) {
|
|
|
231
269
|
on: options.on,
|
|
232
270
|
references: options.references,
|
|
233
271
|
enforce: false,
|
|
272
|
+
onDelete: void 0,
|
|
273
|
+
onUpdate: void 0,
|
|
234
274
|
description: options.description
|
|
235
275
|
};
|
|
236
276
|
}
|
|
@@ -240,10 +280,10 @@ function isSeqLink(value) {
|
|
|
240
280
|
function isSeqTable(value) {
|
|
241
281
|
return typeof value === "object" && value !== null && value.kind === TABLE_BRAND;
|
|
242
282
|
}
|
|
243
|
-
function assertIdentifier({ value, what, file }) {
|
|
244
|
-
if (!
|
|
283
|
+
function assertIdentifier({ value, what, file, pattern = IDENTIFIER_PATTERN }) {
|
|
284
|
+
if (!pattern.test(value) || value.length > MAX_IDENTIFIER_LENGTH) {
|
|
245
285
|
throw new OrmDefinitionError(
|
|
246
|
-
`${what} "${value}" must match ${
|
|
286
|
+
`${what} "${value}" must match ${pattern} and be at most ${MAX_IDENTIFIER_LENGTH} characters`,
|
|
247
287
|
file
|
|
248
288
|
);
|
|
249
289
|
}
|
|
@@ -264,7 +304,13 @@ function defineTable(name, columns, config = {}) {
|
|
|
264
304
|
);
|
|
265
305
|
}
|
|
266
306
|
const columnName = builder.columnNameFor(propertyName);
|
|
267
|
-
assertIdentifier({ value: columnName, what: `table "${name}": column "${propertyName}" column name`, file });
|
|
307
|
+
assertIdentifier({ value: columnName, what: `table "${name}": column "${propertyName}" column name`, file, pattern: COLUMN_IDENTIFIER_PATTERN });
|
|
308
|
+
if (config.materializes === void 0 && (propertyName === CELLS_FIELD || Object.values(CELL_PART_SUFFIX).some((suffix) => columnName.endsWith(suffix) || propertyName.endsWith(suffix)))) {
|
|
309
|
+
throw new OrmDefinitionError(
|
|
310
|
+
`table "${name}": "${propertyName}" collides with the reserved cell-metadata names ('${CELLS_FIELD}', or a ${Object.values(CELL_PART_SUFFIX).join("/")} suffix)`,
|
|
311
|
+
file
|
|
312
|
+
);
|
|
313
|
+
}
|
|
268
314
|
const existing = seenColumnNames.get(columnName);
|
|
269
315
|
if (existing !== void 0) {
|
|
270
316
|
throw new OrmDefinitionError(
|
|
@@ -304,7 +350,8 @@ function defineSyncedTable(name, columnsOrConfig, maybeConfig) {
|
|
|
304
350
|
assertIdentifier({
|
|
305
351
|
value: columnName,
|
|
306
352
|
what: `synced table "${name}": column "${propertyName}" column name`,
|
|
307
|
-
file
|
|
353
|
+
file,
|
|
354
|
+
pattern: COLUMN_IDENTIFIER_PATTERN
|
|
308
355
|
});
|
|
309
356
|
const existing = seenColumnNames.get(columnName);
|
|
310
357
|
if (existing !== void 0) {
|
|
@@ -393,7 +440,7 @@ function defineView(name, columns, config) {
|
|
|
393
440
|
);
|
|
394
441
|
}
|
|
395
442
|
const columnName = builder.columnNameFor(propertyName);
|
|
396
|
-
if (!
|
|
443
|
+
if (!COLUMN_IDENTIFIER_PATTERN.test(columnName) || columnName.length > MAX_IDENTIFIER_LENGTH) {
|
|
397
444
|
throw new OrmDefinitionError(`view "${name}": column "${propertyName}" has invalid column name "${columnName}"`, file);
|
|
398
445
|
}
|
|
399
446
|
const existing = seenColumnNames.get(columnName);
|
|
@@ -511,11 +558,11 @@ function compileViewQueries(input) {
|
|
|
511
558
|
);
|
|
512
559
|
}
|
|
513
560
|
if (!config.query) throw new OrmDefinitionError(`view "${authored.viewName}" produced no query`, authored.file);
|
|
514
|
-
const tableDependencies = pgCore.extractUsedTable(config.query).map((
|
|
515
|
-
const logical = physicalToLogical.get(
|
|
561
|
+
const tableDependencies = pgCore.extractUsedTable(config.query).map((physical2) => {
|
|
562
|
+
const logical = physicalToLogical.get(physical2);
|
|
516
563
|
if (!logical) {
|
|
517
564
|
throw new OrmDefinitionError(
|
|
518
|
-
`view "${authored.viewName}" query references unmanaged relation "${
|
|
565
|
+
`view "${authored.viewName}" query references unmanaged relation "${physical2}" \u2014 use relation(...) with a namespace table or view`,
|
|
519
566
|
authored.file
|
|
520
567
|
);
|
|
521
568
|
}
|
|
@@ -839,9 +886,469 @@ function scalarColumnType(table, property, helper) {
|
|
|
839
886
|
}
|
|
840
887
|
return type;
|
|
841
888
|
}
|
|
889
|
+
var quoteIdentifier = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
890
|
+
var quoteLiteral = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
891
|
+
|
|
892
|
+
// src/checks/compile.ts
|
|
893
|
+
function tokenize(source) {
|
|
894
|
+
const tokens = [];
|
|
895
|
+
for (let position = 0; position < source.length; ) {
|
|
896
|
+
const char = source[position];
|
|
897
|
+
if (/\s/.test(char)) {
|
|
898
|
+
position++;
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
if (char === "(" || char === ")" || char === ",") {
|
|
902
|
+
tokens.push({ kind: char === "(" ? "lparen" : char === ")" ? "rparen" : "comma", value: char, position });
|
|
903
|
+
position++;
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
if (char === "'") {
|
|
907
|
+
const start = position++;
|
|
908
|
+
let value = "";
|
|
909
|
+
let closed = false;
|
|
910
|
+
while (position < source.length) {
|
|
911
|
+
if (source[position] !== "'") {
|
|
912
|
+
value += source[position++];
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
if (source[position + 1] === "'") {
|
|
916
|
+
value += "'";
|
|
917
|
+
position += 2;
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
position++;
|
|
921
|
+
closed = true;
|
|
922
|
+
break;
|
|
923
|
+
}
|
|
924
|
+
if (!closed) throw new Error(`unterminated string at position ${start}`);
|
|
925
|
+
if (/[\x00-\x1f\x7f]/.test(value)) throw new Error(`control character in string at position ${start}`);
|
|
926
|
+
tokens.push({ kind: "string", value, position: start });
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
const operator = /^(<=|>=|<>|!=|!~|=|<|>|~)/.exec(source.slice(position));
|
|
930
|
+
if (operator) {
|
|
931
|
+
tokens.push({ kind: "operator", value: operator[0] === "<>" ? "!=" : operator[0], position });
|
|
932
|
+
position += operator[0].length;
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
const number = /^-?\d+(?:\.\d+)?/.exec(source.slice(position));
|
|
936
|
+
if (number) {
|
|
937
|
+
tokens.push({ kind: "number", value: number[0], position });
|
|
938
|
+
position += number[0].length;
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
const identifier3 = /^[a-zA-Z_][a-zA-Z_0-9]*/.exec(source.slice(position));
|
|
942
|
+
if (identifier3) {
|
|
943
|
+
tokens.push({ kind: "identifier", value: identifier3[0], position });
|
|
944
|
+
position += identifier3[0].length;
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
throw new Error(`unexpected character '${char}' at position ${position}`);
|
|
948
|
+
}
|
|
949
|
+
tokens.push({ kind: "end", value: "", position: source.length });
|
|
950
|
+
if (tokens.length > 256) throw new Error("check expression exceeds 256 tokens");
|
|
951
|
+
return tokens;
|
|
952
|
+
}
|
|
953
|
+
var Parser = class {
|
|
954
|
+
constructor(tokens, columns) {
|
|
955
|
+
this.tokens = tokens;
|
|
956
|
+
this.columns = /* @__PURE__ */ new Map();
|
|
957
|
+
for (const column of columns) {
|
|
958
|
+
for (const alias of [column.name, column.columnName]) {
|
|
959
|
+
const previous = this.columns.get(alias);
|
|
960
|
+
if (previous && previous.columnName !== column.columnName) {
|
|
961
|
+
throw new Error(`ambiguous check column '${alias}'`);
|
|
962
|
+
}
|
|
963
|
+
this.columns.set(alias, column);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
index = 0;
|
|
968
|
+
depth = 0;
|
|
969
|
+
columns;
|
|
970
|
+
parse() {
|
|
971
|
+
const expression = this.parseOr();
|
|
972
|
+
this.expect("end", "end of expression");
|
|
973
|
+
return expression;
|
|
974
|
+
}
|
|
975
|
+
parseOr() {
|
|
976
|
+
let left = this.parseAnd();
|
|
977
|
+
while (this.matchKeyword("or")) left = `(${left} OR ${this.parseAnd()})`;
|
|
978
|
+
return left;
|
|
979
|
+
}
|
|
980
|
+
parseAnd() {
|
|
981
|
+
let left = this.parseNot();
|
|
982
|
+
while (this.matchKeyword("and")) left = `(${left} AND ${this.parseNot()})`;
|
|
983
|
+
return left;
|
|
984
|
+
}
|
|
985
|
+
parseNot() {
|
|
986
|
+
if (this.matchKeyword("not")) return `(NOT ${this.parseNot()})`;
|
|
987
|
+
return this.parsePrimary();
|
|
988
|
+
}
|
|
989
|
+
parsePrimary() {
|
|
990
|
+
if (this.peek().kind === "lparen") {
|
|
991
|
+
if (++this.depth > 32) throw new Error("check expression nesting exceeds 32 levels");
|
|
992
|
+
this.advance();
|
|
993
|
+
const expression = this.parseOr();
|
|
994
|
+
this.expect("rparen", "')'");
|
|
995
|
+
this.depth--;
|
|
996
|
+
return `(${expression})`;
|
|
997
|
+
}
|
|
998
|
+
return this.parseComparison();
|
|
999
|
+
}
|
|
1000
|
+
parseComparison() {
|
|
1001
|
+
const left = this.parseValue();
|
|
1002
|
+
if (this.matchKeyword("is")) {
|
|
1003
|
+
const negate = this.matchKeyword("not");
|
|
1004
|
+
this.expectKeyword("null");
|
|
1005
|
+
return `${left.sql} IS ${negate ? "NOT " : ""}NULL`;
|
|
1006
|
+
}
|
|
1007
|
+
if (this.matchKeyword("between")) {
|
|
1008
|
+
const lower = this.parseValue();
|
|
1009
|
+
this.expectKeyword("and");
|
|
1010
|
+
const upper = this.parseValue();
|
|
1011
|
+
return `${left.sql} BETWEEN ${lower.sql} AND ${upper.sql}`;
|
|
1012
|
+
}
|
|
1013
|
+
if (this.matchKeyword("in")) {
|
|
1014
|
+
this.expect("lparen", "'(' after IN");
|
|
1015
|
+
const values = [this.parseValue()];
|
|
1016
|
+
while (this.peek().kind === "comma") {
|
|
1017
|
+
this.advance();
|
|
1018
|
+
values.push(this.parseValue());
|
|
1019
|
+
}
|
|
1020
|
+
this.expect("rparen", "')' after IN values");
|
|
1021
|
+
if (values.some((value) => !value.literal)) throw new Error("IN values must be literals");
|
|
1022
|
+
return `${left.sql} IN (${values.map((value) => value.sql).join(", ")})`;
|
|
1023
|
+
}
|
|
1024
|
+
const operator = this.expect("operator", "a comparison operator");
|
|
1025
|
+
const right = this.parseValue();
|
|
1026
|
+
if (operator.value === "~" || operator.value === "!~") {
|
|
1027
|
+
if (left.column && left.column.type !== "text") throw new Error("regex checks require a text column");
|
|
1028
|
+
if (!right.literal) throw new Error("regex pattern must be a literal");
|
|
1029
|
+
}
|
|
1030
|
+
return `${left.sql} ${operator.value} ${right.sql}`;
|
|
1031
|
+
}
|
|
1032
|
+
parseValue() {
|
|
1033
|
+
const token = this.advance();
|
|
1034
|
+
if (token.kind === "string") return { sql: quoteLiteral(token.value), literal: true };
|
|
1035
|
+
if (token.kind === "number") {
|
|
1036
|
+
const number = Number(token.value);
|
|
1037
|
+
if (!Number.isFinite(number) || Number.isInteger(number) && !Number.isSafeInteger(number)) {
|
|
1038
|
+
throw new Error(`unsafe numeric literal at position ${token.position}`);
|
|
1039
|
+
}
|
|
1040
|
+
return { sql: token.value, literal: true };
|
|
1041
|
+
}
|
|
1042
|
+
if (token.kind !== "identifier") throw new Error(`expected a column or literal at position ${token.position}`);
|
|
1043
|
+
const keyword = token.value.toLowerCase();
|
|
1044
|
+
if (keyword === "true" || keyword === "false" || keyword === "null") return { sql: keyword.toUpperCase(), literal: true };
|
|
1045
|
+
if (keyword === "btrim") {
|
|
1046
|
+
this.expect("lparen", "'(' after btrim");
|
|
1047
|
+
const column2 = this.parseColumn();
|
|
1048
|
+
this.expect("rparen", "')' after btrim column");
|
|
1049
|
+
if (column2.type !== "text") throw new Error("btrim requires a text column");
|
|
1050
|
+
return { sql: `btrim(${quoteIdentifier(column2.columnName)})`, column: column2 };
|
|
1051
|
+
}
|
|
1052
|
+
const column = this.columns.get(token.value);
|
|
1053
|
+
if (!column) throw new Error(`unknown check column '${token.value}'`);
|
|
1054
|
+
return { sql: quoteIdentifier(column.columnName), column };
|
|
1055
|
+
}
|
|
1056
|
+
parseColumn() {
|
|
1057
|
+
const token = this.expect("identifier", "a column name");
|
|
1058
|
+
const column = this.columns.get(token.value);
|
|
1059
|
+
if (!column) throw new Error(`unknown check column '${token.value}'`);
|
|
1060
|
+
return column;
|
|
1061
|
+
}
|
|
1062
|
+
peek() {
|
|
1063
|
+
return this.tokens[this.index];
|
|
1064
|
+
}
|
|
1065
|
+
advance() {
|
|
1066
|
+
const token = this.peek();
|
|
1067
|
+
if (token.kind !== "end") this.index++;
|
|
1068
|
+
return token;
|
|
1069
|
+
}
|
|
1070
|
+
matchKeyword(keyword) {
|
|
1071
|
+
if (this.peek().kind !== "identifier" || this.peek().value.toLowerCase() !== keyword) return false;
|
|
1072
|
+
this.advance();
|
|
1073
|
+
return true;
|
|
1074
|
+
}
|
|
1075
|
+
expectKeyword(keyword) {
|
|
1076
|
+
if (!this.matchKeyword(keyword)) throw new Error(`expected '${keyword}' at position ${this.peek().position}`);
|
|
1077
|
+
}
|
|
1078
|
+
expect(kind, description) {
|
|
1079
|
+
const token = this.peek();
|
|
1080
|
+
if (token.kind !== kind) throw new Error(`expected ${description} at position ${token.position}`);
|
|
1081
|
+
return this.advance();
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
function compileCheckExpression(source, columns) {
|
|
1085
|
+
return new Parser(tokenize(source), columns).parse();
|
|
1086
|
+
}
|
|
1087
|
+
var policyVariantSchema = zod.z.object({
|
|
1088
|
+
/** Claims-only guard (capability()/role()/claim() comparisons — no columns). */
|
|
1089
|
+
when: zod.z.string().min(1).max(1e3),
|
|
1090
|
+
/** Exactly one of: allow-all bypass, or a row predicate. */
|
|
1091
|
+
allow: zod.z.literal(true).optional(),
|
|
1092
|
+
policy: zod.z.string().min(1).max(2e3).optional()
|
|
1093
|
+
}).refine((v) => v.allow === true !== (typeof v.policy === "string"), {
|
|
1094
|
+
message: "A variant declares exactly one of allow: true or policy"
|
|
1095
|
+
});
|
|
1096
|
+
var policyExpressionSchema = zod.z.union([
|
|
1097
|
+
zod.z.string().min(1).max(2e3),
|
|
1098
|
+
zod.z.object({
|
|
1099
|
+
default: zod.z.string().min(1).max(2e3),
|
|
1100
|
+
variants: zod.z.array(policyVariantSchema).max(10).default([])
|
|
1101
|
+
})
|
|
1102
|
+
]);
|
|
1103
|
+
function parseDurationMs(input) {
|
|
1104
|
+
if (typeof input === "number") return Number.isInteger(input) && input > 0 ? input : void 0;
|
|
1105
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m)$/.exec(input);
|
|
1106
|
+
if (!match) return void 0;
|
|
1107
|
+
const factor = match[2] === "ms" ? 1 : match[2] === "s" ? 1e3 : 6e4;
|
|
1108
|
+
const value = Math.round(Number(match[1]) * factor);
|
|
1109
|
+
return value > 0 ? value : void 0;
|
|
1110
|
+
}
|
|
1111
|
+
var identifier = zod.z.string().min(1).max(63).regex(/^[a-z][a-z0-9_]*$/);
|
|
1112
|
+
var durationMs = zod.z.number().int().positive().max(24 * 60 * 60 * 1e3);
|
|
1113
|
+
var RESOLVED_VIEW_SYSTEM_COLUMNS = {
|
|
1114
|
+
writebackStatus: { name: "writebackStatus", columnName: "_writeback_status", type: "text" },
|
|
1115
|
+
writebackError: { name: "writebackError", columnName: "_writeback_error", type: "text" }
|
|
1116
|
+
};
|
|
1117
|
+
var RESOLVED_SOURCE_SYSTEM_COLUMNS = {
|
|
1118
|
+
tombstoned: { name: "sysTombstoned", columnName: "_tombstoned" },
|
|
1119
|
+
fetchedAt: { name: "sysFetchedAt", columnName: "_fetched_at" },
|
|
1120
|
+
writebackStatus: { name: "sysWritebackStatus", columnName: "_writeback_status" },
|
|
1121
|
+
writebackError: { name: "sysWritebackError", columnName: "_writeback_error" },
|
|
1122
|
+
writebackAt: { name: "sysWritebackAt", columnName: "_writeback_at" }
|
|
1123
|
+
};
|
|
1124
|
+
var RESOLVED_VIEW_CELL_PARTS = ["source", "at", "by", "conflicts"];
|
|
1125
|
+
var RESERVED_RESOLVED_COLUMN_NAMES = /* @__PURE__ */ new Set([
|
|
1126
|
+
...Object.values(RESOLVED_VIEW_SYSTEM_COLUMNS).flatMap((c) => [c.name, c.columnName]),
|
|
1127
|
+
...Object.values(RESOLVED_SOURCE_SYSTEM_COLUMNS).flatMap((c) => [c.name, c.columnName])
|
|
1128
|
+
]);
|
|
1129
|
+
var COMPARATORS = ["latest", "highest", "lowest", "priority", "nonNullFirst", "custom"];
|
|
1130
|
+
var canonicalFunctionRefSchema = zod.z.object({
|
|
1131
|
+
/** `managed` = a Managed Function id/slug; `native` = an in-process Atlas function (reserved). */
|
|
1132
|
+
kind: zod.z.enum(["managed", "native"]),
|
|
1133
|
+
id: zod.z.string().min(1).max(200)
|
|
1134
|
+
}).strict();
|
|
1135
|
+
var canonicalFetchShapeSchema = zod.z.union([
|
|
1136
|
+
zod.z.object({ shape: zod.z.literal("single") }).strict(),
|
|
1137
|
+
zod.z.object({ shape: zod.z.literal("batch"), maxKeys: zod.z.number().int().min(2).max(500) }).strict()
|
|
1138
|
+
]);
|
|
1139
|
+
var canonicalResolvedSourceSchema = zod.z.object({
|
|
1140
|
+
name: identifier,
|
|
1141
|
+
kind: zod.z.enum(["manual", "etl", "prefetch"]),
|
|
1142
|
+
/** Backing relation in this namespace: an internal table (manual/prefetch) or a synced table (etl). */
|
|
1143
|
+
table: identifier,
|
|
1144
|
+
/** Resolved columns (logical names) this source supplies — the primary key is implicit. */
|
|
1145
|
+
columns: zod.z.array(zod.z.string().min(1).max(63)).min(1).max(500),
|
|
1146
|
+
/** etl: logical timestamptz/date column on the synced table used as every cell's updated_at. */
|
|
1147
|
+
updatedAt: zod.z.string().min(1).max(63).optional(),
|
|
1148
|
+
/** prefetch: resolved column holding the source system's key (may be the primary key). */
|
|
1149
|
+
key: zod.z.string().min(1).max(63).optional(),
|
|
1150
|
+
fetch: canonicalFetchShapeSchema.optional(),
|
|
1151
|
+
function: canonicalFunctionRefSchema.optional(),
|
|
1152
|
+
prefetchDefaults: zod.z.object({
|
|
1153
|
+
maxStalenessMs: durationMs,
|
|
1154
|
+
timeoutMs: durationMs,
|
|
1155
|
+
onError: zod.z.enum(["fallback", "fail"])
|
|
1156
|
+
}).strict().optional(),
|
|
1157
|
+
writeback: zod.z.object({
|
|
1158
|
+
columns: zod.z.array(zod.z.string().min(1).max(63)).min(1).max(500),
|
|
1159
|
+
defaultMode: zod.z.enum(["sync", "async"]),
|
|
1160
|
+
timeoutMs: durationMs,
|
|
1161
|
+
/** Remove confirmed cells from the manual source so a stale edit cannot shadow the source. */
|
|
1162
|
+
clearManualOnConfirm: zod.z.boolean()
|
|
1163
|
+
}).strict().optional()
|
|
1164
|
+
}).strict();
|
|
1165
|
+
var canonicalResolveRuleSchema = zod.z.object({
|
|
1166
|
+
column: zod.z.string().min(1).max(63),
|
|
1167
|
+
comparator: zod.z.enum(COMPARATORS),
|
|
1168
|
+
/** Source names, in tie-break order. */
|
|
1169
|
+
candidates: zod.z.array(identifier).min(1).max(16),
|
|
1170
|
+
/** custom only: SQL ORDER BY key over value / updated_at / source / actor / ord. */
|
|
1171
|
+
orderBy: zod.z.string().min(1).max(2e3).optional()
|
|
1172
|
+
}).strict();
|
|
1173
|
+
var canonicalResolvedSchema = zod.z.object({
|
|
1174
|
+
sources: zod.z.array(canonicalResolvedSourceSchema).min(1).max(16),
|
|
1175
|
+
/** One rule per resolved (non-pk) column; documents the generated view for tooling. */
|
|
1176
|
+
resolve: zod.z.array(canonicalResolveRuleSchema).max(500),
|
|
1177
|
+
/** Sources whose tombstone hides the resolved row (v1: the manual source only). */
|
|
1178
|
+
deletedBy: zod.z.array(identifier).max(1).default([]),
|
|
1179
|
+
/**
|
|
1180
|
+
* Materialized: the view reads an indexed internal table (`table`) that the
|
|
1181
|
+
* data plane keeps in step with `compute`, the internal view holding the
|
|
1182
|
+
* resolution query. Omitted when the view computes on read.
|
|
1183
|
+
*/
|
|
1184
|
+
materialized: zod.z.object({ table: identifier, compute: identifier }).strict().optional(),
|
|
1185
|
+
/** Write policies evaluated against the RESOLVED row (reads use the view's read policy). */
|
|
1186
|
+
policies: zod.z.object({
|
|
1187
|
+
write: policyExpressionSchema.optional(),
|
|
1188
|
+
insert: policyExpressionSchema.optional(),
|
|
1189
|
+
update: policyExpressionSchema.optional()
|
|
1190
|
+
}).strict().default({})
|
|
1191
|
+
}).strict();
|
|
1192
|
+
function validateResolvedView({ view, tables, views = [] }) {
|
|
1193
|
+
const resolved = view.resolved;
|
|
1194
|
+
if (!resolved) return [];
|
|
1195
|
+
const errors = [];
|
|
1196
|
+
const fail = (message) => errors.push(`resolved table '${view.view}': ${message}`);
|
|
1197
|
+
const pkColumn = view.primaryKey?.length === 1 ? view.columns.find((c) => c.columnName === view.primaryKey?.[0]) : void 0;
|
|
1198
|
+
if (!pkColumn) {
|
|
1199
|
+
fail("requires exactly one primary-key column");
|
|
1200
|
+
return errors;
|
|
1201
|
+
}
|
|
1202
|
+
const systemNames = new Set(Object.values(RESOLVED_VIEW_SYSTEM_COLUMNS).map((c) => c.name));
|
|
1203
|
+
const scope = {
|
|
1204
|
+
view,
|
|
1205
|
+
resolved,
|
|
1206
|
+
pkColumn,
|
|
1207
|
+
dataColumns: new Map(view.columns.filter((c) => !systemNames.has(c.name) && c.cell === void 0).map((c) => [c.name, c])),
|
|
1208
|
+
sourceByName: /* @__PURE__ */ new Map(),
|
|
1209
|
+
fail
|
|
1210
|
+
};
|
|
1211
|
+
if (resolved.sources.filter((source) => source.kind === "manual").length !== 1) fail("requires exactly one manual source");
|
|
1212
|
+
const tableByName = new Map(tables.map((table) => [table.table, table]));
|
|
1213
|
+
for (const source of resolved.sources) {
|
|
1214
|
+
if (scope.sourceByName.has(source.name)) fail(`duplicate source '${source.name}'`);
|
|
1215
|
+
scope.sourceByName.set(source.name, source);
|
|
1216
|
+
const table = tableByName.get(source.table);
|
|
1217
|
+
if (!table) fail(`source '${source.name}' references unknown table '${source.table}'`);
|
|
1218
|
+
else validateSource({ scope, source, table });
|
|
1219
|
+
}
|
|
1220
|
+
validateRules(scope);
|
|
1221
|
+
validateViewCells(scope);
|
|
1222
|
+
if (resolved.materialized) validateMaterialized({ scope, tableByName, views });
|
|
1223
|
+
for (const name of resolved.deletedBy) {
|
|
1224
|
+
if (scope.sourceByName.get(name)?.kind !== "manual") fail(`deletedBy '${name}' must be the manual source`);
|
|
1225
|
+
}
|
|
1226
|
+
return errors;
|
|
1227
|
+
}
|
|
1228
|
+
function validateSource({ scope, source, table }) {
|
|
1229
|
+
const { fail } = scope;
|
|
1230
|
+
if (source.kind === "etl" && table.source === void 0) fail(`etl source '${source.name}' must reference a synced table`);
|
|
1231
|
+
if (source.kind !== "etl" && (table.internal !== true || table.cellMetadata !== true)) {
|
|
1232
|
+
fail(`${source.kind} source '${source.name}' must reference an internal table with cell metadata`);
|
|
1233
|
+
}
|
|
1234
|
+
const hasRev = table.columns.some((column) => column.cell?.part === "rev");
|
|
1235
|
+
if (source.kind === "manual" && !hasRev) fail(`manual source '${source.name}' must carry '__rev' cell columns`);
|
|
1236
|
+
if (source.kind === "prefetch" && hasRev) fail(`prefetch source '${source.name}' must not carry '__rev' cell columns`);
|
|
1237
|
+
const byName = new Map(table.columns.map((c) => [c.name, c]));
|
|
1238
|
+
validateSourceColumns({ scope, source, byName });
|
|
1239
|
+
if (source.kind === "etl") {
|
|
1240
|
+
const updatedAt = source.updatedAt === void 0 ? void 0 : byName.get(source.updatedAt);
|
|
1241
|
+
if (source.updatedAt !== void 0 && updatedAt?.type !== "timestamptz" && updatedAt?.type !== "date") {
|
|
1242
|
+
fail(`etl source '${source.name}' updatedAt must name a timestamptz/date column on '${source.table}'`);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
validatePrefetchFields({ scope, source });
|
|
1246
|
+
}
|
|
1247
|
+
function validatePrefetchFields({ scope, source }) {
|
|
1248
|
+
const { fail, pkColumn } = scope;
|
|
1249
|
+
if (source.kind !== "prefetch") {
|
|
1250
|
+
if (source.key || source.fetch || source.function || source.prefetchDefaults || source.writeback) {
|
|
1251
|
+
fail(`only prefetch sources may declare key/fetch/function/prefetchDefaults/writeback ('${source.name}')`);
|
|
1252
|
+
}
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
if (!source.key || !source.fetch || !source.function || !source.prefetchDefaults) {
|
|
1256
|
+
fail(`prefetch source '${source.name}' requires key, fetch, function and prefetchDefaults`);
|
|
1257
|
+
} else if (source.key !== pkColumn.name && !source.columns.includes(source.key)) {
|
|
1258
|
+
fail(`prefetch source '${source.name}' key '${source.key}' must be the primary key or one of its columns`);
|
|
1259
|
+
}
|
|
1260
|
+
for (const column of source.writeback?.columns ?? []) {
|
|
1261
|
+
if (!source.columns.includes(column)) fail(`prefetch source '${source.name}' writes back '${column}', which it does not supply`);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
function validateSourceColumns({ scope, source, byName }) {
|
|
1265
|
+
const { fail, pkColumn, dataColumns } = scope;
|
|
1266
|
+
for (const column of [pkColumn.name, ...source.columns]) {
|
|
1267
|
+
const own = column === pkColumn.name ? pkColumn : dataColumns.get(column);
|
|
1268
|
+
const theirs = byName.get(column);
|
|
1269
|
+
if (!own) fail(`source '${source.name}' supplies unknown column '${column}'`);
|
|
1270
|
+
else if (!theirs) fail(`source '${source.name}' table '${source.table}' has no column '${column}'`);
|
|
1271
|
+
else if (JSON.stringify(theirs.type) !== JSON.stringify(own.type)) fail(`column '${column}' has a different type on source '${source.name}'`);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
function validateRules({ resolved, pkColumn, dataColumns, sourceByName, fail }) {
|
|
1275
|
+
const ruled = /* @__PURE__ */ new Set();
|
|
1276
|
+
for (const rule of resolved.resolve) {
|
|
1277
|
+
if (!dataColumns.has(rule.column) || rule.column === pkColumn.name) {
|
|
1278
|
+
fail(`resolve rule targets unknown column '${rule.column}'`);
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
if (ruled.has(rule.column)) fail(`column '${rule.column}' has more than one resolve rule`);
|
|
1282
|
+
ruled.add(rule.column);
|
|
1283
|
+
if (rule.comparator === "custom" !== (rule.orderBy !== void 0)) {
|
|
1284
|
+
fail(`column '${rule.column}': orderBy is required for custom comparators and only allowed there`);
|
|
1285
|
+
}
|
|
1286
|
+
if (new Set(rule.candidates).size !== rule.candidates.length) fail(`column '${rule.column}' repeats a candidate`);
|
|
1287
|
+
for (const candidate of rule.candidates) {
|
|
1288
|
+
const source = sourceByName.get(candidate);
|
|
1289
|
+
if (!source) fail(`column '${rule.column}' names unknown candidate source '${candidate}'`);
|
|
1290
|
+
else if (!source.columns.includes(rule.column)) fail(`column '${rule.column}' candidate '${candidate}' does not supply it`);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
for (const column of dataColumns.keys()) {
|
|
1294
|
+
if (column !== pkColumn.name && !ruled.has(column)) fail(`column '${column}' has no resolve rule`);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
var VIEW_CELL_TYPES = {
|
|
1298
|
+
source: "text",
|
|
1299
|
+
at: "timestamptz",
|
|
1300
|
+
by: "text",
|
|
1301
|
+
conflicts: "jsonb"
|
|
1302
|
+
};
|
|
1303
|
+
function validateViewCells({ view, dataColumns, pkColumn, fail }) {
|
|
1304
|
+
const expected = /* @__PURE__ */ new Map();
|
|
1305
|
+
for (const column of dataColumns.values()) {
|
|
1306
|
+
if (column.name === pkColumn.name) continue;
|
|
1307
|
+
for (const part of RESOLVED_VIEW_CELL_PARTS) {
|
|
1308
|
+
expected.set(cellColumnName(column.name, part), { part, columnName: cellColumnName(column.columnName, part) });
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
for (const column of view.columns) {
|
|
1312
|
+
if (!column.cell) continue;
|
|
1313
|
+
const want = expected.get(column.name);
|
|
1314
|
+
if (!want || want.part !== column.cell.part || want.columnName !== column.columnName || cellColumnName(column.cell.of, column.cell.part) !== column.name) {
|
|
1315
|
+
fail(`unexpected cell column '${column.name}'`);
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
if (column.type !== VIEW_CELL_TYPES[column.cell.part]) fail(`cell column '${column.name}' must be ${VIEW_CELL_TYPES[column.cell.part]}`);
|
|
1319
|
+
expected.delete(column.name);
|
|
1320
|
+
}
|
|
1321
|
+
for (const name of expected.keys()) fail(`missing cell column '${name}'`);
|
|
1322
|
+
}
|
|
1323
|
+
var columnSignature = (columns) => JSON.stringify(columns.map((column) => [column.name, column.columnName, column.type]));
|
|
1324
|
+
function validateMaterialized({ scope, tableByName, views }) {
|
|
1325
|
+
const { view, resolved, fail } = scope;
|
|
1326
|
+
const { table: tableName, compute: computeName } = resolved.materialized;
|
|
1327
|
+
const table = tableByName.get(tableName);
|
|
1328
|
+
if (!table || table.internal !== true || table.materializes !== view.view) {
|
|
1329
|
+
fail(`materialized table '${tableName}' must be an internal table that materializes '${view.view}'`);
|
|
1330
|
+
} else {
|
|
1331
|
+
if (columnSignature(table.columns) !== columnSignature(view.columns)) fail(`materialized table '${tableName}' must have exactly the view's columns`);
|
|
1332
|
+
if (JSON.stringify(table.primaryKey ?? []) !== JSON.stringify(view.primaryKey ?? [])) fail(`materialized table '${tableName}' must share the view's primary key`);
|
|
1333
|
+
}
|
|
1334
|
+
const compute = views.find((candidate) => candidate.view === computeName);
|
|
1335
|
+
if (!compute || compute.internal !== true) fail(`compute view '${computeName}' must be an internal view`);
|
|
1336
|
+
else if (columnSignature(compute.columns) !== columnSignature(view.columns)) fail(`compute view '${computeName}' must have exactly the view's columns`);
|
|
1337
|
+
for (const source of resolved.sources) {
|
|
1338
|
+
if (source.kind !== "etl") continue;
|
|
1339
|
+
const read = tableByName.get(source.table)?.policies?.read;
|
|
1340
|
+
const unconditional = typeof read === "string" ? read.trim() === "true" : read !== void 0 && read.default.trim() === "true" && read.variants.length === 0;
|
|
1341
|
+
if (!unconditional) {
|
|
1342
|
+
fail(`etl source '${source.name}' must have read policy 'true' to be materialized (the copy is shared by every caller)`);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// src/canonical/v2.ts
|
|
842
1348
|
var CANONICAL_FORMAT = 2;
|
|
843
1349
|
var IDENTIFIER_RE = /^[a-z][a-z0-9_]*$/;
|
|
844
|
-
var
|
|
1350
|
+
var identifier2 = zod.z.string().min(1).max(63).regex(IDENTIFIER_RE);
|
|
1351
|
+
var columnIdentifier = zod.z.string().min(1).max(63).regex(COLUMN_IDENTIFIER_PATTERN);
|
|
845
1352
|
var COLUMN_TYPES = [
|
|
846
1353
|
"uuid",
|
|
847
1354
|
"text",
|
|
@@ -856,59 +1363,71 @@ var COLUMN_TYPES = [
|
|
|
856
1363
|
];
|
|
857
1364
|
var columnTypeSchema = zod.z.enum(COLUMN_TYPES);
|
|
858
1365
|
var canonicalEnumSchema = zod.z.object({
|
|
859
|
-
name:
|
|
1366
|
+
name: identifier2,
|
|
860
1367
|
values: zod.z.array(zod.z.string().min(1).max(63)).min(1).max(100)
|
|
861
1368
|
});
|
|
862
1369
|
var canonicalColumnSchema = zod.z.object({
|
|
863
1370
|
/** Property name in TS (camelCase); columnName is the Postgres name. */
|
|
864
1371
|
name: zod.z.string().min(1).max(63),
|
|
865
|
-
columnName:
|
|
866
|
-
type: zod.z.union([columnTypeSchema, zod.z.object({ enum:
|
|
1372
|
+
columnName: columnIdentifier,
|
|
1373
|
+
type: zod.z.union([columnTypeSchema, zod.z.object({ enum: identifier2 })]),
|
|
867
1374
|
nullable: zod.z.boolean().default(true),
|
|
868
1375
|
unique: zod.z.boolean().default(false),
|
|
869
1376
|
/** Literal default, or one of the allowlisted SQL functions. */
|
|
870
1377
|
default: zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean(), zod.z.null()]).optional(),
|
|
871
1378
|
defaultFn: zod.z.enum(["gen_random_uuid()", "now()"]).optional(),
|
|
1379
|
+
/**
|
|
1380
|
+
* Stamp this column from the database clock on UPDATE (`now()`). The
|
|
1381
|
+
* column stays insertable (defaults fill gaps) but is omitted from
|
|
1382
|
+
* SetInput / settable so callers cannot backdate it.
|
|
1383
|
+
*/
|
|
1384
|
+
onUpdateFn: zod.z.enum(["now()"]).optional(),
|
|
872
1385
|
description: zod.z.string().max(500).optional(),
|
|
873
1386
|
/** Migration hints — same semantics as v1. */
|
|
874
|
-
renamedFrom:
|
|
1387
|
+
renamedFrom: columnIdentifier.optional(),
|
|
875
1388
|
cast: zod.z.string().max(200).optional(),
|
|
876
|
-
deprecated: zod.z.boolean().default(false)
|
|
1389
|
+
deprecated: zod.z.boolean().default(false),
|
|
1390
|
+
/**
|
|
1391
|
+
* Marks a system cell-metadata column (see CELL_PARTS in constants): part
|
|
1392
|
+
* `part` of the cell of logical column `of`. Synthesized by authoring,
|
|
1393
|
+
* never client-writable, absent from GraphQL scalar fields. Omitted on
|
|
1394
|
+
* ordinary columns so their hashes are unchanged.
|
|
1395
|
+
*/
|
|
1396
|
+
cell: zod.z.object({ of: zod.z.string().min(1).max(63), part: zod.z.enum(CELL_PARTS) }).strict().optional()
|
|
1397
|
+
}).superRefine((column, ctx) => {
|
|
1398
|
+
if (column.onUpdateFn === void 0) return;
|
|
1399
|
+
if (typeof column.type !== "string" || column.type !== "timestamptz" && column.type !== "date") {
|
|
1400
|
+
ctx.addIssue({
|
|
1401
|
+
code: "custom",
|
|
1402
|
+
path: ["onUpdateFn"],
|
|
1403
|
+
message: `onUpdateFn '${column.onUpdateFn}' is only valid on timestamptz/date columns`
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
877
1406
|
});
|
|
878
1407
|
var canonicalLinkSchema = zod.z.object({
|
|
879
1408
|
name: zod.z.string().min(1).max(63),
|
|
880
1409
|
kind: zod.z.enum(["toOne", "toMany", "toPlatformUser"]),
|
|
881
1410
|
/** Target table name (namespace-local). Absent for toPlatformUser. */
|
|
882
|
-
target:
|
|
1411
|
+
target: identifier2.optional(),
|
|
883
1412
|
/** FK column on the owning side (toOne/toPlatformUser: on this table; toMany: on the target). */
|
|
884
|
-
on:
|
|
1413
|
+
on: columnIdentifier,
|
|
885
1414
|
/** Referenced column on the target (default: target's PK / platform user `sub`). */
|
|
886
|
-
references:
|
|
1415
|
+
references: columnIdentifier.optional(),
|
|
887
1416
|
/** enforce: true emits a real FK constraint (same-namespace toOne only). */
|
|
888
1417
|
enforce: zod.z.boolean().default(false),
|
|
1418
|
+
onDelete: zod.z.enum(["cascade", "restrict", "no action", "set null", "set default"]).optional(),
|
|
1419
|
+
onUpdate: zod.z.enum(["cascade", "restrict", "no action", "set null", "set default"]).optional(),
|
|
889
1420
|
description: zod.z.string().max(500).optional()
|
|
890
1421
|
});
|
|
891
1422
|
var canonicalIndexSchema = zod.z.object({
|
|
892
|
-
columns: zod.z.array(
|
|
1423
|
+
columns: zod.z.array(columnIdentifier).min(1),
|
|
893
1424
|
unique: zod.z.boolean().default(false),
|
|
894
|
-
name:
|
|
1425
|
+
name: identifier2.optional()
|
|
895
1426
|
});
|
|
896
|
-
var
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
allow: zod.z.literal(true).optional(),
|
|
901
|
-
policy: zod.z.string().min(1).max(2e3).optional()
|
|
902
|
-
}).refine((v) => v.allow === true !== (typeof v.policy === "string"), {
|
|
903
|
-
message: "A variant declares exactly one of allow: true or policy"
|
|
904
|
-
});
|
|
905
|
-
var policyExpressionSchema = zod.z.union([
|
|
906
|
-
zod.z.string().min(1).max(2e3),
|
|
907
|
-
zod.z.object({
|
|
908
|
-
default: zod.z.string().min(1).max(2e3),
|
|
909
|
-
variants: zod.z.array(policyVariantSchema).max(10).default([])
|
|
910
|
-
})
|
|
911
|
-
]);
|
|
1427
|
+
var canonicalCheckSchema = zod.z.object({
|
|
1428
|
+
name: identifier2,
|
|
1429
|
+
expression: zod.z.string().min(1).max(2e3)
|
|
1430
|
+
}).strict();
|
|
912
1431
|
var canonicalPoliciesSchema = zod.z.object({
|
|
913
1432
|
read: policyExpressionSchema.optional(),
|
|
914
1433
|
write: policyExpressionSchema.optional(),
|
|
@@ -938,8 +1457,165 @@ function isSingleViewQuerySql(value) {
|
|
|
938
1457
|
}
|
|
939
1458
|
return state === "normal" && !/\$[a-z_0-9]*\$/i.test(query);
|
|
940
1459
|
}
|
|
1460
|
+
var VIEW_QUERY_FUNCTION_ALLOWLIST = [
|
|
1461
|
+
"abs",
|
|
1462
|
+
"age",
|
|
1463
|
+
"array_agg",
|
|
1464
|
+
"array_append",
|
|
1465
|
+
"array_cat",
|
|
1466
|
+
"array_dims",
|
|
1467
|
+
"array_length",
|
|
1468
|
+
"array_lower",
|
|
1469
|
+
"array_ndims",
|
|
1470
|
+
"array_position",
|
|
1471
|
+
"array_positions",
|
|
1472
|
+
"array_prepend",
|
|
1473
|
+
"array_remove",
|
|
1474
|
+
"array_replace",
|
|
1475
|
+
"array_to_string",
|
|
1476
|
+
"array_trim",
|
|
1477
|
+
"array_upper",
|
|
1478
|
+
"ascii",
|
|
1479
|
+
"avg",
|
|
1480
|
+
"bit_and",
|
|
1481
|
+
"bit_or",
|
|
1482
|
+
"bit_xor",
|
|
1483
|
+
"bool_and",
|
|
1484
|
+
"bool_or",
|
|
1485
|
+
"btrim",
|
|
1486
|
+
"cardinality",
|
|
1487
|
+
"cbrt",
|
|
1488
|
+
"ceil",
|
|
1489
|
+
"ceiling",
|
|
1490
|
+
"char_length",
|
|
1491
|
+
"character_length",
|
|
1492
|
+
"chr",
|
|
1493
|
+
"concat",
|
|
1494
|
+
"concat_ws",
|
|
1495
|
+
"count",
|
|
1496
|
+
"cume_dist",
|
|
1497
|
+
"date_bin",
|
|
1498
|
+
"date_part",
|
|
1499
|
+
"date_trunc",
|
|
1500
|
+
"decode",
|
|
1501
|
+
"degrees",
|
|
1502
|
+
"dense_rank",
|
|
1503
|
+
"encode",
|
|
1504
|
+
"enum_first",
|
|
1505
|
+
"enum_last",
|
|
1506
|
+
"enum_range",
|
|
1507
|
+
"every",
|
|
1508
|
+
"exp",
|
|
1509
|
+
"first_value",
|
|
1510
|
+
"floor",
|
|
1511
|
+
"format",
|
|
1512
|
+
"grouping",
|
|
1513
|
+
"initcap",
|
|
1514
|
+
"json_agg",
|
|
1515
|
+
"json_array_length",
|
|
1516
|
+
"json_build_array",
|
|
1517
|
+
"json_build_object",
|
|
1518
|
+
"json_extract_path",
|
|
1519
|
+
"json_extract_path_text",
|
|
1520
|
+
"json_object_agg",
|
|
1521
|
+
"json_object_keys",
|
|
1522
|
+
"json_strip_nulls",
|
|
1523
|
+
"json_typeof",
|
|
1524
|
+
"jsonb_agg",
|
|
1525
|
+
"jsonb_array_length",
|
|
1526
|
+
"jsonb_build_array",
|
|
1527
|
+
"jsonb_build_object",
|
|
1528
|
+
"jsonb_extract_path",
|
|
1529
|
+
"jsonb_extract_path_text",
|
|
1530
|
+
"jsonb_insert",
|
|
1531
|
+
"jsonb_object_agg",
|
|
1532
|
+
"jsonb_object_keys",
|
|
1533
|
+
"jsonb_path_exists",
|
|
1534
|
+
"jsonb_path_match",
|
|
1535
|
+
"jsonb_path_query",
|
|
1536
|
+
"jsonb_path_query_array",
|
|
1537
|
+
"jsonb_path_query_first",
|
|
1538
|
+
"jsonb_pretty",
|
|
1539
|
+
"jsonb_set",
|
|
1540
|
+
"jsonb_set_lax",
|
|
1541
|
+
"jsonb_strip_nulls",
|
|
1542
|
+
"jsonb_typeof",
|
|
1543
|
+
"justify_days",
|
|
1544
|
+
"justify_hours",
|
|
1545
|
+
"justify_interval",
|
|
1546
|
+
"lag",
|
|
1547
|
+
"last_value",
|
|
1548
|
+
"lead",
|
|
1549
|
+
"left",
|
|
1550
|
+
"length",
|
|
1551
|
+
"ln",
|
|
1552
|
+
"log",
|
|
1553
|
+
"lower",
|
|
1554
|
+
"lpad",
|
|
1555
|
+
"ltrim",
|
|
1556
|
+
"make_date",
|
|
1557
|
+
"make_interval",
|
|
1558
|
+
"make_time",
|
|
1559
|
+
"make_timestamp",
|
|
1560
|
+
"make_timestamptz",
|
|
1561
|
+
"max",
|
|
1562
|
+
"md5",
|
|
1563
|
+
"min",
|
|
1564
|
+
"mod",
|
|
1565
|
+
"nth_value",
|
|
1566
|
+
"ntile",
|
|
1567
|
+
"octet_length",
|
|
1568
|
+
"percent_rank",
|
|
1569
|
+
"pi",
|
|
1570
|
+
"power",
|
|
1571
|
+
"quote_ident",
|
|
1572
|
+
"quote_literal",
|
|
1573
|
+
"quote_nullable",
|
|
1574
|
+
"radians",
|
|
1575
|
+
"rank",
|
|
1576
|
+
"regexp_count",
|
|
1577
|
+
"regexp_instr",
|
|
1578
|
+
"regexp_like",
|
|
1579
|
+
"regexp_match",
|
|
1580
|
+
"regexp_matches",
|
|
1581
|
+
"regexp_replace",
|
|
1582
|
+
"regexp_split_to_array",
|
|
1583
|
+
"regexp_split_to_table",
|
|
1584
|
+
"repeat",
|
|
1585
|
+
"replace",
|
|
1586
|
+
"reverse",
|
|
1587
|
+
"right",
|
|
1588
|
+
"round",
|
|
1589
|
+
"row_number",
|
|
1590
|
+
"rpad",
|
|
1591
|
+
"rtrim",
|
|
1592
|
+
"sign",
|
|
1593
|
+
"split_part",
|
|
1594
|
+
"sqrt",
|
|
1595
|
+
"starts_with",
|
|
1596
|
+
"string_agg",
|
|
1597
|
+
"string_to_array",
|
|
1598
|
+
"strpos",
|
|
1599
|
+
"substr",
|
|
1600
|
+
"substring",
|
|
1601
|
+
"sum",
|
|
1602
|
+
"timezone",
|
|
1603
|
+
"to_ascii",
|
|
1604
|
+
"to_char",
|
|
1605
|
+
"to_date",
|
|
1606
|
+
"to_hex",
|
|
1607
|
+
"to_json",
|
|
1608
|
+
"to_jsonb",
|
|
1609
|
+
"to_number",
|
|
1610
|
+
"to_timestamp",
|
|
1611
|
+
"translate",
|
|
1612
|
+
"trunc",
|
|
1613
|
+
"unnest",
|
|
1614
|
+
"upper",
|
|
1615
|
+
"width_bucket"
|
|
1616
|
+
];
|
|
941
1617
|
var canonicalActionSchema = zod.z.object({
|
|
942
|
-
name:
|
|
1618
|
+
name: identifier2,
|
|
943
1619
|
requires: zod.z.string().max(1e3).optional(),
|
|
944
1620
|
params: zod.z.record(zod.z.string(), zod.z.object({
|
|
945
1621
|
type: columnTypeSchema,
|
|
@@ -951,9 +1627,9 @@ var canonicalActionSchema = zod.z.object({
|
|
|
951
1627
|
});
|
|
952
1628
|
var canonicalCowMutationSchema = zod.z.object({
|
|
953
1629
|
kind: zod.z.enum(["override", "revert"]),
|
|
954
|
-
name:
|
|
1630
|
+
name: identifier2,
|
|
955
1631
|
/** Synced table (same namespace) the overlay copies from / falls back to. */
|
|
956
|
-
synced:
|
|
1632
|
+
synced: identifier2,
|
|
957
1633
|
/** Single-column primary key PROPERTY (TS camelCase, like column `name`) shared by overlay and synced. */
|
|
958
1634
|
pk: zod.z.string().min(1).max(63),
|
|
959
1635
|
/** Override: contract properties copied INSERT…SELECT from synced (pk included). Empty for revert. */
|
|
@@ -980,17 +1656,28 @@ var canonicalCowMutationSchema = zod.z.object({
|
|
|
980
1656
|
}
|
|
981
1657
|
}
|
|
982
1658
|
});
|
|
1659
|
+
var canonicalForeignKeySchema = zod.z.object({
|
|
1660
|
+
name: identifier2,
|
|
1661
|
+
columns: zod.z.array(columnIdentifier).min(1).max(8),
|
|
1662
|
+
target: identifier2,
|
|
1663
|
+
references: zod.z.array(columnIdentifier).min(1).max(8),
|
|
1664
|
+
deferrable: zod.z.enum(["initially-immediate", "initially-deferred"]).optional(),
|
|
1665
|
+
onDelete: zod.z.enum(["cascade", "restrict", "no action", "set null", "set default"]).optional(),
|
|
1666
|
+
onUpdate: zod.z.enum(["cascade", "restrict", "no action", "set null", "set default"]).optional()
|
|
1667
|
+
}).strict();
|
|
983
1668
|
var canonicalTableSchema = zod.z.object({
|
|
984
|
-
table:
|
|
1669
|
+
table: identifier2,
|
|
985
1670
|
description: zod.z.string().max(2e3).optional(),
|
|
986
1671
|
/** Table-level rename hint (new in v2). */
|
|
987
|
-
renamedFrom:
|
|
1672
|
+
renamedFrom: identifier2.optional(),
|
|
988
1673
|
/** Synced/reflected read-only tables (`source: gold.*`) — v1 semantics. */
|
|
989
1674
|
source: zod.z.string().regex(/^gold\.[a-z][a-z0-9_]*$/).optional(),
|
|
990
1675
|
columns: zod.z.array(canonicalColumnSchema).default([]),
|
|
991
|
-
primaryKey: zod.z.array(
|
|
1676
|
+
primaryKey: zod.z.array(columnIdentifier).min(1).max(8).optional(),
|
|
992
1677
|
links: zod.z.array(canonicalLinkSchema).default([]),
|
|
993
1678
|
indexes: zod.z.array(canonicalIndexSchema).default([]),
|
|
1679
|
+
/** Omitted by old definitions, preserving their existing content hashes. */
|
|
1680
|
+
checks: zod.z.array(canonicalCheckSchema).max(100).optional(),
|
|
994
1681
|
policies: canonicalPoliciesSchema.default({}),
|
|
995
1682
|
/**
|
|
996
1683
|
* v1-conversion carry-over only. v2 has no action surface — the runtime
|
|
@@ -999,26 +1686,64 @@ var canonicalTableSchema = zod.z.object({
|
|
|
999
1686
|
*/
|
|
1000
1687
|
actions: zod.z.array(canonicalActionSchema).default([]),
|
|
1001
1688
|
/** Optional (not defaulted) so pre-CoW canonical documents keep byte-identical hashes. */
|
|
1002
|
-
cowMutations: zod.z.array(canonicalCowMutationSchema).optional()
|
|
1689
|
+
cowMutations: zod.z.array(canonicalCowMutationSchema).optional(),
|
|
1690
|
+
/** Omitted by old definitions, preserving their existing content hashes. */
|
|
1691
|
+
foreignKeys: zod.z.array(canonicalForeignKeySchema).max(100).optional(),
|
|
1692
|
+
/**
|
|
1693
|
+
* Per-cell edit metadata (`cellMetadata: true` in authoring): every tracked
|
|
1694
|
+
* column `c` carries typed `c__at` / `c__by` system columns (plus `c__rev`
|
|
1695
|
+
* on a resolved table's internal sources), listed explicitly in `columns`
|
|
1696
|
+
* with a `cell` marker so DDL and migrations treat them as ordinary columns.
|
|
1697
|
+
* Omitted when off so existing definitions hash unchanged.
|
|
1698
|
+
*/
|
|
1699
|
+
cellMetadata: zod.z.literal(true).optional(),
|
|
1700
|
+
/**
|
|
1701
|
+
* System-owned backing table of a resolved table (its manual or prefetch
|
|
1702
|
+
* source). Internal tables get DDL and IR metadata but no GraphQL surface;
|
|
1703
|
+
* only the data plane writes them. Omitted when false.
|
|
1704
|
+
*/
|
|
1705
|
+
internal: zod.z.literal(true).optional(),
|
|
1706
|
+
/**
|
|
1707
|
+
* The materialized copy of resolved table `materializes` (internal): the
|
|
1708
|
+
* same columns as that view, including its cell parts, maintained by the
|
|
1709
|
+
* data plane. Omitted otherwise.
|
|
1710
|
+
*/
|
|
1711
|
+
materializes: identifier2.optional()
|
|
1003
1712
|
});
|
|
1713
|
+
var MAX_VIEW_QUERY = 1e5;
|
|
1714
|
+
var MAX_RESOLVED_VIEW_QUERY = 5e6;
|
|
1004
1715
|
var canonicalViewSchema = zod.z.object({
|
|
1005
|
-
view:
|
|
1716
|
+
view: identifier2,
|
|
1006
1717
|
description: zod.z.string().max(2e3).optional(),
|
|
1007
1718
|
columns: zod.z.array(canonicalColumnSchema).min(1),
|
|
1008
1719
|
/** Logical identity for GraphQL only; PostgreSQL views have no PK constraint. */
|
|
1009
|
-
primaryKey: zod.z.array(
|
|
1720
|
+
primaryKey: zod.z.array(columnIdentifier).min(1).max(8).optional(),
|
|
1010
1721
|
links: zod.z.array(canonicalLinkSchema).default([]),
|
|
1011
1722
|
policies: zod.z.object({ read: policyExpressionSchema }).strict(),
|
|
1012
|
-
query: zod.z.string().min(1).max(
|
|
1723
|
+
query: zod.z.string().min(1).max(MAX_RESOLVED_VIEW_QUERY).refine(isSingleViewQuerySql, {
|
|
1013
1724
|
message: "view query must be one SELECT/WITH statement without comments or dollar-quoted bodies"
|
|
1014
1725
|
}),
|
|
1015
1726
|
/** Namespace-local logical relation names, sorted for deterministic hashing. */
|
|
1016
|
-
dependencies: zod.z.array(
|
|
1727
|
+
dependencies: zod.z.array(identifier2).max(500),
|
|
1728
|
+
/**
|
|
1729
|
+
* Present when this view is a resolved table (defineResolvedTable): the
|
|
1730
|
+
* multi-source metadata behind its generated query. Omitted otherwise so
|
|
1731
|
+
* existing views hash unchanged.
|
|
1732
|
+
*/
|
|
1733
|
+
resolved: canonicalResolvedSchema.optional(),
|
|
1734
|
+
/** System-owned view (a materialized resolved table's compute view): no GraphQL surface. */
|
|
1735
|
+
internal: zod.z.literal(true).optional()
|
|
1017
1736
|
}).superRefine((view, ctx) => {
|
|
1737
|
+
if (view.resolved === void 0 && view.query.length > MAX_VIEW_QUERY) {
|
|
1738
|
+
ctx.addIssue({ code: "custom", path: ["query"], message: `view query must be at most ${MAX_VIEW_QUERY} characters` });
|
|
1739
|
+
}
|
|
1018
1740
|
for (const [index, column] of view.columns.entries()) {
|
|
1019
1741
|
if (column.default !== void 0 || column.defaultFn !== void 0) {
|
|
1020
1742
|
ctx.addIssue({ code: "custom", path: ["columns", index], message: `view column '${column.name}' cannot declare a default` });
|
|
1021
1743
|
}
|
|
1744
|
+
if (column.onUpdateFn !== void 0) {
|
|
1745
|
+
ctx.addIssue({ code: "custom", path: ["columns", index], message: `view column '${column.name}' cannot declare onUpdateFn` });
|
|
1746
|
+
}
|
|
1022
1747
|
if (column.unique) {
|
|
1023
1748
|
ctx.addIssue({ code: "custom", path: ["columns", index], message: `view column '${column.name}' cannot declare unique` });
|
|
1024
1749
|
}
|
|
@@ -1026,15 +1751,82 @@ var canonicalViewSchema = zod.z.object({
|
|
|
1026
1751
|
ctx.addIssue({ code: "custom", path: ["columns", index], message: `view column '${column.name}' cannot declare migration hints` });
|
|
1027
1752
|
}
|
|
1028
1753
|
}
|
|
1754
|
+
if (view.resolved === void 0 && view.internal !== true) {
|
|
1755
|
+
for (const [index, column] of view.columns.entries()) {
|
|
1756
|
+
if (column.cell) ctx.addIssue({ code: "custom", path: ["columns", index], message: `only resolved tables carry cell columns ('${column.name}')` });
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1029
1759
|
for (const [index, link] of view.links.entries()) {
|
|
1030
1760
|
if (link.enforce) {
|
|
1031
1761
|
ctx.addIssue({ code: "custom", path: ["links", index, "enforce"], message: `view link '${link.name}' cannot enforce a foreign key` });
|
|
1032
1762
|
}
|
|
1763
|
+
if (link.onDelete !== void 0 || link.onUpdate !== void 0) {
|
|
1764
|
+
ctx.addIssue({ code: "custom", path: ["links", index], message: `view link '${link.name}' cannot declare referential actions` });
|
|
1765
|
+
}
|
|
1033
1766
|
}
|
|
1034
1767
|
});
|
|
1768
|
+
var CELL_PART_TYPES = {
|
|
1769
|
+
at: "timestamptz",
|
|
1770
|
+
by: "text",
|
|
1771
|
+
rev: "uuid",
|
|
1772
|
+
source: "text",
|
|
1773
|
+
conflicts: "jsonb"
|
|
1774
|
+
};
|
|
1775
|
+
function trackedCellColumns(table) {
|
|
1776
|
+
const pk = new Set(table.primaryKey ?? []);
|
|
1777
|
+
return table.columns.filter((column) => column.cell === void 0 && !pk.has(column.columnName) && column.onUpdateFn === void 0);
|
|
1778
|
+
}
|
|
1779
|
+
var SUFFIXES = Object.values(CELL_PART_SUFFIX);
|
|
1780
|
+
function validateCellMetadata(table, index, ctx) {
|
|
1781
|
+
const fail = (message, path = ["tables", index]) => ctx.addIssue({ code: "custom", path, message });
|
|
1782
|
+
const cells = table.columns.flatMap((column, position) => column.cell ? [{ column, position, cell: column.cell }] : []);
|
|
1783
|
+
if (table.materializes !== void 0) return;
|
|
1784
|
+
if (table.cellMetadata !== true) {
|
|
1785
|
+
for (const { position } of cells) fail(`cell-metadata columns require cellMetadata on table '${table.table}'`, ["tables", index, "columns", position]);
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
if (table.source !== void 0) {
|
|
1789
|
+
fail(`synced table '${table.table}' cannot track cell metadata`);
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
const tracked = trackedCellColumns(table);
|
|
1793
|
+
const byName = new Map(tracked.map((column) => [column.name, column]));
|
|
1794
|
+
const withRev = cells.some(({ cell }) => cell.part === "rev");
|
|
1795
|
+
if (withRev && table.internal !== true) fail(`only a resolved table's internal sources carry '__rev' cell columns ('${table.table}')`);
|
|
1796
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1797
|
+
for (const { column, position, cell } of cells) {
|
|
1798
|
+
const path = ["tables", index, "columns", position];
|
|
1799
|
+
const owner = byName.get(cell.of);
|
|
1800
|
+
if (!owner) {
|
|
1801
|
+
fail(`cell column '${column.name}' belongs to '${cell.of}', which is not a tracked column`, path);
|
|
1802
|
+
continue;
|
|
1803
|
+
}
|
|
1804
|
+
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);
|
|
1805
|
+
if (column.name !== cellColumnName(owner.name, cell.part) || column.columnName !== cellColumnName(owner.columnName, cell.part)) {
|
|
1806
|
+
fail(`cell column for '${owner.name}' ${cell.part} must be named '${cellColumnName(owner.name, cell.part)}' / '${cellColumnName(owner.columnName, cell.part)}'`, path);
|
|
1807
|
+
}
|
|
1808
|
+
if (column.type !== CELL_PART_TYPES[cell.part] || !column.nullable || column.unique || column.default !== void 0 || column.defaultFn !== void 0) {
|
|
1809
|
+
fail(`cell column '${column.name}' must be a nullable, default-less ${CELL_PART_TYPES[cell.part]}`, path);
|
|
1810
|
+
}
|
|
1811
|
+
const key = `${cell.of}:${cell.part}`;
|
|
1812
|
+
if (seen.has(key)) fail(`duplicate cell column '${column.name}'`, path);
|
|
1813
|
+
seen.add(key);
|
|
1814
|
+
}
|
|
1815
|
+
for (const column of tracked) {
|
|
1816
|
+
if (column.columnName.length > MAX_TRACKED_COLUMN_NAME) {
|
|
1817
|
+
fail(`column '${column.columnName}' is too long to track cell metadata (max ${MAX_TRACKED_COLUMN_NAME} characters)`);
|
|
1818
|
+
}
|
|
1819
|
+
if (SUFFIXES.some((suffix) => column.columnName.endsWith(suffix))) {
|
|
1820
|
+
fail(`column '${column.columnName}' ends in a reserved cell-metadata suffix (${SUFFIXES.join(", ")})`);
|
|
1821
|
+
}
|
|
1822
|
+
for (const part of withRev ? ["at", "by", "rev"] : ["at", "by"]) {
|
|
1823
|
+
if (!seen.has(`${column.name}:${part}`)) fail(`tracked column '${column.name}' is missing its '${cellColumnName(column.columnName, part)}' cell column`);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1035
1827
|
var canonicalDefinitionSchema = zod.z.object({
|
|
1036
1828
|
definition_format: zod.z.literal(CANONICAL_FORMAT),
|
|
1037
|
-
namespace:
|
|
1829
|
+
namespace: identifier2.max(41),
|
|
1038
1830
|
description: zod.z.string().max(2e3).optional(),
|
|
1039
1831
|
enums: zod.z.array(canonicalEnumSchema).default([]),
|
|
1040
1832
|
tables: zod.z.array(canonicalTableSchema).min(0).max(500),
|
|
@@ -1052,6 +1844,74 @@ var canonicalDefinitionSchema = zod.z.object({
|
|
|
1052
1844
|
ctx.addIssue({ code: "custom", path: ["tables", index, "table"], message: `duplicate relation '${table.table}'` });
|
|
1053
1845
|
}
|
|
1054
1846
|
seen.add(table.table);
|
|
1847
|
+
validateCellMetadata(table, index, ctx);
|
|
1848
|
+
if (table.source !== void 0) {
|
|
1849
|
+
for (const [columnIndex, column] of table.columns.entries()) {
|
|
1850
|
+
if (column.onUpdateFn !== void 0) {
|
|
1851
|
+
ctx.addIssue({
|
|
1852
|
+
code: "custom",
|
|
1853
|
+
path: ["tables", index, "columns", columnIndex],
|
|
1854
|
+
message: `synced table '${table.table}' column '${column.name}' cannot declare onUpdateFn`
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
const fkTables = new Map(definition.tables.map((table) => [table.table, table]));
|
|
1861
|
+
for (const [tableIndex, table] of definition.tables.entries()) {
|
|
1862
|
+
const names = /* @__PURE__ */ new Set([
|
|
1863
|
+
...table.primaryKey?.length ? [`${table.table}_pkey`] : [],
|
|
1864
|
+
...table.columns.filter((column) => column.unique && !(table.primaryKey?.length === 1 && table.primaryKey[0] === column.columnName)).map((column) => `${table.table}_${column.columnName}_unique`),
|
|
1865
|
+
...table.links.filter((link) => link.enforce).map((link) => `fk_${table.table}_${link.name}`)
|
|
1866
|
+
]);
|
|
1867
|
+
for (const [index, link] of table.links.entries()) {
|
|
1868
|
+
if ((link.onDelete !== void 0 || link.onUpdate !== void 0) && (!link.enforce || link.kind !== "toOne")) {
|
|
1869
|
+
ctx.addIssue({ code: "custom", path: ["tables", tableIndex, "links", index], message: "referential actions require an enforced toOne link" });
|
|
1870
|
+
}
|
|
1871
|
+
if (link.onDelete === "set null" || link.onUpdate === "set null") {
|
|
1872
|
+
const source = table.columns.find((column) => column.columnName === link.on);
|
|
1873
|
+
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" });
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
for (const [index, check] of (table.checks ?? []).entries()) {
|
|
1877
|
+
const fail = (message) => ctx.addIssue({ code: "custom", path: ["tables", tableIndex, "checks", index], message });
|
|
1878
|
+
if (table.source) fail("synced tables cannot declare checks");
|
|
1879
|
+
if (names.has(check.name)) fail(`duplicate constraint '${check.name}'`);
|
|
1880
|
+
names.add(check.name);
|
|
1881
|
+
try {
|
|
1882
|
+
compileCheckExpression(check.expression, table.columns);
|
|
1883
|
+
} catch (error) {
|
|
1884
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
for (const [index, fk] of (table.foreignKeys ?? []).entries()) {
|
|
1888
|
+
const fail = (message) => ctx.addIssue({ code: "custom", path: ["tables", tableIndex, "foreignKeys", index], message });
|
|
1889
|
+
if (names.has(fk.name)) fail(`duplicate foreign key '${fk.name}'`);
|
|
1890
|
+
names.add(fk.name);
|
|
1891
|
+
const target = fkTables.get(fk.target);
|
|
1892
|
+
if (table.source || !target || target.source) {
|
|
1893
|
+
fail("foreign keys must connect owned tables in the same namespace");
|
|
1894
|
+
continue;
|
|
1895
|
+
}
|
|
1896
|
+
if (fk.columns.length !== fk.references.length) fail("foreign key columns and references must have the same number of columns");
|
|
1897
|
+
if (new Set(fk.columns).size !== fk.columns.length || new Set(fk.references).size !== fk.references.length) fail("foreign key columns must not repeat");
|
|
1898
|
+
for (const [position, name] of fk.columns.entries()) {
|
|
1899
|
+
const sourceColumn = table.columns.find((column) => column.columnName === name);
|
|
1900
|
+
const targetColumn = target.columns.find((column) => column.columnName === fk.references[position]);
|
|
1901
|
+
if (!sourceColumn || !targetColumn) fail("foreign key references an unknown column");
|
|
1902
|
+
else if (JSON.stringify(sourceColumn.type) !== JSON.stringify(targetColumn.type)) fail("foreign key column types must match");
|
|
1903
|
+
}
|
|
1904
|
+
if (fk.onDelete === "set null" || fk.onUpdate === "set null") {
|
|
1905
|
+
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");
|
|
1906
|
+
}
|
|
1907
|
+
const uniqueKeys = [target.primaryKey ?? [], ...target.indexes.filter((index2) => index2.unique).map((index2) => index2.columns), ...target.columns.filter((column) => column.unique).map((column) => [column.columnName])];
|
|
1908
|
+
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");
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
for (const [viewIndex, view] of (definition.views ?? []).entries()) {
|
|
1912
|
+
for (const message of validateResolvedView({ view, tables: definition.tables, views: definition.views ?? [] })) {
|
|
1913
|
+
ctx.addIssue({ code: "custom", path: ["views", viewIndex, "resolved"], message });
|
|
1914
|
+
}
|
|
1055
1915
|
}
|
|
1056
1916
|
const tableByName = new Map(definition.tables.map((table) => [table.table, table]));
|
|
1057
1917
|
for (const [tableIndex, table] of definition.tables.entries()) {
|
|
@@ -1174,12 +2034,14 @@ function normalizeCanonical(definition) {
|
|
|
1174
2034
|
...definition,
|
|
1175
2035
|
enums: sortByName(definition.enums),
|
|
1176
2036
|
tables: [...definition.tables].sort((a, b) => a.table.localeCompare(b.table)).map((t2) => {
|
|
1177
|
-
const { cowMutations, ...rest } = t2;
|
|
2037
|
+
const { cowMutations, foreignKeys, checks, ...rest } = t2;
|
|
1178
2038
|
return {
|
|
1179
2039
|
...rest,
|
|
1180
2040
|
links: sortByName(t2.links),
|
|
1181
2041
|
indexes: [...t2.indexes].sort((a, b) => (a.name ?? a.columns.join("_")).localeCompare(b.name ?? b.columns.join("_"))),
|
|
1182
2042
|
actions: sortByName(t2.actions),
|
|
2043
|
+
...foreignKeys?.length ? { foreignKeys: sortByName(foreignKeys) } : {},
|
|
2044
|
+
...checks?.length ? { checks: sortByName(checks) } : {},
|
|
1183
2045
|
// Omitted entirely when absent/empty so pre-CoW documents hash unchanged.
|
|
1184
2046
|
...cowMutations !== void 0 && cowMutations.length > 0 ? { cowMutations: sortByName(cowMutations) } : {}
|
|
1185
2047
|
};
|
|
@@ -1194,6 +2056,454 @@ function normalizeCanonical(definition) {
|
|
|
1194
2056
|
}
|
|
1195
2057
|
return normalized;
|
|
1196
2058
|
}
|
|
2059
|
+
|
|
2060
|
+
// src/authoring/resolved-sources.ts
|
|
2061
|
+
function cloneColumn({ builder, property, notNull, primaryKey }) {
|
|
2062
|
+
const canonical = builder.toCanonicalColumn(property);
|
|
2063
|
+
let clone = new ColumnBuilder(canonical.type, canonical.columnName);
|
|
2064
|
+
if (primaryKey) clone = clone.primaryKey();
|
|
2065
|
+
if (notNull) clone = clone.notNull();
|
|
2066
|
+
if (canonical.description !== void 0) clone = clone.description(canonical.description);
|
|
2067
|
+
return clone;
|
|
2068
|
+
}
|
|
2069
|
+
function planSource(input) {
|
|
2070
|
+
const { sourceName, input: source, fail } = input;
|
|
2071
|
+
if (!IDENTIFIER_PATTERN.test(sourceName)) fail(`source name "${sourceName}" must match ${IDENTIFIER_PATTERN}`);
|
|
2072
|
+
switch (source.kind) {
|
|
2073
|
+
case "manual":
|
|
2074
|
+
return {
|
|
2075
|
+
name: sourceName,
|
|
2076
|
+
kind: "manual",
|
|
2077
|
+
columns: input.dataColumns,
|
|
2078
|
+
table: internalTable(input, input.dataColumns, {
|
|
2079
|
+
[RESOLVED_SOURCE_SYSTEM_COLUMNS.tombstoned.name]: tombstoned(),
|
|
2080
|
+
[RESOLVED_SOURCE_SYSTEM_COLUMNS.writebackStatus.name]: t.text(RESOLVED_SOURCE_SYSTEM_COLUMNS.writebackStatus.columnName),
|
|
2081
|
+
[RESOLVED_SOURCE_SYSTEM_COLUMNS.writebackError.name]: t.text(RESOLVED_SOURCE_SYSTEM_COLUMNS.writebackError.columnName),
|
|
2082
|
+
[RESOLVED_SOURCE_SYSTEM_COLUMNS.writebackAt.name]: t.timestamptz(RESOLVED_SOURCE_SYSTEM_COLUMNS.writebackAt.columnName)
|
|
2083
|
+
}, { revisions: true })
|
|
2084
|
+
};
|
|
2085
|
+
case "etl":
|
|
2086
|
+
return planEtl(input, source);
|
|
2087
|
+
case "prefetch":
|
|
2088
|
+
return planPrefetch(input, source);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
var tombstoned = () => t.boolean(RESOLVED_SOURCE_SYSTEM_COLUMNS.tombstoned.columnName).notNull().default(false);
|
|
2092
|
+
function internalTable(input, supplied, system, options = {}) {
|
|
2093
|
+
const { viewName, sourceName, columns, pk, pkBuilder } = input;
|
|
2094
|
+
const tableColumns = { [pk]: cloneColumn({ builder: pkBuilder, property: pk, notNull: true, primaryKey: true }) };
|
|
2095
|
+
for (const property of supplied) {
|
|
2096
|
+
tableColumns[property] = cloneColumn({ builder: columns[property], property, notNull: false, primaryKey: false });
|
|
2097
|
+
}
|
|
2098
|
+
return defineTable(`${viewName}__${sourceName}`, { ...tableColumns, ...system }, {
|
|
2099
|
+
description: `Internal ${input.input.kind} source "${sourceName}" of resolved table "${viewName}". System-maintained.`,
|
|
2100
|
+
cellMetadata: true,
|
|
2101
|
+
// The manual source identifies each edit (`c__rev`) so an async
|
|
2102
|
+
// confirmation clears only the exact edit it delivered.
|
|
2103
|
+
...options.revisions ? { cellRevisions: true } : {},
|
|
2104
|
+
internal: true
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
function assertColumns(input, list, what) {
|
|
2108
|
+
for (const column of list) if (!input.dataColumns.includes(column)) input.fail(`${what} names unknown column "${column}"`);
|
|
2109
|
+
}
|
|
2110
|
+
function planEtl(input, source) {
|
|
2111
|
+
const { sourceName, pk, dataColumns, fail } = input;
|
|
2112
|
+
const table = typeof source.table === "function" ? source.table() : source.table;
|
|
2113
|
+
if (!isSeqTable(table) || table.source === void 0) fail(`etl source "${sourceName}" must reference a defineSyncedTable`);
|
|
2114
|
+
const synced = table;
|
|
2115
|
+
if (!(pk in synced.columns)) fail(`etl source "${sourceName}" table "${synced.tableName}" has no "${pk}" column`);
|
|
2116
|
+
const supplied = source.columns ? [...source.columns] : dataColumns.filter((column) => column in synced.columns);
|
|
2117
|
+
assertColumns(input, supplied, `etl source "${sourceName}"`);
|
|
2118
|
+
if (source.updatedAt !== void 0 && !(source.updatedAt in synced.columns)) {
|
|
2119
|
+
fail(`etl source "${sourceName}" updatedAt "${source.updatedAt}" is not a column of "${synced.tableName}"`);
|
|
2120
|
+
}
|
|
2121
|
+
return { name: sourceName, kind: "etl", table: synced, columns: supplied, ...source.updatedAt ? { updatedAt: source.updatedAt } : {} };
|
|
2122
|
+
}
|
|
2123
|
+
function planPrefetch(input, source) {
|
|
2124
|
+
const { sourceName, pk, fail } = input;
|
|
2125
|
+
assertColumns(input, source.columns, `prefetch source "${sourceName}"`);
|
|
2126
|
+
if (source.key !== pk && !source.columns.includes(source.key)) {
|
|
2127
|
+
fail(`prefetch source "${sourceName}" key "${source.key}" must be the primary key or one of its columns`);
|
|
2128
|
+
}
|
|
2129
|
+
for (const column of source.writeback?.columns ?? []) {
|
|
2130
|
+
if (!source.columns.includes(column)) fail(`prefetch source "${sourceName}" writes back "${column}", which it does not supply`);
|
|
2131
|
+
}
|
|
2132
|
+
const writeback = source.writeback;
|
|
2133
|
+
return {
|
|
2134
|
+
name: sourceName,
|
|
2135
|
+
kind: "prefetch",
|
|
2136
|
+
columns: [...source.columns],
|
|
2137
|
+
key: source.key,
|
|
2138
|
+
function: source.function,
|
|
2139
|
+
fetch: source.fetch === "single" ? { shape: "single" } : { shape: "batch", maxKeys: source.fetch.batch.maxKeys },
|
|
2140
|
+
prefetchDefaults: {
|
|
2141
|
+
maxStalenessMs: parseDuration(source.prefetchDefaults?.maxStaleness ?? "30s"),
|
|
2142
|
+
timeoutMs: parseDuration(source.prefetchDefaults?.timeout ?? "1500ms"),
|
|
2143
|
+
onError: source.prefetchDefaults?.onError ?? "fallback"
|
|
2144
|
+
},
|
|
2145
|
+
...writeback ? {
|
|
2146
|
+
writeback: {
|
|
2147
|
+
columns: [...writeback.columns],
|
|
2148
|
+
defaultMode: writeback.defaultMode ?? "sync",
|
|
2149
|
+
timeoutMs: parseDuration(writeback.timeout ?? "5s"),
|
|
2150
|
+
clearManualOnConfirm: writeback.clearManualOnConfirm ?? true
|
|
2151
|
+
}
|
|
2152
|
+
} : {},
|
|
2153
|
+
table: internalTable(input, source.columns, {
|
|
2154
|
+
[RESOLVED_SOURCE_SYSTEM_COLUMNS.fetchedAt.name]: t.timestamptz(RESOLVED_SOURCE_SYSTEM_COLUMNS.fetchedAt.columnName).notNull().default("now()"),
|
|
2155
|
+
[RESOLVED_SOURCE_SYSTEM_COLUMNS.tombstoned.name]: tombstoned()
|
|
2156
|
+
})
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
2159
|
+
function parseDuration(input) {
|
|
2160
|
+
const value = parseDurationMs(input);
|
|
2161
|
+
if (value === void 0) throw new OrmDefinitionError(`invalid duration "${input}" \u2014 use e.g. '1500ms', '30s', '5m'`);
|
|
2162
|
+
return value;
|
|
2163
|
+
}
|
|
2164
|
+
var PG_TYPE = {
|
|
2165
|
+
uuid: "uuid",
|
|
2166
|
+
text: "text",
|
|
2167
|
+
integer: "integer",
|
|
2168
|
+
bigint: "bigint",
|
|
2169
|
+
numeric: "numeric",
|
|
2170
|
+
double: "double precision",
|
|
2171
|
+
boolean: "boolean",
|
|
2172
|
+
timestamptz: "timestamptz",
|
|
2173
|
+
date: "date",
|
|
2174
|
+
jsonb: "jsonb"
|
|
2175
|
+
};
|
|
2176
|
+
var ident = (value) => `"${value.replace(/"/g, '""')}"`;
|
|
2177
|
+
var literal = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
2178
|
+
var physical = ({ source, property }) => source.table.columns[property].columnNameFor(property);
|
|
2179
|
+
function candidateSql({ source, column, pk, pgType }) {
|
|
2180
|
+
const a = ident(`s_${source.name}`);
|
|
2181
|
+
const name = physical({ source, property: column });
|
|
2182
|
+
const value = `CAST(${a}.${ident(name)} AS ${pgType})`;
|
|
2183
|
+
if (source.kind === "etl") {
|
|
2184
|
+
return {
|
|
2185
|
+
source: source.name,
|
|
2186
|
+
value,
|
|
2187
|
+
has: `(${a}.${ident(physical({ source, property: pk }))} IS NOT NULL)`,
|
|
2188
|
+
at: source.updatedAt ? `CAST(${a}.${ident(physical({ source, property: source.updatedAt }))} AS timestamptz)` : "CAST(NULL AS timestamptz)",
|
|
2189
|
+
by: "CAST(NULL AS text)"
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
const at = `${a}.${ident(cellColumnName(name, "at"))}`;
|
|
2193
|
+
const tombstoned2 = source.kind === "prefetch" ? ` AND NOT ${a}.${ident(RESOLVED_SOURCE_SYSTEM_COLUMNS.tombstoned.columnName)}` : "";
|
|
2194
|
+
return { source: source.name, value, has: `(${at} IS NOT NULL${tombstoned2})`, at, by: `${a}.${ident(cellColumnName(name, "by"))}` };
|
|
2195
|
+
}
|
|
2196
|
+
function outranks({ i, j, key, direction, inclusive }) {
|
|
2197
|
+
const ki = i[key];
|
|
2198
|
+
const kj = j[key];
|
|
2199
|
+
const op = inclusive ? `${direction}=` : direction;
|
|
2200
|
+
return inclusive ? `(${kj} IS NULL OR (${ki} IS NOT NULL AND ${ki} ${op} ${kj}))` : `(${ki} IS NOT NULL AND (${kj} IS NULL OR ${ki} ${op} ${kj}))`;
|
|
2201
|
+
}
|
|
2202
|
+
function rankedWinner({ candidates, key, direction }) {
|
|
2203
|
+
const branches = candidates.map((candidate, index) => {
|
|
2204
|
+
const beats = candidates.map((other, otherIndex) => otherIndex === index ? null : `(NOT ${other.has} OR ${outranks({ i: candidate, j: other, key, direction, inclusive: index < otherIndex })})`).filter((clause) => clause !== null);
|
|
2205
|
+
return `WHEN ${[candidate.has, ...beats].join(" AND ")} THEN ${index + 1}`;
|
|
2206
|
+
});
|
|
2207
|
+
return `CASE ${branches.join(" ")} END`;
|
|
2208
|
+
}
|
|
2209
|
+
function winnerIndex({ comparator, candidates }) {
|
|
2210
|
+
const firstWith = (test) => candidates.map((candidate, index) => `WHEN ${test(candidate)} THEN ${index + 1}`).join(" ");
|
|
2211
|
+
if (candidates.length === 1 || comparator === "priority") return `CASE ${firstWith((c) => c.has)} END`;
|
|
2212
|
+
switch (comparator) {
|
|
2213
|
+
case "latest":
|
|
2214
|
+
return rankedWinner({ candidates, key: "at", direction: ">" });
|
|
2215
|
+
case "highest":
|
|
2216
|
+
return rankedWinner({ candidates, key: "value", direction: ">" });
|
|
2217
|
+
case "lowest":
|
|
2218
|
+
return rankedWinner({ candidates, key: "value", direction: "<" });
|
|
2219
|
+
case "nonNullFirst":
|
|
2220
|
+
return `CASE ${firstWith((c) => `${c.has} AND ${c.value} IS NOT NULL`)} ${firstWith((c) => c.has)} END`;
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
function pick(winner, candidates, field) {
|
|
2224
|
+
return `CASE ${winner} ${candidates.map((candidate, index) => `WHEN ${index + 1} THEN ${field(candidate)}`).join(" ")} END`;
|
|
2225
|
+
}
|
|
2226
|
+
function loserSql({ loser, winner }) {
|
|
2227
|
+
return `CASE WHEN ${loser.has} AND ${loser.value} IS DISTINCT FROM ${winner.value} AND ${loser.at} > ${winner.at} THEN jsonb_build_array(jsonb_build_object('source', ${literal(loser.source)}, 'value', to_jsonb(${loser.value}), 'at', ${loser.at})) ELSE '[]'::jsonb END`;
|
|
2228
|
+
}
|
|
2229
|
+
function losersAgainst({ candidates, winner, isWinner }) {
|
|
2230
|
+
const losers = candidates.flatMap((loser, index) => {
|
|
2231
|
+
const excluded = isWinner(loser, index);
|
|
2232
|
+
if (excluded === true) return [];
|
|
2233
|
+
const clause = loserSql({ loser, winner });
|
|
2234
|
+
return [excluded === false ? clause : `CASE WHEN ${excluded} THEN '[]'::jsonb ELSE ${clause} END`];
|
|
2235
|
+
});
|
|
2236
|
+
return losers.length === 0 ? "CAST(NULL AS jsonb)" : `NULLIF(${losers.join(" || ")}, '[]'::jsonb)`;
|
|
2237
|
+
}
|
|
2238
|
+
function builtinCell({ comparator, candidates }) {
|
|
2239
|
+
const winner = winnerIndex({ comparator, candidates });
|
|
2240
|
+
const conflicts = comparator === "latest" || candidates.length < 2 ? "CAST(NULL AS jsonb)" : `CASE ${winner} ${candidates.map((known, index) => `WHEN ${index + 1} THEN ${losersAgainst({ candidates, winner: known, isWinner: (_c, other) => other === index })}`).join(" ")} END`;
|
|
2241
|
+
return {
|
|
2242
|
+
value: pick(winner, candidates, (c) => c.value),
|
|
2243
|
+
parts: {
|
|
2244
|
+
source: pick(winner, candidates, (c) => literal(c.source)),
|
|
2245
|
+
at: pick(winner, candidates, (c) => c.at),
|
|
2246
|
+
by: pick(winner, candidates, (c) => c.by),
|
|
2247
|
+
conflicts
|
|
2248
|
+
}
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
function customCell({ candidates, orderBy, index }) {
|
|
2252
|
+
const w = ident(`w_${index}`);
|
|
2253
|
+
const rows = candidates.map((c, ord) => `(${literal(c.source)}, ${c.has}, ${c.value}, ${c.at}, ${c.by}, ${ord})`);
|
|
2254
|
+
const lateral = ` LEFT JOIN LATERAL (SELECT max(winner.source) AS source, (array_agg(winner.value))[1] AS value, max(winner.updated_at) AS updated_at, max(winner.actor) AS actor FROM (WITH cand(source, has, value, updated_at, actor, ord) AS (VALUES ${rows.join(", ")}) SELECT c.source, c.value, c.updated_at, c.actor FROM cand c WHERE c.has ORDER BY ${orderBy}, c.ord LIMIT 1) AS winner) AS ${w} ON true`;
|
|
2255
|
+
const winner = { value: `${w}.value`, at: `${w}.updated_at` };
|
|
2256
|
+
return {
|
|
2257
|
+
value: winner.value,
|
|
2258
|
+
lateral,
|
|
2259
|
+
parts: {
|
|
2260
|
+
source: `${w}.source`,
|
|
2261
|
+
at: winner.at,
|
|
2262
|
+
by: `${w}.actor`,
|
|
2263
|
+
conflicts: candidates.length < 2 ? "CAST(NULL AS jsonb)" : losersAgainst({ candidates, winner, isWinner: (c) => `${w}.source = ${literal(c.source)}` })
|
|
2264
|
+
}
|
|
2265
|
+
};
|
|
2266
|
+
}
|
|
2267
|
+
function buildResolvedQuery({ context, plan }) {
|
|
2268
|
+
const { qb, relation } = context;
|
|
2269
|
+
const pkColumn = plan.columns[plan.pk].columnNameFor(plan.pk);
|
|
2270
|
+
const alias = (source) => ident(`s_${source}`);
|
|
2271
|
+
const tombstoned2 = RESOLVED_SOURCE_SYSTEM_COLUMNS.tombstoned.columnName;
|
|
2272
|
+
const sourceByName = new Map(plan.plans.map((source) => [source.name, source]));
|
|
2273
|
+
const schema = pgCore.getTableConfig(relation(plan.plans[0].table)).schema;
|
|
2274
|
+
const keyBranches = plan.plans.map((source) => {
|
|
2275
|
+
const where = source.kind === "prefetch" ? drizzleOrm.sql.raw(` WHERE NOT ${ident(tombstoned2)}`) : drizzleOrm.sql.raw("");
|
|
2276
|
+
const sourcePk = ident(physical({ source, property: plan.pk }));
|
|
2277
|
+
const selectedPk = sourcePk === ident(pkColumn) ? sourcePk : `${sourcePk} AS ${ident(pkColumn)}`;
|
|
2278
|
+
return drizzleOrm.sql`SELECT ${drizzleOrm.sql.raw(selectedPk)} FROM ${relation(source.table)}${where}`;
|
|
2279
|
+
});
|
|
2280
|
+
const joins = plan.plans.map(
|
|
2281
|
+
(source) => drizzleOrm.sql` LEFT JOIN ${relation(source.table)} AS ${drizzleOrm.sql.raw(alias(source.name))} ON ${drizzleOrm.sql.raw(`${alias(source.name)}.${ident(physical({ source, property: plan.pk }))} = "k".${ident(pkColumn)}`)}`
|
|
2282
|
+
);
|
|
2283
|
+
const cells = /* @__PURE__ */ new Map();
|
|
2284
|
+
plan.rules.forEach((rule, index) => {
|
|
2285
|
+
const canonical = plan.columns[rule.column].toCanonicalColumn(rule.column);
|
|
2286
|
+
const pgType = typeof canonical.type === "string" ? PG_TYPE[canonical.type] : `${schema === void 0 ? "" : `${ident(schema)}.`}${ident(canonical.type.enum)}`;
|
|
2287
|
+
const candidates = rule.candidates.map((name) => candidateSql({ source: sourceByName.get(name), column: rule.column, pk: plan.pk, pgType }));
|
|
2288
|
+
cells.set(rule.column, rule.comparator === "custom" ? customCell({ candidates, orderBy: rule.orderBy, index }) : builtinCell({ comparator: rule.comparator, candidates }));
|
|
2289
|
+
});
|
|
2290
|
+
const laterals = [...cells.values()].flatMap((cell) => cell.lateral ? [drizzleOrm.sql.raw(cell.lateral)] : []);
|
|
2291
|
+
const from = drizzleOrm.sql.join([drizzleOrm.sql`(${drizzleOrm.sql.join(keyBranches, drizzleOrm.sql.raw(" UNION "))}) AS "k"`, ...joins, ...laterals], drizzleOrm.sql.raw(""));
|
|
2292
|
+
const selected = {
|
|
2293
|
+
[plan.pk]: drizzleOrm.sql.raw(`"k".${ident(pkColumn)}`).as(pkColumn)
|
|
2294
|
+
};
|
|
2295
|
+
for (const [property, builder] of Object.entries(plan.columns)) {
|
|
2296
|
+
if (property === plan.pk) continue;
|
|
2297
|
+
const cell = cells.get(property);
|
|
2298
|
+
const columnName = builder.columnNameFor(property);
|
|
2299
|
+
selected[property] = drizzleOrm.sql.raw(cell.value).as(columnName);
|
|
2300
|
+
for (const [part, expression] of Object.entries(cell.parts)) {
|
|
2301
|
+
selected[cellColumnName(property, part)] = drizzleOrm.sql.raw(expression).as(cellColumnName(columnName, part));
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
const manual = plan.plans.find((source) => source.kind === "manual");
|
|
2305
|
+
const system = RESOLVED_VIEW_SYSTEM_COLUMNS;
|
|
2306
|
+
selected[system.writebackStatus.name] = drizzleOrm.sql.raw(`${alias(manual.name)}.${ident(RESOLVED_SOURCE_SYSTEM_COLUMNS.writebackStatus.columnName)}`).as(system.writebackStatus.columnName);
|
|
2307
|
+
selected[system.writebackError.name] = drizzleOrm.sql.raw(`${alias(manual.name)}.${ident(RESOLVED_SOURCE_SYSTEM_COLUMNS.writebackError.columnName)}`).as(system.writebackError.columnName);
|
|
2308
|
+
const hidden = plan.deletedBy.map((name) => `NOT COALESCE(${alias(name)}.${ident(tombstoned2)}, false)`);
|
|
2309
|
+
const query = qb.select(selected).from(from);
|
|
2310
|
+
return hidden.length > 0 ? query.where(drizzleOrm.sql.raw(hidden.join(" AND "))) : query;
|
|
2311
|
+
}
|
|
2312
|
+
function managedFn(id) {
|
|
2313
|
+
return { kind: "managed", id };
|
|
2314
|
+
}
|
|
2315
|
+
function manualSource() {
|
|
2316
|
+
return { kind: "manual" };
|
|
2317
|
+
}
|
|
2318
|
+
function etlSource(input) {
|
|
2319
|
+
return { kind: "etl", ...input };
|
|
2320
|
+
}
|
|
2321
|
+
function prefetchSource(input) {
|
|
2322
|
+
return { kind: "prefetch", ...input };
|
|
2323
|
+
}
|
|
2324
|
+
var latest = (candidates) => ({ kind: "latest", candidates });
|
|
2325
|
+
var highest = (candidates) => ({ kind: "highest", candidates });
|
|
2326
|
+
var lowest = (candidates) => ({ kind: "lowest", candidates });
|
|
2327
|
+
var priority = (candidates) => ({ kind: "priority", candidates });
|
|
2328
|
+
var nonNullFirst = (candidates) => ({ kind: "nonNullFirst", candidates });
|
|
2329
|
+
var custom = (candidates, orderBy) => ({ kind: "custom", candidates, orderBy });
|
|
2330
|
+
var SYSTEM_VIEW_COLUMNS = {
|
|
2331
|
+
[RESOLVED_VIEW_SYSTEM_COLUMNS.writebackStatus.name]: () => t.text(RESOLVED_VIEW_SYSTEM_COLUMNS.writebackStatus.columnName),
|
|
2332
|
+
[RESOLVED_VIEW_SYSTEM_COLUMNS.writebackError.name]: () => t.text(RESOLVED_VIEW_SYSTEM_COLUMNS.writebackError.columnName)
|
|
2333
|
+
};
|
|
2334
|
+
function defineResolvedTable(name, columns, config) {
|
|
2335
|
+
const file = activeAuthoringContext()?.currentFile;
|
|
2336
|
+
const fail = (message) => {
|
|
2337
|
+
throw new OrmDefinitionError(`resolved table "${name}": ${message}`, file);
|
|
2338
|
+
};
|
|
2339
|
+
if (!IDENTIFIER_PATTERN.test(name)) fail(`name must match ${IDENTIFIER_PATTERN}`);
|
|
2340
|
+
for (const property of Object.keys(columns)) {
|
|
2341
|
+
if (RESERVED_RESOLVED_COLUMN_NAMES.has(property) || RESERVED_RESOLVED_COLUMN_NAMES.has(columns[property].columnNameFor(property))) {
|
|
2342
|
+
fail(`column "${property}" collides with a reserved system column`);
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
for (const property of Object.keys(columns)) {
|
|
2346
|
+
if (columns[property].columnNameFor(property).length > MAX_TRACKED_COLUMN_NAME) {
|
|
2347
|
+
fail(`column "${property}" is too long for a resolved table (max ${MAX_TRACKED_COLUMN_NAME} characters, room for cell parts)`);
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
const pkEntries = Object.entries(columns).filter(([, builder]) => builder.isPrimaryKey);
|
|
2351
|
+
if (pkEntries.length !== 1) fail("declare exactly one .primaryKey() column");
|
|
2352
|
+
const [pk, pkBuilder] = pkEntries[0];
|
|
2353
|
+
const dataColumns = Object.keys(columns).filter((property) => property !== pk);
|
|
2354
|
+
if (dataColumns.length === 0) fail("declare at least one non-key column");
|
|
2355
|
+
const sourceEntries = Object.entries(config.sources);
|
|
2356
|
+
if (sourceEntries.filter(([, source]) => source.kind === "manual").length !== 1) fail("declare exactly one manualSource()");
|
|
2357
|
+
const plans = sourceEntries.map(
|
|
2358
|
+
([sourceName, input]) => planSource({ viewName: name, sourceName, input, columns, pk, pkBuilder, dataColumns, fail })
|
|
2359
|
+
);
|
|
2360
|
+
const rules = planRules({ config, plans, dataColumns, fail });
|
|
2361
|
+
const deletedBy = [...config.rows?.deletedBy ?? []];
|
|
2362
|
+
for (const source of deletedBy) {
|
|
2363
|
+
if (plans.find((plan) => plan.name === source)?.kind !== "manual") fail(`rows.deletedBy "${source}" must be the manual source`);
|
|
2364
|
+
}
|
|
2365
|
+
const partType = { source: t.text, at: t.timestamptz, by: t.text, conflicts: t.jsonb };
|
|
2366
|
+
const viewColumns = {};
|
|
2367
|
+
for (const [property, builder] of Object.entries(columns)) {
|
|
2368
|
+
viewColumns[property] = cloneColumn({ builder, property, notNull: property === pk, primaryKey: property === pk });
|
|
2369
|
+
if (property === pk) continue;
|
|
2370
|
+
const columnName = builder.columnNameFor(property);
|
|
2371
|
+
for (const part of RESOLVED_VIEW_CELL_PARTS) {
|
|
2372
|
+
viewColumns[cellColumnName(property, part)] = partType[part](cellColumnName(columnName, part)).cellPart(property, part);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
for (const [property, make] of Object.entries(SYSTEM_VIEW_COLUMNS)) viewColumns[property] = make();
|
|
2376
|
+
const { read, ...writePolicies } = config.policies;
|
|
2377
|
+
const resolved = {
|
|
2378
|
+
internalTables: plans.filter((plan) => plan.kind !== "etl").map((plan) => plan.table),
|
|
2379
|
+
sources: plans,
|
|
2380
|
+
rules,
|
|
2381
|
+
deletedBy,
|
|
2382
|
+
policies: writePolicies
|
|
2383
|
+
};
|
|
2384
|
+
const queryPlan = { columns, pk, plans, rules, deletedBy };
|
|
2385
|
+
const typedColumns = viewColumns;
|
|
2386
|
+
const surface = {
|
|
2387
|
+
...config.description !== void 0 ? { description: config.description } : {},
|
|
2388
|
+
...config.links !== void 0 ? { links: config.links } : {},
|
|
2389
|
+
policies: { read }
|
|
2390
|
+
};
|
|
2391
|
+
if (!config.materialize) {
|
|
2392
|
+
return defineView(name, typedColumns, { ...surface, query: (context) => buildResolvedQuery({ context, plan: queryPlan }), resolved });
|
|
2393
|
+
}
|
|
2394
|
+
const materialize = config.materialize === true ? {} : config.materialize;
|
|
2395
|
+
const computeName = `${name}__compute`;
|
|
2396
|
+
const tableName = `${name}__materialized`;
|
|
2397
|
+
const compute = defineView(computeName, typedColumns, {
|
|
2398
|
+
policies: { read },
|
|
2399
|
+
query: (context) => buildResolvedQuery({ context, plan: queryPlan }),
|
|
2400
|
+
internal: true
|
|
2401
|
+
});
|
|
2402
|
+
const table = defineTable(tableName, viewColumns, {
|
|
2403
|
+
description: `Materialized rows of resolved table "${name}". System-maintained from "${computeName}".`,
|
|
2404
|
+
internal: true,
|
|
2405
|
+
materializes: name,
|
|
2406
|
+
...materialize.indexes !== void 0 ? { indexes: materialize.indexes } : {}
|
|
2407
|
+
});
|
|
2408
|
+
return defineView(name, typedColumns, {
|
|
2409
|
+
...surface,
|
|
2410
|
+
query: (context) => {
|
|
2411
|
+
const handle = context.relation(table);
|
|
2412
|
+
const selected = Object.fromEntries(Object.entries(viewColumns).map(([property, builder]) => [
|
|
2413
|
+
property,
|
|
2414
|
+
drizzleOrm.sql`${handle[property]}`.as(builder.columnNameFor(property))
|
|
2415
|
+
]));
|
|
2416
|
+
return context.qb.select(selected).from(context.relation(table));
|
|
2417
|
+
},
|
|
2418
|
+
resolved: {
|
|
2419
|
+
...resolved,
|
|
2420
|
+
internalTables: [...resolved.internalTables, table],
|
|
2421
|
+
internalViews: [compute],
|
|
2422
|
+
materialized: { table: tableName, compute: computeName }
|
|
2423
|
+
}
|
|
2424
|
+
});
|
|
2425
|
+
}
|
|
2426
|
+
function planRules({ config, plans, dataColumns, fail }) {
|
|
2427
|
+
const kindOrder = { manual: 0, prefetch: 1, etl: 2 };
|
|
2428
|
+
const fallback = config.default ?? priority([...plans].sort((a, b) => kindOrder[a.kind] - kindOrder[b.kind]).map((plan) => plan.name));
|
|
2429
|
+
for (const column of Object.keys(config.resolve ?? {})) {
|
|
2430
|
+
if (!dataColumns.includes(column)) fail(`resolve names unknown column "${column}"`);
|
|
2431
|
+
}
|
|
2432
|
+
return dataColumns.map((column) => {
|
|
2433
|
+
const explicit = config.resolve?.[column];
|
|
2434
|
+
const comparator = explicit ?? fallback;
|
|
2435
|
+
const candidates = explicit ? [...explicit.candidates] : comparator.candidates.filter((name) => plans.find((plan) => plan.name === name)?.columns.includes(column));
|
|
2436
|
+
for (const candidate of candidates) {
|
|
2437
|
+
const plan = plans.find((entry) => entry.name === candidate);
|
|
2438
|
+
if (!plan) fail(`column "${column}" names unknown source "${candidate}"`);
|
|
2439
|
+
else if (!plan.columns.includes(column)) fail(`column "${column}" candidate "${candidate}" does not supply it`);
|
|
2440
|
+
}
|
|
2441
|
+
if (candidates.length === 0) fail(`column "${column}" has no candidate source`);
|
|
2442
|
+
if (comparator.kind === "custom") assertSafeOrderBy({ orderBy: comparator.orderBy ?? "", column, fail });
|
|
2443
|
+
return {
|
|
2444
|
+
column,
|
|
2445
|
+
comparator: comparator.kind,
|
|
2446
|
+
candidates,
|
|
2447
|
+
...comparator.kind === "custom" && comparator.orderBy !== void 0 ? { orderBy: comparator.orderBy } : {}
|
|
2448
|
+
};
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
var ORDER_BY_KEYWORDS = /* @__PURE__ */ new Set([
|
|
2452
|
+
"value",
|
|
2453
|
+
"updated_at",
|
|
2454
|
+
"source",
|
|
2455
|
+
"actor",
|
|
2456
|
+
"ord",
|
|
2457
|
+
"asc",
|
|
2458
|
+
"desc",
|
|
2459
|
+
"nulls",
|
|
2460
|
+
"first",
|
|
2461
|
+
"last",
|
|
2462
|
+
"case",
|
|
2463
|
+
"when",
|
|
2464
|
+
"then",
|
|
2465
|
+
"else",
|
|
2466
|
+
"end",
|
|
2467
|
+
"and",
|
|
2468
|
+
"or",
|
|
2469
|
+
"not",
|
|
2470
|
+
"is",
|
|
2471
|
+
"null",
|
|
2472
|
+
"true",
|
|
2473
|
+
"false",
|
|
2474
|
+
"in",
|
|
2475
|
+
"distinct",
|
|
2476
|
+
"from",
|
|
2477
|
+
"coalesce",
|
|
2478
|
+
"nullif",
|
|
2479
|
+
"greatest",
|
|
2480
|
+
"least",
|
|
2481
|
+
"text",
|
|
2482
|
+
"numeric",
|
|
2483
|
+
"integer",
|
|
2484
|
+
"bigint",
|
|
2485
|
+
"boolean",
|
|
2486
|
+
"timestamptz",
|
|
2487
|
+
"date",
|
|
2488
|
+
"jsonb",
|
|
2489
|
+
"interval"
|
|
2490
|
+
]);
|
|
2491
|
+
var ALLOWED_FUNCTIONS = new Set(VIEW_QUERY_FUNCTION_ALLOWLIST);
|
|
2492
|
+
var ORDER_BY_TOKEN = /\s+|'(?:[^']|'')*'|\d+(?:\.\d+)?|[a-z_][a-z0-9_]*|::|<=|>=|<>|!=|[-+*/%<>=(),|]/giy;
|
|
2493
|
+
function assertSafeOrderBy({ orderBy, column, fail }) {
|
|
2494
|
+
ORDER_BY_TOKEN.lastIndex = 0;
|
|
2495
|
+
let consumed = 0;
|
|
2496
|
+
for (let match = ORDER_BY_TOKEN.exec(orderBy); match; match = ORDER_BY_TOKEN.exec(orderBy)) {
|
|
2497
|
+
consumed = ORDER_BY_TOKEN.lastIndex;
|
|
2498
|
+
const token = match[0].toLowerCase();
|
|
2499
|
+
if (/^[a-z_]/.test(token) && !ORDER_BY_KEYWORDS.has(token) && !ALLOWED_FUNCTIONS.has(token)) {
|
|
2500
|
+
fail(`column "${column}" custom orderBy uses "${match[0]}" \u2014 only value, updated_at, source, actor, ord, SQL keywords and allowlisted functions`);
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
if (consumed !== orderBy.length || orderBy.trim() === "") {
|
|
2504
|
+
fail(`column "${column}" custom orderBy contains an unsupported character near "${orderBy.slice(consumed, consumed + 10)}"`);
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
1197
2507
|
function stableJson(value) {
|
|
1198
2508
|
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
1199
2509
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
@@ -1222,8 +2532,19 @@ function defineNamespace(input) {
|
|
|
1222
2532
|
return config;
|
|
1223
2533
|
}
|
|
1224
2534
|
function serializeNamespace(input) {
|
|
1225
|
-
const { config
|
|
1226
|
-
const views = input.views ?? [];
|
|
2535
|
+
const { config } = input;
|
|
2536
|
+
const views = [...input.views ?? []];
|
|
2537
|
+
const tables = [...input.tables];
|
|
2538
|
+
for (const view of views) {
|
|
2539
|
+
for (const internal of view.config.resolved?.internalTables ?? []) {
|
|
2540
|
+
if (!tables.includes(internal)) tables.push(internal);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
for (const view of [...views]) {
|
|
2544
|
+
for (const internal of view.config.resolved?.internalViews ?? []) {
|
|
2545
|
+
if (!views.includes(internal)) views.push(internal);
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
1227
2548
|
const enums = input.enums ?? [];
|
|
1228
2549
|
const tablesByName = /* @__PURE__ */ new Map();
|
|
1229
2550
|
for (const table of tables) {
|
|
@@ -1325,6 +2646,9 @@ function buildCanonicalTable({ table, relationsByName, enumsByName }) {
|
|
|
1325
2646
|
if (table.config.renamedFrom !== void 0) candidate.renamedFrom = table.config.renamedFrom;
|
|
1326
2647
|
if (table.source !== void 0) candidate.source = table.source;
|
|
1327
2648
|
if (table.source !== void 0) {
|
|
2649
|
+
if (table.config.checks?.length) {
|
|
2650
|
+
throw new OrmDefinitionError(`synced table "${table.tableName}" is system-owned; checks are not allowed`, table.file);
|
|
2651
|
+
}
|
|
1328
2652
|
if (table.config.indexes !== void 0 && table.config.indexes.length > 0) {
|
|
1329
2653
|
throw new OrmDefinitionError(
|
|
1330
2654
|
`synced table "${table.tableName}" is system-owned; indexes are not allowed`,
|
|
@@ -1362,6 +2686,11 @@ function buildCanonicalTable({ table, relationsByName, enumsByName }) {
|
|
|
1362
2686
|
if (builder.isPrimaryKey) columnLevelPk.push(column.columnName);
|
|
1363
2687
|
candidate.columns?.push(column);
|
|
1364
2688
|
}
|
|
2689
|
+
if (table.config.internal === true) candidate.internal = true;
|
|
2690
|
+
if (table.config.materializes !== void 0) candidate.materializes = table.config.materializes;
|
|
2691
|
+
if (table.config.cellMetadata === true && table.source !== void 0) {
|
|
2692
|
+
throw new OrmDefinitionError(`synced table "${table.tableName}" cannot track cell metadata \u2014 its DDL is owned by the sync`, table.file);
|
|
2693
|
+
}
|
|
1365
2694
|
if (table.config.primaryKey !== void 0) {
|
|
1366
2695
|
if (columnLevelPk.length > 0) {
|
|
1367
2696
|
throw new OrmDefinitionError(
|
|
@@ -1375,6 +2704,45 @@ function buildCanonicalTable({ table, relationsByName, enumsByName }) {
|
|
|
1375
2704
|
} else if (columnLevelPk.length > 0) {
|
|
1376
2705
|
candidate.primaryKey = columnLevelPk;
|
|
1377
2706
|
}
|
|
2707
|
+
if (table.config.cellMetadata === true) {
|
|
2708
|
+
candidate.cellMetadata = true;
|
|
2709
|
+
const parts = table.config.cellRevisions === true ? ["at", "by", "rev"] : ["at", "by"];
|
|
2710
|
+
const tracked = trackedCellColumns({ columns: candidate.columns ?? [], primaryKey: candidate.primaryKey });
|
|
2711
|
+
for (const column of tracked) {
|
|
2712
|
+
for (const part of parts) {
|
|
2713
|
+
candidate.columns?.push({
|
|
2714
|
+
name: cellColumnName(column.name, part),
|
|
2715
|
+
columnName: cellColumnName(column.columnName, part),
|
|
2716
|
+
type: CELL_PART_TYPES[part],
|
|
2717
|
+
nullable: true,
|
|
2718
|
+
unique: false,
|
|
2719
|
+
deprecated: false,
|
|
2720
|
+
cell: { of: column.name, part }
|
|
2721
|
+
});
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
if (table.config.foreignKeys?.length) {
|
|
2726
|
+
if (table.source) throw new OrmDefinitionError(`synced table "${table.tableName}" cannot enforce foreign keys`, table.file);
|
|
2727
|
+
candidate.foreignKeys = table.config.foreignKeys.map((fk) => {
|
|
2728
|
+
const target = resolveRelationRef({ ref: fk.target, fail: (message) => {
|
|
2729
|
+
throw new OrmDefinitionError(message, table.file);
|
|
2730
|
+
} });
|
|
2731
|
+
if (!isSeqTable(target) || !relationsByName.has(target.tableName)) throw new OrmDefinitionError("foreign key target must be a table in this namespace", table.file);
|
|
2732
|
+
return {
|
|
2733
|
+
name: fk.name,
|
|
2734
|
+
columns: fk.columns.map((ref) => resolveColumnRef({ relation: table, ref, role: `foreign key "${fk.name}" column` })),
|
|
2735
|
+
target: target.tableName,
|
|
2736
|
+
references: fk.references.map((ref) => resolveColumnRef({ relation: target, ref, role: `foreign key "${fk.name}" reference` })),
|
|
2737
|
+
...fk.deferrable ? { deferrable: fk.deferrable } : {},
|
|
2738
|
+
...fk.onDelete ? { onDelete: fk.onDelete } : {},
|
|
2739
|
+
...fk.onUpdate ? { onUpdate: fk.onUpdate } : {}
|
|
2740
|
+
};
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
if (table.config.checks?.length) {
|
|
2744
|
+
candidate.checks = table.config.checks.map((check) => ({ name: check.name, expression: check.expression }));
|
|
2745
|
+
}
|
|
1378
2746
|
for (const index of table.config.indexes ?? []) {
|
|
1379
2747
|
const entry = {
|
|
1380
2748
|
columns: index.columns.map((ref) => resolveColumnRef({ relation: table, ref, role: "index" }))
|
|
@@ -1427,6 +2795,12 @@ function buildCanonicalView({
|
|
|
1427
2795
|
if (column.default !== void 0 || column.defaultFn !== void 0) {
|
|
1428
2796
|
throw new OrmDefinitionError(`view "${view.viewName}": column "${propertyName}" cannot declare a default`, view.file);
|
|
1429
2797
|
}
|
|
2798
|
+
if (column.onUpdateFn !== void 0) {
|
|
2799
|
+
throw new OrmDefinitionError(
|
|
2800
|
+
`view "${view.viewName}": column "${propertyName}" cannot declare onUpdateFn`,
|
|
2801
|
+
view.file
|
|
2802
|
+
);
|
|
2803
|
+
}
|
|
1430
2804
|
if (column.unique) {
|
|
1431
2805
|
throw new OrmDefinitionError(`view "${view.viewName}": column "${propertyName}" cannot declare unique`, view.file);
|
|
1432
2806
|
}
|
|
@@ -1464,7 +2838,33 @@ function buildCanonicalView({
|
|
|
1464
2838
|
columns,
|
|
1465
2839
|
...primaryKey !== void 0 ? { primaryKey } : {},
|
|
1466
2840
|
links,
|
|
1467
|
-
policies: { read: toCanonicalPolicy(view.config.policies.read) }
|
|
2841
|
+
policies: { read: toCanonicalPolicy(view.config.policies.read) },
|
|
2842
|
+
...view.config.resolved !== void 0 ? { resolved: buildCanonicalResolved(view.config.resolved) } : {},
|
|
2843
|
+
...view.config.internal === true ? { internal: true } : {}
|
|
2844
|
+
};
|
|
2845
|
+
}
|
|
2846
|
+
function buildCanonicalResolved(resolved) {
|
|
2847
|
+
const policies = {};
|
|
2848
|
+
if (resolved.policies.write !== void 0) policies.write = toCanonicalPolicy(resolved.policies.write);
|
|
2849
|
+
if (resolved.policies.insert !== void 0) policies.insert = toCanonicalPolicy(resolved.policies.insert);
|
|
2850
|
+
if (resolved.policies.update !== void 0) policies.update = toCanonicalPolicy(resolved.policies.update);
|
|
2851
|
+
return {
|
|
2852
|
+
sources: resolved.sources.map((source) => ({
|
|
2853
|
+
name: source.name,
|
|
2854
|
+
kind: source.kind,
|
|
2855
|
+
table: source.table.tableName,
|
|
2856
|
+
columns: [...source.columns],
|
|
2857
|
+
...source.updatedAt !== void 0 ? { updatedAt: source.updatedAt } : {},
|
|
2858
|
+
...source.key !== void 0 ? { key: source.key } : {},
|
|
2859
|
+
...source.fetch !== void 0 ? { fetch: source.fetch } : {},
|
|
2860
|
+
...source.function !== void 0 ? { function: { ...source.function } } : {},
|
|
2861
|
+
...source.prefetchDefaults !== void 0 ? { prefetchDefaults: { ...source.prefetchDefaults } } : {},
|
|
2862
|
+
...source.writeback !== void 0 ? { writeback: { ...source.writeback, columns: [...source.writeback.columns] } } : {}
|
|
2863
|
+
})),
|
|
2864
|
+
resolve: resolved.rules.map((rule) => ({ ...rule, candidates: [...rule.candidates] })),
|
|
2865
|
+
deletedBy: [...resolved.deletedBy],
|
|
2866
|
+
...resolved.materialized ? { materialized: { ...resolved.materialized } } : {},
|
|
2867
|
+
policies
|
|
1468
2868
|
};
|
|
1469
2869
|
}
|
|
1470
2870
|
function buildCanonicalLink({
|
|
@@ -1481,6 +2881,9 @@ function buildCanonicalLink({
|
|
|
1481
2881
|
if (link.enforce && link.linkKind !== "toOne") {
|
|
1482
2882
|
return fail("sets enforce: true, which is only supported on toOne links");
|
|
1483
2883
|
}
|
|
2884
|
+
if ((link.onDelete !== void 0 || link.onUpdate !== void 0) && !link.enforce) {
|
|
2885
|
+
return fail("sets a referential action without enforce: true");
|
|
2886
|
+
}
|
|
1484
2887
|
if (link.linkKind === "toPlatformUser") {
|
|
1485
2888
|
if (link.target !== void 0) return fail("is toPlatformUser and must not declare a target table");
|
|
1486
2889
|
return {
|
|
@@ -1509,6 +2912,8 @@ function buildCanonicalLink({
|
|
|
1509
2912
|
on: resolveColumnRef({ relation: owningSide, ref: link.on, role: `link "${linkName}" on` }),
|
|
1510
2913
|
...link.references !== void 0 ? { references: resolveColumnRef({ relation: referencedSide, ref: link.references, role: `link "${linkName}" references` }) } : {},
|
|
1511
2914
|
enforce: link.enforce,
|
|
2915
|
+
...link.onDelete !== void 0 ? { onDelete: link.onDelete } : {},
|
|
2916
|
+
...link.onUpdate !== void 0 ? { onUpdate: link.onUpdate } : {},
|
|
1512
2917
|
...link.description !== void 0 ? { description: link.description } : {}
|
|
1513
2918
|
};
|
|
1514
2919
|
}
|
|
@@ -1687,6 +3092,10 @@ async function loadNamespaceFromDir(dir, options = {}) {
|
|
|
1687
3092
|
fsCache: false,
|
|
1688
3093
|
...options.ormModulePath !== void 0 ? {
|
|
1689
3094
|
alias: {
|
|
3095
|
+
// The public /drizzle barrel re-exports this pinned dependency.
|
|
3096
|
+
// Explicitly map it before the root alias: otherwise jiti appends
|
|
3097
|
+
// "/drizzle" to the overridden module's filename.
|
|
3098
|
+
"@sequenceholdings/orm/drizzle": module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('authoring.cjs', document.baseURI).href))).resolve("drizzle-orm"),
|
|
1690
3099
|
"@sequenceholdings/orm": options.ormModulePath,
|
|
1691
3100
|
"@sequenceholdings/orm/authoring": options.ormModulePath
|
|
1692
3101
|
}
|
|
@@ -1778,23 +3187,34 @@ exports.activeAuthoringContext = activeAuthoringContext;
|
|
|
1778
3187
|
exports.beginAuthoringContext = beginAuthoringContext;
|
|
1779
3188
|
exports.computeCanonicalHash = computeCanonicalHash;
|
|
1780
3189
|
exports.computeSyncedContractHash = computeSyncedContractHash;
|
|
3190
|
+
exports.custom = custom;
|
|
1781
3191
|
exports.defineEnum = defineEnum;
|
|
1782
3192
|
exports.defineNamespace = defineNamespace;
|
|
3193
|
+
exports.defineResolvedTable = defineResolvedTable;
|
|
1783
3194
|
exports.defineSyncedTable = defineSyncedTable;
|
|
1784
3195
|
exports.defineTable = defineTable;
|
|
1785
3196
|
exports.defineView = defineView;
|
|
1786
3197
|
exports.deriveColumnName = deriveColumnName;
|
|
1787
3198
|
exports.endAuthoringContext = endAuthoringContext;
|
|
3199
|
+
exports.etlSource = etlSource;
|
|
1788
3200
|
exports.extractOrmSyncedTableContract = extractOrmSyncedTableContract;
|
|
3201
|
+
exports.highest = highest;
|
|
1789
3202
|
exports.isColumnBuilder = isColumnBuilder;
|
|
1790
3203
|
exports.isEnumRef = isEnumRef;
|
|
1791
3204
|
exports.isSeqLink = isSeqLink;
|
|
1792
3205
|
exports.isSeqNamespaceConfig = isSeqNamespaceConfig;
|
|
1793
3206
|
exports.isSeqTable = isSeqTable;
|
|
1794
3207
|
exports.isSeqView = isSeqView;
|
|
3208
|
+
exports.latest = latest;
|
|
1795
3209
|
exports.loadNamespaceFromDir = loadNamespaceFromDir;
|
|
3210
|
+
exports.lowest = lowest;
|
|
3211
|
+
exports.managedFn = managedFn;
|
|
3212
|
+
exports.manualSource = manualSource;
|
|
3213
|
+
exports.nonNullFirst = nonNullFirst;
|
|
1796
3214
|
exports.overlayWinsUnion = overlayWinsUnion;
|
|
1797
3215
|
exports.overrideRowMutation = overrideRowMutation;
|
|
3216
|
+
exports.prefetchSource = prefetchSource;
|
|
3217
|
+
exports.priority = priority;
|
|
1798
3218
|
exports.revertOverlayMutation = revertOverlayMutation;
|
|
1799
3219
|
exports.serializeNamespace = serializeNamespace;
|
|
1800
3220
|
exports.syncedContractSurface = syncedContractSurface;
|