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