@jarenjs/db 0.56.0 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +412 -56
- package/README.md +600 -57
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +169 -20
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +752 -64
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +230 -47
- package/src/cli.js +165 -59
- package/src/cursor.js +417 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +268 -113
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +244 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +17 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +430 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1567 -273
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1158 -27
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +27 -4
- package/types/wasm.d.ts +14 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://jarenjs.dev/schemas/jaren-replication",
|
|
4
|
+
"title": "Portable logical replication transaction",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"$replication",
|
|
9
|
+
"replica",
|
|
10
|
+
"seq",
|
|
11
|
+
"frontier",
|
|
12
|
+
"model",
|
|
13
|
+
"operations"
|
|
14
|
+
],
|
|
15
|
+
"properties": {
|
|
16
|
+
"$replication": {
|
|
17
|
+
"const": "0.1"
|
|
18
|
+
},
|
|
19
|
+
"replica": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"minLength": 1,
|
|
22
|
+
"maxLength": 128
|
|
23
|
+
},
|
|
24
|
+
"seq": {
|
|
25
|
+
"type": "integer",
|
|
26
|
+
"minimum": 1,
|
|
27
|
+
"maximum": 9007199254740991
|
|
28
|
+
},
|
|
29
|
+
"frontier": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"propertyNames": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"minLength": 1,
|
|
34
|
+
"maxLength": 128
|
|
35
|
+
},
|
|
36
|
+
"additionalProperties": {
|
|
37
|
+
"type": "integer",
|
|
38
|
+
"minimum": 0,
|
|
39
|
+
"maximum": 9007199254740991
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"model": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"minLength": 1
|
|
45
|
+
},
|
|
46
|
+
"operations": {
|
|
47
|
+
"type": "array",
|
|
48
|
+
"minItems": 1,
|
|
49
|
+
"items": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"additionalProperties": false,
|
|
52
|
+
"required": [
|
|
53
|
+
"table",
|
|
54
|
+
"key",
|
|
55
|
+
"before",
|
|
56
|
+
"after"
|
|
57
|
+
],
|
|
58
|
+
"properties": {
|
|
59
|
+
"table": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
|
|
62
|
+
},
|
|
63
|
+
"key": {
|
|
64
|
+
"type": "string"
|
|
65
|
+
},
|
|
66
|
+
"before": {
|
|
67
|
+
"type": [
|
|
68
|
+
"object",
|
|
69
|
+
"null"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
"after": {
|
|
73
|
+
"type": [
|
|
74
|
+
"object",
|
|
75
|
+
"null"
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/algebra.js
CHANGED
|
@@ -27,7 +27,23 @@ export const PLAN_VERSION = 2;
|
|
|
27
27
|
* schema-declared type or `'unknown'`; `column` is the generated
|
|
28
28
|
* column name when the collection indexes this path.
|
|
29
29
|
*
|
|
30
|
-
* @typedef {{ lit: unknown } | { ext: string }} PlanOperand
|
|
30
|
+
* @typedef {{ lit: unknown } | { ext: string } | { seek: string }} PlanOperand
|
|
31
|
+
* A SEEK operand names one of the plan's own seeks: a scalar the
|
|
32
|
+
* database itself supplies before the statement runs (`PlanSeek`).
|
|
33
|
+
*
|
|
34
|
+
* @typedef {{ name: string, kind: 'number' | 'text', ref: PlanRef,
|
|
35
|
+
* bound: { op: 'le' | 'ge', lit: number | string },
|
|
36
|
+
* inner: 'max' | 'min', outer: 'min' | 'max',
|
|
37
|
+
* group: PlanRef | null, keys: (string | number)[] | null }} PlanSeek
|
|
38
|
+
* One anchor the plan asks the database for before it binds. An
|
|
39
|
+
* as-of batch cannot bound its far side by arithmetic on the probes:
|
|
40
|
+
* the row that answers the EARLIEST probe may lie arbitrarily far
|
|
41
|
+
* before it. The tight bound is the data's own — per group, the last
|
|
42
|
+
* instant at or before that probe — and the least of those over the
|
|
43
|
+
* groups is a scalar every group's answer is at or above. It is one
|
|
44
|
+
* aggregate read through the same declared index, and it binds
|
|
45
|
+
* through a TYPED slot: its type is the instant column's declared
|
|
46
|
+
* type, so the comparison needs no bind-time type branch.
|
|
31
47
|
*
|
|
32
48
|
* @typedef {(
|
|
33
49
|
* { p: 'and' | 'or', items: PlanPredicate[] } |
|
|
@@ -42,7 +58,11 @@ export const PLAN_VERSION = 2;
|
|
|
42
58
|
* { p: 'bboxOverlap', columns: { w: string, s: string, e: string,
|
|
43
59
|
* n: string }, probe: { box: number[] } | { ext: string } } |
|
|
44
60
|
* { p: 'cellIn', column: string, cells: string[] } |
|
|
45
|
-
* { p: 'cellPrefix', column: string, prefix: string }
|
|
61
|
+
* { p: 'cellPrefix', column: string, prefix: string } |
|
|
62
|
+
* { p: 'interval', columns: { start: string, end: string },
|
|
63
|
+
* probe: { from: number, to: number } } |
|
|
64
|
+
* { p: 'colCmp', op: 'eq' | 'lt' | 'le' | 'gt' | 'ge',
|
|
65
|
+
* column: string, operand: { lit: unknown } | { seek: string } }
|
|
46
66
|
* )} PlanPredicate
|
|
47
67
|
* The last three are the SPATIAL forms: predicates over the derived
|
|
48
68
|
* index columns a model declares, which a spatial conjunct either
|
|
@@ -56,8 +76,62 @@ export const PLAN_VERSION = 2;
|
|
|
56
76
|
* box or no cell answers FALSE rather than SQL's NULL and negation
|
|
57
77
|
* still composes classically.
|
|
58
78
|
*
|
|
79
|
+
* `colCmp` is the same idea one comparison wide: a bound over a
|
|
80
|
+
* DECLARED column, with no `json_type` beside it. The planner builds
|
|
81
|
+
* it only where the comparison NARROWS and something else decides —
|
|
82
|
+
* the temporal refinements, whose residual re-runs the caller's own
|
|
83
|
+
* operator — because without the guard the column's own type rules
|
|
84
|
+
* decide a cross-type row rather than the engine's. Narrowing is what
|
|
85
|
+
* makes that safe: SQL's ordering keeps a row the guard would have
|
|
86
|
+
* dropped, never the other way about, and the kernel then answers for
|
|
87
|
+
* it. In exchange the statement stops reading the document once per
|
|
88
|
+
* row to discriminate a member the model already declared.
|
|
89
|
+
*
|
|
90
|
+
* `interval` is the temporal one and carries no guard for the same
|
|
91
|
+
* kind of reason: the planner admits it only over a member the schema
|
|
92
|
+
* types as an object of two REQUIRED numeric bounds, so a stored
|
|
93
|
+
* bound that is absent or textual is a row the collection cannot
|
|
94
|
+
* hold. It is true where the row's half-open span meets the probe's
|
|
95
|
+
* — and ALSO where the row's own span is empty or reversed, which is
|
|
96
|
+
* not an overlap but a row §8.16 RAISES on: a pre-filter narrows, and
|
|
97
|
+
* a narrowing that swallowed an error would answer where the engine
|
|
98
|
+
* does not.
|
|
99
|
+
*
|
|
100
|
+
* @typedef {(
|
|
101
|
+
* { p: 'leaf', index: number } |
|
|
102
|
+
* { p: 'key', index: number } |
|
|
103
|
+
* { p: 'agg', index: number } |
|
|
104
|
+
* { p: 'lit', value: unknown } |
|
|
105
|
+
* { p: 'object', members: { name: string, node: PlanProjectionNode }[] } |
|
|
106
|
+
* { p: 'array', items: PlanProjectionNode[] }
|
|
107
|
+
* )} PlanProjectionNode
|
|
108
|
+
* The shape one projected row answers, rebuilt by the decoder from
|
|
109
|
+
* the LEAVES the statement fetched — never by parsing a JSON text the
|
|
110
|
+
* database assembled, which could not tell an absent member from a
|
|
111
|
+
* present `null`. `leaf` indexes the plan's `leaves`, one entry per
|
|
112
|
+
* DISTINCT member path (a path used twice is fetched once); `lit` is a
|
|
113
|
+
* value from the caller's document, present in every row even when it
|
|
114
|
+
* is `null`, where a leaf that finds nothing is omitted from its
|
|
115
|
+
* object and skipped in its array — the engine's own rule.
|
|
116
|
+
*
|
|
59
117
|
* @typedef {{ ref: PlanRef, desc: boolean, emptyGreatest: boolean }} PlanOrderTerm
|
|
60
118
|
*
|
|
119
|
+
* @typedef {{ keys: { as: string, ref: PlanRef }[],
|
|
120
|
+
* aggregates: { as: string, fn: 'rows' | 'sum' | 'avg' | 'min' | 'max',
|
|
121
|
+
* ref: PlanRef | null, empty: 'zero' | 'omit' | 'null' }[],
|
|
122
|
+
* tree: PlanProjectionNode,
|
|
123
|
+
* order: 'first-seen' | { index: number, desc: boolean, nullsFirst: boolean }[]
|
|
124
|
+
* }} PlanGroup
|
|
125
|
+
* The GENERAL `GROUP BY`: one key per declared grouping name, the
|
|
126
|
+
* closed aggregate set over the group's rows, and the projection tree
|
|
127
|
+
* the decoder rebuilds each group's answer from — where a `key` node
|
|
128
|
+
* reads a key's value beside its JSON type (an absent key is a group
|
|
129
|
+
* whose member is omitted, which SQL's `NULL` alone could not say) and
|
|
130
|
+
* an `agg` node reads one aggregate under its `empty` rule. `order` is
|
|
131
|
+
* `'first-seen'`, the engine's own order of first appearance, or the
|
|
132
|
+
* group-key ordering an `$orderby` declared. A plan carrying a group
|
|
133
|
+
* carries no `bucket`, no `aggregate` and no `rank`.
|
|
134
|
+
*
|
|
61
135
|
* @typedef {{ ref: PlanRef, every: number, origin: number, as: string,
|
|
62
136
|
* order: 'asc' | 'desc' | 'first-seen',
|
|
63
137
|
* aggregates: { fn: 'rows' | 'sum' | 'avg' | 'min' | 'max',
|
|
@@ -73,13 +147,18 @@ export const PLAN_VERSION = 2;
|
|
|
73
147
|
* which is the engine's own "order of first appearance" (§6.5).
|
|
74
148
|
* A plan carrying a bucket carries no `aggregate` and no `rank`.
|
|
75
149
|
*
|
|
76
|
-
* @typedef {{ column: string, dims: number,
|
|
150
|
+
* @typedef {{ alternatives: { column: string, dims: number }[],
|
|
77
151
|
* probe: { lit: number[] } | { ext: string },
|
|
78
152
|
* offset: number, limit: number, margin: number }} PlanRank
|
|
79
|
-
* The k-nearest stage:
|
|
80
|
-
* its
|
|
81
|
-
*
|
|
82
|
-
*
|
|
153
|
+
* The k-nearest stage: every packed vector column declared over the
|
|
154
|
+
* subject with its width — ONE for a literal probe, whose width is
|
|
155
|
+
* known at plan time, and one per declared width for an external
|
|
156
|
+
* probe, whose width the BIND names — the probe (a plan-time literal
|
|
157
|
+
* vector, or the external that carries one at call time), the window
|
|
158
|
+
* the ENGINE will apply, and the inclusive score margin of the
|
|
159
|
+
* candidate cut. A plan with several alternatives emits one statement
|
|
160
|
+
* per width and binds exactly one: a `CASE` across the columns would
|
|
161
|
+
* read every one of them per row.
|
|
83
162
|
* The column cuts — every row whose column score is within `margin`
|
|
84
163
|
* of the `offset + limit`-th best is a candidate — and the engine
|
|
85
164
|
* decides: the original document, its whole ordering and window
|
|
@@ -96,10 +175,23 @@ export const PLAN_VERSION = 2;
|
|
|
96
175
|
* window: { offset: number, limit: number | null } | null,
|
|
97
176
|
* rank: PlanRank | null,
|
|
98
177
|
* bucket: PlanBucket | null,
|
|
178
|
+
* group: PlanGroup | null,
|
|
179
|
+
* seeks: PlanSeek[],
|
|
99
180
|
* aggregate: { fn: 'count' | 'sum' | 'avg' | 'min' | 'max',
|
|
100
|
-
* ref: PlanRef | null }
|
|
101
|
-
*
|
|
181
|
+
* ref: PlanRef | null }
|
|
182
|
+
* | { fn: 'registered', ref: PlanRef, operator: string, sql: string }
|
|
183
|
+
* | null,
|
|
184
|
+
* project: 'document' | { path: PlanRef }
|
|
185
|
+
* | { tree: PlanProjectionNode, leaves: PlanRef[] },
|
|
102
186
|
* }} Plan
|
|
187
|
+
* `project` is what each row answers: the whole document, or ONE
|
|
188
|
+
* member path — its value as JSON text beside its JSON type, so the
|
|
189
|
+
* reader tells a present `null` from an absent member and a boolean
|
|
190
|
+
* from an integer exactly as the engine does. An aggregate whose `fn`
|
|
191
|
+
* is `'registered'` is the Ring-3 one: `operator` is the registry name
|
|
192
|
+
* the document used and `sql` the function the store registered for it
|
|
193
|
+
* — a NAME, not a fragment of any query language, exactly as a `udf`
|
|
194
|
+
* predicate carries one.
|
|
103
195
|
*/
|
|
104
196
|
|
|
105
197
|
/**
|
|
@@ -117,11 +209,137 @@ export function selectPlan(collection) {
|
|
|
117
209
|
window: null,
|
|
118
210
|
rank: null,
|
|
119
211
|
bucket: null,
|
|
212
|
+
group: null,
|
|
213
|
+
seeks: [],
|
|
120
214
|
aggregate: null,
|
|
121
215
|
project: 'document',
|
|
122
216
|
};
|
|
123
217
|
}
|
|
124
218
|
|
|
219
|
+
/**
|
|
220
|
+
* @typedef {{ source: 'column' | 'document' | 'group' | 'identity',
|
|
221
|
+
* binding: string | null, column: string | null,
|
|
222
|
+
* path: (string | number)[] | null, desc: boolean,
|
|
223
|
+
* nullsFirst: boolean | null, tieBreaker: boolean }} EffectiveOrderTerm
|
|
224
|
+
* One term of the order a statement actually executes under. `source`
|
|
225
|
+
* is closed: a mapped `column`, a `document` path the dialect
|
|
226
|
+
* extracts, the `group` key of a bucketed plan, or the row `identity`
|
|
227
|
+
* the emitters append so a sequence answers in insertion order. Only
|
|
228
|
+
* a `column` term carries a column name, only a `document` term a
|
|
229
|
+
* path, and the identity term carries neither — its value is the
|
|
230
|
+
* row's, not the document's, which is why `nullsFirst` is `null`
|
|
231
|
+
* there (a row identity is never absent). `tieBreaker` marks a term
|
|
232
|
+
* the plan appended rather than one the caller declared.
|
|
233
|
+
*/
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Whether an order term reads its value from a mapped column rather
|
|
237
|
+
* than the document. Two ref shapes reach here — a collection
|
|
238
|
+
* {@link PlanRef}, which has a column or does not, and an entity ref,
|
|
239
|
+
* which also carries a flavor: an `entity-epoch` column exists but
|
|
240
|
+
* orders by the document string (mixed stored precisions would sort
|
|
241
|
+
* the integer column differently), so a flavored ref answers by its
|
|
242
|
+
* flavor and only a plain one by its column.
|
|
243
|
+
* @param {any} ref
|
|
244
|
+
* @returns {boolean}
|
|
245
|
+
*/
|
|
246
|
+
export function ordersByColumn(ref) {
|
|
247
|
+
return ref.flavor === undefined ? ref.column !== null : ref.flavor === 'entity-column';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* One declared order term as its effective form.
|
|
252
|
+
* @param {{ ref: any, desc: boolean, emptyGreatest: boolean }} term
|
|
253
|
+
* @param {string | null} [binding]
|
|
254
|
+
* @returns {EffectiveOrderTerm}
|
|
255
|
+
*/
|
|
256
|
+
function declaredTerm(term, binding = null) {
|
|
257
|
+
const byColumn = ordersByColumn(term.ref);
|
|
258
|
+
return {
|
|
259
|
+
// Jaren's default sorts an empty key least: NULLS FIRST ascending,
|
|
260
|
+
// NULLS LAST descending — and mirrored for `$empty: 'greatest'`
|
|
261
|
+
source: byColumn ? 'column' : 'document',
|
|
262
|
+
binding,
|
|
263
|
+
column: byColumn ? term.ref.column : null,
|
|
264
|
+
path: byColumn ? null : term.ref.segments.map((segment) =>
|
|
265
|
+
('name' in segment ? segment.name : segment.index)),
|
|
266
|
+
desc: term.desc,
|
|
267
|
+
nullsFirst: term.emptyGreatest === term.desc,
|
|
268
|
+
tieBreaker: false,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The row-identity tie-breaker an emitter appends.
|
|
274
|
+
* @param {string | null} [binding]
|
|
275
|
+
* @param {boolean} [tieBreaker]
|
|
276
|
+
* @returns {EffectiveOrderTerm}
|
|
277
|
+
*/
|
|
278
|
+
function identityTerm(binding = null, tieBreaker = true) {
|
|
279
|
+
return { source: 'identity', binding, column: null, path: null,
|
|
280
|
+
desc: false, nullsFirst: null, tieBreaker };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The effective order a set of declared terms executes under: the
|
|
285
|
+
* terms themselves, then one row-identity tie-breaker per binding —
|
|
286
|
+
* the ordering the emitters append so a collection answers in its
|
|
287
|
+
* insertion order and a join in the engine's nested-loop order. Given
|
|
288
|
+
* `keyColumns`, the appended tie-breaker is the primary key instead
|
|
289
|
+
* (keyset mode: a continuation must resume from a value the row
|
|
290
|
+
* carries, which a row identity is not).
|
|
291
|
+
* @param {{ ref: any, desc: boolean, emptyGreatest: boolean,
|
|
292
|
+
* binding?: string }[] | null} terms
|
|
293
|
+
* @param {{ bindings?: (string | null)[],
|
|
294
|
+
* keyColumns?: readonly string[] }} [options]
|
|
295
|
+
* @returns {EffectiveOrderTerm[]}
|
|
296
|
+
*/
|
|
297
|
+
export function effectiveOrder(terms, options = undefined) {
|
|
298
|
+
const declared = (terms ?? []).map((term) =>
|
|
299
|
+
declaredTerm(term, term.binding ?? null));
|
|
300
|
+
const keyColumns = options?.keyColumns;
|
|
301
|
+
if (keyColumns !== undefined) {
|
|
302
|
+
for (const column of keyColumns) {
|
|
303
|
+
// a key column is NOT NULL, so its null placement is the ASC
|
|
304
|
+
// default, spelled rather than branched on
|
|
305
|
+
if (!declared.some((term) => term.source === 'column' && term.column === column)) {
|
|
306
|
+
declared.push({ source: 'column', binding: null, column,
|
|
307
|
+
desc: false, nullsFirst: true, path: null, tieBreaker: true });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return declared;
|
|
311
|
+
}
|
|
312
|
+
for (const binding of options?.bindings ?? [null]) declared.push(identityTerm(binding));
|
|
313
|
+
return declared;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The effective order of one plan — the same normalized order its
|
|
318
|
+
* emitter renders, never read back out of SQL. `null` is the honest
|
|
319
|
+
* answer for a statement that orders nothing: an aggregate answers one
|
|
320
|
+
* row, and a k-nearest fetch is deliberately unordered because the
|
|
321
|
+
* ENGINE ranks the candidates it returns.
|
|
322
|
+
* @param {any} plan - a select, entity-select or entity-join plan
|
|
323
|
+
* @returns {EffectiveOrderTerm[] | null}
|
|
324
|
+
*/
|
|
325
|
+
export function planOrder(plan) {
|
|
326
|
+
if (plan === null || plan === undefined) return null;
|
|
327
|
+
if (plan.alg === 'entity-select' || plan.alg === 'entity-join') {
|
|
328
|
+
if (plan.aggregate !== null) return null;
|
|
329
|
+
return effectiveOrder(plan.order, { bindings: plan.bindings.map((b) => b.name) });
|
|
330
|
+
}
|
|
331
|
+
if (plan.aggregate !== null || plan.rank !== null) return null;
|
|
332
|
+
if (plan.bucket !== null) {
|
|
333
|
+
// the bucket owns its own ordering: the group key, or — for the
|
|
334
|
+
// engine's order of first appearance — the group's earliest row
|
|
335
|
+
return [plan.bucket.order === 'first-seen'
|
|
336
|
+
? identityTerm(null, false)
|
|
337
|
+
: { source: 'group', binding: null, column: plan.bucket.as, path: null,
|
|
338
|
+
desc: plan.bucket.order === 'desc', nullsFirst: false, tieBreaker: false }];
|
|
339
|
+
}
|
|
340
|
+
return effectiveOrder(plan.order);
|
|
341
|
+
}
|
|
342
|
+
|
|
125
343
|
/**
|
|
126
344
|
* Conjoin a predicate onto a plan's filter.
|
|
127
345
|
* @param {PlanPredicate | null} filter
|
package/src/backup.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Online backup with atomic publication. `backupTo(targetPath)`
|
|
4
|
+
* copies a live store through the driver's backup primitive and
|
|
5
|
+
* publishes the copy by rename, so the target path never holds a
|
|
6
|
+
* partial file: the copy is written to a temporary sibling in the same
|
|
7
|
+
* directory (one file system, so the rename is atomic), renamed onto
|
|
8
|
+
* the target only once the platform reported the copy complete, and
|
|
9
|
+
* deleted on every other outcome — a cancellation, a copy failure, a
|
|
10
|
+
* rename failure. An incomplete backup is never something a later
|
|
11
|
+
* process could mistake for a good one.
|
|
12
|
+
*
|
|
13
|
+
* The copy runs OFF the store gate: an online backup's whole point is
|
|
14
|
+
* that writers proceed. SQLite updates the copy in place when this
|
|
15
|
+
* connection writes during it and restarts the copy when another
|
|
16
|
+
* connection does; only the checkpoint that fixes the snapshot boundary
|
|
17
|
+
* takes the gate, exactly as the other maintenance operations do.
|
|
18
|
+
*
|
|
19
|
+
* Cancellation is honoured BETWEEN pages, at the granularity the
|
|
20
|
+
* platform reports progress (`rate` pages per step): the platform
|
|
21
|
+
* itself takes no signal, so the signal is checked in the progress
|
|
22
|
+
* callback and a throw there is what stops the pump. There is no
|
|
23
|
+
* progress event with a zero remainder — the platform's completion
|
|
24
|
+
* signal is the resolved copy, which answers the page total — so
|
|
25
|
+
* progress events are handed on verbatim and completion is the promise.
|
|
26
|
+
*
|
|
27
|
+
* This module imports no runtime builtin: the copy, the rename and the
|
|
28
|
+
* removal are the driver's primitives (`connection.backup`), present on
|
|
29
|
+
* the Node binding and declared absent elsewhere.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { DbRuntimeError, wrapDriverError } from './errors.js';
|
|
33
|
+
import { chain, attempt, toPromise } from './driver.js';
|
|
34
|
+
import { refuseCancelled } from './cancellation.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The temporary sibling of a target path: same directory, a marker a
|
|
38
|
+
* sweep can recognise, and a suffix from the store's injected
|
|
39
|
+
* randomness.
|
|
40
|
+
* @param {string} targetPath
|
|
41
|
+
* @param {() => number} random
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function temporaryPathFor(targetPath, random) {
|
|
45
|
+
const word = () => Math.floor(random() * 0x100000000).toString(16).padStart(8, '0');
|
|
46
|
+
return `${targetPath}.jaren-tmp-${word()}${word()}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build `backupTo` over a connection.
|
|
51
|
+
* @param {{ connection: any, readOnly: boolean,
|
|
52
|
+
* gated: (fn: () => any, what: string) => any,
|
|
53
|
+
* checkpoint: (options?: { mode?: string }) => any,
|
|
54
|
+
* random: () => number, now: () => number }} context - `checkpoint`
|
|
55
|
+
* is the maintenance surface's own (ungated; the gate is taken here
|
|
56
|
+
* around it); `now` is the clock a deadline is read against
|
|
57
|
+
* @returns {{ capability: boolean,
|
|
58
|
+
* backupTo: (targetPath: string, options?: any) => any }}
|
|
59
|
+
*/
|
|
60
|
+
export function createBackup({ connection, readOnly, gated, checkpoint, random, now }) {
|
|
61
|
+
const capability = connection.capabilities.backup === true && connection.backup !== null;
|
|
62
|
+
|
|
63
|
+
/** The driver's own failure, classed and wrapped under the backup's
|
|
64
|
+
* lifecycle code; a coded error is the error. A file-system failure
|
|
65
|
+
* (a refused rename, a missing directory the platform reports as
|
|
66
|
+
* `unable to open`) rides the same wrap. */
|
|
67
|
+
const failed = (error) => wrapDriverError(error, { code: 'JD2078', reason: 'the backup failed', always: true });
|
|
68
|
+
|
|
69
|
+
/** @param {AbortSignal} signal */
|
|
70
|
+
const cancelled = (signal) => new DbRuntimeError('JD2079',
|
|
71
|
+
'the backup was cancelled between pages; the temporary file was removed and the '
|
|
72
|
+
+ 'target path was not written', { cause: signal.reason });
|
|
73
|
+
/** The check before the copy starts and between its pages: an abort
|
|
74
|
+
* is the backup's own lifecycle code, a passed deadline the one
|
|
75
|
+
* deadline code, both with the file consequences named. */
|
|
76
|
+
const callable = (options, ran) => refuseCancelled(options, now,
|
|
77
|
+
{ abortCode: 'JD2079', aborted: 'the copy started', passed: 'the next page', ran });
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
capability,
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} targetPath
|
|
83
|
+
* @param {{ rate?: number, onProgress?: (progress: { totalPages: number,
|
|
84
|
+
* remainingPages: number }) => void, signal?: AbortSignal,
|
|
85
|
+
* deadline?: number, checkpoint?: string | false }} [options]
|
|
86
|
+
* @returns {any} value-or-promise of `{ path, pages, checkpoint }`
|
|
87
|
+
*/
|
|
88
|
+
backupTo(targetPath, options = undefined) {
|
|
89
|
+
if (typeof targetPath !== 'string' || targetPath.length === 0)
|
|
90
|
+
throw new TypeError('backupTo: targetPath is a non-empty string');
|
|
91
|
+
const rate = options?.rate;
|
|
92
|
+
if (rate !== undefined && (!Number.isSafeInteger(rate) || rate < 1))
|
|
93
|
+
throw new TypeError(`backupTo: rate is a positive integer (pages per step), got ${JSON.stringify(rate)}`);
|
|
94
|
+
const onProgress = options?.onProgress;
|
|
95
|
+
if (onProgress !== undefined && typeof onProgress !== 'function')
|
|
96
|
+
throw new TypeError('backupTo: onProgress is a function');
|
|
97
|
+
const signal = options?.signal;
|
|
98
|
+
if (!capability) {
|
|
99
|
+
throw new DbRuntimeError('JD2077',
|
|
100
|
+
"the maintenance operation 'backupTo' is unavailable on this store: the driver's binding "
|
|
101
|
+
+ 'declares no backup primitive');
|
|
102
|
+
}
|
|
103
|
+
// the snapshot boundary: the checkpoint mode, `false` to skip it,
|
|
104
|
+
// and skipped by default on a read-only store, where a checkpoint
|
|
105
|
+
// is not an operation this store may run
|
|
106
|
+
const mode = options?.checkpoint === undefined
|
|
107
|
+
? (readOnly ? false : 'passive')
|
|
108
|
+
: options.checkpoint;
|
|
109
|
+
if (mode !== false && (typeof mode !== 'string'))
|
|
110
|
+
throw new TypeError("backupTo: checkpoint is a wal_checkpoint mode or false");
|
|
111
|
+
callable(options, 'no file was written');
|
|
112
|
+
|
|
113
|
+
const boundary = mode === false
|
|
114
|
+
? null
|
|
115
|
+
: gated(() => checkpoint({ mode }), 'a backup checkpoint');
|
|
116
|
+
return chain(boundary, (checkpointed) => {
|
|
117
|
+
callable(options, 'no file was written');
|
|
118
|
+
const tmpPath = temporaryPathFor(targetPath, random);
|
|
119
|
+
const files = connection.backup;
|
|
120
|
+
/** Remove the temporary file, then settle with `error`; a
|
|
121
|
+
* removal that itself fails rides along rather than replacing
|
|
122
|
+
* the reason the backup failed. */
|
|
123
|
+
const discard = (error) => chain(
|
|
124
|
+
attempt(() => files.remove(tmpPath), (removal) => {
|
|
125
|
+
error.cleanupError = removal;
|
|
126
|
+
return error;
|
|
127
|
+
}),
|
|
128
|
+
() => { throw error; });
|
|
129
|
+
let aborted = null;
|
|
130
|
+
const progress = (report) => {
|
|
131
|
+
if (signal?.aborted === true) {
|
|
132
|
+
aborted = cancelled(signal);
|
|
133
|
+
throw aborted;
|
|
134
|
+
}
|
|
135
|
+
// a passed deadline stops the pump between pages exactly as an
|
|
136
|
+
// abort does, under its own code
|
|
137
|
+
callable(options, 'the temporary file was removed and the target path was not written');
|
|
138
|
+
onProgress?.({ totalPages: report.totalPages, remainingPages: report.remainingPages });
|
|
139
|
+
};
|
|
140
|
+
let copying;
|
|
141
|
+
try {
|
|
142
|
+
copying = files.copy(tmpPath, rate === undefined ? { progress } : { rate, progress });
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
return discard(aborted ?? failed(error));
|
|
146
|
+
}
|
|
147
|
+
// publication: the platform's resolved copy is its completion
|
|
148
|
+
// signal (it answers the page total); only then is the
|
|
149
|
+
// temporary file renamed onto the target. The file primitives
|
|
150
|
+
// are asynchronous on every host that has them, so this leg is
|
|
151
|
+
// a promise on purpose
|
|
152
|
+
const publish = (pages) => chain(
|
|
153
|
+
attempt(() => files.rename(tmpPath, targetPath), failed),
|
|
154
|
+
() => Object.freeze({ path: targetPath, pages: Number(pages), checkpoint: checkpointed ?? null }));
|
|
155
|
+
return toPromise(copying)
|
|
156
|
+
.then((pages) => toPromise(publish(pages)))
|
|
157
|
+
.catch((error) => discard(aborted ?? failed(error)));
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The ONE cancellation check every unit of work in this package
|
|
4
|
+
* runs before it starts: an aborted signal is a coded refusal naming
|
|
5
|
+
* what was cancelled, a deadline is validated and read against the
|
|
6
|
+
* clock the caller was given — the store's runtime record's, never the
|
|
7
|
+
* platform's — and a passed one is `JD2075`, the one deadline code,
|
|
8
|
+
* whatever the unit (a statement, a row, a step, a batch, a page).
|
|
9
|
+
*
|
|
10
|
+
* The check runs BETWEEN units, never inside one: the shipped SQLite
|
|
11
|
+
* drivers expose no interrupt, so a statement that has started runs to
|
|
12
|
+
* its end, and the capability report says so (`cancellation.midStatement`
|
|
13
|
+
* is `false`). Each caller names the abort code its lifecycle owns —
|
|
14
|
+
* `JD2072` for a query, `JD2080` for a migration, `JD2081` for a
|
|
15
|
+
* maintenance operation, `JD2079` for a backup — and the unit it was
|
|
16
|
+
* about to start, so the refusal says exactly where the work stopped.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { DbRuntimeError } from './errors.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Refuse to start the next unit of work when the caller's signal has
|
|
23
|
+
* aborted or its deadline has passed.
|
|
24
|
+
* @param {{ signal?: AbortSignal, deadline?: number } | undefined} options
|
|
25
|
+
* @param {() => number} now - the clock the deadline is read against
|
|
26
|
+
* @param {{ abortCode: string, aborted: string, passed: string, ran?: string }} what -
|
|
27
|
+
* the abort code this lifecycle owns, the unit an abort was caught
|
|
28
|
+
* before ("the call was aborted before <aborted>"), the unit a passed
|
|
29
|
+
* deadline was caught before ("the deadline passed before <passed>"),
|
|
30
|
+
* and what has NOT happened as a result (default: "no statement was
|
|
31
|
+
* issued")
|
|
32
|
+
*/
|
|
33
|
+
export function refuseCancelled(options, now, what) {
|
|
34
|
+
const ran = what.ran ?? 'no statement was issued';
|
|
35
|
+
const signal = options?.signal;
|
|
36
|
+
if (signal?.aborted === true) {
|
|
37
|
+
throw new DbRuntimeError(what.abortCode,
|
|
38
|
+
`the call was aborted before ${what.aborted}: ${ran}`, { cause: signal.reason });
|
|
39
|
+
}
|
|
40
|
+
const deadline = options?.deadline;
|
|
41
|
+
if (deadline === undefined) return;
|
|
42
|
+
if (typeof deadline !== 'number' || !Number.isFinite(deadline))
|
|
43
|
+
throw new TypeError('deadline is an epoch-millisecond number');
|
|
44
|
+
if (now() > deadline) {
|
|
45
|
+
throw new DbRuntimeError('JD2075',
|
|
46
|
+
`the deadline passed before ${what.passed} (${new Date(deadline).toISOString()}); ${ran}`);
|
|
47
|
+
}
|
|
48
|
+
}
|