@minnowdb/core 0.2.1 → 0.3.0
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 +21 -32
- package/dist/engine/artifact-cache.d.ts +2 -0
- package/dist/engine/artifact-cache.d.ts.map +1 -1
- package/dist/engine/artifact-cache.js +5 -0
- package/dist/engine/artifact-cache.js.map +1 -1
- package/dist/engine/batch.d.ts +6 -1
- package/dist/engine/batch.d.ts.map +1 -1
- package/dist/engine/batch.js +17 -6
- package/dist/engine/batch.js.map +1 -1
- package/dist/engine/catalog.d.ts +22 -2
- package/dist/engine/catalog.d.ts.map +1 -1
- package/dist/engine/catalog.js +30 -3
- package/dist/engine/catalog.js.map +1 -1
- package/dist/engine/client.d.ts +2 -0
- package/dist/engine/client.d.ts.map +1 -1
- package/dist/engine/client.js +20 -10
- package/dist/engine/client.js.map +1 -1
- package/dist/engine/database.d.ts +67 -5
- package/dist/engine/database.d.ts.map +1 -1
- package/dist/engine/database.js +3508 -369
- package/dist/engine/database.js.map +1 -1
- package/dist/engine/defaults.d.ts +4 -7
- package/dist/engine/defaults.d.ts.map +1 -1
- package/dist/engine/defaults.js +47 -21
- package/dist/engine/defaults.js.map +1 -1
- package/dist/engine/fts.d.ts.map +1 -1
- package/dist/engine/fts.js +2 -0
- package/dist/engine/fts.js.map +1 -1
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/live.d.ts.map +1 -1
- package/dist/engine/live.js +10 -2
- package/dist/engine/live.js.map +1 -1
- package/dist/engine/optimizer.d.ts.map +1 -1
- package/dist/engine/optimizer.js +258 -23
- package/dist/engine/optimizer.js.map +1 -1
- package/dist/engine/query-cache.d.ts +1 -1
- package/dist/engine/query-cache.d.ts.map +1 -1
- package/dist/engine/query-cache.js +3 -1
- package/dist/engine/query-cache.js.map +1 -1
- package/dist/engine/query.d.ts +130 -22
- package/dist/engine/query.d.ts.map +1 -1
- package/dist/engine/query.js +1344 -241
- package/dist/engine/query.js.map +1 -1
- package/dist/engine/schema-wire.d.ts +6 -2
- package/dist/engine/schema-wire.d.ts.map +1 -1
- package/dist/engine/schema-wire.js +40 -33
- package/dist/engine/schema-wire.js.map +1 -1
- package/dist/engine/schema.d.ts +157 -76
- package/dist/engine/schema.d.ts.map +1 -1
- package/dist/engine/schema.js +548 -103
- package/dist/engine/schema.js.map +1 -1
- package/dist/engine/sort-keys.d.ts +4 -3
- package/dist/engine/sort-keys.d.ts.map +1 -1
- package/dist/engine/sort-keys.js +42 -27
- package/dist/engine/sort-keys.js.map +1 -1
- package/dist/engine/sql-domains.d.ts +26 -0
- package/dist/engine/sql-domains.d.ts.map +1 -0
- package/dist/engine/sql-domains.js +421 -0
- package/dist/engine/sql-domains.js.map +1 -0
- package/dist/engine/sql-driver.d.ts +19 -0
- package/dist/engine/sql-driver.d.ts.map +1 -0
- package/dist/engine/sql-driver.js +2 -0
- package/dist/engine/sql-driver.js.map +1 -0
- package/dist/engine/sql-json.d.ts +7 -8
- package/dist/engine/sql-json.d.ts.map +1 -1
- package/dist/engine/sql-json.js +28 -14
- package/dist/engine/sql-json.js.map +1 -1
- package/dist/engine/sql-semantics.d.ts +2 -0
- package/dist/engine/sql-semantics.d.ts.map +1 -1
- package/dist/engine/sql-semantics.js +69 -3
- package/dist/engine/sql-semantics.js.map +1 -1
- package/dist/engine/vector.d.ts +5 -1
- package/dist/engine/vector.d.ts.map +1 -1
- package/dist/engine/vector.js +433 -61
- package/dist/engine/vector.js.map +1 -1
- package/dist/engine/worker-host.d.ts +1 -0
- package/dist/engine/worker-host.d.ts.map +1 -1
- package/dist/engine/worker-host.js +4 -0
- package/dist/engine/worker-host.js.map +1 -1
- package/dist/plan/index.d.ts +3 -3
- package/dist/plan/index.js +3 -3
- package/dist/storage/indexeddb.d.ts +44 -5
- package/dist/storage/indexeddb.d.ts.map +1 -1
- package/dist/storage/indexeddb.js +738 -175
- package/dist/storage/indexeddb.js.map +1 -1
- package/dist/storage/memory.d.ts +27 -10
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +67 -18
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/opfs/leader.d.ts +28 -6
- package/dist/storage/opfs/leader.d.ts.map +1 -1
- package/dist/storage/opfs/leader.js +301 -34
- package/dist/storage/opfs/leader.js.map +1 -1
- package/dist/storage/opfs/rpc.d.ts.map +1 -1
- package/dist/storage/opfs/rpc.js +2 -1
- package/dist/storage/opfs/rpc.js.map +1 -1
- package/dist/storage/opfs/store.d.ts +34 -5
- package/dist/storage/opfs/store.d.ts.map +1 -1
- package/dist/storage/opfs/store.js +41 -0
- package/dist/storage/opfs/store.js.map +1 -1
- package/dist/storage/snapshot.d.ts +14 -0
- package/dist/storage/snapshot.d.ts.map +1 -1
- package/dist/storage/snapshot.js +44 -0
- package/dist/storage/snapshot.js.map +1 -1
- package/dist/storage/toolkit/index.d.ts +1 -1
- package/dist/storage/toolkit/index.d.ts.map +1 -1
- package/dist/storage/toolkit/index.js +1 -1
- package/dist/storage/toolkit/index.js.map +1 -1
- package/dist/storage/toolkit/record-core.d.ts +19 -5
- package/dist/storage/toolkit/record-core.d.ts.map +1 -1
- package/dist/storage/toolkit/record-core.js +353 -78
- package/dist/storage/toolkit/record-core.js.map +1 -1
- package/dist/storage/toolkit/wire.d.ts +15 -0
- package/dist/storage/toolkit/wire.d.ts.map +1 -1
- package/dist/storage/toolkit/wire.js +134 -0
- package/dist/storage/toolkit/wire.js.map +1 -1
- package/dist/storage/types.d.ts +213 -31
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/storage/types.js +291 -20
- package/dist/storage/types.js.map +1 -1
- package/dist/testing/block-store-conformance.d.ts.map +1 -1
- package/dist/testing/block-store-conformance.js +153 -2
- package/dist/testing/block-store-conformance.js.map +1 -1
- package/dist/testing/index.d.ts +9 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +23 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/simulator.d.ts +98 -0
- package/dist/testing/simulator.d.ts.map +1 -0
- package/dist/testing/simulator.js +560 -0
- package/dist/testing/simulator.js.map +1 -0
- package/dist/testing/sqllogictest.d.ts +90 -0
- package/dist/testing/sqllogictest.d.ts.map +1 -0
- package/dist/testing/sqllogictest.js +435 -0
- package/dist/testing/sqllogictest.js.map +1 -0
- package/dist/transactions/index.d.ts +18 -3
- package/dist/transactions/index.d.ts.map +1 -1
- package/dist/transactions/index.js +64 -8
- package/dist/transactions/index.js.map +1 -1
- package/package.json +5 -3
- package/postgres-feature-profile.json +223 -0
- package/sql-feature-matrix.json +172 -252
package/dist/engine/schema.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { validateColumnDefault, validateEnumValues, } from "../storage/index.js";
|
|
2
|
-
import { compileCheckExpression, expressionColumns } from "./query.js";
|
|
1
|
+
import { validateColumnDefault, validateEnumValues, validateSqlDomain, } from "../storage/index.js";
|
|
2
|
+
import { compileCheckExpression, expressionColumns, validateDefaultExpression, } from "./query.js";
|
|
3
|
+
import { externalSqlDomainValue, normalizeSqlDomainValue } from "./sql-domains.js";
|
|
3
4
|
/**
|
|
4
5
|
* The constraint name `migrate()` gives a declared relation. It matches the name the SQL parser
|
|
5
6
|
* derives for an unnamed inline REFERENCES, so a table built either way has the same catalog.
|
|
@@ -7,12 +8,20 @@ import { compileCheckExpression, expressionColumns } from "./query.js";
|
|
|
7
8
|
export function foreignKeyName(tableName, columnName) {
|
|
8
9
|
return `${tableName}_${columnName}_fkey`;
|
|
9
10
|
}
|
|
10
|
-
function defaultSpecFromArg(type, value) {
|
|
11
|
+
function defaultSpecFromArg(type, value, sqlDomain) {
|
|
12
|
+
if (sqlDomain?.kind === "numeric") {
|
|
13
|
+
if ((typeof value !== "number" || !Number.isFinite(value)) && typeof value !== "string") {
|
|
14
|
+
throw new TypeError("NUMERIC default must be a finite number or decimal string");
|
|
15
|
+
}
|
|
16
|
+
normalizeSqlDomainValue(sqlDomain, value);
|
|
17
|
+
return { kind: "literal", value };
|
|
18
|
+
}
|
|
11
19
|
switch (type) {
|
|
12
20
|
case "datetime":
|
|
13
|
-
if (value
|
|
14
|
-
throw new TypeError(
|
|
15
|
-
|
|
21
|
+
if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {
|
|
22
|
+
throw new TypeError("Default literal must be a valid Date");
|
|
23
|
+
}
|
|
24
|
+
return { kind: "literal", value: new Date(value.getTime()) };
|
|
16
25
|
case "string":
|
|
17
26
|
if (typeof value !== "string")
|
|
18
27
|
throw new TypeError("Default literal must be a string");
|
|
@@ -34,70 +43,135 @@ function createColumn(type, state = {}) {
|
|
|
34
43
|
type,
|
|
35
44
|
isNullable: (state.isNullable ?? false),
|
|
36
45
|
isUnique: (state.isUnique ?? false),
|
|
37
|
-
hasDefault: (state.defaultSpec !== undefined
|
|
46
|
+
hasDefault: (state.defaultSpec !== undefined),
|
|
47
|
+
integer: state.integer ?? false,
|
|
48
|
+
...(state.sqlDomain === undefined ? {} : { sqlDomain: state.sqlDomain }),
|
|
38
49
|
...(state.defaultSpec === undefined ? {} : { defaultSpec: state.defaultSpec }),
|
|
39
|
-
...(state.defaultFn === undefined
|
|
40
|
-
? {}
|
|
41
|
-
: { defaultFn: state.defaultFn }),
|
|
42
50
|
...(state.renamedFromName === undefined ? {} : { renamedFromName: state.renamedFromName }),
|
|
43
51
|
...(state.reference === undefined ? {} : { reference: state.reference }),
|
|
44
52
|
...(state.enumValues === undefined ? {} : { enumValues: state.enumValues }),
|
|
45
|
-
...(state.backfillValue === undefined
|
|
46
|
-
? {}
|
|
47
|
-
: { backfillValue: state.backfillValue }),
|
|
53
|
+
...(state.backfillValue === undefined ? {} : { backfillValue: state.backfillValue }),
|
|
48
54
|
};
|
|
49
55
|
return {
|
|
50
56
|
...base,
|
|
51
|
-
nullable: () => createColumn(type, {
|
|
52
|
-
unique: () => createColumn(type, { ...state, isUnique: true }),
|
|
53
|
-
renamedFrom: (name) => createColumn(type, { ...state, renamedFromName: name }),
|
|
54
|
-
backfill: (value) => createColumn(type, { ...state, backfillValue: value }),
|
|
55
|
-
references: (table, referencedColumn, options = {}) => createColumn(type, {
|
|
57
|
+
nullable: () => createColumn(type, {
|
|
56
58
|
...state,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
isNullable: true,
|
|
60
|
+
}),
|
|
61
|
+
unique: () => createColumn(type, {
|
|
62
|
+
...state,
|
|
63
|
+
isUnique: true,
|
|
62
64
|
}),
|
|
65
|
+
renamedFrom: (name) => {
|
|
66
|
+
validateSchemaName(name, "Rename source");
|
|
67
|
+
return createColumn(type, { ...state, renamedFromName: name });
|
|
68
|
+
},
|
|
69
|
+
backfill: (value) => createColumn(type, { ...state, backfillValue: value }),
|
|
70
|
+
references: (table, referencedColumn, options = {}) => {
|
|
71
|
+
validateSchemaName(table, "Referenced table");
|
|
72
|
+
validateSchemaName(referencedColumn, "Referenced column");
|
|
73
|
+
return createColumn(type, {
|
|
74
|
+
...state,
|
|
75
|
+
reference: {
|
|
76
|
+
table,
|
|
77
|
+
column: referencedColumn,
|
|
78
|
+
onDelete: options.onDelete ?? "restrict",
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
},
|
|
63
82
|
default: ((value) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
83
|
+
return createColumn(type, {
|
|
84
|
+
...state,
|
|
85
|
+
defaultSpec: defaultSpecFromArg(type, value, state.sqlDomain),
|
|
86
|
+
});
|
|
87
|
+
}),
|
|
88
|
+
defaultSql: ((sql) => {
|
|
89
|
+
const expression = sql.trim();
|
|
90
|
+
if (expression.length === 0 || expression !== sql) {
|
|
91
|
+
throw new TypeError("Default SQL must be a trimmed non-empty expression");
|
|
92
|
+
}
|
|
93
|
+
return createColumn(type, {
|
|
94
|
+
...state,
|
|
95
|
+
defaultSpec: { kind: "expression", sql: expression },
|
|
96
|
+
});
|
|
71
97
|
}),
|
|
72
98
|
autoIncrement: (() => {
|
|
73
99
|
if (type !== "number") {
|
|
74
100
|
throw new TypeError("Auto-increment requires a number column");
|
|
75
101
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
102
|
+
return createColumn(type, {
|
|
103
|
+
...state,
|
|
104
|
+
defaultSpec: { kind: "autoincrement" },
|
|
105
|
+
});
|
|
79
106
|
}),
|
|
80
107
|
};
|
|
81
108
|
}
|
|
109
|
+
function validateSchemaName(name, kind) {
|
|
110
|
+
if (name.length === 0)
|
|
111
|
+
throw new TypeError(`${kind} name cannot be empty`);
|
|
112
|
+
if (name.trim() !== name) {
|
|
113
|
+
throw new TypeError(`${kind} name cannot start or end with whitespace: ${JSON.stringify(name)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
82
116
|
/**
|
|
83
117
|
* Rebuilds a column carrying an exact default spec — the wire layer's escape hatch, since the
|
|
84
|
-
* public `.default()`
|
|
118
|
+
* public `.default()` accepts literals and cannot express auto-increment catalog metadata.
|
|
85
119
|
*/
|
|
86
120
|
export function columnWithDefaultSpec(base, spec) {
|
|
87
|
-
return
|
|
121
|
+
return columnFromState({
|
|
122
|
+
type: base.type,
|
|
88
123
|
isNullable: base.isNullable,
|
|
89
124
|
isUnique: base.isUnique,
|
|
125
|
+
integer: base.integer ?? false,
|
|
126
|
+
...(base.sqlDomain === undefined ? {} : { sqlDomain: base.sqlDomain }),
|
|
90
127
|
...(base.renamedFromName === undefined ? {} : { renamedFromName: base.renamedFromName }),
|
|
91
128
|
...(base.reference === undefined ? {} : { reference: base.reference }),
|
|
92
129
|
...(base.enumValues === undefined ? {} : { enumValues: base.enumValues }),
|
|
93
130
|
defaultSpec: spec,
|
|
94
131
|
});
|
|
95
132
|
}
|
|
133
|
+
/** Rebuilds a fluent column from structured-clone-safe metadata. */
|
|
134
|
+
export function columnFromState(state) {
|
|
135
|
+
return createColumn(state.type, {
|
|
136
|
+
isNullable: state.isNullable,
|
|
137
|
+
isUnique: state.isUnique,
|
|
138
|
+
integer: state.integer ?? false,
|
|
139
|
+
...(state.sqlDomain === undefined ? {} : { sqlDomain: state.sqlDomain }),
|
|
140
|
+
...(state.renamedFromName === undefined ? {} : { renamedFromName: state.renamedFromName }),
|
|
141
|
+
...(state.reference === undefined ? {} : { reference: state.reference }),
|
|
142
|
+
...(state.defaultSpec === undefined ? {} : { defaultSpec: state.defaultSpec }),
|
|
143
|
+
...(state.enumValues === undefined ? {} : { enumValues: state.enumValues }),
|
|
144
|
+
...(state.backfillValue === undefined ? {} : { backfillValue: state.backfillValue }),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
96
147
|
export const column = {
|
|
97
148
|
boolean: () => createColumn("boolean"),
|
|
98
149
|
number: () => createColumn("number"),
|
|
150
|
+
/** Exact safe-integer semantics, matching SQL INTEGER/SMALLINT/BIGINT. */
|
|
151
|
+
integer: () => createColumn("number", { integer: true }),
|
|
99
152
|
string: () => createColumn("string"),
|
|
100
153
|
datetime: () => createColumn("datetime"),
|
|
154
|
+
/** Exact decimal SQL NUMERIC. Selects return strings; writes accept strings or numbers. */
|
|
155
|
+
numeric: (options = {}) => {
|
|
156
|
+
const sqlDomain = validateSqlDomain({ kind: "numeric", ...options }, "numeric column");
|
|
157
|
+
return createColumn("string", { sqlDomain });
|
|
158
|
+
},
|
|
159
|
+
json: () => createColumn("string", { sqlDomain: { kind: "json" } }),
|
|
160
|
+
jsonb: () => createColumn("string", { sqlDomain: { kind: "jsonb" } }),
|
|
161
|
+
uuid: () => createColumn("string", { sqlDomain: { kind: "uuid" } }),
|
|
162
|
+
time: () => createColumn("string", { sqlDomain: { kind: "time" } }),
|
|
163
|
+
interval: () => createColumn("string", { sqlDomain: { kind: "interval" } }),
|
|
164
|
+
/** JSON array text at the JavaScript boundary, with the SQL element type retained in metadata. */
|
|
165
|
+
array: (element) => createColumn("string", {
|
|
166
|
+
sqlDomain: validateSqlDomain({ kind: "array", element }, "array column"),
|
|
167
|
+
}),
|
|
168
|
+
/** A named SQL enum domain, distinct from the lightweight `column.enum()` restriction. */
|
|
169
|
+
sqlEnum: (name, values) => {
|
|
170
|
+
validateSchemaName(name, "Enum type");
|
|
171
|
+
return createColumn("string", {
|
|
172
|
+
sqlDomain: validateSqlDomain({ kind: "enum", name, values: validateEnumValues(values, name) }, name),
|
|
173
|
+
});
|
|
174
|
+
},
|
|
101
175
|
/**
|
|
102
176
|
* A string column restricted to a closed set of values, typed as their literal union:
|
|
103
177
|
*
|
|
@@ -112,7 +186,35 @@ export const column = {
|
|
|
112
186
|
*/
|
|
113
187
|
enum: (values) => createColumn("string", { enumValues: validateEnumValues(values, "enum column") }),
|
|
114
188
|
};
|
|
189
|
+
function validateDeclaredColumnValue(definition, value, context, label) {
|
|
190
|
+
if (definition.sqlDomain !== undefined) {
|
|
191
|
+
try {
|
|
192
|
+
normalizeSqlDomainValue(definition.sqlDomain, value);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
throw new TypeError(`${label} does not fit ${context}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const matches = definition.type === "datetime"
|
|
200
|
+
? value instanceof Date && Number.isFinite(value.getTime())
|
|
201
|
+
: definition.type === "number"
|
|
202
|
+
? typeof value === "number" &&
|
|
203
|
+
Number.isFinite(value) &&
|
|
204
|
+
(!definition.integer || Number.isSafeInteger(value))
|
|
205
|
+
: typeof value === definition.type;
|
|
206
|
+
if (!matches) {
|
|
207
|
+
const expected = definition.integer ? "safe integer" : definition.type;
|
|
208
|
+
throw new TypeError(`${label} value must be a ${expected}: ${context}`);
|
|
209
|
+
}
|
|
210
|
+
if (definition.enumValues !== undefined &&
|
|
211
|
+
typeof value === "string" &&
|
|
212
|
+
!definition.enumValues.includes(value)) {
|
|
213
|
+
throw new TypeError(`${label} value must be one of: ${definition.enumValues.join(", ")} (${context})`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
115
216
|
export function table(name, columns, options = {}) {
|
|
217
|
+
validateSchemaName(name, "Table");
|
|
116
218
|
const entries = Object.entries(columns);
|
|
117
219
|
if (entries.length === 0)
|
|
118
220
|
throw new TypeError(`Table ${name} needs at least one column`);
|
|
@@ -121,26 +223,48 @@ export function table(name, columns, options = {}) {
|
|
|
121
223
|
throw new TypeError(`Table ${name} may name at most one unique column`);
|
|
122
224
|
}
|
|
123
225
|
const uniqueEntry = uniqueColumns[0];
|
|
226
|
+
const primaryKey = options.primaryKey ?? [];
|
|
227
|
+
if (uniqueEntry !== undefined && primaryKey.length > 0) {
|
|
228
|
+
throw new TypeError(`Table ${name} cannot declare both .unique() and a table primary key`);
|
|
229
|
+
}
|
|
230
|
+
if (primaryKey.length === 0 && options.primaryKey !== undefined) {
|
|
231
|
+
throw new TypeError(`Table ${name} primary key needs at least one column`);
|
|
232
|
+
}
|
|
233
|
+
if (new Set(primaryKey).size !== primaryKey.length) {
|
|
234
|
+
throw new TypeError(`Table ${name} primary key columns must be distinct`);
|
|
235
|
+
}
|
|
236
|
+
for (const columnName of primaryKey) {
|
|
237
|
+
const definition = columns[columnName];
|
|
238
|
+
if (definition === undefined) {
|
|
239
|
+
throw new TypeError(`PRIMARY KEY column not found: ${name}.${columnName}`);
|
|
240
|
+
}
|
|
241
|
+
if (definition.isNullable) {
|
|
242
|
+
throw new TypeError(`PRIMARY KEY cannot be nullable: ${name}.${columnName}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
124
245
|
if (uniqueEntry?.[1].isNullable === true) {
|
|
125
246
|
throw new TypeError(`Table ${name} unique column must not be nullable: ${uniqueEntry[0]}`);
|
|
126
247
|
}
|
|
127
248
|
for (const [columnName, definition] of entries) {
|
|
128
|
-
|
|
129
|
-
|
|
249
|
+
validateSchemaName(columnName, "Column");
|
|
250
|
+
if (definition.integer && definition.type !== "number") {
|
|
251
|
+
throw new TypeError(`Integer domain requires a number column: ${name}.${columnName}`);
|
|
252
|
+
}
|
|
253
|
+
if (definition.sqlDomain !== undefined) {
|
|
254
|
+
if (definition.type !== "string") {
|
|
255
|
+
throw new TypeError(`SQL domains require string storage: ${name}.${columnName}`);
|
|
256
|
+
}
|
|
257
|
+
validateSqlDomain(definition.sqlDomain, `${name}.${columnName}`);
|
|
258
|
+
}
|
|
259
|
+
if (definition.integer && definition.sqlDomain !== undefined) {
|
|
260
|
+
throw new TypeError(`A column cannot be both integer and a SQL domain: ${name}.${columnName}`);
|
|
261
|
+
}
|
|
262
|
+
if (definition.enumValues !== undefined && definition.sqlDomain !== undefined) {
|
|
263
|
+
throw new TypeError(`A column cannot have both enum restrictions and a SQL domain: ${name}.${columnName}`);
|
|
130
264
|
}
|
|
131
265
|
const backfill = definition.backfillValue;
|
|
132
266
|
if (backfill !== undefined && typeof backfill !== "function") {
|
|
133
|
-
|
|
134
|
-
? backfill instanceof Date
|
|
135
|
-
: typeof backfill === definition.type;
|
|
136
|
-
if (!matches) {
|
|
137
|
-
throw new TypeError(`Backfill value must be a ${definition.type}: ${name}.${columnName}`);
|
|
138
|
-
}
|
|
139
|
-
if (definition.enumValues !== undefined &&
|
|
140
|
-
typeof backfill === "string" &&
|
|
141
|
-
!definition.enumValues.includes(backfill)) {
|
|
142
|
-
throw new TypeError(`Backfill value must be one of: ${definition.enumValues.join(", ")} (${name}.${columnName})`);
|
|
143
|
-
}
|
|
267
|
+
validateDeclaredColumnValue(definition, backfill, `${name}.${columnName}`, "Backfill");
|
|
144
268
|
}
|
|
145
269
|
if (backfill !== undefined && definition.isNullable) {
|
|
146
270
|
throw new TypeError(`A nullable column needs no backfill: ${name}.${columnName}. Rows without it already read NULL.`);
|
|
@@ -154,16 +278,57 @@ export function table(name, columns, options = {}) {
|
|
|
154
278
|
validateColumnDefault({
|
|
155
279
|
name: columnName,
|
|
156
280
|
type: definition.type,
|
|
281
|
+
...(definition.integer ? { integer: true } : {}),
|
|
282
|
+
...(definition.sqlDomain === undefined ? {} : { sqlDomain: definition.sqlDomain }),
|
|
157
283
|
nullable: definition.isNullable,
|
|
158
|
-
isUniqueKey: definition.isUnique,
|
|
284
|
+
isUniqueKey: definition.isUnique || (primaryKey.length === 1 && primaryKey[0] === columnName),
|
|
159
285
|
...(definition.enumValues === undefined ? {} : { enumValues: definition.enumValues }),
|
|
160
286
|
}, spec);
|
|
287
|
+
if (spec.kind === "expression") {
|
|
288
|
+
validateDefaultExpression(spec.sql, {
|
|
289
|
+
name: `${name}.${columnName}`,
|
|
290
|
+
type: definition.type,
|
|
291
|
+
...(definition.sqlDomain === undefined ? {} : { sqlDomain: definition.sqlDomain }),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
if (spec.kind === "literal" && definition.sqlDomain !== undefined) {
|
|
295
|
+
normalizeSqlDomainValue(definition.sqlDomain, spec.value);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const foreignKeys = options.foreignKeys ?? [];
|
|
299
|
+
const foreignKeyNames = new Set();
|
|
300
|
+
for (const key of foreignKeys) {
|
|
301
|
+
validateSchemaName(key.name, "FOREIGN KEY");
|
|
302
|
+
if (foreignKeyNames.has(key.name)) {
|
|
303
|
+
throw new TypeError(`Duplicate FOREIGN KEY in table ${name}: ${key.name}`);
|
|
304
|
+
}
|
|
305
|
+
foreignKeyNames.add(key.name);
|
|
306
|
+
if (key.columns.length === 0 || new Set(key.columns).size !== key.columns.length) {
|
|
307
|
+
throw new TypeError(`FOREIGN KEY ${key.name} needs distinct child columns`);
|
|
308
|
+
}
|
|
309
|
+
if (key.references.columns.length === 0 ||
|
|
310
|
+
new Set(key.references.columns).size !== key.references.columns.length) {
|
|
311
|
+
throw new TypeError(`FOREIGN KEY ${key.name} needs distinct parent columns`);
|
|
312
|
+
}
|
|
313
|
+
if (key.columns.length !== key.references.columns.length) {
|
|
314
|
+
throw new TypeError(`FOREIGN KEY ${key.name} has different child and parent arity`);
|
|
315
|
+
}
|
|
316
|
+
validateSchemaName(key.references.table, "Referenced table");
|
|
317
|
+
key.references.columns.forEach((columnName) => validateSchemaName(columnName, "Referenced column"));
|
|
318
|
+
for (const columnName of key.columns) {
|
|
319
|
+
const definition = columns[columnName];
|
|
320
|
+
if (definition === undefined) {
|
|
321
|
+
throw new TypeError(`FOREIGN KEY ${key.name} names an unknown column: ${name}.${columnName}`);
|
|
322
|
+
}
|
|
323
|
+
if ((key.onDelete ?? "restrict") === "set null" && !definition.isNullable) {
|
|
324
|
+
throw new TypeError(`FOREIGN KEY ${key.name} cannot SET NULL a NOT NULL column`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
161
327
|
}
|
|
162
328
|
const checks = options.checks ?? [];
|
|
163
329
|
const checkNames = new Set();
|
|
164
330
|
for (const check of checks) {
|
|
165
|
-
|
|
166
|
-
throw new TypeError(`Table ${name} has an unnamed CHECK`);
|
|
331
|
+
validateSchemaName(check.name, "CHECK");
|
|
167
332
|
if (checkNames.has(check.name)) {
|
|
168
333
|
throw new TypeError(`Duplicate CHECK in table ${name}: ${check.name}`);
|
|
169
334
|
}
|
|
@@ -176,6 +341,8 @@ export function table(name, columns, options = {}) {
|
|
|
176
341
|
kind: "table",
|
|
177
342
|
name,
|
|
178
343
|
columns,
|
|
344
|
+
primaryKey,
|
|
345
|
+
foreignKeys,
|
|
179
346
|
checks,
|
|
180
347
|
"~standard": {
|
|
181
348
|
version: 1,
|
|
@@ -188,27 +355,31 @@ export function table(name, columns, options = {}) {
|
|
|
188
355
|
const issues = [];
|
|
189
356
|
for (const [columnName, definition] of entries) {
|
|
190
357
|
const columnValue = row[columnName];
|
|
191
|
-
if (columnValue === undefined
|
|
192
|
-
|
|
193
|
-
// function defaults) fills it at insert time.
|
|
194
|
-
if (!definition.isNullable &&
|
|
195
|
-
definition.defaultSpec === undefined &&
|
|
196
|
-
definition.defaultFn === undefined) {
|
|
358
|
+
if (columnValue === undefined) {
|
|
359
|
+
if (!definition.isNullable && definition.defaultSpec === undefined) {
|
|
197
360
|
issues.push({ message: `Missing non-nullable column`, path: [columnName] });
|
|
198
361
|
}
|
|
199
362
|
continue;
|
|
200
363
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
364
|
+
if (columnValue === null) {
|
|
365
|
+
if (!definition.isNullable) {
|
|
366
|
+
issues.push({ message: `Expected non-null value`, path: [columnName] });
|
|
367
|
+
}
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
validateDeclaredColumnValue(definition, columnValue, `${name}.${columnName}`, "Value");
|
|
206
372
|
}
|
|
207
|
-
|
|
208
|
-
typeof columnValue === "string"
|
|
209
|
-
|
|
373
|
+
catch (error) {
|
|
374
|
+
const message = definition.enumValues !== undefined && typeof columnValue === "string"
|
|
375
|
+
? `Expected one of: ${definition.enumValues.join(", ")}`
|
|
376
|
+
: definition.sqlDomain === undefined
|
|
377
|
+
? `Expected ${definition.integer ? "safe integer" : definition.type}`
|
|
378
|
+
: error instanceof Error
|
|
379
|
+
? error.message
|
|
380
|
+
: `Invalid value`;
|
|
210
381
|
issues.push({
|
|
211
|
-
message
|
|
382
|
+
message,
|
|
212
383
|
path: [columnName],
|
|
213
384
|
});
|
|
214
385
|
}
|
|
@@ -241,6 +412,19 @@ export function declaredForeignKeys(definition) {
|
|
|
241
412
|
onDelete: reference.onDelete,
|
|
242
413
|
});
|
|
243
414
|
}
|
|
415
|
+
for (const key of definition.foreignKeys) {
|
|
416
|
+
const childColumns = [...key.columns];
|
|
417
|
+
const parentColumns = [...key.references.columns];
|
|
418
|
+
keys.push({
|
|
419
|
+
name: key.name,
|
|
420
|
+
column: childColumns[0] ?? "",
|
|
421
|
+
...(childColumns.length === 1 ? {} : { columns: childColumns }),
|
|
422
|
+
parentTable: key.references.table,
|
|
423
|
+
parentColumn: parentColumns[0] ?? "",
|
|
424
|
+
...(parentColumns.length === 1 ? {} : { parentColumns }),
|
|
425
|
+
onDelete: key.onDelete ?? "restrict",
|
|
426
|
+
});
|
|
427
|
+
}
|
|
244
428
|
return keys;
|
|
245
429
|
}
|
|
246
430
|
/**
|
|
@@ -255,16 +439,18 @@ export function declaredForeignKeys(definition) {
|
|
|
255
439
|
* ```
|
|
256
440
|
*/
|
|
257
441
|
export function view(name, definition) {
|
|
442
|
+
validateSchemaName(name, "View");
|
|
258
443
|
const entries = Object.entries(definition.columns);
|
|
259
444
|
if (entries.length === 0)
|
|
260
445
|
throw new TypeError(`View ${name} needs at least one column`);
|
|
261
446
|
if (definition.sql.trim().length === 0)
|
|
262
447
|
throw new TypeError(`View ${name} has no query`);
|
|
263
448
|
for (const [columnName, columnDefinition] of entries) {
|
|
449
|
+
validateSchemaName(columnName, "Column");
|
|
264
450
|
if (columnDefinition.isUnique) {
|
|
265
451
|
throw new TypeError(`A view column cannot be a unique key: ${name}.${columnName}`);
|
|
266
452
|
}
|
|
267
|
-
if (columnDefinition.defaultSpec !== undefined
|
|
453
|
+
if (columnDefinition.defaultSpec !== undefined) {
|
|
268
454
|
throw new TypeError(`A view column cannot have a default: ${name}.${columnName}`);
|
|
269
455
|
}
|
|
270
456
|
}
|
|
@@ -286,14 +472,38 @@ export function schema(tables, options = {}) {
|
|
|
286
472
|
names.add(definition.name);
|
|
287
473
|
}
|
|
288
474
|
for (const definition of tables) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
475
|
+
const declaredNames = new Set();
|
|
476
|
+
for (const key of declaredForeignKeys(definition)) {
|
|
477
|
+
if (declaredNames.has(key.name)) {
|
|
478
|
+
throw new TypeError(`Duplicate FOREIGN KEY in table ${definition.name}: ${key.name}`);
|
|
479
|
+
}
|
|
480
|
+
declaredNames.add(key.name);
|
|
481
|
+
const childNames = key.columns ?? [key.column];
|
|
482
|
+
const parentNames = key.parentColumns ?? [key.parentColumn];
|
|
483
|
+
const target = tables.find(({ name }) => name === key.parentTable);
|
|
484
|
+
if (target === undefined ||
|
|
485
|
+
childNames.some((columnName) => !(columnName in definition.columns)) ||
|
|
486
|
+
parentNames.some((columnName) => !(columnName in target.columns))) {
|
|
487
|
+
throw new TypeError(`Relation target does not exist: ${definition.name}.${childNames.join(",")} -> ${key.parentTable}.${parentNames.join(",")}`);
|
|
488
|
+
}
|
|
489
|
+
const targetPrimary = target.primaryKey.length > 0
|
|
490
|
+
? [...target.primaryKey]
|
|
491
|
+
: Object.entries(target.columns).flatMap(([name, columnDefinition]) => columnDefinition.isUnique ? [name] : []);
|
|
492
|
+
if (parentNames.length !== targetPrimary.length ||
|
|
493
|
+
parentNames.some((columnName, index) => columnName !== targetPrimary[index])) {
|
|
494
|
+
throw new TypeError(`Relation target must be the unique key: ${definition.name}.${childNames.join(",")} -> ${key.parentTable}(${targetPrimary.join(", ")})`);
|
|
296
495
|
}
|
|
496
|
+
childNames.forEach((childName, index) => {
|
|
497
|
+
const child = definition.columns[childName];
|
|
498
|
+
const parentName = parentNames[index];
|
|
499
|
+
const parent = parentName === undefined ? undefined : target.columns[parentName];
|
|
500
|
+
if (child === undefined ||
|
|
501
|
+
child.type !== parent?.type ||
|
|
502
|
+
child.integer !== parent.integer ||
|
|
503
|
+
JSON.stringify(child.sqlDomain ?? null) !== JSON.stringify(parent.sqlDomain ?? null)) {
|
|
504
|
+
throw new TypeError(`Relation types must match: ${definition.name}.${childName} and ${key.parentTable}.${String(parentName)}`);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
297
507
|
}
|
|
298
508
|
}
|
|
299
509
|
return { kind: "schema", tables, views };
|
|
@@ -367,16 +577,22 @@ export function isDestructiveStep(step) {
|
|
|
367
577
|
* the catalog still points at it. Each of these would leave a constraint or key referring to a
|
|
368
578
|
* column no longer there, so the drop is refused rather than silently invalidating them.
|
|
369
579
|
*/
|
|
370
|
-
function assertColumnDroppable(record, column) {
|
|
580
|
+
export function assertColumnDroppable(record, column) {
|
|
371
581
|
const where = `${record.name}.${column.name}`;
|
|
372
|
-
if (record.uniqueKeyColumnId === column.id
|
|
582
|
+
if (record.uniqueKeyColumnId === column.id ||
|
|
583
|
+
(record.primaryKeyColumnIds ?? []).includes(column.id)) {
|
|
373
584
|
throw new TypeError(`The unique key cannot be dropped: ${where}. Unique-key changes need the table recreated.`);
|
|
374
585
|
}
|
|
375
586
|
for (const key of record.foreignKeys) {
|
|
376
|
-
if (key.
|
|
587
|
+
if ((key.columns ?? [key.column]).includes(column.name)) {
|
|
377
588
|
throw new TypeError(`FOREIGN KEY ${key.name} still uses this column: ${where}`);
|
|
378
589
|
}
|
|
379
590
|
}
|
|
591
|
+
for (const index of record.indexes ?? []) {
|
|
592
|
+
if (index.columns.some(({ name }) => name === column.name)) {
|
|
593
|
+
throw new TypeError(`Index ${index.name} still uses this column: ${where}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
380
596
|
for (const check of record.checks) {
|
|
381
597
|
let referenced;
|
|
382
598
|
try {
|
|
@@ -391,6 +607,25 @@ function assertColumnDroppable(record, column) {
|
|
|
391
607
|
}
|
|
392
608
|
}
|
|
393
609
|
}
|
|
610
|
+
function assertColumnRenamable(catalog, record, column) {
|
|
611
|
+
const where = `${record.name}.${column.name}`;
|
|
612
|
+
for (const owner of catalog.tables) {
|
|
613
|
+
for (const key of owner.foreignKeys) {
|
|
614
|
+
const usesChild = owner.name === record.name && (key.columns ?? [key.column]).includes(column.name);
|
|
615
|
+
const usesParent = key.parentTable === record.name &&
|
|
616
|
+
(key.parentColumns ?? [key.parentColumn]).includes(column.name);
|
|
617
|
+
if (usesChild || usesParent) {
|
|
618
|
+
throw new TypeError(`FOREIGN KEY ${key.name} prevents renaming ${where}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
for (const check of record.checks) {
|
|
623
|
+
const referenced = expressionColumns(compileCheckExpression(check.sql, check.name));
|
|
624
|
+
if (referenced.includes(column.name)) {
|
|
625
|
+
throw new TypeError(`CHECK ${check.name} prevents renaming ${where}`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
394
629
|
/**
|
|
395
630
|
* Freezes a column's backfill. A generator runs exactly once — here, while the migration is being
|
|
396
631
|
* planned — so the catalog stores a value rather than a function and no two readers can disagree.
|
|
@@ -399,7 +634,27 @@ function resolveBackfill(definition) {
|
|
|
399
634
|
const declared = definition.backfillValue;
|
|
400
635
|
if (declared === undefined)
|
|
401
636
|
return undefined;
|
|
402
|
-
|
|
637
|
+
const value = typeof declared === "function" ? declared() : declared;
|
|
638
|
+
validateDeclaredColumnValue(definition, value, "added column", "Backfill");
|
|
639
|
+
return value;
|
|
640
|
+
}
|
|
641
|
+
function storedBackfill(definition, value) {
|
|
642
|
+
if (definition.sqlDomain === undefined)
|
|
643
|
+
return value;
|
|
644
|
+
const normalized = normalizeSqlDomainValue(definition.sqlDomain, value);
|
|
645
|
+
if (normalized === null)
|
|
646
|
+
throw new TypeError("A backfill cannot be NULL");
|
|
647
|
+
return normalized;
|
|
648
|
+
}
|
|
649
|
+
function backfillsEqual(left, right) {
|
|
650
|
+
const externalLeft = externalSqlDomainValue(left);
|
|
651
|
+
const externalRight = externalSqlDomainValue(right);
|
|
652
|
+
if (externalLeft instanceof Date || externalRight instanceof Date) {
|
|
653
|
+
return (externalLeft instanceof Date &&
|
|
654
|
+
externalRight instanceof Date &&
|
|
655
|
+
externalLeft.getTime() === externalRight.getTime());
|
|
656
|
+
}
|
|
657
|
+
return externalLeft === externalRight;
|
|
403
658
|
}
|
|
404
659
|
/**
|
|
405
660
|
* Constraints on an existing table cannot change through a metadata-only step. Attaching a
|
|
@@ -409,7 +664,8 @@ function resolveBackfill(definition) {
|
|
|
409
664
|
* discipline every other unprovable change gets: an explicit error naming the fix.
|
|
410
665
|
*/
|
|
411
666
|
function assertConstraintsUnchanged(record, definition) {
|
|
412
|
-
const describeKey = (key) => `${key.
|
|
667
|
+
const describeKey = (key) => `${(key.columns ?? [key.column]).join(",")} -> ${key.parentTable}.` +
|
|
668
|
+
`${(key.parentColumns ?? [key.parentColumn]).join(",")} ON DELETE ${key.onDelete}`;
|
|
413
669
|
const existingKeys = new Map(record.foreignKeys.map((key) => [key.name, key]));
|
|
414
670
|
const declaredKeys = new Map(declaredForeignKeys(definition).map((key) => [key.name, key]));
|
|
415
671
|
for (const [name, declared] of declaredKeys) {
|
|
@@ -466,9 +722,13 @@ export function planMigration(catalog, definition, options = {}) {
|
|
|
466
722
|
if (existing === undefined && from !== undefined) {
|
|
467
723
|
const source = recordColumnsByName.get(from);
|
|
468
724
|
if (source !== undefined) {
|
|
725
|
+
if (renameSources.has(from)) {
|
|
726
|
+
throw new TypeError(`Rename source is used more than once: ${tableDefinition.name}.${from}`);
|
|
727
|
+
}
|
|
469
728
|
if (definedNames.has(from)) {
|
|
470
729
|
throw new TypeError(`Rename source is still defined: ${tableDefinition.name}.${from}`);
|
|
471
730
|
}
|
|
731
|
+
assertColumnRenamable(catalog, record, source);
|
|
472
732
|
steps.push({
|
|
473
733
|
kind: "rename-column",
|
|
474
734
|
tableName: tableDefinition.name,
|
|
@@ -484,7 +744,7 @@ export function planMigration(catalog, definition, options = {}) {
|
|
|
484
744
|
if (!columnDefinition.isNullable && backfill === undefined) {
|
|
485
745
|
throw new TypeError(`Added columns must be nullable, or carry a backfill: ${tableDefinition.name}.${columnName}`);
|
|
486
746
|
}
|
|
487
|
-
if (columnDefinition.isUnique) {
|
|
747
|
+
if (columnDefinition.isUnique || tableDefinition.primaryKey.includes(columnName)) {
|
|
488
748
|
throw new TypeError(`Unique keys cannot be added after creation: ${tableDefinition.name}.${columnName}`);
|
|
489
749
|
}
|
|
490
750
|
steps.push({
|
|
@@ -499,6 +759,23 @@ export function planMigration(catalog, definition, options = {}) {
|
|
|
499
759
|
if (existing.type !== columnDefinition.type) {
|
|
500
760
|
throw new TypeError(`Column types cannot change: ${tableDefinition.name}.${columnName} is ${existing.type}, schema says ${columnDefinition.type}`);
|
|
501
761
|
}
|
|
762
|
+
if ((existing.integer === true) !== columnDefinition.integer ||
|
|
763
|
+
JSON.stringify(existing.sqlDomain ?? null) !==
|
|
764
|
+
JSON.stringify(columnDefinition.sqlDomain ?? null)) {
|
|
765
|
+
throw new TypeError(`Column domains cannot change: ${tableDefinition.name}.${columnName}`);
|
|
766
|
+
}
|
|
767
|
+
if (existing.backfill === undefined && columnDefinition.backfillValue !== undefined) {
|
|
768
|
+
throw new TypeError(`Backfills cannot be added after a column exists: ${tableDefinition.name}.${columnName}`);
|
|
769
|
+
}
|
|
770
|
+
if (existing.backfill !== undefined && columnDefinition.backfillValue === undefined) {
|
|
771
|
+
throw new TypeError(`Backfills cannot be removed: ${tableDefinition.name}.${columnName}`);
|
|
772
|
+
}
|
|
773
|
+
if (existing.backfill !== undefined &&
|
|
774
|
+
columnDefinition.backfillValue !== undefined &&
|
|
775
|
+
typeof columnDefinition.backfillValue !== "function" &&
|
|
776
|
+
!backfillsEqual(existing.backfill, columnDefinition.backfillValue)) {
|
|
777
|
+
throw new TypeError(`Backfills cannot change: ${tableDefinition.name}.${columnName}`);
|
|
778
|
+
}
|
|
502
779
|
const existingEnum = existing.enumValues;
|
|
503
780
|
const definedEnum = columnDefinition.enumValues;
|
|
504
781
|
if (definedEnum !== undefined && existingEnum === undefined) {
|
|
@@ -528,8 +805,10 @@ export function planMigration(catalog, definition, options = {}) {
|
|
|
528
805
|
}
|
|
529
806
|
}
|
|
530
807
|
}
|
|
531
|
-
const existingIsKey = record.uniqueKeyColumnId === existing.id
|
|
532
|
-
|
|
808
|
+
const existingIsKey = record.uniqueKeyColumnId === existing.id ||
|
|
809
|
+
(record.primaryKeyColumnIds ?? []).includes(existing.id);
|
|
810
|
+
const definedIsKey = columnDefinition.isUnique || tableDefinition.primaryKey.includes(columnName);
|
|
811
|
+
if (existingIsKey !== definedIsKey) {
|
|
533
812
|
throw new TypeError(`Unique keys cannot change: ${tableDefinition.name}.${columnName}`);
|
|
534
813
|
}
|
|
535
814
|
if (existing.nullable && !columnDefinition.isNullable) {
|
|
@@ -578,6 +857,20 @@ export function planMigration(catalog, definition, options = {}) {
|
|
|
578
857
|
columnName: columnRecord.name,
|
|
579
858
|
});
|
|
580
859
|
}
|
|
860
|
+
const existingKeyIds = record.primaryKeyColumnIds ??
|
|
861
|
+
(record.uniqueKeyColumnId === undefined ? [] : [record.uniqueKeyColumnId]);
|
|
862
|
+
const desiredKeyNames = tableDefinition.primaryKey.length > 0
|
|
863
|
+
? tableDefinition.primaryKey
|
|
864
|
+
: Object.entries(tableDefinition.columns).flatMap(([name, definition]) => definition.isUnique ? [name] : []);
|
|
865
|
+
const desiredKeyIds = desiredKeyNames.map((name) => {
|
|
866
|
+
const definition = tableDefinition.columns[name];
|
|
867
|
+
const currentName = definition?.renamedFromName ?? name;
|
|
868
|
+
return recordColumnsByName.get(name)?.id ?? recordColumnsByName.get(currentName)?.id;
|
|
869
|
+
});
|
|
870
|
+
if (desiredKeyIds.length !== existingKeyIds.length ||
|
|
871
|
+
desiredKeyIds.some((id, index) => id !== existingKeyIds[index])) {
|
|
872
|
+
throw new TypeError(`Primary key order cannot change: ${tableDefinition.name}`);
|
|
873
|
+
}
|
|
581
874
|
assertConstraintsUnchanged(record, tableDefinition);
|
|
582
875
|
}
|
|
583
876
|
if (options.schemaOwnsDatabase === true) {
|
|
@@ -591,32 +884,164 @@ export function planMigration(catalog, definition, options = {}) {
|
|
|
591
884
|
}
|
|
592
885
|
}
|
|
593
886
|
planViewSteps(catalog, definition, steps);
|
|
594
|
-
|
|
887
|
+
// A managed view can depend on a managed table the same authoritative schema removes. Drop
|
|
888
|
+
// dependents first; the SQL engine correctly refuses the reverse order. Replacements stay
|
|
889
|
+
// after table-creation steps so a newly declared body can resolve its sources.
|
|
890
|
+
const droppedViews = steps.filter((step) => step.kind === "drop-view");
|
|
891
|
+
const droppedTables = steps.filter((step) => step.kind === "drop-table");
|
|
892
|
+
return {
|
|
893
|
+
steps: [
|
|
894
|
+
...steps.filter((step) => step.kind !== "drop-view" && step.kind !== "drop-table"),
|
|
895
|
+
...droppedViews,
|
|
896
|
+
...droppedTables,
|
|
897
|
+
],
|
|
898
|
+
};
|
|
595
899
|
}
|
|
596
900
|
/**
|
|
597
|
-
* A thin, fully typed handle
|
|
598
|
-
*
|
|
901
|
+
* A thin, fully typed SQL handle: inserts require every non-nullable column, updates exclude the
|
|
902
|
+
* unique key, and reads return the complete inferred row shape. Every operation is rendered as
|
|
903
|
+
* parameterized SQL and goes through the parser/planner/executor; this helper has no private
|
|
904
|
+
* batch or table-read semantics that can drift from SQL.
|
|
599
905
|
*/
|
|
600
906
|
export function typedTable(database, definition) {
|
|
601
907
|
const columnNames = Object.keys(definition.columns);
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
908
|
+
const quote = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
909
|
+
const scalarUniqueKey = Object.entries(definition.columns).find(([, columnDefinition]) => columnDefinition.isUnique)?.[0];
|
|
910
|
+
const keyColumns = definition.primaryKey.length > 0
|
|
911
|
+
? [...definition.primaryKey]
|
|
912
|
+
: scalarUniqueKey === undefined
|
|
913
|
+
? []
|
|
914
|
+
: [scalarUniqueKey];
|
|
915
|
+
const normalizedRows = (rows) => {
|
|
916
|
+
if (rows.length === 0)
|
|
917
|
+
throw new TypeError("A batch needs at least one row");
|
|
918
|
+
// Keep extra runtime keys in the SQL column list. TypeScript normally excludes them, but a
|
|
919
|
+
// cast or JavaScript caller must still get the engine's "column does not exist" error rather
|
|
920
|
+
// than having data silently discarded by this facade.
|
|
921
|
+
const names = new Set(columnNames);
|
|
922
|
+
for (const row of rows)
|
|
923
|
+
for (const name of Object.keys(row))
|
|
924
|
+
names.add(name);
|
|
925
|
+
const ordered = [...names];
|
|
926
|
+
return { names: ordered, rows };
|
|
927
|
+
};
|
|
928
|
+
const insert = (rows, replace) => {
|
|
929
|
+
const normalized = normalizedRows(rows);
|
|
930
|
+
const params = [];
|
|
931
|
+
const values = normalized.rows
|
|
932
|
+
.map((row) => {
|
|
933
|
+
const placeholders = normalized.names.map((name) => {
|
|
934
|
+
const value = row[name];
|
|
935
|
+
if (!Object.hasOwn(row, name) || value === undefined)
|
|
936
|
+
return "DEFAULT";
|
|
937
|
+
params.push(value);
|
|
938
|
+
return `$${String(params.length)}`;
|
|
939
|
+
});
|
|
940
|
+
return `(${placeholders.join(", ")})`;
|
|
941
|
+
})
|
|
942
|
+
.join(", ");
|
|
943
|
+
let sql = `INSERT INTO ${quote(definition.name)} (${normalized.names.map(quote).join(", ")}) VALUES ${values}`;
|
|
944
|
+
if (replace) {
|
|
945
|
+
if (keyColumns.length === 0) {
|
|
946
|
+
throw new TypeError(`Upsert requires a unique key: ${definition.name}`);
|
|
947
|
+
}
|
|
948
|
+
sql += ` ON CONFLICT (${keyColumns.map(quote).join(", ")}) DO REPLACE`;
|
|
949
|
+
}
|
|
950
|
+
return database.execute(sql, params);
|
|
951
|
+
};
|
|
952
|
+
const requireUniqueKey = (operation) => {
|
|
953
|
+
if (keyColumns.length === 0) {
|
|
954
|
+
throw new TypeError(`${operation} requires a table with a unique key: ${definition.name}`);
|
|
955
|
+
}
|
|
956
|
+
return keyColumns;
|
|
957
|
+
};
|
|
958
|
+
const keyParts = (key) => {
|
|
959
|
+
if (keyColumns.length === 1)
|
|
960
|
+
return [key];
|
|
961
|
+
if (typeof key !== "object" || key === null) {
|
|
962
|
+
throw new TypeError(`Composite key for ${definition.name} must be an object`);
|
|
963
|
+
}
|
|
964
|
+
return keyColumns.map((name) => key[name]);
|
|
965
|
+
};
|
|
966
|
+
const keyToken = (value) => {
|
|
967
|
+
const parts = keyParts(value);
|
|
968
|
+
return JSON.stringify(parts.map((part) => part instanceof Date ? ["date", part.getTime()] : [typeof part, String(part)]));
|
|
969
|
+
};
|
|
970
|
+
const assertKeys = (keys, operation) => {
|
|
971
|
+
if (keys.length === 0)
|
|
972
|
+
throw new TypeError(`A ${operation} batch needs at least one key`);
|
|
973
|
+
const seen = new Set();
|
|
974
|
+
for (const key of keys) {
|
|
975
|
+
if (keyParts(key).some((part) => part === undefined || part === null)) {
|
|
976
|
+
throw new TypeError(`Missing ${operation} key value for ${definition.name}`);
|
|
977
|
+
}
|
|
978
|
+
const token = keyToken(key);
|
|
979
|
+
if (seen.has(token))
|
|
980
|
+
throw new TypeError(`Duplicate key in ${operation} batch: ${String(key)}`);
|
|
981
|
+
seen.add(token);
|
|
982
|
+
}
|
|
983
|
+
};
|
|
610
984
|
return {
|
|
611
985
|
definition,
|
|
612
|
-
insert: (rows) =>
|
|
613
|
-
upsert: (rows) =>
|
|
614
|
-
update: (input) =>
|
|
615
|
-
keys
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
986
|
+
insert: (rows) => insert(rows, false),
|
|
987
|
+
upsert: (rows) => insert(rows, true),
|
|
988
|
+
update: (input) => {
|
|
989
|
+
const keys = requireUniqueKey("UPDATE");
|
|
990
|
+
assertKeys(input.keys, "update");
|
|
991
|
+
const assignments = [];
|
|
992
|
+
const params = [];
|
|
993
|
+
for (const [name, rawValues] of Object.entries(input.changes)) {
|
|
994
|
+
if (!(name in definition.columns)) {
|
|
995
|
+
// Render it anyway so SQL owns the public validation error.
|
|
996
|
+
}
|
|
997
|
+
const values = rawValues;
|
|
998
|
+
if (values.length !== input.keys.length) {
|
|
999
|
+
throw new TypeError(`Update column ${name} has ${String(values.length)} values for ${String(input.keys.length)} keys`);
|
|
1000
|
+
}
|
|
1001
|
+
const branches = [];
|
|
1002
|
+
values.forEach((value, index) => {
|
|
1003
|
+
if (value === undefined)
|
|
1004
|
+
return;
|
|
1005
|
+
const keyValue = input.keys[index];
|
|
1006
|
+
if (keyValue === undefined) {
|
|
1007
|
+
throw new TypeError(`Missing update key at position ${String(index)}`);
|
|
1008
|
+
}
|
|
1009
|
+
const conditions = keyParts(keyValue).map((part, keyIndex) => {
|
|
1010
|
+
params.push(part);
|
|
1011
|
+
return `${quote(keys[keyIndex] ?? "")} = $${String(params.length)}`;
|
|
1012
|
+
});
|
|
1013
|
+
params.push(value);
|
|
1014
|
+
branches.push(`WHEN ${conditions.join(" AND ")} THEN $${String(params.length)}`);
|
|
1015
|
+
});
|
|
1016
|
+
if (branches.length > 0) {
|
|
1017
|
+
assignments.push(`${quote(name)} = CASE ${branches.join(" ")} ELSE ${quote(name)} END`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (assignments.length === 0)
|
|
1021
|
+
throw new TypeError("An update batch needs at least one change");
|
|
1022
|
+
const predicates = input.keys.map((keyValue) => {
|
|
1023
|
+
const conditions = keyParts(keyValue).map((part, keyIndex) => {
|
|
1024
|
+
params.push(part);
|
|
1025
|
+
return `${quote(keys[keyIndex] ?? "")} = $${String(params.length)}`;
|
|
1026
|
+
});
|
|
1027
|
+
return `(${conditions.join(" AND ")})`;
|
|
1028
|
+
});
|
|
1029
|
+
return database.execute(`UPDATE ${quote(definition.name)} SET ${assignments.join(", ")} WHERE ${predicates.join(" OR ")}`, params);
|
|
1030
|
+
},
|
|
1031
|
+
delete: (input) => {
|
|
1032
|
+
const keys = requireUniqueKey("DELETE");
|
|
1033
|
+
assertKeys(input.keys, "delete");
|
|
1034
|
+
const params = [];
|
|
1035
|
+
const predicates = input.keys.map((keyValue) => {
|
|
1036
|
+
const conditions = keyParts(keyValue).map((part, keyIndex) => {
|
|
1037
|
+
params.push(part);
|
|
1038
|
+
return `${quote(keys[keyIndex] ?? "")} = $${String(params.length)}`;
|
|
1039
|
+
});
|
|
1040
|
+
return `(${conditions.join(" AND ")})`;
|
|
1041
|
+
});
|
|
1042
|
+
return database.execute(`DELETE FROM ${quote(definition.name)} WHERE ${predicates.join(" OR ")}`, params);
|
|
1043
|
+
},
|
|
1044
|
+
rows: async () => (await database.query(`SELECT ${columnNames.map(quote).join(", ")} FROM ${quote(definition.name)}`)).rows,
|
|
620
1045
|
};
|
|
621
1046
|
}
|
|
622
1047
|
/** Applies one table's alteration steps onto its current column records. */
|
|
@@ -635,10 +1060,21 @@ export function applyColumnSteps(record, steps, createId) {
|
|
|
635
1060
|
id: createId(),
|
|
636
1061
|
name: step.columnName,
|
|
637
1062
|
type: step.definition.type,
|
|
1063
|
+
...(step.definition.integer ? { integer: true } : {}),
|
|
1064
|
+
...(step.definition.sqlDomain === undefined
|
|
1065
|
+
? {}
|
|
1066
|
+
: { sqlDomain: structuredClone(step.definition.sqlDomain) }),
|
|
638
1067
|
// A backfill is what lets the column be non-nullable: every row has a value, either
|
|
639
1068
|
// written or substituted at read time.
|
|
640
1069
|
nullable: step.backfill === undefined ? true : step.definition.isNullable,
|
|
641
|
-
...(step.backfill === undefined
|
|
1070
|
+
...(step.backfill === undefined
|
|
1071
|
+
? {}
|
|
1072
|
+
: {
|
|
1073
|
+
backfill: storedBackfill(step.definition, step.backfill),
|
|
1074
|
+
}),
|
|
1075
|
+
...(step.definition.defaultSpec === undefined
|
|
1076
|
+
? {}
|
|
1077
|
+
: { defaultValue: step.definition.defaultSpec }),
|
|
642
1078
|
...(step.definition.enumValues === undefined
|
|
643
1079
|
? {}
|
|
644
1080
|
: { enumValues: [...step.definition.enumValues] }),
|
|
@@ -704,6 +1140,15 @@ function columnDefaultsEqual(left, right) {
|
|
|
704
1140
|
return left === right;
|
|
705
1141
|
if (left.kind !== right.kind)
|
|
706
1142
|
return false;
|
|
707
|
-
|
|
1143
|
+
if (left.kind === "expression" && right.kind === "expression")
|
|
1144
|
+
return left.sql === right.sql;
|
|
1145
|
+
if (left.kind !== "literal" || right.kind !== "literal")
|
|
1146
|
+
return true;
|
|
1147
|
+
if (left.value instanceof Date || right.value instanceof Date) {
|
|
1148
|
+
return (left.value instanceof Date &&
|
|
1149
|
+
right.value instanceof Date &&
|
|
1150
|
+
left.value.getTime() === right.value.getTime());
|
|
1151
|
+
}
|
|
1152
|
+
return left.value === right.value;
|
|
708
1153
|
}
|
|
709
1154
|
//# sourceMappingURL=schema.js.map
|