@jarenjs/db 0.34.0 → 0.43.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +115 -12
- package/README.md +47 -0
- package/dist/types/algebra.d.ts +38 -2
- package/dist/types/ddl.d.ts +40 -6
- package/dist/types/derive.d.ts +161 -0
- package/dist/types/dialect.d.ts +11 -2
- package/dist/types/driver.d.ts +2 -20
- package/dist/types/emit.d.ts +6 -3
- package/dist/types/errors.d.ts +6 -4
- package/dist/types/index.d.ts +1 -0
- package/dist/types/jobs.d.ts +7 -1
- package/dist/types/migrate.d.ts +7 -1
- package/dist/types/plan.d.ts +15 -1
- package/dist/types/residual.d.ts +17 -6
- package/dist/types/udf.d.ts +6 -1
- package/docs/JOBS-FORMAT.md +12 -1
- package/docs/MIGRATION-FORMAT.md +30 -1
- package/docs/MODEL-FORMAT.md +155 -4
- package/package.json +4 -4
- package/schemas/jaren-migration.draft-07.schema.json +71 -0
- package/schemas/jaren-migration.schema.json +71 -0
- package/schemas/jaren-model.draft-07.schema.json +14 -1
- package/schemas/jaren-model.schema.json +18 -5
- package/src/algebra.js +17 -2
- package/src/ddl.js +146 -17
- package/src/derive.js +284 -0
- package/src/dialect.js +89 -25
- package/src/dialects/sqlite.js +16 -1
- package/src/driver.js +6 -28
- package/src/emit.js +122 -22
- package/src/errors.js +6 -4
- package/src/index.js +5 -0
- package/src/jobs.js +36 -7
- package/src/migrate.js +132 -19
- package/src/plan.js +514 -32
- package/src/query.js +61 -9
- package/src/residual.js +18 -10
- package/src/store.js +122 -8
- package/src/udf.js +12 -3
package/src/dialect.js
CHANGED
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
* limitClause: (limit: number, offset?: number) => string,
|
|
43
43
|
* jsonPathText: (segments: JsonPathSegment[]) => string | null,
|
|
44
44
|
* jsonExtract: (columnSql: string, pathText: string) => string,
|
|
45
|
+
* derivedExpression?: (memberSql: string, column: { derive: string,
|
|
46
|
+
* precision?: number, component?: string }) => string,
|
|
45
47
|
* jsonSet: (exprSql: string, pathText: string, valueSql: string) => string,
|
|
46
48
|
* jsonRemove: (exprSql: string, pathText: string) => string,
|
|
47
49
|
* jsonAppend: (exprSql: string, arrayPathText: string, valueSql: string) => string,
|
|
@@ -50,7 +52,8 @@
|
|
|
50
52
|
* jsonAgg: (exprSql: string) => string,
|
|
51
53
|
* jsonTypeOf: (columnSql: string, pathText: string) => string,
|
|
52
54
|
* valueTypeOf: (paramSql: string) => string,
|
|
53
|
-
* strStartsWith: (valueSql: string,
|
|
55
|
+
* strStartsWith: (valueSql: string, lowerParamSql: string, upperParamSql: string) => string,
|
|
56
|
+
* strStartsWithExact: (valueSql: string, patternA: string, patternB: string) => string,
|
|
54
57
|
* strEndsWith: (valueSql: string, patternA: string, patternB: string, patternC: string) => string,
|
|
55
58
|
* strContains: (valueSql: string, patternSql: string) => string,
|
|
56
59
|
* orderNulls: (nullsFirst: boolean) => string,
|
|
@@ -77,6 +80,24 @@ export function createDialect(spec) {
|
|
|
77
80
|
const q = spec.quoteIdentifier;
|
|
78
81
|
const p = spec.parameterRef;
|
|
79
82
|
|
|
83
|
+
/**
|
|
84
|
+
* One planned column's definition. A path column is a VIRTUAL
|
|
85
|
+
* generated column over the document; a DERIVED column is the same
|
|
86
|
+
* shape over a registered deterministic function, except where the
|
|
87
|
+
* driver cannot index one — there the store writes the value and the
|
|
88
|
+
* column is an ordinary one.
|
|
89
|
+
* @param {string} docColumn
|
|
90
|
+
* @param {{ name: string, type: string, pathText: string,
|
|
91
|
+
* expression?: string | null, stored?: boolean }} column
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
const generatedColumnSql = (docColumn, column) => {
|
|
95
|
+
if (column.stored === true) return `${q(column.name)} ${column.type}`;
|
|
96
|
+
const expression = column.expression
|
|
97
|
+
?? spec.jsonExtract(q(docColumn), column.pathText);
|
|
98
|
+
return `${q(column.name)} ${column.type} GENERATED ALWAYS AS (${expression}) VIRTUAL`;
|
|
99
|
+
};
|
|
100
|
+
|
|
80
101
|
const ddl = Object.freeze({
|
|
81
102
|
/**
|
|
82
103
|
* One collection's physical table: a key column, the JSON document
|
|
@@ -90,9 +111,7 @@ export function createDialect(spec) {
|
|
|
90
111
|
const columns = [
|
|
91
112
|
`${q(keyColumn)} ${keyType} PRIMARY KEY`,
|
|
92
113
|
`${q(docColumn)} ${spec.docColumnType} NOT NULL`,
|
|
93
|
-
...generated.map((g) =>
|
|
94
|
-
`${q(g.name)} ${g.type} GENERATED ALWAYS AS `
|
|
95
|
-
+ `(${spec.jsonExtract(q(docColumn), g.pathText)}) VIRTUAL`),
|
|
114
|
+
...generated.map((g) => generatedColumnSql(docColumn, g)),
|
|
96
115
|
];
|
|
97
116
|
return `CREATE TABLE ${q(table)} (${columns.join(', ')})${spec.tableSuffix}`;
|
|
98
117
|
},
|
|
@@ -113,8 +132,7 @@ export function createDialect(spec) {
|
|
|
113
132
|
* @returns {string}
|
|
114
133
|
*/
|
|
115
134
|
addGeneratedColumn({ table, docColumn, column }) {
|
|
116
|
-
return `ALTER TABLE ${q(table)} ADD COLUMN ${
|
|
117
|
-
+ `GENERATED ALWAYS AS (${spec.jsonExtract(q(docColumn), column.pathText)}) VIRTUAL`;
|
|
135
|
+
return `ALTER TABLE ${q(table)} ADD COLUMN ${generatedColumnSql(docColumn, column)}`;
|
|
118
136
|
},
|
|
119
137
|
/**
|
|
120
138
|
* @param {string} table
|
|
@@ -217,26 +235,47 @@ export function createDialect(spec) {
|
|
|
217
235
|
});
|
|
218
236
|
|
|
219
237
|
const dml = Object.freeze({
|
|
220
|
-
/**
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
238
|
+
/**
|
|
239
|
+
* A collection insert. `stored` names the derived columns the
|
|
240
|
+
* store computes itself — empty on every driver that can index a
|
|
241
|
+
* registered function, because there the column generates itself.
|
|
242
|
+
* @param {{ table: string, keyColumn: string, docColumn: string,
|
|
243
|
+
* stored?: string[] }} s
|
|
244
|
+
*/
|
|
245
|
+
insert({ table, keyColumn, docColumn, stored }) {
|
|
246
|
+
const extra = stored ?? [];
|
|
247
|
+
const names = [q(keyColumn), q(docColumn), ...extra.map(q)];
|
|
248
|
+
const values = [p(1, 'key'), spec.jsonEncode(p(2, 'doc')),
|
|
249
|
+
...extra.map((name, i) => p(i + 3, name))];
|
|
250
|
+
return `INSERT INTO ${q(table)} (${names.join(', ')}) VALUES (${values.join(', ')})`;
|
|
224
251
|
},
|
|
225
252
|
/**
|
|
226
253
|
* Insert with a database-allocated key, read back in the same
|
|
227
254
|
* statement.
|
|
228
|
-
* @param {{ table: string, keyColumn: string, docColumn: string
|
|
255
|
+
* @param {{ table: string, keyColumn: string, docColumn: string,
|
|
256
|
+
* stored?: string[] }} s
|
|
229
257
|
*/
|
|
230
|
-
insertAllocated({ table, keyColumn, docColumn }) {
|
|
231
|
-
|
|
232
|
-
|
|
258
|
+
insertAllocated({ table, keyColumn, docColumn, stored }) {
|
|
259
|
+
const extra = stored ?? [];
|
|
260
|
+
const names = [q(docColumn), ...extra.map(q)];
|
|
261
|
+
const values = [spec.jsonEncode(p(1, 'doc')),
|
|
262
|
+
...extra.map((name, i) => p(i + 2, name))];
|
|
263
|
+
return `INSERT INTO ${q(table)} (${names.join(', ')}) VALUES (${values.join(', ')}) `
|
|
264
|
+
+ `RETURNING ${q(keyColumn)} AS ${q('key')}`;
|
|
233
265
|
},
|
|
234
|
-
/**
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
266
|
+
/**
|
|
267
|
+
* @param {{ table: string, keyColumn: string, docColumn: string,
|
|
268
|
+
* stored?: string[] }} s
|
|
269
|
+
*/
|
|
270
|
+
upsert({ table, keyColumn, docColumn, stored }) {
|
|
271
|
+
const extra = stored ?? [];
|
|
272
|
+
const names = [q(keyColumn), q(docColumn), ...extra.map(q)];
|
|
273
|
+
const values = [p(1, 'key'), spec.jsonEncode(p(2, 'doc')),
|
|
274
|
+
...extra.map((name, i) => p(i + 3, name))];
|
|
275
|
+
const assignments = [q(docColumn), ...extra.map(q)]
|
|
276
|
+
.map((column) => `${column} = ${spec.excludedRef(column)}`);
|
|
277
|
+
return `INSERT INTO ${q(table)} (${names.join(', ')}) VALUES (${values.join(', ')}) `
|
|
278
|
+
+ `ON CONFLICT (${q(keyColumn)}) DO UPDATE SET ${assignments.join(', ')}`;
|
|
240
279
|
},
|
|
241
280
|
/** @param {{ table: string, keyColumn: string, docColumn: string }} s */
|
|
242
281
|
get({ table, keyColumn, docColumn }) {
|
|
@@ -250,13 +289,26 @@ export function createDialect(spec) {
|
|
|
250
289
|
/**
|
|
251
290
|
* Rewrite the document column through a JSON-set expression chain
|
|
252
291
|
* (the translated-patch path) or a bound parameter (the fallback).
|
|
253
|
-
*
|
|
292
|
+
* Stored derived columns are rewritten with it — they are computed
|
|
293
|
+
* FROM the document, so leaving them behind would let a query read a
|
|
294
|
+
* value the document no longer carries.
|
|
295
|
+
*
|
|
296
|
+
* `nextParam` is the first FREE parameter slot after the
|
|
297
|
+
* expression's own: the derived values take it and the ones after
|
|
298
|
+
* it, and the key binds LAST. That order is the statement's TEXT
|
|
299
|
+
* order, which is what a positional dialect numbers by — and with
|
|
300
|
+
* no derived columns it degenerates to the key at `nextParam`.
|
|
301
|
+
* @param {{ table: string, keyColumn: string, docColumn: string,
|
|
302
|
+
* stored?: string[] }} s
|
|
254
303
|
* @param {string} expression - SQL over the document column
|
|
255
|
-
* @param {number}
|
|
304
|
+
* @param {number} nextParam - 1-based first free parameter slot
|
|
256
305
|
*/
|
|
257
|
-
updateDoc({ table, keyColumn, docColumn }, expression,
|
|
258
|
-
|
|
259
|
-
|
|
306
|
+
updateDoc({ table, keyColumn, docColumn, stored }, expression, nextParam) {
|
|
307
|
+
const extra = stored ?? [];
|
|
308
|
+
const assignments = [`${q(docColumn)} = ${expression}`,
|
|
309
|
+
...extra.map((name, i) => `${q(name)} = ${p(nextParam + i, name)}`)];
|
|
310
|
+
return `UPDATE ${q(table)} SET ${assignments.join(', ')} `
|
|
311
|
+
+ `WHERE ${q(keyColumn)} = ${p(nextParam + extra.length, 'key')}`;
|
|
260
312
|
},
|
|
261
313
|
});
|
|
262
314
|
|
|
@@ -272,6 +324,17 @@ export function createDialect(spec) {
|
|
|
272
324
|
limitClause: spec.limitClause,
|
|
273
325
|
jsonPathText: spec.jsonPathText,
|
|
274
326
|
jsonExtract: spec.jsonExtract,
|
|
327
|
+
/**
|
|
328
|
+
* The expression a DERIVED column is generated from: the member at
|
|
329
|
+
* the index path, as JSON text, handed to the deterministic
|
|
330
|
+
* function that computes the cell or the box edge.
|
|
331
|
+
* @param {string} docColumnSql
|
|
332
|
+
* @param {string} pathText
|
|
333
|
+
* @param {{ derive: string, precision?: number, component?: string }} column
|
|
334
|
+
* @returns {string}
|
|
335
|
+
*/
|
|
336
|
+
derivedColumn: (docColumnSql, pathText, column) => spec.derivedExpression(
|
|
337
|
+
spec.jsonText(spec.jsonExtract(docColumnSql, pathText)), column),
|
|
275
338
|
jsonSet: spec.jsonSet,
|
|
276
339
|
jsonRemove: spec.jsonRemove,
|
|
277
340
|
jsonAppend: spec.jsonAppend,
|
|
@@ -281,6 +344,7 @@ export function createDialect(spec) {
|
|
|
281
344
|
jsonTypeOf: spec.jsonTypeOf,
|
|
282
345
|
valueTypeOf: spec.valueTypeOf,
|
|
283
346
|
strStartsWith: spec.strStartsWith,
|
|
347
|
+
strStartsWithExact: spec.strStartsWithExact,
|
|
284
348
|
strEndsWith: spec.strEndsWith,
|
|
285
349
|
strContains: spec.strContains,
|
|
286
350
|
orderNulls: spec.orderNulls,
|
package/src/dialects/sqlite.js
CHANGED
|
@@ -101,6 +101,14 @@ export const sqliteDialect = createDialect({
|
|
|
101
101
|
jsonPathText,
|
|
102
102
|
jsonExtract: (columnSql, pathText) =>
|
|
103
103
|
`jsonb_extract(${columnSql}, ${stringLiteral(pathText)})`,
|
|
104
|
+
// a DERIVED column's expression: the member as JSON text handed to
|
|
105
|
+
// the deterministic function the store registers at open. The
|
|
106
|
+
// precision is a LITERAL, not a parameter — a generated column's
|
|
107
|
+
// expression takes none — which is also what makes two precisions
|
|
108
|
+
// over one path two different columns by declared text.
|
|
109
|
+
derivedExpression: (memberSql, column) => (column.derive === 'geohash'
|
|
110
|
+
? `jaren_geohash(${memberSql}, ${Math.trunc(Number(column.precision))})`
|
|
111
|
+
: `jaren_bbox_${column.component}(${memberSql})`),
|
|
104
112
|
jsonSet: (exprSql, pathText, valueSql) =>
|
|
105
113
|
`jsonb_set(${exprSql}, ${stringLiteral(pathText)}, ${valueSql})`,
|
|
106
114
|
jsonRemove: (exprSql, pathText) =>
|
|
@@ -113,7 +121,14 @@ export const sqliteDialect = createDialect({
|
|
|
113
121
|
jsonTypeOf: (columnSql, pathText) =>
|
|
114
122
|
`json_type(${columnSql}, ${stringLiteral(pathText)})`,
|
|
115
123
|
valueTypeOf: (paramSql) => `typeof(${paramSql})`,
|
|
116
|
-
|
|
124
|
+
// the half-open range over the prefix, which an index on the value
|
|
125
|
+
// can seek; `substr(value, 1, n) = p` and `value LIKE 'p%'` both read
|
|
126
|
+
// every row. SQLite's default BINARY collation compares UTF-8 bytes,
|
|
127
|
+
// which orders code points, so the range holds exactly the values
|
|
128
|
+
// that begin with the prefix
|
|
129
|
+
strStartsWith: (valueSql, lowerParamSql, upperParamSql) =>
|
|
130
|
+
`(${valueSql} >= ${lowerParamSql} AND ${valueSql} < ${upperParamSql})`,
|
|
131
|
+
strStartsWithExact: (valueSql, patternA, patternB) =>
|
|
117
132
|
`substr(${valueSql}, 1, length(${patternA})) = ${patternB}`,
|
|
118
133
|
strEndsWith: (valueSql, patternA, patternB, patternC) =>
|
|
119
134
|
`(length(${patternA}) = 0 OR substr(${valueSql}, -length(${patternB})) = ${patternC})`,
|
package/src/driver.js
CHANGED
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
* silent degradation this suite refuses.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
import { isThenable, chain, toPromise } from '@jarenjs/core/function';
|
|
34
|
+
|
|
33
35
|
import { DbCompileError } from './errors.js';
|
|
34
36
|
|
|
35
37
|
/** The minimum SQLite the store accepts, asserted at open. */
|
|
@@ -44,34 +46,10 @@ export const SQLITE_FLOOR = '3.45.0';
|
|
|
44
46
|
*/
|
|
45
47
|
export const DEFAULT_QUEUE_TIMEOUT = 5000;
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
export function isThenable(value) {
|
|
52
|
-
return value !== null && typeof value === 'object' && typeof value.then === 'function';
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Sync-capable-async composition: apply `next` to a driver result
|
|
57
|
-
* without allocating a promise when the result is already a value.
|
|
58
|
-
* @param {any} value - A driver return: a value or a promise
|
|
59
|
-
* @param {(value: any) => any} next
|
|
60
|
-
* @returns {any} `next`'s result, promise-wrapped only if the input was
|
|
61
|
-
*/
|
|
62
|
-
export function chain(value, next) {
|
|
63
|
-
return isThenable(value) ? value.then(next) : next(value);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Lift a driver result into a promise — the ONE allocation the public
|
|
68
|
-
* asynchronous surface pays per call.
|
|
69
|
-
* @param {any} value
|
|
70
|
-
* @returns {Promise<any>}
|
|
71
|
-
*/
|
|
72
|
-
export function toPromise(value) {
|
|
73
|
-
return isThenable(value) ? value : Promise.resolve(value);
|
|
74
|
-
}
|
|
49
|
+
// the sync-capable-async helpers are `@jarenjs/core/function`'s (one
|
|
50
|
+
// implementation in the suite); re-exported here because every store
|
|
51
|
+
// module and the public `@jarenjs/db` surface reach them through this seam
|
|
52
|
+
export { isThenable, chain, toPromise };
|
|
75
53
|
|
|
76
54
|
/**
|
|
77
55
|
* Compare two dotted version strings numerically.
|
package/src/emit.js
CHANGED
|
@@ -16,10 +16,84 @@
|
|
|
16
16
|
* logic never decides a row.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { codePointPrefixSuccessor } from '@jarenjs/core/string';
|
|
20
|
+
|
|
19
21
|
/**
|
|
20
|
-
* @typedef {{ external: string } | { literal: unknown }
|
|
22
|
+
* @typedef {{ external: string } | { literal: unknown } |
|
|
23
|
+
* { derived: { kind: 'bboxAxis', external: string,
|
|
24
|
+
* axis: 'w' | 's' | 'e' | 'n' } }} ParamSlot
|
|
25
|
+
* Three kinds, closed. A DERIVED slot is the escape for a value SQL
|
|
26
|
+
* cannot bind at all: a GeoJSON region arrives as an external object,
|
|
27
|
+
* and what the statement needs is one edge of its bounding box, so
|
|
28
|
+
* the binder computes that edge from the bound value. It is the same
|
|
29
|
+
* shape the prefix successor uses — compute in JavaScript what SQL
|
|
30
|
+
* cannot, bind an ordinary parameter — and it stays closed on
|
|
31
|
+
* purpose: a general expression slot would be a second query language
|
|
32
|
+
* living in the emitter.
|
|
21
33
|
*/
|
|
22
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The SQL for one string predicate. The prefix case is the only one
|
|
37
|
+
* with an index-usable spelling: a function of the value (`substr`) or
|
|
38
|
+
* a pattern match (`LIKE`) can only be scanned, while the half-open
|
|
39
|
+
* range `value >= p AND value < successor(p)` is a seek, and holds
|
|
40
|
+
* exactly the values beginning with `p` under a code-point ordering.
|
|
41
|
+
* The planner reaches here only with a non-empty literal pattern, so
|
|
42
|
+
* the successor is computable at emit time and needs no slot kind of
|
|
43
|
+
* its own; the one pattern that has no successor keeps the scannable
|
|
44
|
+
* form, which is correct, merely slow.
|
|
45
|
+
* @param {any} dialect
|
|
46
|
+
* @param {(slot: ParamSlot) => string} param
|
|
47
|
+
* @param {string} valueSql
|
|
48
|
+
* @param {any} pred
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
function stropForm(dialect, param, valueSql, pred) {
|
|
52
|
+
const pattern = pred.operand.lit;
|
|
53
|
+
const bind = () => param({ literal: pattern });
|
|
54
|
+
if (pred.kind === 'ends')
|
|
55
|
+
return dialect.strEndsWith(valueSql, bind(), bind(), bind());
|
|
56
|
+
if (pred.kind === 'contains')
|
|
57
|
+
return dialect.strContains(valueSql, bind());
|
|
58
|
+
const upper = codePointPrefixSuccessor(pattern);
|
|
59
|
+
return upper === null
|
|
60
|
+
? dialect.strStartsWithExact(valueSql, bind(), bind())
|
|
61
|
+
: dialect.strStartsWith(valueSql, bind(), param({ literal: upper }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The name a dialect uses for one slot's parameter reference.
|
|
66
|
+
* @param {ParamSlot} slot
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function slotName(slot) {
|
|
70
|
+
if ('external' in slot) return slot.external;
|
|
71
|
+
if ('derived' in slot) return slot.derived.external;
|
|
72
|
+
return 'value';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Where each edge sits in a `[west, south, east, north]` box. */
|
|
76
|
+
const BOX_AT = { w: 0, s: 1, e: 2, n: 3 };
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Bind one edge of the probe box: a plan-time literal box binds its own
|
|
80
|
+
* number, an external one binds a derived slot the binder computes from
|
|
81
|
+
* the value at call time.
|
|
82
|
+
*
|
|
83
|
+
* A positional dialect numbers parameters by the statement's TEXT
|
|
84
|
+
* order, so this is called in the order the placeholders appear and
|
|
85
|
+
* never in the order the box carries its edges.
|
|
86
|
+
* @param {any} probe
|
|
87
|
+
* @param {(slot: ParamSlot) => string} param
|
|
88
|
+
* @param {'w' | 's' | 'e' | 'n'} axis
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
function probeEdge(probe, param, axis) {
|
|
92
|
+
return 'box' in probe
|
|
93
|
+
? param({ literal: probe.box[BOX_AT[axis]] })
|
|
94
|
+
: param({ derived: { kind: 'bboxAxis', external: probe.ext, axis } });
|
|
95
|
+
}
|
|
96
|
+
|
|
23
97
|
/**
|
|
24
98
|
* Emit one plan as SQL plus its ordered parameter slots.
|
|
25
99
|
* @param {import('./algebra.js').Plan} plan
|
|
@@ -34,7 +108,7 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
34
108
|
const slots = [];
|
|
35
109
|
const param = (slot) => {
|
|
36
110
|
slots.push(slot);
|
|
37
|
-
return dialect.parameterRef(slots.length,
|
|
111
|
+
return dialect.parameterRef(slots.length, slotName(slot));
|
|
38
112
|
};
|
|
39
113
|
|
|
40
114
|
/** SQL for a ref's VALUE: the generated column when one exists. */
|
|
@@ -132,15 +206,51 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
132
206
|
return `${pred.name}(${dialect.jsonText(docColumn)})`;
|
|
133
207
|
case 'strop': {
|
|
134
208
|
const jt = typeOf(pred.ref);
|
|
135
|
-
const
|
|
136
|
-
const bind = () => param({ literal: /** @type {any} */ (pred.operand).lit });
|
|
137
|
-
const form = pred.kind === 'starts'
|
|
138
|
-
? dialect.strStartsWith(value, bind(), bind())
|
|
139
|
-
: pred.kind === 'ends'
|
|
140
|
-
? dialect.strEndsWith(value, bind(), bind(), bind())
|
|
141
|
-
: dialect.strContains(value, bind());
|
|
209
|
+
const form = stropForm(dialect, param, valueOf(pred.ref), pred);
|
|
142
210
|
return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
|
|
143
211
|
}
|
|
212
|
+
case 'bboxOverlap': {
|
|
213
|
+
// Two boxes meet when neither is wholly past the other, and
|
|
214
|
+
// TOUCHING counts (`bboxIntersects`), so these are `<=`/`>=`:
|
|
215
|
+
// a strict comparison would disagree with the engine on every
|
|
216
|
+
// shared edge. Emitted in the index's covered column order —
|
|
217
|
+
// (w, e, s, n) — so the leading longitude bound sits in front.
|
|
218
|
+
// The leading `IS NOT NULL` keeps the form TOTAL — a row with no
|
|
219
|
+
// box answers FALSE, not NULL, so a negation over an exact box
|
|
220
|
+
// test still composes classically — and it is also what makes
|
|
221
|
+
// the term SEEKABLE: it bounds the leading column from below,
|
|
222
|
+
// and a one-sided range alone loses to a table scan in SQLite's
|
|
223
|
+
// cost model, which prices a virtual generated column as a free
|
|
224
|
+
// column read when it is a host function call per row.
|
|
225
|
+
const c = pred.columns;
|
|
226
|
+
const edge = (axis) => probeEdge(pred.probe, param, axis);
|
|
227
|
+
return `(${q(c.w)} IS NOT NULL AND ${q(c.w)} <= ${edge('e')}`
|
|
228
|
+
+ ` AND ${q(c.e)} >= ${edge('w')}`
|
|
229
|
+
+ ` AND ${q(c.s)} <= ${edge('n')} AND ${q(c.n)} >= ${edge('s')})`;
|
|
230
|
+
}
|
|
231
|
+
case 'cellIn': {
|
|
232
|
+
// The cells are whole values of the column, so this is an
|
|
233
|
+
// equality set — and `IN` is the spelling that keeps it one:
|
|
234
|
+
// nine OR-ed ranges defeat SQLite's multi-index OR optimization
|
|
235
|
+
// (it gives up past a handful of terms) and fall back to a scan,
|
|
236
|
+
// which for a virtual generated column is a host function call
|
|
237
|
+
// per row.
|
|
238
|
+
const column = q(pred.column);
|
|
239
|
+
const list = pred.cells.map((cell) => param({ literal: cell })).join(', ');
|
|
240
|
+
return `(${column} IS NOT NULL AND ${column} IN (${list}))`;
|
|
241
|
+
}
|
|
242
|
+
case 'cellPrefix': {
|
|
243
|
+
// A cell SHORTER than the column's own: the half-open range an
|
|
244
|
+
// index seeks. Every cell is base-32, so the code-point
|
|
245
|
+
// successor always exists.
|
|
246
|
+
const column = q(pred.column);
|
|
247
|
+
const upper = codePointPrefixSuccessor(pred.prefix);
|
|
248
|
+
if (upper === null)
|
|
249
|
+
throw new Error('emit: a geohash cell has no code-point successor');
|
|
250
|
+
const range = dialect.strStartsWith(column, param({ literal: pred.prefix }),
|
|
251
|
+
param({ literal: upper }));
|
|
252
|
+
return `(${column} IS NOT NULL AND ${range})`;
|
|
253
|
+
}
|
|
144
254
|
default:
|
|
145
255
|
throw new Error(`emit: unknown predicate node '${/** @type {any} */ (pred).p}'`);
|
|
146
256
|
}
|
|
@@ -209,12 +319,7 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
209
319
|
: `(${jt} IS NOT NULL AND ${jt} NOT IN (${list}))`;
|
|
210
320
|
}
|
|
211
321
|
if (pred.p === 'strop') {
|
|
212
|
-
const
|
|
213
|
-
const form = pred.kind === 'starts'
|
|
214
|
-
? dialect.strStartsWith(value, bind(), bind())
|
|
215
|
-
: pred.kind === 'ends'
|
|
216
|
-
? dialect.strEndsWith(value, bind(), bind(), bind())
|
|
217
|
-
: dialect.strContains(value, bind());
|
|
322
|
+
const form = stropForm(dialect, param, value, pred);
|
|
218
323
|
return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
|
|
219
324
|
}
|
|
220
325
|
const lit = pred.operand.lit;
|
|
@@ -243,12 +348,7 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
243
348
|
: `(${column} IS NOT NULL AND ${column} <> ${param({ literal: wanted })})`;
|
|
244
349
|
}
|
|
245
350
|
if (pred.p === 'strop') {
|
|
246
|
-
const
|
|
247
|
-
const form = pred.kind === 'starts'
|
|
248
|
-
? dialect.strStartsWith(column, bind(), bind())
|
|
249
|
-
: pred.kind === 'ends'
|
|
250
|
-
? dialect.strEndsWith(column, bind(), bind(), bind())
|
|
251
|
-
: dialect.strContains(column, bind());
|
|
351
|
+
const form = stropForm(dialect, param, column, pred);
|
|
252
352
|
return `(${column} IS NOT NULL AND ${form})`;
|
|
253
353
|
}
|
|
254
354
|
const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
@@ -330,7 +430,7 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
|
330
430
|
const slots = [];
|
|
331
431
|
const param = (slot) => {
|
|
332
432
|
slots.push(slot);
|
|
333
|
-
return dialect.parameterRef(slots.length,
|
|
433
|
+
return dialect.parameterRef(slots.length, slotName(slot));
|
|
334
434
|
};
|
|
335
435
|
|
|
336
436
|
const aliases = new Map(plan.bindings.map((binding, i) => [
|
package/src/errors.js
CHANGED
|
@@ -22,7 +22,7 @@ export const DB_CODES = Object.freeze({
|
|
|
22
22
|
JD0001: 'the SQLite library is below the supported floor',
|
|
23
23
|
JD0002: 'the declared model disagrees with the existing database',
|
|
24
24
|
JD0003: 'the driver binding is unavailable on this runtime',
|
|
25
|
-
JD0004: '
|
|
25
|
+
JD0004: 'a declared index cannot be mapped to a column',
|
|
26
26
|
JD0005: 'the model document is invalid',
|
|
27
27
|
JD0010: 'strict mode refused a residual',
|
|
28
28
|
JD0011: 'the profile refused the document',
|
|
@@ -66,9 +66,11 @@ export const DB_CODES = Object.freeze({
|
|
|
66
66
|
* - `JD0003` — the runtime builtin behind a driver could not be
|
|
67
67
|
* loaded here (Node cannot resolve `bun:`; Bun ships no
|
|
68
68
|
* `node:sqlite`), or an injected handle is missing
|
|
69
|
-
* - `JD0004` —
|
|
70
|
-
*
|
|
71
|
-
*
|
|
69
|
+
* - `JD0004` — a declared index cannot be mapped to a column: its
|
|
70
|
+
* path does not select exactly one member (wildcards, slices,
|
|
71
|
+
* filters and descendants are not indexable), or its `derive`
|
|
72
|
+
* declaration is not one the storage vocabulary carries; the reason
|
|
73
|
+
* names the expression or the member and `docPath` points at it
|
|
72
74
|
* - `JD0005` — the model document is invalid; `docPath` points at
|
|
73
75
|
* the offending member
|
|
74
76
|
* - `JD0010` — `strict: true` and part of the query would have run
|
package/src/index.js
CHANGED
|
@@ -29,6 +29,11 @@ export { selectPlan, conjoin, assertNoSqlText, PLAN_VERSION } from './algebra.js
|
|
|
29
29
|
export { typeOfPath, isNumericType } from './types.js';
|
|
30
30
|
export { compileSetResidual, compileRowResidual, sequenceResult } from './residual.js';
|
|
31
31
|
export { deterministicFragment, registerFragment } from './udf.js';
|
|
32
|
+
export {
|
|
33
|
+
DERIVE_KINDS, BBOX_COMPONENTS, BBOX_INDEX_ORDER, PRECISION_MIN, PRECISION_MAX,
|
|
34
|
+
deriveGeohash, deriveBboxEdge, derivedValue, memberAt, storedMemberForm,
|
|
35
|
+
registerDeriveFunctions,
|
|
36
|
+
} from './derive.js';
|
|
32
37
|
export {
|
|
33
38
|
createQueryEngine, createQueryState, createEntityQueryEngine,
|
|
34
39
|
createLoadEngine, INCLUDE_DEPTH_DEFAULT,
|
package/src/jobs.js
CHANGED
|
@@ -26,7 +26,10 @@
|
|
|
26
26
|
* - **Shutdown is bounded.** Handlers receive an `AbortSignal` and
|
|
27
27
|
* `stop()` takes a deadline, so a handler that never settles cannot
|
|
28
28
|
* hold `stop()` — and therefore `store.close()`, and therefore the
|
|
29
|
-
* database file — open forever.
|
|
29
|
+
* database file — open forever. A loop the deadline could not drain
|
|
30
|
+
* is CANCELLED, not merely left behind: when its handler finally
|
|
31
|
+
* settles it exits without another claim, store write or poll
|
|
32
|
+
* timer, and its abandoned job recovers by lease expiry (§5).
|
|
30
33
|
*/
|
|
31
34
|
|
|
32
35
|
import { chain } from './driver.js';
|
|
@@ -370,6 +373,17 @@ export function createJobEngine(options) {
|
|
|
370
373
|
|
|
371
374
|
/** Aborted when `stop()` is called: the handler's cue to wind up. */
|
|
372
375
|
let shutdown = new AbortController();
|
|
376
|
+
/**
|
|
377
|
+
* The per-`start()` loop session. When a `stop()` grace deadline
|
|
378
|
+
* expires the session is cancelled: a loop that could not be
|
|
379
|
+
* drained must abandon its job — no completion or failure write,
|
|
380
|
+
* no further claim, no re-armed poll timer — because the store it
|
|
381
|
+
* would touch is the one the caller is about to close. The
|
|
382
|
+
* abandoned lease expires and the next claim re-runs the job (§5).
|
|
383
|
+
* A later `start()` opens a NEW session, so a cancelled loop can
|
|
384
|
+
* never be revived.
|
|
385
|
+
*/
|
|
386
|
+
let session = { cancelled: false };
|
|
373
387
|
/** In-flight handler count, so `stop()` can report what it left. */
|
|
374
388
|
let inFlight = 0;
|
|
375
389
|
|
|
@@ -389,7 +403,8 @@ export function createJobEngine(options) {
|
|
|
389
403
|
}
|
|
390
404
|
};
|
|
391
405
|
|
|
392
|
-
|
|
406
|
+
/** @param {{ cancelled: boolean }} loopSession */
|
|
407
|
+
const runOne = async (job, loopSession) => {
|
|
393
408
|
stats.claims += 1;
|
|
394
409
|
inFlight += 1;
|
|
395
410
|
try {
|
|
@@ -399,9 +414,13 @@ export function createJobEngine(options) {
|
|
|
399
414
|
{ job, checkpointsFor, signal: shutdown.signal });
|
|
400
415
|
}
|
|
401
416
|
catch (error) {
|
|
417
|
+
// past cancellation the store is closing: leave the leased
|
|
418
|
+
// row to expiry-based recovery (§5) instead of racing it
|
|
419
|
+
if (loopSession.cancelled) return;
|
|
402
420
|
await recordFailure(job, error);
|
|
403
421
|
return;
|
|
404
422
|
}
|
|
423
|
+
if (loopSession.cancelled) return;
|
|
405
424
|
try {
|
|
406
425
|
// a §7 handler may have completed transactionally already; the
|
|
407
426
|
// guarded update makes this a no-op then
|
|
@@ -420,8 +439,9 @@ export function createJobEngine(options) {
|
|
|
420
439
|
}
|
|
421
440
|
};
|
|
422
441
|
|
|
423
|
-
|
|
424
|
-
|
|
442
|
+
/** @param {{ cancelled: boolean }} loopSession */
|
|
443
|
+
const loop = async (loopSession) => {
|
|
444
|
+
while (running && !loopSession.cancelled) {
|
|
425
445
|
let job;
|
|
426
446
|
try {
|
|
427
447
|
job = await Promise.resolve(claim({
|
|
@@ -430,13 +450,13 @@ export function createJobEngine(options) {
|
|
|
430
450
|
catch {
|
|
431
451
|
job = undefined; // a transient storage failure: back off to the poll
|
|
432
452
|
}
|
|
433
|
-
if (!running) return;
|
|
453
|
+
if (!running || loopSession.cancelled) return;
|
|
434
454
|
if (job === undefined) {
|
|
435
455
|
await sleep();
|
|
436
456
|
continue;
|
|
437
457
|
}
|
|
438
458
|
try {
|
|
439
|
-
await runOne(job);
|
|
459
|
+
await runOne(job, loopSession);
|
|
440
460
|
}
|
|
441
461
|
catch {
|
|
442
462
|
// `runOne` normalizes every handler outcome, so reaching here
|
|
@@ -453,7 +473,12 @@ export function createJobEngine(options) {
|
|
|
453
473
|
if (running) throw new TypeError('the worker is already started');
|
|
454
474
|
running = true;
|
|
455
475
|
shutdown = new AbortController();
|
|
456
|
-
|
|
476
|
+
session = { cancelled: false };
|
|
477
|
+
loops = Array.from({ length: concurrency }, () => loop(session));
|
|
478
|
+
// a restart re-registers what stop() removed: the
|
|
479
|
+
// wake-on-enqueue hook and the stopAll membership
|
|
480
|
+
wakers.add(onWake);
|
|
481
|
+
workers.add(worker);
|
|
457
482
|
return worker;
|
|
458
483
|
},
|
|
459
484
|
/**
|
|
@@ -461,6 +486,9 @@ export function createJobEngine(options) {
|
|
|
461
486
|
* the loops — but only up to `graceMs`. A handler that ignores its
|
|
462
487
|
* signal cannot hold the process open; the resolved record says so
|
|
463
488
|
* instead, and the lease expiry (§5) lets another worker re-claim.
|
|
489
|
+
* A loop the grace period could not drain is cancelled outright:
|
|
490
|
+
* when its handler finally settles it exits without another
|
|
491
|
+
* claim, store write or poll timer.
|
|
464
492
|
* @param {{ graceMs?: number }} [stopOptions]
|
|
465
493
|
* @returns {Promise<{ drained: boolean, inFlight: number }>}
|
|
466
494
|
*/
|
|
@@ -478,6 +506,7 @@ export function createJobEngine(options) {
|
|
|
478
506
|
]);
|
|
479
507
|
clearTimeout(timer);
|
|
480
508
|
if (drained) loops = [];
|
|
509
|
+
else session.cancelled = true; // cancel what could not be drained
|
|
481
510
|
wakers.delete(onWake);
|
|
482
511
|
workers.delete(worker);
|
|
483
512
|
return { drained: drained === true, inFlight };
|