@impetik/xeer-mcp 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +4 -1
  2. package/dist/dev-session.d.ts +1 -1
  3. package/dist/network-policy.js +1 -1
  4. package/dist/server.d.ts +1 -1
  5. package/dist/server.js +4 -4
  6. package/dist/test-run.d.ts +1 -1
  7. package/dist/xeer-cli.d.ts +1 -1
  8. package/package.json +8 -5
  9. package/vendor/spec/actions.d.ts +1250 -0
  10. package/vendor/spec/actions.js +805 -0
  11. package/vendor/spec/admin-sql.d.ts +59 -0
  12. package/vendor/spec/admin-sql.js +147 -0
  13. package/vendor/spec/admin.d.ts +110 -0
  14. package/vendor/spec/admin.js +58 -0
  15. package/vendor/spec/canonical.d.ts +3 -0
  16. package/vendor/spec/canonical.js +36 -0
  17. package/vendor/spec/diagnostics.d.ts +49 -0
  18. package/vendor/spec/diagnostics.js +500 -0
  19. package/vendor/spec/docs.d.ts +21 -0
  20. package/vendor/spec/docs.js +57 -0
  21. package/vendor/spec/events.d.ts +8 -0
  22. package/vendor/spec/events.js +21 -0
  23. package/vendor/spec/identity-keys.d.ts +36 -0
  24. package/vendor/spec/identity-keys.js +72 -0
  25. package/vendor/spec/index.d.ts +20 -0
  26. package/vendor/spec/index.js +20 -0
  27. package/vendor/spec/local-identity.d.ts +69 -0
  28. package/vendor/spec/local-identity.js +132 -0
  29. package/vendor/spec/network-policy.d.ts +16 -0
  30. package/vendor/spec/network-policy.js +50 -0
  31. package/vendor/spec/public-assets.d.ts +153 -0
  32. package/vendor/spec/public-assets.js +166 -0
  33. package/vendor/spec/review.d.ts +82 -0
  34. package/vendor/spec/review.js +175 -0
  35. package/vendor/spec/route.d.ts +43 -0
  36. package/vendor/spec/route.js +87 -0
  37. package/vendor/spec/schema-lifecycle.d.ts +6 -0
  38. package/vendor/spec/schema-lifecycle.js +59 -0
  39. package/vendor/spec/schema-plan.d.ts +98 -0
  40. package/vendor/spec/schema-plan.js +194 -0
  41. package/vendor/spec/schema.d.ts +166 -0
  42. package/vendor/spec/schema.js +409 -0
  43. package/vendor/spec/sql-expression.d.ts +91 -0
  44. package/vendor/spec/sql-expression.js +650 -0
  45. package/vendor/spec/state-export.d.ts +143 -0
  46. package/vendor/spec/state-export.js +341 -0
  47. package/vendor/spec/storage.d.ts +61 -0
  48. package/vendor/spec/storage.js +120 -0
  49. package/vendor/spec/table-ddl.d.ts +162 -0
  50. package/vendor/spec/table-ddl.js +508 -0
  51. package/vendor/spec/types.d.ts +275 -0
  52. package/vendor/spec/types.js +11 -0
  53. package/vendor/spec/value.d.ts +22 -0
  54. package/vendor/spec/value.js +72 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Translation from a declared application schema into the SQLite DDL that backs it: one real table
3
+ * per declared table, one real column per declared field.
4
+ *
5
+ * This replaces a generic single-table document store, where every application table's rows shared
6
+ * one physical `xeer_records` table with their fields packed into a JSON column. That shape made
7
+ * four things structurally impossible, because a table-level constraint cannot be scoped to a
8
+ * subset of a shared table's rows:
9
+ *
10
+ * per-field NOT NULL, per-table CHECK (so: no enums), FOREIGN KEY (so: no cascade), real types.
11
+ *
12
+ * With one table per declared table those are ordinary DDL, and the runtime stops hand-rolling what
13
+ * the database already does. The module is dependency-free and lives in `spec` for the reason
14
+ * `storage.ts` gives: the compiler validates against these rules and the runtime applies them, and
15
+ * a rule the two disagreed on would mean an application that builds and then fails at call time.
16
+ *
17
+ * Everything the manifest can say about a column is said in DDL here, and every identifier and
18
+ * literal reaches the statement through `sql-expression.ts` rather than by concatenation — which is
19
+ * also why an author-written expression is lexed and re-emitted instead of interpolated.
20
+ *
21
+ * The generator is total: `tableDdl` throws rather than emitting a statement it cannot vouch for, so
22
+ * a definition that reaches it is either valid DDL or an error, never invalid SQL. The manifest
23
+ * schema runs it as its final check for exactly that reason.
24
+ *
25
+ * That promise once held only for what this module inspected and not for what it merely passed
26
+ * through. An author-written expression was lexed but never parsed, so `title AND`, `lower()` and
27
+ * `max(position)` each generated a clean `CREATE TABLE` that SQLite then refused — `near ")": syntax
28
+ * error`, `wrong number of arguments to function lower()`, `misuse of aggregate function max()` —
29
+ * turning a diagnostic into a failure when the schema was applied. `sql-expression.ts` now validates
30
+ * grammar and argument counts, which is what closes the gap; the tests below carry the corpus of
31
+ * expressions that used to get through.
32
+ */
33
+ import type { NormalizedDatabaseSchemaV0 } from './schema-plan.js';
34
+ import type { CollationName, TableDefinition, TableIndexDefinition } from './types.js';
35
+ /**
36
+ * Identifier prefixes an application may not name a table with.
37
+ *
38
+ * `xeer_` and `_xeer_` are the runtime's own bookkeeping tables. `sqlite_` and `_cf_` are refused by
39
+ * SQLite and by workerd respectively, so those two are reservations the platform already enforces.
40
+ * `__cf_` and `cf_` are *not* — measured in workerd, a table called `__cf_anything` is created
41
+ * happily — but the platform keeps its key-value data in a hidden `__cf_kv`, so this list reserves
42
+ * the prefix as policy rather than relying on an enforcement that does not exist.
43
+ *
44
+ * A declared table is created under its own plain name — so that `SELECT * FROM posts` is what an
45
+ * operator actually types — which is only safe if these cannot collide with it.
46
+ */
47
+ export declare const RESERVED_TABLE_PREFIXES: readonly string[];
48
+ /** SQLite identifiers are case-insensitive, so the reservation has to be too. */
49
+ export declare function reservedTableName(name: string): boolean;
50
+ /**
51
+ * The physical name of a declared index.
52
+ *
53
+ * Length-prefixed because SQLite holds indexes and tables in one namespace and a naive
54
+ * `${table}_${index}` is ambiguous: table `posts` index `by_slug` and table `posts_by` index `slug`
55
+ * would both want `posts_by_slug`. Index names are never typed by hand, so legibility is worth less
56
+ * here than a proof that two declarations can never claim one name.
57
+ */
58
+ export declare function physicalIndexName(tableName: string, indexName: string): string;
59
+ /**
60
+ * The index over `(created_at, id)` that every table carries whether or not it declares one.
61
+ *
62
+ * Every read the runtime issues ends `ORDER BY created_at, id` — it is the total order rows are
63
+ * returned in, and the one admin paging seeks into — so without this each read is a full scan and a
64
+ * sort. The single-table store had exactly this index and it was lost when rows moved into per-table
65
+ * tables; this puts it back per table.
66
+ *
67
+ * A distinct prefix from {@link physicalIndexName}, so it can never collide with a declared index
68
+ * however that index is named.
69
+ */
70
+ export declare function rowOrderIndexName(tableName: string): string;
71
+ /** The runtime-owned columns, named here so a declaration can be checked against them. */
72
+ export declare const RUNTIME_COLUMN_NAMES: readonly string[];
73
+ /**
74
+ * Cloudflare's SQLite caps a table at 100 columns, well below stock SQLite's 2000. Measured in
75
+ * workerd: the hundredth column is accepted and the hundred-and-first fails with "too many columns".
76
+ */
77
+ export declare const MAX_TABLE_COLUMNS = 100;
78
+ /**
79
+ * The most fields a table may declare: the platform's column limit less the runtime's own three.
80
+ *
81
+ * Generated columns are charged against it, virtual and stored alike — confirmed in workerd, where
82
+ * a table of 100 columns including five generated ones is accepted and 101 is not. Refusing this at
83
+ * check time turns a schema that cannot be applied into a diagnostic while it is being written.
84
+ */
85
+ export declare const MAX_TABLE_FIELDS: number;
86
+ export declare class TableDdlError extends Error {
87
+ constructor(message: string);
88
+ }
89
+ /** One index member with every option settled, so nothing downstream re-derives a default. */
90
+ export type NormalizedIndexTermV0 = {
91
+ readonly column: string;
92
+ readonly expression: null;
93
+ readonly collate: CollationName | null;
94
+ readonly desc: boolean;
95
+ } | {
96
+ readonly column: null;
97
+ readonly expression: string;
98
+ readonly collate: null;
99
+ readonly desc: boolean;
100
+ };
101
+ export interface NormalizedIndexV0 {
102
+ readonly columns: readonly NormalizedIndexTermV0[];
103
+ readonly unique: boolean;
104
+ readonly where: string | null;
105
+ }
106
+ /**
107
+ * The full form of an index, which the array shorthand is sugar for. Every consumer — the DDL below,
108
+ * the schema hash, the change plan — reads the index through here, so the two spellings of one index
109
+ * are the same index everywhere and not just where someone remembered to handle both.
110
+ */
111
+ export declare function normalizedIndex(definition: TableIndexDefinition): NormalizedIndexV0;
112
+ /** The declared columns an index reads, for callers that only need to know what it covers. */
113
+ export declare function indexColumns(definition: TableIndexDefinition): string[];
114
+ /** How one index member reads in a diagnostic: `status`, `position DESC`, `(lower("title"))`. */
115
+ export declare function indexTermLabel(term: NormalizedIndexTermV0): string;
116
+ export interface TableDdlV0 {
117
+ readonly table: string;
118
+ readonly createTable: string;
119
+ /**
120
+ * The `(created_at, id)` index every table carries. Separate from {@link createIndexes}, which
121
+ * means the indexes the *schema declared* — a caller that reports or diffs declared indexes should
122
+ * not have to filter a runtime-owned one out of them.
123
+ */
124
+ readonly rowOrderIndex: string;
125
+ readonly createIndexes: readonly string[];
126
+ }
127
+ export declare function tableDdl(name: string, table: TableDefinition): TableDdlV0;
128
+ /**
129
+ * Declared tables ordered so that a table always follows the tables it references.
130
+ *
131
+ * `CREATE TABLE` does not need this — SQLite resolves a forward `REFERENCES` at write time, so any
132
+ * order creates cleanly. `DROP TABLE` does: with foreign keys on, dropping a table runs an implicit
133
+ * `DELETE FROM`, which fires the referencing table's constraints. Dropping a parent first therefore
134
+ * either fails outright or cascades into a child nobody asked to empty, and reversing this order
135
+ * removes the problem instead of deferring it. That matters because the alternative —
136
+ * `PRAGMA defer_foreign_keys` — moves the failure to the commit at the request boundary, which in a
137
+ * Durable Object resets the whole object and discards everything the request did.
138
+ *
139
+ * Alphabetical within a tier and alphabetical at the roots, so the result is still a byte-stable
140
+ * function of the schema. A reference cycle cannot be ordered; the walk breaks it at a deterministic
141
+ * point and leaves the caller to cope, which for `CREATE` is free and for `DROP` is the one case
142
+ * ordering cannot solve.
143
+ */
144
+ /**
145
+ * A cycle in the reference graph, as the tables that form it, or `null` when there is none.
146
+ *
147
+ * A cycle is refused rather than ordered around, because there is no order that satisfies it. Two
148
+ * tables that reference each other can be *created* in either order — SQLite resolves a forward
149
+ * reference at write time — but they cannot be *dropped* in any order at all: whichever goes first
150
+ * fires the other's constraint. The only escape is `PRAGMA defer_foreign_keys`, and a violation
151
+ * still pending when the request ends resets the whole Durable Object and discards everything the
152
+ * request did. So the hazard is removed here, at check time, rather than mitigated at apply time.
153
+ *
154
+ * A self-reference is not a cycle: a tree's `parentId` orders against nothing and drops fine.
155
+ */
156
+ export declare function referenceCycle(schema: NormalizedDatabaseSchemaV0): string[] | null;
157
+ export declare function tableCreationOrder(schema: NormalizedDatabaseSchemaV0): string[];
158
+ /**
159
+ * Every table's DDL, parents first, in a deterministic order so two runs of the same schema are
160
+ * byte-identical. Reverse this order to drop them; see {@link tableCreationOrder}.
161
+ */
162
+ export declare function databaseDdl(schema: NormalizedDatabaseSchemaV0): readonly TableDdlV0[];
@@ -0,0 +1,508 @@
1
+ /**
2
+ * Translation from a declared application schema into the SQLite DDL that backs it: one real table
3
+ * per declared table, one real column per declared field.
4
+ *
5
+ * This replaces a generic single-table document store, where every application table's rows shared
6
+ * one physical `xeer_records` table with their fields packed into a JSON column. That shape made
7
+ * four things structurally impossible, because a table-level constraint cannot be scoped to a
8
+ * subset of a shared table's rows:
9
+ *
10
+ * per-field NOT NULL, per-table CHECK (so: no enums), FOREIGN KEY (so: no cascade), real types.
11
+ *
12
+ * With one table per declared table those are ordinary DDL, and the runtime stops hand-rolling what
13
+ * the database already does. The module is dependency-free and lives in `spec` for the reason
14
+ * `storage.ts` gives: the compiler validates against these rules and the runtime applies them, and
15
+ * a rule the two disagreed on would mean an application that builds and then fails at call time.
16
+ *
17
+ * Everything the manifest can say about a column is said in DDL here, and every identifier and
18
+ * literal reaches the statement through `sql-expression.ts` rather than by concatenation — which is
19
+ * also why an author-written expression is lexed and re-emitted instead of interpolated.
20
+ *
21
+ * The generator is total: `tableDdl` throws rather than emitting a statement it cannot vouch for, so
22
+ * a definition that reaches it is either valid DDL or an error, never invalid SQL. The manifest
23
+ * schema runs it as its final check for exactly that reason.
24
+ *
25
+ * That promise once held only for what this module inspected and not for what it merely passed
26
+ * through. An author-written expression was lexed but never parsed, so `title AND`, `lower()` and
27
+ * `max(position)` each generated a clean `CREATE TABLE` that SQLite then refused — `near ")": syntax
28
+ * error`, `wrong number of arguments to function lower()`, `misuse of aggregate function max()` —
29
+ * turning a diagnostic into a failure when the schema was applied. `sql-expression.ts` now validates
30
+ * grammar and argument counts, which is what closes the gap; the tests below carry the corpus of
31
+ * expressions that used to get through.
32
+ */
33
+ import { compileSqlExpression, sqlBlobLiteral, sqlIdentifier, sqlNumberLiteral, sqlStringLiteral, } from './sql-expression.js';
34
+ /**
35
+ * Identifier prefixes an application may not name a table with.
36
+ *
37
+ * `xeer_` and `_xeer_` are the runtime's own bookkeeping tables. `sqlite_` and `_cf_` are refused by
38
+ * SQLite and by workerd respectively, so those two are reservations the platform already enforces.
39
+ * `__cf_` and `cf_` are *not* — measured in workerd, a table called `__cf_anything` is created
40
+ * happily — but the platform keeps its key-value data in a hidden `__cf_kv`, so this list reserves
41
+ * the prefix as policy rather than relying on an enforcement that does not exist.
42
+ *
43
+ * A declared table is created under its own plain name — so that `SELECT * FROM posts` is what an
44
+ * operator actually types — which is only safe if these cannot collide with it.
45
+ */
46
+ export const RESERVED_TABLE_PREFIXES = Object.freeze(['xeer_', '_xeer_', 'sqlite_', '_cf_', '__cf_']);
47
+ /** SQLite identifiers are case-insensitive, so the reservation has to be too. */
48
+ export function reservedTableName(name) {
49
+ const normalized = name.toLowerCase();
50
+ return RESERVED_TABLE_PREFIXES.some((prefix) => normalized.startsWith(prefix));
51
+ }
52
+ const quote = sqlIdentifier;
53
+ /**
54
+ * The physical name of a declared index.
55
+ *
56
+ * Length-prefixed because SQLite holds indexes and tables in one namespace and a naive
57
+ * `${table}_${index}` is ambiguous: table `posts` index `by_slug` and table `posts_by` index `slug`
58
+ * would both want `posts_by_slug`. Index names are never typed by hand, so legibility is worth less
59
+ * here than a proof that two declarations can never claim one name.
60
+ */
61
+ export function physicalIndexName(tableName, indexName) {
62
+ return `xeer_index_${tableName.length}_${tableName}_${indexName.length}_${indexName}`;
63
+ }
64
+ /**
65
+ * The index over `(created_at, id)` that every table carries whether or not it declares one.
66
+ *
67
+ * Every read the runtime issues ends `ORDER BY created_at, id` — it is the total order rows are
68
+ * returned in, and the one admin paging seeks into — so without this each read is a full scan and a
69
+ * sort. The single-table store had exactly this index and it was lost when rows moved into per-table
70
+ * tables; this puts it back per table.
71
+ *
72
+ * A distinct prefix from {@link physicalIndexName}, so it can never collide with a declared index
73
+ * however that index is named.
74
+ */
75
+ export function rowOrderIndexName(tableName) {
76
+ return `xeer_rows_${tableName.length}_${tableName}`;
77
+ }
78
+ /**
79
+ * The storage class each declared type is held in.
80
+ *
81
+ * `number` is NUMERIC rather than REAL so that a whole number is stored — and dumped by a CLI — as
82
+ * `7` rather than `7.0`, while a fractional one keeps its precision. A JavaScript number is an IEEE
83
+ * double either way; NUMERIC only decides which of the two SQLite representations it lands in. When
84
+ * the schema vocabulary grows an explicit integer type — what `z.number().int()` would compile to
85
+ * if schemas ever move into TypeScript — it maps to INTEGER here and `number` keeps NUMERIC.
86
+ *
87
+ * `datetime` is ISO-8601 TEXT so lexical order is chronological order, which is what lets `ORDER BY`
88
+ * work on a timestamp with no conversion. `bytes` is a real BLOB rather than base64 in a string:
89
+ * smaller, and one encode/decode hop removed from every read and write. `ref` is TEXT because the
90
+ * `id` it points at is.
91
+ */
92
+ const STORAGE_CLASS = Object.freeze({
93
+ string: 'TEXT',
94
+ number: 'NUMERIC',
95
+ boolean: 'INTEGER',
96
+ datetime: 'TEXT',
97
+ bytes: 'BLOB',
98
+ json: 'TEXT',
99
+ ref: 'TEXT',
100
+ });
101
+ const REFERENTIAL_ACTION_SQL = Object.freeze({
102
+ noAction: 'NO ACTION',
103
+ restrict: 'RESTRICT',
104
+ cascade: 'CASCADE',
105
+ setNull: 'SET NULL',
106
+ setDefault: 'SET DEFAULT',
107
+ });
108
+ const COLLATION_SQL = Object.freeze({
109
+ binary: 'BINARY',
110
+ nocase: 'NOCASE',
111
+ rtrim: 'RTRIM',
112
+ });
113
+ /** The runtime-owned columns, named here so a declaration can be checked against them. */
114
+ export const RUNTIME_COLUMN_NAMES = Object.freeze(['id', 'created_at', 'updated_at']);
115
+ /**
116
+ * Cloudflare's SQLite caps a table at 100 columns, well below stock SQLite's 2000. Measured in
117
+ * workerd: the hundredth column is accepted and the hundred-and-first fails with "too many columns".
118
+ */
119
+ export const MAX_TABLE_COLUMNS = 100;
120
+ /**
121
+ * The most fields a table may declare: the platform's column limit less the runtime's own three.
122
+ *
123
+ * Generated columns are charged against it, virtual and stored alike — confirmed in workerd, where
124
+ * a table of 100 columns including five generated ones is accepted and 101 is not. Refusing this at
125
+ * check time turns a schema that cannot be applied into a diagnostic while it is being written.
126
+ */
127
+ export const MAX_TABLE_FIELDS = MAX_TABLE_COLUMNS - RUNTIME_COLUMN_NAMES.length;
128
+ /**
129
+ * The runtime-owned columns every table carries. They lead the column list so a `SELECT *` — in the
130
+ * CLI, in a dump, in the admin editor — reads identity first and payload second, and they are
131
+ * reserved in the manifest schema so a declared field can never collide with one.
132
+ */
133
+ const RUNTIME_COLUMNS = Object.freeze([
134
+ '"id" TEXT PRIMARY KEY NOT NULL',
135
+ '"created_at" TEXT NOT NULL',
136
+ '"updated_at" TEXT NOT NULL',
137
+ ]);
138
+ export class TableDdlError extends Error {
139
+ constructor(message) {
140
+ super(message);
141
+ this.name = 'TableDdlError';
142
+ }
143
+ }
144
+ function fail(message) {
145
+ throw new TableDdlError(message);
146
+ }
147
+ /**
148
+ * A default is written in the JavaScript type its field is written in, so the field type decides
149
+ * both which literal syntax to emit and which JavaScript type is even accepted. Getting this wrong
150
+ * is otherwise invisible until a row is inserted: SQLite does not check a DEFAULT against the
151
+ * column's own CHECK constraints at CREATE time, only when the default is actually used.
152
+ */
153
+ function defaultLiteral(name, field) {
154
+ const value = field.default;
155
+ const wrong = (expected) => fail(`Field ${JSON.stringify(name)} has a ${typeof value} default but a ${field.type} field needs ${expected}.`);
156
+ switch (field.type) {
157
+ case 'number':
158
+ return typeof value === 'number' ? sqlNumberLiteral(value) : wrong('a number');
159
+ case 'boolean':
160
+ return typeof value === 'boolean' ? (value ? '1' : '0') : wrong('a boolean');
161
+ case 'bytes':
162
+ return typeof value === 'string' ? sqlBlobLiteral(value) : wrong('a lowercase hex string');
163
+ default:
164
+ return typeof value === 'string' ? sqlStringLiteral(value) : wrong('a string');
165
+ }
166
+ }
167
+ /**
168
+ * Column-level CHECKs that hold a value to its declared contract.
169
+ *
170
+ * Nothing here special-cases an optional field: SQLite evaluates a CHECK against NULL to NULL, and a
171
+ * CHECK only fails on a definite false, so a nullable column passes without an `OR … IS NULL` arm.
172
+ */
173
+ function checks(name, field) {
174
+ const column = quote(name);
175
+ const constraints = [];
176
+ if (field.type === 'boolean')
177
+ constraints.push(`${column} IN (0, 1)`);
178
+ // Affinity is a preference, not a constraint: a NUMERIC column still accepts 'abc' and stores it
179
+ // as text. This is what actually makes the column numeric. (A STRICT table looks like it would do
180
+ // the same job for every type at once, and is twice unusable: STRICT forbids NUMERIC as a column
181
+ // type, which would force whole numbers back to `7.0`, and it does not even buy the guarantee —
182
+ // measured in workerd, a STRICT INTEGER column silently coerces the string '42' to 42. So the
183
+ // guarantee is bought per column instead.)
184
+ if (field.type === 'number')
185
+ constraints.push(`typeof(${column}) IN ('integer', 'real')`);
186
+ if (field.type === 'json')
187
+ constraints.push(`json_valid(${column})`);
188
+ // `length()` counts characters on TEXT and bytes on a BLOB, which is exactly what `maxLength`
189
+ // means for each of the two types the manifest allows it on.
190
+ if (field.maxLength !== undefined)
191
+ constraints.push(`length(${column}) <= ${sqlNumberLiteral(field.maxLength)}`);
192
+ if (field.enum !== undefined) {
193
+ if (field.type !== 'string')
194
+ fail(`Field ${JSON.stringify(name)} declares an enum, which only a string field may.`);
195
+ if (field.enum.length === 0)
196
+ fail(`Field ${JSON.stringify(name)} declares an empty enum, which no value could satisfy.`);
197
+ constraints.push(`${column} IN (${field.enum.map(sqlStringLiteral).join(', ')})`);
198
+ }
199
+ return constraints.map((constraint) => `CHECK (${constraint})`);
200
+ }
201
+ /**
202
+ * The FOREIGN KEY clause of a `ref` column.
203
+ *
204
+ * `SET NULL` on a NOT NULL column and `SET DEFAULT` on a column with no default are both accepted by
205
+ * `CREATE TABLE` and then fail every delete that triggers them — a schema that is valid to write and
206
+ * impossible to use. They are refused here instead.
207
+ *
208
+ * There is a third case of that shape which cannot be refused here, because it depends on rows
209
+ * rather than on the schema: `SET DEFAULT` whose default is not the id of any row in the referenced
210
+ * table. Measured in workerd, the delete that fires it fails with `FOREIGN KEY constraint failed`,
211
+ * because the action writes a value the constraint then rejects. Nothing static can see that, so a
212
+ * default used this way has to name a row the application guarantees exists.
213
+ *
214
+ * There is deliberately no way to declare a constraint `DEFERRABLE INITIALLY DEFERRED`, and it must
215
+ * stay that way. The clause parses, but the violation is then only detected when the transaction
216
+ * commits — and a Durable Object answers a failed commit by resetting and rolling back the entire
217
+ * object, not by rejecting the row. Measured in workerd: the orphan insert returns success and the
218
+ * object is destroyed a moment later. A constraint whose cost is the whole object is worse than one
219
+ * that fires immediately, so every FK here is immediate.
220
+ */
221
+ function referenceClause(name, field) {
222
+ if (field.table === undefined) {
223
+ fail(`Field ${JSON.stringify(name)} is a ref but names no table to reference.`);
224
+ }
225
+ if (reservedTableName(field.table)) {
226
+ fail(`Field ${JSON.stringify(name)} references ${JSON.stringify(field.table)}, which is a reserved table name.`);
227
+ }
228
+ for (const [event, action] of [['DELETE', field.onDelete], ['UPDATE', field.onUpdate]]) {
229
+ if (action === 'setNull' && field.optional !== true) {
230
+ fail(`Field ${JSON.stringify(name)} declares ON ${event} SET NULL but is not optional, so every ${event.toLowerCase()} it fires on would fail.`);
231
+ }
232
+ if (action === 'setDefault' && field.default === undefined) {
233
+ fail(`Field ${JSON.stringify(name)} declares ON ${event} SET DEFAULT but has no default, so every ${event.toLowerCase()} it fires on would fail.`);
234
+ }
235
+ }
236
+ return [
237
+ `REFERENCES ${quote(field.table)} ("id")`,
238
+ ...(field.onDelete === undefined ? [] : [`ON DELETE ${REFERENTIAL_ACTION_SQL[field.onDelete]}`]),
239
+ ...(field.onUpdate === undefined ? [] : [`ON UPDATE ${REFERENTIAL_ACTION_SQL[field.onUpdate]}`]),
240
+ ].join(' ');
241
+ }
242
+ function columnDefinition(name, field, columns) {
243
+ if (field.generated && field.default !== undefined) {
244
+ fail(`Field ${JSON.stringify(name)} is generated and also declares a default, which SQLite has no meaning for.`);
245
+ }
246
+ // A generated column may read the others but not itself; SQLite would reject the loop, later.
247
+ const generated = field.generated
248
+ ? compileSqlExpression(field.generated.expression, columns.filter((column) => column !== name))
249
+ : null;
250
+ return [
251
+ quote(name),
252
+ STORAGE_CLASS[field.type],
253
+ ...(generated ? [`GENERATED ALWAYS AS (${generated.sql}) ${field.generated.stored === true ? 'STORED' : 'VIRTUAL'}`] : []),
254
+ ...(field.optional === true ? [] : ['NOT NULL']),
255
+ ...(field.default === undefined ? [] : [`DEFAULT ${defaultLiteral(name, field)}`]),
256
+ // Before UNIQUE, so the implicit index SQLite builds for it compares under this collation.
257
+ ...(field.collate === undefined ? [] : [`COLLATE ${COLLATION_SQL[field.collate]}`]),
258
+ ...(field.unique === true ? ['UNIQUE'] : []),
259
+ ...checks(name, field),
260
+ ...(field.type === 'ref' ? [referenceClause(name, field)] : []),
261
+ ].join(' ');
262
+ }
263
+ /**
264
+ * SQLite rejects a generated column that depends on itself only once the whole CREATE TABLE is
265
+ * parsed, and reports it as a "generated column loop" naming no column. Finding the cycle here means
266
+ * the diagnostic can name the columns in it.
267
+ */
268
+ function refuseGeneratedCycles(table, readable) {
269
+ const reads = new Map();
270
+ const names = Object.keys(table.fields).sort();
271
+ for (const name of names) {
272
+ const generated = table.fields[name].generated;
273
+ if (!generated)
274
+ continue;
275
+ const { columns } = compileSqlExpression(generated.expression, readable.filter((other) => other !== name));
276
+ // A runtime column is never generated, so it can never be part of a cycle.
277
+ reads.set(name, columns.filter((column) => table.fields[column]?.generated !== undefined));
278
+ }
279
+ const settled = new Set();
280
+ const walking = new Set();
281
+ const walk = (name, path) => {
282
+ if (settled.has(name))
283
+ return;
284
+ if (walking.has(name)) {
285
+ fail(`Generated columns form a cycle: ${[...path, name].map((entry) => JSON.stringify(entry)).join(' → ')}.`);
286
+ }
287
+ walking.add(name);
288
+ for (const next of reads.get(name) ?? [])
289
+ walk(next, [...path, name]);
290
+ walking.delete(name);
291
+ settled.add(name);
292
+ };
293
+ for (const name of reads.keys())
294
+ walk(name, []);
295
+ }
296
+ function normalizedTerm(term) {
297
+ if (typeof term === 'string')
298
+ return { column: term, expression: null, collate: null, desc: false };
299
+ if ('column' in term) {
300
+ return { column: term.column, expression: null, collate: term.collate ?? null, desc: term.desc === true };
301
+ }
302
+ return { column: null, expression: term.expression, collate: null, desc: term.desc === true };
303
+ }
304
+ /**
305
+ * The full form of an index, which the array shorthand is sugar for. Every consumer — the DDL below,
306
+ * the schema hash, the change plan — reads the index through here, so the two spellings of one index
307
+ * are the same index everywhere and not just where someone remembered to handle both.
308
+ */
309
+ export function normalizedIndex(definition) {
310
+ const index = Array.isArray(definition) ? { columns: definition } : definition;
311
+ return {
312
+ columns: index.columns.map(normalizedTerm),
313
+ unique: index.unique === true,
314
+ where: index.where ?? null,
315
+ };
316
+ }
317
+ /** The declared columns an index reads, for callers that only need to know what it covers. */
318
+ export function indexColumns(definition) {
319
+ return normalizedIndex(definition).columns
320
+ .flatMap((term) => (term.column === null ? [] : [term.column]));
321
+ }
322
+ /** How one index member reads in a diagnostic: `status`, `position DESC`, `(lower("title"))`. */
323
+ export function indexTermLabel(term) {
324
+ return [
325
+ term.column ?? `(${term.expression})`,
326
+ ...(term.collate === null ? [] : [`COLLATE ${COLLATION_SQL[term.collate]}`]),
327
+ ...(term.desc ? ['DESC'] : []),
328
+ ].join(' ');
329
+ }
330
+ /**
331
+ * One index member as SQL.
332
+ *
333
+ * A word on the per-term `collate`: prefer a collation on the *column* wherever the choice exists.
334
+ * An index built `COLLATE NOCASE` over a column whose own collation is BINARY is only used when the
335
+ * query repeats `COLLATE NOCASE` in the same place — otherwise the planner considers the index
336
+ * inapplicable and falls back to a full scan, which is measurable in workerd and silent in
337
+ * production. A column-level collation has no such trap, because every comparison on the column
338
+ * inherits it. Term-level collation is here for the case a column genuinely needs two orderings.
339
+ */
340
+ function indexTerm(term, columns) {
341
+ const parts = [];
342
+ if (term.column === null) {
343
+ parts.push(`(${compileSqlExpression(term.expression, columns).sql})`);
344
+ }
345
+ else {
346
+ if (!columns.includes(term.column))
347
+ fail(`Index term ${JSON.stringify(term.column)} is not a declared field.`);
348
+ parts.push(quote(term.column));
349
+ if (term.collate !== null)
350
+ parts.push(`COLLATE ${COLLATION_SQL[term.collate]}`);
351
+ }
352
+ if (term.desc)
353
+ parts.push('DESC');
354
+ return parts.join(' ');
355
+ }
356
+ export function tableDdl(name, table) {
357
+ if (reservedTableName(name)) {
358
+ throw new Error(`Table name ${JSON.stringify(name)} is reserved: it would shadow a platform table. `
359
+ + `Reserved prefixes are ${RESERVED_TABLE_PREFIXES.join(', ')}.`);
360
+ }
361
+ const fields = Object.keys(table.fields).sort();
362
+ if (fields.length > MAX_TABLE_FIELDS) {
363
+ fail(`Table ${JSON.stringify(name)} declares ${fields.length} fields and the limit is ${MAX_TABLE_FIELDS}: `
364
+ + `the platform caps a table at ${MAX_TABLE_COLUMNS} columns and the runtime owns `
365
+ + `${RUNTIME_COLUMN_NAMES.join(', ')}. Generated columns count against it too.`);
366
+ }
367
+ // The runtime columns are emitted unconditionally, so a field of the same name would be a second
368
+ // column of that name and SQLite would answer `duplicate column name`. The manifest schema also
369
+ // reserves the camel-cased spellings an author is more likely to reach for; this catches the
370
+ // physical names, which are what actually collide.
371
+ for (const field of fields) {
372
+ if (RUNTIME_COLUMN_NAMES.includes(field)) {
373
+ fail(`Field ${JSON.stringify(field)} on ${JSON.stringify(name)} is a reserved runtime column: `
374
+ + `every table already carries ${RUNTIME_COLUMN_NAMES.join(', ')}. An expression may read them, `
375
+ + 'but a field may not redeclare one.');
376
+ }
377
+ }
378
+ /**
379
+ * What an expression or index term of this table may name: the declared fields plus the runtime
380
+ * columns, which are real columns of the table. `ORDER BY created_at DESC` is the ordinary way to
381
+ * read a table and it needs an index, which it cannot have if the column cannot be named.
382
+ */
383
+ const readable = [...RUNTIME_COLUMN_NAMES, ...fields].sort();
384
+ refuseGeneratedCycles(table, readable);
385
+ const uniqueConstraints = (table.unique ?? []).map((tuple) => {
386
+ if (tuple.length === 0)
387
+ fail(`Table ${JSON.stringify(name)} declares an empty UNIQUE tuple.`);
388
+ const seen = new Set();
389
+ for (const field of tuple) {
390
+ if (!fields.includes(field))
391
+ fail(`UNIQUE tuple on ${JSON.stringify(name)} names ${JSON.stringify(field)}, which is not a declared field.`);
392
+ if (seen.has(field))
393
+ fail(`UNIQUE tuple on ${JSON.stringify(name)} names ${JSON.stringify(field)} twice.`);
394
+ seen.add(field);
395
+ }
396
+ return `UNIQUE (${tuple.map(quote).join(', ')})`;
397
+ });
398
+ // Named, because SQLite quotes the constraint name in the error it raises — which is the whole
399
+ // difference between "CHECK constraint failed: posts" and one that says which rule was broken.
400
+ const checkConstraints = Object.keys(table.checks ?? {}).sort().map((checkName) => `CONSTRAINT ${quote(checkName)} CHECK (${compileSqlExpression(table.checks[checkName], readable).sql})`);
401
+ const columns = [
402
+ ...RUNTIME_COLUMNS,
403
+ ...fields.map((field) => columnDefinition(field, table.fields[field], readable)),
404
+ ...uniqueConstraints,
405
+ ...checkConstraints,
406
+ ];
407
+ const createTable = `CREATE TABLE IF NOT EXISTS ${quote(name)} (\n ${columns.join(',\n ')}\n)`;
408
+ const rowOrderIndex = `CREATE INDEX IF NOT EXISTS ${quote(rowOrderIndexName(name))} `
409
+ + `ON ${quote(name)} (${quote('created_at')}, ${quote('id')})`;
410
+ const createIndexes = Object.keys(table.indexes ?? {}).sort().map((indexName) => {
411
+ const index = normalizedIndex(table.indexes[indexName]);
412
+ if (index.columns.length === 0)
413
+ fail(`Index ${JSON.stringify(indexName)} on ${JSON.stringify(name)} has no terms.`);
414
+ const termList = index.columns.map((term) => indexTerm(term, readable)).join(', ');
415
+ return `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS ${quote(physicalIndexName(name, indexName))} `
416
+ + `ON ${quote(name)} (${termList})`
417
+ + (index.where === null ? '' : ` WHERE ${compileSqlExpression(index.where, readable).sql}`);
418
+ });
419
+ return { table: name, createTable, rowOrderIndex, createIndexes };
420
+ }
421
+ /**
422
+ * Declared tables ordered so that a table always follows the tables it references.
423
+ *
424
+ * `CREATE TABLE` does not need this — SQLite resolves a forward `REFERENCES` at write time, so any
425
+ * order creates cleanly. `DROP TABLE` does: with foreign keys on, dropping a table runs an implicit
426
+ * `DELETE FROM`, which fires the referencing table's constraints. Dropping a parent first therefore
427
+ * either fails outright or cascades into a child nobody asked to empty, and reversing this order
428
+ * removes the problem instead of deferring it. That matters because the alternative —
429
+ * `PRAGMA defer_foreign_keys` — moves the failure to the commit at the request boundary, which in a
430
+ * Durable Object resets the whole object and discards everything the request did.
431
+ *
432
+ * Alphabetical within a tier and alphabetical at the roots, so the result is still a byte-stable
433
+ * function of the schema. A reference cycle cannot be ordered; the walk breaks it at a deterministic
434
+ * point and leaves the caller to cope, which for `CREATE` is free and for `DROP` is the one case
435
+ * ordering cannot solve.
436
+ */
437
+ /**
438
+ * A cycle in the reference graph, as the tables that form it, or `null` when there is none.
439
+ *
440
+ * A cycle is refused rather than ordered around, because there is no order that satisfies it. Two
441
+ * tables that reference each other can be *created* in either order — SQLite resolves a forward
442
+ * reference at write time — but they cannot be *dropped* in any order at all: whichever goes first
443
+ * fires the other's constraint. The only escape is `PRAGMA defer_foreign_keys`, and a violation
444
+ * still pending when the request ends resets the whole Durable Object and discards everything the
445
+ * request did. So the hazard is removed here, at check time, rather than mitigated at apply time.
446
+ *
447
+ * A self-reference is not a cycle: a tree's `parentId` orders against nothing and drops fine.
448
+ */
449
+ export function referenceCycle(schema) {
450
+ const parentsOf = (name) => [...new Set(Object.values(schema.tables[name].fields)
451
+ .filter((field) => field.type === 'ref' && field.table !== undefined
452
+ && field.table !== name && schema.tables[field.table] !== undefined)
453
+ .map((field) => field.table))].sort();
454
+ const settled = new Set();
455
+ const walking = [];
456
+ const walk = (name) => {
457
+ const open = walking.indexOf(name);
458
+ if (open >= 0)
459
+ return [...walking.slice(open), name];
460
+ if (settled.has(name))
461
+ return null;
462
+ walking.push(name);
463
+ for (const parent of parentsOf(name)) {
464
+ const cycle = walk(parent);
465
+ if (cycle)
466
+ return cycle;
467
+ }
468
+ walking.pop();
469
+ settled.add(name);
470
+ return null;
471
+ };
472
+ for (const name of Object.keys(schema.tables).sort()) {
473
+ const cycle = walk(name);
474
+ if (cycle)
475
+ return cycle;
476
+ }
477
+ return null;
478
+ }
479
+ export function tableCreationOrder(schema) {
480
+ const names = Object.keys(schema.tables).sort();
481
+ const referenced = (name) => [...new Set(Object.values(schema.tables[name].fields)
482
+ .filter((field) => field.type === 'ref' && field.table !== undefined
483
+ && field.table !== name && schema.tables[field.table] !== undefined)
484
+ .map((field) => field.table))].sort();
485
+ const ordered = [];
486
+ const placed = new Set();
487
+ const walking = new Set();
488
+ const visit = (name) => {
489
+ if (placed.has(name) || walking.has(name))
490
+ return;
491
+ walking.add(name);
492
+ for (const parent of referenced(name))
493
+ visit(parent);
494
+ walking.delete(name);
495
+ placed.add(name);
496
+ ordered.push(name);
497
+ };
498
+ for (const name of names)
499
+ visit(name);
500
+ return ordered;
501
+ }
502
+ /**
503
+ * Every table's DDL, parents first, in a deterministic order so two runs of the same schema are
504
+ * byte-identical. Reverse this order to drop them; see {@link tableCreationOrder}.
505
+ */
506
+ export function databaseDdl(schema) {
507
+ return tableCreationOrder(schema).map((name) => tableDdl(name, schema.tables[name]));
508
+ }