@jarenjs/db 0.49.2 → 0.66.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/ARCHITECTURE.md +420 -71
  2. package/README.md +711 -79
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +309 -45
  5. package/docs/LIVE-FORMAT.md +156 -19
  6. package/docs/MIGRATION-FORMAT.md +247 -40
  7. package/docs/MODEL-FORMAT.md +968 -86
  8. package/package.json +21 -8
  9. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  10. package/schemas/jaren-migration.schema.json +73 -0
  11. package/schemas/jaren-model.draft-07.schema.json +224 -162
  12. package/schemas/jaren-model.schema.json +224 -162
  13. package/src/algebra.js +227 -9
  14. package/src/backup.js +161 -0
  15. package/src/cancellation.js +48 -0
  16. package/src/capture.js +255 -44
  17. package/src/cli.js +337 -50
  18. package/src/cursor.js +411 -0
  19. package/src/dag-job.js +154 -21
  20. package/src/ddl.js +125 -11
  21. package/src/dialect.js +267 -112
  22. package/src/dialects/expression-read.js +158 -0
  23. package/src/dialects/postgres.js +618 -0
  24. package/src/dialects/rtree-ddl.js +129 -0
  25. package/src/dialects/sqlite.js +245 -12
  26. package/src/document-files.js +311 -0
  27. package/src/document-steps.js +422 -0
  28. package/src/documents.js +335 -0
  29. package/src/driver.js +503 -69
  30. package/src/drivers/bun.js +37 -1
  31. package/src/drivers/indexeddb-snapshot.js +149 -0
  32. package/src/drivers/node-pool.js +11 -0
  33. package/src/drivers/node-worker-endpoint.js +105 -0
  34. package/src/drivers/node-worker.js +204 -0
  35. package/src/drivers/node.js +41 -7
  36. package/src/drivers/postgres.js +331 -0
  37. package/src/drivers/wasm-oo1.js +97 -0
  38. package/src/drivers/wasm-session.js +67 -0
  39. package/src/drivers/wasm.js +18 -83
  40. package/src/drivers/worker-pool.js +183 -0
  41. package/src/drivers/worker-protocol.js +79 -0
  42. package/src/drivers/worker-queue.js +60 -0
  43. package/src/emit-model.js +14 -0
  44. package/src/emit.js +349 -51
  45. package/src/entity.js +102 -59
  46. package/src/errors.js +430 -2
  47. package/src/expression.js +284 -0
  48. package/src/graph.js +64 -8
  49. package/src/index.js +48 -19
  50. package/src/introspect.js +583 -0
  51. package/src/jobs.js +870 -99
  52. package/src/json-bytes.js +58 -0
  53. package/src/live-time.js +12 -3
  54. package/src/live.js +11 -1
  55. package/src/maintenance.js +175 -0
  56. package/src/migrate.js +606 -333
  57. package/src/model.js +241 -8
  58. package/src/plan.js +1238 -160
  59. package/src/pragmas.js +314 -0
  60. package/src/profile.js +151 -3
  61. package/src/query.js +1748 -312
  62. package/src/residual.js +17 -0
  63. package/src/series.js +12 -4
  64. package/src/store.js +1672 -276
  65. package/src/tracker.js +367 -68
  66. package/src/udf.js +88 -7
  67. package/types/index.d.ts +1246 -32
  68. package/types/node-pool.d.ts +28 -0
  69. package/types/node-worker.d.ts +54 -0
  70. package/types/node.d.ts +72 -3
  71. package/types/postgres.d.ts +46 -0
  72. package/types/typed.d.ts +81 -3
  73. package/types/wasm.d.ts +21 -0
  74. package/dist/types/algebra.d.ts +0 -230
  75. package/dist/types/app.d.ts +0 -49
  76. package/dist/types/capture.d.ts +0 -85
  77. package/dist/types/cli.d.ts +0 -2
  78. package/dist/types/dag-job.d.ts +0 -40
  79. package/dist/types/ddl.d.ts +0 -229
  80. package/dist/types/derive.d.ts +0 -250
  81. package/dist/types/dialect.d.ts +0 -154
  82. package/dist/types/dialects/sqlite.d.ts +0 -9
  83. package/dist/types/driver.d.ts +0 -110
  84. package/dist/types/drivers/bun.d.ts +0 -47
  85. package/dist/types/drivers/node.d.ts +0 -37
  86. package/dist/types/drivers/wasm.d.ts +0 -65
  87. package/dist/types/emit-model.d.ts +0 -44
  88. package/dist/types/emit.d.ts +0 -75
  89. package/dist/types/entity.d.ts +0 -23
  90. package/dist/types/errors.d.ts +0 -170
  91. package/dist/types/graph.d.ts +0 -28
  92. package/dist/types/index.d.ts +0 -37
  93. package/dist/types/jobs.d.ts +0 -140
  94. package/dist/types/knn.d.ts +0 -69
  95. package/dist/types/live-time.d.ts +0 -141
  96. package/dist/types/live.d.ts +0 -64
  97. package/dist/types/migrate.d.ts +0 -170
  98. package/dist/types/model.d.ts +0 -36
  99. package/dist/types/patch-sql.d.ts +0 -37
  100. package/dist/types/plan.d.ts +0 -142
  101. package/dist/types/profile.d.ts +0 -80
  102. package/dist/types/query.d.ts +0 -112
  103. package/dist/types/residual.d.ts +0 -64
  104. package/dist/types/series.d.ts +0 -227
  105. package/dist/types/store.d.ts +0 -60
  106. package/dist/types/tracker.d.ts +0 -43
  107. package/dist/types/typed.d.ts +0 -15
  108. package/dist/types/types.d.ts +0 -26
  109. package/dist/types/udf.d.ts +0 -75
  110. package/dist/types/window.d.ts +0 -52
package/src/dialect.js CHANGED
@@ -8,11 +8,13 @@
8
8
  * concatenates SQL; that costs one indirection now, and without it a
9
9
  * second backend is a rewrite.
10
10
  *
11
- * Deliberately NOT in the dialect, because they are behavioural rather
12
- * than syntactic: whether functions can be registered per connection,
13
- * whether change capture exists and in what form, and whether tables
14
- * can be restructured in place. Those are capabilities on the
15
- * connection.
11
+ * A dialect answers a CLOSED capability set of its own
12
+ * ({@link DIALECT_CAPABILITIES}), and every one of those answers is
13
+ * SYNTACTIC what SQL this engine will accept. Deliberately NOT here,
14
+ * because they are behavioural rather than syntactic: whether functions
15
+ * can be registered on THIS connection, whether the library was built
16
+ * with the R\*Tree module, whether change capture exists and in what
17
+ * form. Those are the connection's, probed once at open.
16
18
  */
17
19
 
18
20
  /**
@@ -23,6 +25,92 @@
23
25
  * @typedef {{ name: string } | { index: number }} JsonPathSegment
24
26
  */
25
27
 
28
+ /**
29
+ * The closed set of dialect capabilities, each with the answer a
30
+ * spelling spec that says nothing gets. They are SYNTACTIC — what SQL
31
+ * this engine will accept — as opposed to the connection capabilities
32
+ * {@link module:driver}'s probe reports, which are what a particular
33
+ * library and build can DO.
34
+ *
35
+ * Closed, and defaulted to the conservative answer, for one reason: a
36
+ * misspelled capability must not read as a quiet `false` on the dialect
37
+ * that has the feature, nor as a quiet `true` on the one that does not.
38
+ * {@link createDialect} refuses a name outside this set.
39
+ */
40
+ export const DIALECT_CAPABILITIES = Object.freeze({
41
+ /** A binary JSON storage type distinct from text. */
42
+ jsonb: false,
43
+ /** Columns whose value is an expression over another column. */
44
+ generatedColumns: false,
45
+ /** A generated column may be indexed. PostgreSQL 18's VIRTUAL ones
46
+ * may not, which is why storage and indexability are two answers. */
47
+ indexableGeneratedColumns: false,
48
+ /** A column may be declared with no scalar type — SQLite's `ANY`.
49
+ * Where this is false, an indexed path the schema does not type has
50
+ * no honest column type and the planner keeps it in the residual. */
51
+ untypedColumns: false,
52
+ /** `INSERT ... RETURNING`. */
53
+ returning: false,
54
+ /** `INSERT ... ON CONFLICT (key) DO UPDATE`. */
55
+ upsert: false,
56
+ /** `SAVEPOINT` / `RELEASE` / `ROLLBACK TO`. */
57
+ savepoints: false,
58
+ /** A `SAVEPOINT` outside a transaction STARTS one. SQLite's does,
59
+ * which is why a top-level transaction there can be one checkpoint;
60
+ * PostgreSQL refuses a savepoint outside a transaction block, so a
61
+ * top-level transaction has to be `BEGIN` and `COMMIT` there. */
62
+ savepointStartsTransaction: false,
63
+ /** A transaction can take the write lock up front (`tx.beginImmediate`
64
+ * is a distinct statement rather than a synonym for `tx.begin`). */
65
+ immediateTransactions: false,
66
+ /** A `GROUP BY` / `ORDER BY` term may name a result alias, so a
67
+ * bucket ladder is written once rather than three times. */
68
+ groupByAlias: false,
69
+ /** `ALTER TABLE` can restructure in place (drop a constraint, retype
70
+ * a column) rather than only add and drop columns. */
71
+ alterTableFull: false,
72
+ /** `CREATE VIRTUAL TABLE` — the second physical realization of a
73
+ * `derive: 'bbox'` column set. */
74
+ virtualTables: false,
75
+ /** Row triggers, which the virtual-table mapping is kept in sync by. */
76
+ triggers: false,
77
+ /** A closed configuration vocabulary applied per connection — the
78
+ * `pragma` group. Where this is false the store applies none and the
79
+ * effective record is empty. */
80
+ pragmas: false,
81
+ /** The engine stores each schema object's CREATE text verbatim, so a
82
+ * declared-text comparison is available to the drift check. Where it
83
+ * is false the drift check is the structural one (columns, indexes,
84
+ * foreign keys) and says so. */
85
+ declaredSqlText: false,
86
+ /** Referential integrity is always enforced, so no per-connection
87
+ * switch is set or verified at open. */
88
+ foreignKeysAlwaysOn: false,
89
+ /** A per-row identity that reproduces INSERTION order — SQLite's
90
+ * `rowid`, or a declared identity column this dialect adds to every
91
+ * table it creates. A collection is a sequence; without one, its
92
+ * order is whatever the engine answers in. */
93
+ rowIdentity: false,
94
+ });
95
+
96
+ /**
97
+ * Merge a spec's declared capabilities onto the closed defaults,
98
+ * refusing a name outside the set.
99
+ * @param {Record<string, any>} declared
100
+ * @returns {Readonly<Record<string, boolean>>}
101
+ */
102
+ function normalizeCapabilities(declared) {
103
+ const out = { ...DIALECT_CAPABILITIES };
104
+ for (const [name, value] of Object.entries(declared ?? {})) {
105
+ if (!Object.hasOwn(DIALECT_CAPABILITIES, name)) {
106
+ throw new TypeError(`createDialect: '${name}' is not a dialect capability; the set is `
107
+ + Object.keys(DIALECT_CAPABILITIES).map((n) => `'${n}'`).join(', '));
108
+ }
109
+ out[name] = value === true;
110
+ }
111
+ return Object.freeze(out);
112
+ }
113
+
26
114
  /**
27
115
  * Compose a dialect from its spelling spec. Every statement the store
28
116
  * ever runs is built here from the spec's primitives, so a spec with
@@ -40,9 +128,15 @@
40
128
  * stringLiteral: (s: string) => string,
41
129
  * booleanLiteral: (b: boolean) => string,
42
130
  * typeFor: (schemaType: string | undefined, hint: string) => string,
131
+ * autoKeyType?: string,
132
+ * comparableColumnType?: (declaredType: string) => string,
133
+ * columnUsableFor?: (declaredType: string | undefined, kind: string) => boolean,
134
+ * generatedStorage: string,
135
+ * identityColumn?: { name: string, type: string },
136
+ * epochFromRfc3339: (valueSql: string) => string,
43
137
  * limitClause: (limit: number, offset?: number) => string,
44
138
  * jsonPathText: (segments: JsonPathSegment[]) => string | null,
45
- * jsonExtract: (columnSql: string, pathText: string) => string,
139
+ * jsonExtract: (columnSql: string, pathText: string, kind?: string) => string,
46
140
  * derivedExpression?: (memberSql: string, column: { derive: string,
47
141
  * precision?: number, component?: string, dims?: number }) => string,
48
142
  * jsonSet: (exprSql: string, pathText: string, valueSql: string) => string,
@@ -52,6 +146,12 @@
52
146
  * jsonText: (columnSql: string) => string,
53
147
  * jsonAgg: (exprSql: string) => string,
54
148
  * jsonTypeOf: (columnSql: string, pathText: string) => string,
149
+ * numericTypeNames: readonly string[],
150
+ * jsonObject: (pairsSql: string) => string,
151
+ * jsonEmbed: (columnSql: string) => string,
152
+ * externalEncoding?: 'value' | 'json',
153
+ * externalCompare?: (valueSql: string, kind: string) => string,
154
+ * externalRef?: (paramSql: string, kind: string) => string,
55
155
  * valueTypeOf: (paramSql: string) => string,
56
156
  * strStartsWith: (valueSql: string, lowerParamSql: string, upperParamSql: string) => string,
57
157
  * strStartsWithExact: (valueSql: string, patternA: string, patternB: string) => string,
@@ -64,16 +164,28 @@
64
164
  * rowIdentity: () => string,
65
165
  * identityIn: (identitySql: string, paramSqls: string[]) => string,
66
166
  * rtree?: { module: string, columns: readonly string[] },
167
+ * rtreeDdl?: Record<string, Function>,
67
168
  * explainQuery: (sql: string) => string,
169
+ * explainLines: (rows: any[]) => string[],
170
+ * isFullScan: (line: string, tables: readonly string[]) => boolean,
171
+ * usesIndex: (line: string, index: string) => boolean,
68
172
  * excludedRef: (columnSql: string) => string,
69
173
  * tx: { begin: string, beginImmediate: string, commit: string,
70
174
  * rollback: string,
71
175
  * savepoint: (n: string) => string, release: (n: string) => string,
72
176
  * rollbackTo: (n: string) => string },
73
- * pragma: { busyTimeout: (ms: number) => string,
74
- * journalMode: (mode: string) => string,
75
- * foreignKeys: (on: boolean) => string },
177
+ * pragma?: { set: (name: string, value: number | string) => string,
178
+ * foreignKeys: (on: boolean) => string,
179
+ * foreignKeyCheck: () => string,
180
+ * walCheckpoint: (mode: string) => string,
181
+ * integrityCheck: (limit?: number) => string,
182
+ * optimize: () => string },
183
+ * schemaTypeOf?: (declaredType: string) => string | undefined,
184
+ * memberPathOf?: (expression: string) => (JsonPathSegment[] | null),
185
+ * expressionOf?: (expression: string, byName: Record<string, string>) => (any | null),
186
+ * readGenerated?: (rows: any[]) => { name: string, expression: string }[],
76
187
  * introspect: { version: () => string, compileOptions: () => string,
188
+ * pragma: (name: string) => string,
77
189
  * tableExists: () => string, columns: (table: string) => string,
78
190
  * indexes: (table: string) => string,
79
191
  * indexColumns: (index: string) => string,
@@ -102,11 +214,45 @@ export function createDialect(spec) {
102
214
  const generatedColumnSql = (docColumn, column) => {
103
215
  if (column.stored === true) return `${q(column.name)} ${column.type}`;
104
216
  const expression = column.expression
105
- ?? spec.jsonExtract(q(docColumn), column.pathText);
106
- return `${q(column.name)} ${column.type} GENERATED ALWAYS AS (${expression}) VIRTUAL`;
217
+ ?? spec.jsonExtract(q(docColumn), column.pathText, column.kind);
218
+ return `${q(column.name)} ${column.type} GENERATED ALWAYS AS (${expression}) `
219
+ + spec.generatedStorage;
107
220
  };
108
221
 
222
+ /**
223
+ * The identity column this dialect adds to every table it creates, or
224
+ * `null`. An engine with no per-row identity of its own declares one
225
+ * here rather than losing the collection's INSERTION order: the store
226
+ * orders by `rowIdentity()` wherever the model says "the sequence",
227
+ * and an engine that answers rows in whatever order an index scan
228
+ * produced is not answering the same question. Declared, so the shape
229
+ * check sees it and introspection can tell it from a model member.
230
+ * @returns {string[]} zero or one column definition
231
+ */
232
+ const identityColumnSql = () => (spec.identityColumn === undefined
233
+ ? []
234
+ : [`${q(spec.identityColumn.name)} ${spec.identityColumn.type}`]);
235
+
109
236
  const ddl = Object.freeze({
237
+ // the R*Tree mapping's own statements, when this spelling spec
238
+ // carries one; a dialect without `capabilities.virtualTables`
239
+ // composes none of them and the planner never asks
240
+ ...(spec.rtreeDdl ?? {}),
241
+ /**
242
+ * The idempotent form of a CREATE statement this dialect emitted —
243
+ * what the open path runs: two processes racing to create one fresh
244
+ * file both probe "absent", and the loser's CREATE must be a no-op
245
+ * rather than a failure. Only the open path takes it; a migration's
246
+ * planned DDL keeps its exact text. Shape verification still runs on
247
+ * the row the probe found, so an existing table is never silently
248
+ * accepted.
249
+ * @param {string} sql - a statement one of the builders below emitted
250
+ * @returns {string}
251
+ */
252
+ idempotent(sql) {
253
+ return sql.replace(/^CREATE (TABLE|UNIQUE INDEX|INDEX|VIRTUAL TABLE|TRIGGER) /,
254
+ 'CREATE $1 IF NOT EXISTS ');
255
+ },
110
256
  /**
111
257
  * One collection's physical table: a key column, the JSON document
112
258
  * column, and a virtual generated column per indexed path.
@@ -119,6 +265,7 @@ export function createDialect(spec) {
119
265
  const columns = [
120
266
  `${q(keyColumn)} ${keyType} PRIMARY KEY`,
121
267
  `${q(docColumn)} ${spec.docColumnType} NOT NULL`,
268
+ ...identityColumnSql(),
122
269
  ...generated.map((g) => generatedColumnSql(docColumn, g)),
123
270
  ];
124
271
  return `CREATE TABLE ${q(table)} (${columns.join(', ')})${spec.tableSuffix}`;
@@ -175,104 +322,6 @@ export function createDialect(spec) {
175
322
  dropIndex(name) {
176
323
  return `DROP INDEX ${q(name)}`;
177
324
  },
178
- /**
179
- * The second physical realization of a `derive: 'bbox'` column set
180
- * (MODEL-FORMAT §2.1, `physical: 'rtree'`): an R\*Tree virtual
181
- * table beside the collection, keyed by the collection's row id and
182
- * carrying the four box edges as `(minx, maxx, miny, maxy)`.
183
- *
184
- * The coordinates are 32-bit floats rounded OUTWARD, so the stored
185
- * box is a superset of the row's — no false negatives, which is
186
- * what an implied conjunct needs, and the reason `$bbox-intersects`
187
- * stops being exact under this mapping.
188
- * @param {{ name: string }} shape
189
- * @returns {string}
190
- */
191
- createVirtualTable({ name }) {
192
- return `CREATE VIRTUAL TABLE ${q(name)} USING ${spec.rtree.module}(`
193
- + `${spec.rtree.columns.map(q).join(', ')})`;
194
- },
195
- /**
196
- * @param {string} name
197
- * @returns {string}
198
- */
199
- dropVirtualTable(name) {
200
- return `DROP TABLE ${q(name)}`;
201
- },
202
- /**
203
- * @param {string} name
204
- * @returns {string}
205
- */
206
- dropTrigger(name) {
207
- return `DROP TRIGGER ${q(name)}`;
208
- },
209
- /**
210
- * Fill an R\*Tree from the documents already stored — the migration
211
- * step that turns a `columns` collection into an `rtree` one. The
212
- * `IS NOT NULL` is §3.2's rule in SQL: a row with no bounded
213
- * position is ABSENT from the index, not at `[0, 0]`.
214
- * @param {{ table: string, virtualTable: string,
215
- * edges: { name: string }[] }} shape
216
- * @returns {string}
217
- */
218
- fillVirtualTable({ table, virtualTable, edges }) {
219
- const columns = spec.rtree.columns;
220
- const sources = [spec.rowIdentity(), ...edges.map((edge) => q(edge.name))];
221
- return `INSERT INTO ${q(virtualTable)} (${columns.map(q).join(', ')}) `
222
- + `SELECT ${sources.join(', ')} FROM ${q(table)} `
223
- + `WHERE ${q(edges[0].name)} IS NOT NULL`;
224
- },
225
- /**
226
- * The three triggers that keep an R\*Tree in sync with its
227
- * collection — insert, update, delete — as DECLARED objects of the
228
- * collection table.
229
- *
230
- * Declared, and not a second write path in JavaScript: a trigger is
231
- * inside the writing transaction by construction (SQLite cannot
232
- * separate them), no write path can bypass it (`insert`,
233
- * `insertAllocated`, `upsert`, a translated patch, the patch
234
- * fallback, a delete and a migration backfill all fire it), and it
235
- * belongs to the collection table, so the existing declared-text
236
- * drift check sees it for free.
237
- *
238
- * The body reads the DERIVED COLUMNS through `NEW` rather than
239
- * restating the box expression, so the columns stay the box's one
240
- * definition — and that text works unchanged on the stored-column
241
- * branch, where those columns are ordinary ones.
242
- *
243
- * The `IS NOT NULL` guard is load-bearing: an R\*Tree coerces a
244
- * `NULL` coordinate to `0.0` without complaint, so without it every
245
- * unbounded document would land on Null Island instead of being
246
- * absent (MODEL-FORMAT §3.2).
247
- * @param {{ table: string, virtualTable: string, prefix: string,
248
- * edges: { name: string }[] }} shape - `edges` are the four
249
- * derived columns in `(w, e, s, n)` order, which is the order the
250
- * virtual table's `(minx, maxx, miny, maxy)` carry
251
- * @returns {{ name: string, sql: string }[]}
252
- */
253
- createSyncTriggers({ table, virtualTable, prefix, edges }) {
254
- const rid = spec.rowIdentity();
255
- const target = `${q(virtualTable)} (${spec.rtree.columns.map(q).join(', ')})`;
256
- const guard = `${q(edges[0].name)} IS NOT NULL`;
257
- const values = (row) => [`${row}.${rid}`,
258
- ...edges.map((edge) => `${row}.${q(edge.name)}`)].join(', ');
259
- return [
260
- { name: `${prefix}_ai`,
261
- sql: `CREATE TRIGGER ${q(`${prefix}_ai`)} AFTER INSERT ON ${q(table)} `
262
- + `WHEN NEW.${guard} BEGIN `
263
- + `INSERT INTO ${target} VALUES (${values('NEW')}); END` },
264
- // one trigger, not two: the old id leaves and the new box
265
- // arrives only when it exists, so a document that loses its
266
- // geometry leaves the index rather than keeping a stale box
267
- { name: `${prefix}_au`,
268
- sql: `CREATE TRIGGER ${q(`${prefix}_au`)} AFTER UPDATE ON ${q(table)} BEGIN `
269
- + `DELETE FROM ${q(virtualTable)} WHERE ${q(spec.rtree.columns[0])} = OLD.${rid}; `
270
- + `INSERT INTO ${target} SELECT ${values('NEW')} WHERE NEW.${guard}; END` },
271
- { name: `${prefix}_ad`,
272
- sql: `CREATE TRIGGER ${q(`${prefix}_ad`)} AFTER DELETE ON ${q(table)} BEGIN `
273
- + `DELETE FROM ${q(virtualTable)} WHERE ${q(spec.rtree.columns[0])} = OLD.${rid}; END` },
274
- ];
275
- },
276
325
  /**
277
326
  * @param {string} table
278
327
  * @returns {string}
@@ -323,6 +372,7 @@ export function createDialect(spec) {
323
372
  }
324
373
  return sql;
325
374
  });
375
+ rendered.push(...identityColumnSql());
326
376
  if (compositeKey !== undefined && compositeKey.length > 0)
327
377
  rendered.push(`PRIMARY KEY (${compositeKey.map(q).join(', ')})`);
328
378
  return `CREATE TABLE ${q(table)} (${rendered.join(', ')})${spec.tableSuffix}`;
@@ -334,8 +384,9 @@ export function createDialect(spec) {
334
384
  * @returns {string}
335
385
  */
336
386
  createPlainTable({ table, columns }) {
337
- const rendered = columns.map((column) =>
338
- `${q(column.name)} ${column.type}${column.primaryKey === true ? ' PRIMARY KEY' : ''}`);
387
+ const rendered = [...columns.map((column) =>
388
+ `${q(column.name)} ${column.type}${column.primaryKey === true ? ' PRIMARY KEY' : ''}`),
389
+ ...identityColumnSql()];
339
390
  return `CREATE TABLE IF NOT EXISTS ${q(table)} (${rendered.join(', ')})${spec.tableSuffix}`;
340
391
  },
341
392
  });
@@ -439,7 +490,7 @@ export function createDialect(spec) {
439
490
 
440
491
  return Object.freeze({
441
492
  name: spec.name,
442
- capabilities: Object.freeze({ ...spec.capabilities }),
493
+ capabilities: normalizeCapabilities(spec.capabilities),
443
494
  docColumnType: spec.docColumnType,
444
495
  // the declared type of a `derive: 'vector'` column — the bytes of
445
496
  // the packed form, spelled by the dialect like every other type
@@ -449,6 +500,35 @@ export function createDialect(spec) {
449
500
  stringLiteral: spec.stringLiteral,
450
501
  booleanLiteral: spec.booleanLiteral,
451
502
  typeFor: spec.typeFor,
503
+ /**
504
+ * The declared type of a key the DATABASE allocates
505
+ * (`identity: 'integer'`), when that is not simply the integer type
506
+ * — an engine whose auto-allocation is a column property rather
507
+ * than a consequence of the key's type spells it here.
508
+ */
509
+ autoKeyType: spec.autoKeyType,
510
+ /**
511
+ * One declared column type reduced to the form the catalog reports
512
+ * it as, so the shape check compares like with like: an auto-key's
513
+ * clause, a collation, a width the engine normalizes away. Applied
514
+ * to BOTH sides of the comparison.
515
+ */
516
+ comparableColumnType: spec.comparableColumnType
517
+ ?? ((declaredType) => String(declaredType).toUpperCase()),
518
+ /**
519
+ * Whether a generated column over a member the schema types
520
+ * `declaredType` can be compared against a value of `kind`
521
+ * (`'text'`, `'number'`, `'boolean'`). On an engine whose columns
522
+ * carry a real SQL type, comparing a `text` column to a number is a
523
+ * type error rather than a false row, so the emitter reads the
524
+ * member out of the document instead — the same answer, unindexed.
525
+ */
526
+ columnUsableFor: spec.columnUsableFor ?? (() => true),
527
+ /** The storage word a generated column is declared with. */
528
+ generatedStorage: spec.generatedStorage,
529
+ /** The identity column added to every created table, or undefined. */
530
+ identityColumn: spec.identityColumn === undefined
531
+ ? undefined : Object.freeze({ ...spec.identityColumn }),
452
532
  limitClause: spec.limitClause,
453
533
  jsonPathText: spec.jsonPathText,
454
534
  jsonExtract: spec.jsonExtract,
@@ -470,6 +550,35 @@ export function createDialect(spec) {
470
550
  jsonText: spec.jsonText,
471
551
  jsonAgg: spec.jsonAgg,
472
552
  jsonTypeOf: spec.jsonTypeOf,
553
+ /**
554
+ * The spellings {@link jsonTypeOf} answers for a JSON NUMBER. SQLite
555
+ * discriminates the two it stores (`integer`, `real`); an engine with
556
+ * one JSON number type answers one name. Everything else in the
557
+ * vocabulary is shared — `text`, `true`, `false`, `null`, `object`,
558
+ * `array` — because the row decoder reads those names in JavaScript.
559
+ */
560
+ numericTypeNames: Object.freeze([...spec.numericTypeNames]),
561
+ /** An object built from alternating name/value SQL — the shape a
562
+ * graph load's nested include answers. */
563
+ jsonObject: spec.jsonObject,
564
+ /** The document column as a NESTED JSON value rather than as text:
565
+ * what an enclosing object embeds, as opposed to what a caller
566
+ * reads back. On an engine with one JSON type the two differ. */
567
+ jsonEmbed: spec.jsonEmbed,
568
+ /**
569
+ * How an EXTERNAL operand's value reaches a statement. `'value'`
570
+ * binds it as itself — a dynamically typed engine compares it with
571
+ * whatever the member holds. `'json'` binds its JSON encoding,
572
+ * because a statically typed engine cannot bind one placeholder
573
+ * against a text member in one branch and a numeric member in
574
+ * another: the parameter's own type is fixed when it is bound, and
575
+ * a guard the row would fail does not stop the coercion.
576
+ */
577
+ externalEncoding: spec.externalEncoding ?? 'value',
578
+ /** The MEMBER side of an external comparison, at the branch's kind. */
579
+ externalCompare: spec.externalCompare ?? ((valueSql) => valueSql),
580
+ /** The PARAMETER side of an external comparison, at the same kind. */
581
+ externalRef: spec.externalRef ?? ((paramSql) => paramSql),
473
582
  valueTypeOf: spec.valueTypeOf,
474
583
  strStartsWith: spec.strStartsWith,
475
584
  strStartsWithExact: spec.strStartsWithExact,
@@ -487,6 +596,9 @@ export function createDialect(spec) {
487
596
  /** One grouped aggregate; `null` counts ROWS rather than values. */
488
597
  groupAggregate: spec.groupAggregate,
489
598
  rowIdentity: spec.rowIdentity,
599
+ /** Membership of the row identity in a bound list — the fetch of a
600
+ * k-nearest plan's candidates after the engine's cut. */
601
+ identityIn: spec.identityIn,
490
602
  /**
491
603
  * The R\*Tree spelling: the module name and the virtual table's own
492
604
  * column list, in `(id, minx, maxx, miny, maxy)` order. Read only
@@ -494,7 +606,50 @@ export function createDialect(spec) {
494
606
  * spec that omits it simply cannot carry that mapping.
495
607
  */
496
608
  rtree: Object.freeze({ ...spec.rtree }),
609
+ /**
610
+ * The schema type a declared column type came from — the inverse of
611
+ * {@link typeFor}, as far as one exists. `undefined` where the
612
+ * column carries no scalar type, and where a mapping is lossy the
613
+ * introspector says so rather than guessing: two schema types that
614
+ * share a column type cannot be told apart on the way back.
615
+ */
616
+ schemaTypeOf: spec.schemaTypeOf,
617
+ /**
618
+ * The member path a GENERATED column's expression reads, recovered
619
+ * from the dialect's own spelling of it. `null` for an expression
620
+ * this dialect did not write — which is a loss the introspector
621
+ * reports rather than a path it invents.
622
+ */
623
+ memberPathOf: spec.memberPathOf,
624
+ /**
625
+ * The declared index EXPRESSION a generated column's SQL computes,
626
+ * recovered from the dialect's own spelling of it. `byName` maps the
627
+ * engine's function name back to the model's — the same name where
628
+ * the store registered it, the host's `sql` name where the engine
629
+ * has its own. `null` for anything this dialect did not write, which
630
+ * the introspector reports rather than guesses.
631
+ */
632
+ expressionOf: spec.expressionOf,
633
+ /**
634
+ * The `{ name, expression }` pairs behind `introspect.generated`'s
635
+ * rows. Two engines answer that question with different row shapes
636
+ * — one hands back a catalog column, the other the table's whole
637
+ * CREATE text — and the dialect that wrote the expression is the
638
+ * one that can read it.
639
+ */
640
+ readGenerated: spec.readGenerated,
497
641
  explainQuery: spec.explainQuery,
642
+ /** The plan narrative, one line per row the engine answered. */
643
+ explainLines: spec.explainLines,
644
+ /**
645
+ * Whether one narrative line reports a FULL TABLE SCAN of one of
646
+ * the named tables (or of an alias a join statement gave one). The
647
+ * safe profile's scan refusal is verified against the database's own
648
+ * plan, and only the dialect can read that engine's words.
649
+ */
650
+ isFullScan: spec.isFullScan,
651
+ /** Whether one narrative line reports a seek through the named index. */
652
+ usesIndex: spec.usesIndex,
498
653
  excludedRef: spec.excludedRef,
499
654
  epochFromRfc3339: spec.epochFromRfc3339,
500
655
  tx: Object.freeze({ ...spec.tx }),
@@ -0,0 +1,158 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Reading an index expression back out of the SQL a dialect wrote
4
+ * for it — the inverse of `expressionSql`, and the reason introspection
5
+ * can round-trip one.
6
+ *
7
+ * The grammar is tiny and closed, because the emitter's is: a call is
8
+ * `name(arg, …)`, an argument is another call, a member read, or a
9
+ * literal. What differs between engines is only how a member and a
10
+ * function name are SPELLED, so both pass those two in and share
11
+ * everything else — a second copy of this walk is a second place for
12
+ * the two halves to drift apart.
13
+ */
14
+
15
+ /**
16
+ * @param {string} sql
17
+ * @param {{ memberOf: (text: string) => (any | null),
18
+ * nameOf: (text: string) => (string | null),
19
+ * stringOf: (text: string) => (string | null) }} spelling
20
+ * @returns {any | null} the expression, or `null` for SQL this dialect
21
+ * did not write
22
+ */
23
+ export function readExpression(sql, spelling) {
24
+ const text = String(sql).trim();
25
+ const parsed = parseNode(text, spelling);
26
+ return parsed === null || parsed.rest.trim().length > 0 ? null : parsed.node;
27
+ }
28
+
29
+ /**
30
+ * One node, and whatever follows it.
31
+ * @param {string} text
32
+ * @param {any} spelling
33
+ * @returns {{ node: any, rest: string } | null}
34
+ */
35
+ function parseNode(text, spelling) {
36
+ let source = text.trimStart();
37
+ // a parenthesised node: the emitter brackets a member read, and an
38
+ // engine that re-renders adds its own
39
+ if (source.startsWith('(')) {
40
+ const inner = balanced(source);
41
+ if (inner === null) return null;
42
+ const member = spelling.memberOf(inner.body);
43
+ if (member !== null) return { node: member, rest: inner.rest };
44
+ const parsed = parseNode(inner.body, spelling);
45
+ if (parsed !== null && parsed.rest.trim().length === 0)
46
+ return { node: parsed.node, rest: inner.rest };
47
+ return null;
48
+ }
49
+ // a CALL first: a member read and a call both start with an
50
+ // identifier, and only the declared names are calls — trying the
51
+ // member first would swallow the call's own arguments
52
+ const match = /^([A-Za-z_][A-Za-z0-9_.]*)\s*\(/.exec(source);
53
+ const name = match === null ? null : spelling.nameOf(match[1]);
54
+ if (match !== null && name !== null) {
55
+ source = source.slice(match[1].length).trimStart();
56
+ const inner = balanced(source);
57
+ if (inner === null) return null;
58
+ const args = [];
59
+ let body = inner.body.trim();
60
+ while (body.length > 0) {
61
+ const parsed = parseNode(body, spelling);
62
+ if (parsed === null) return null;
63
+ args.push(parsed.node);
64
+ body = parsed.rest.trimStart();
65
+ if (body.startsWith(',')) { body = body.slice(1); continue; }
66
+ if (body.length > 0) return null;
67
+ }
68
+ return { node: { call: name, args }, rest: inner.rest };
69
+ }
70
+
71
+ const member = memberPrefix(source, spelling);
72
+ if (member !== null) return member;
73
+ return literalPrefix(source, spelling);
74
+ }
75
+
76
+ /**
77
+ * A member read at the head of `text`, as this dialect spells one.
78
+ * @param {string} text
79
+ * @param {any} spelling
80
+ * @returns {{ node: any, rest: string } | null}
81
+ */
82
+ function memberPrefix(text, spelling) {
83
+ // the whole remaining head up to a comma or a closing parenthesis at
84
+ // depth zero: a member read is a self-contained expression
85
+ const head = headOf(text);
86
+ if (head === null) return null;
87
+ const member = spelling.memberOf(head.body);
88
+ return member === null ? null : { node: member, rest: head.rest };
89
+ }
90
+
91
+ /**
92
+ * A literal at the head of `text`.
93
+ * @param {string} text
94
+ * @param {any} spelling
95
+ * @returns {{ node: any, rest: string } | null}
96
+ */
97
+ function literalPrefix(text, spelling) {
98
+ const head = headOf(text);
99
+ if (head === null) return null;
100
+ const body = head.body.trim();
101
+ const string = spelling.stringOf(body);
102
+ if (string !== null) return { node: { value: string }, rest: head.rest };
103
+ if (/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(body))
104
+ return { node: { value: Number(body) }, rest: head.rest };
105
+ if (body === 'TRUE' || body === '1') return { node: { value: true }, rest: head.rest };
106
+ if (body === 'FALSE' || body === '0') return { node: { value: false }, rest: head.rest };
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * The text up to the next comma or unmatched close parenthesis at depth
112
+ * zero, and what follows it.
113
+ * @param {string} text
114
+ * @returns {{ body: string, rest: string } | null}
115
+ */
116
+ function headOf(text) {
117
+ let depth = 0;
118
+ for (let i = 0; i < text.length; i++) {
119
+ const c = text[i];
120
+ if (c === "'") {
121
+ i = text.indexOf("'", i + 1);
122
+ if (i < 0) return null;
123
+ continue;
124
+ }
125
+ if (c === '(') depth += 1;
126
+ else if (c === ')') {
127
+ if (depth === 0) return { body: text.slice(0, i), rest: text.slice(i) };
128
+ depth -= 1;
129
+ }
130
+ else if (c === ',' && depth === 0) return { body: text.slice(0, i), rest: text.slice(i) };
131
+ }
132
+ return { body: text, rest: '' };
133
+ }
134
+
135
+ /**
136
+ * The body of the parenthesised group `text` opens with, and what
137
+ * follows the close.
138
+ * @param {string} text
139
+ * @returns {{ body: string, rest: string } | null}
140
+ */
141
+ function balanced(text) {
142
+ if (!text.startsWith('(')) return null;
143
+ let depth = 0;
144
+ for (let i = 0; i < text.length; i++) {
145
+ const c = text[i];
146
+ if (c === "'") {
147
+ i = text.indexOf("'", i + 1);
148
+ if (i < 0) return null;
149
+ continue;
150
+ }
151
+ if (c === '(') depth += 1;
152
+ else if (c === ')') {
153
+ depth -= 1;
154
+ if (depth === 0) return { body: text.slice(1, i), rest: text.slice(i + 1) };
155
+ }
156
+ }
157
+ return null;
158
+ }