@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.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +412 -56
  2. package/README.md +600 -57
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +169 -20
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +752 -64
  8. package/docs/REPLICATION-FORMAT.md +208 -0
  9. package/package.json +21 -7
  10. package/schemas/jaren-model.draft-07.schema.json +224 -162
  11. package/schemas/jaren-model.schema.json +224 -162
  12. package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
  13. package/schemas/jaren-replication-snapshot.schema.json +83 -0
  14. package/schemas/jaren-replication.draft-07.schema.json +82 -0
  15. package/schemas/jaren-replication.schema.json +82 -0
  16. package/src/algebra.js +227 -9
  17. package/src/backup.js +161 -0
  18. package/src/cancellation.js +48 -0
  19. package/src/capture.js +230 -47
  20. package/src/cli.js +165 -59
  21. package/src/cursor.js +417 -0
  22. package/src/dag-job.js +154 -21
  23. package/src/ddl.js +102 -8
  24. package/src/dialect.js +268 -113
  25. package/src/dialects/expression-read.js +158 -0
  26. package/src/dialects/postgres.js +618 -0
  27. package/src/dialects/rtree-ddl.js +129 -0
  28. package/src/dialects/sqlite.js +244 -11
  29. package/src/document-files.js +311 -0
  30. package/src/document-steps.js +422 -0
  31. package/src/documents.js +335 -0
  32. package/src/driver.js +448 -61
  33. package/src/drivers/bun.js +37 -1
  34. package/src/drivers/indexeddb-snapshot.js +149 -0
  35. package/src/drivers/node-pool.js +11 -0
  36. package/src/drivers/node-worker-endpoint.js +105 -0
  37. package/src/drivers/node-worker.js +204 -0
  38. package/src/drivers/node.js +41 -7
  39. package/src/drivers/postgres.js +331 -0
  40. package/src/drivers/wasm-oo1.js +97 -0
  41. package/src/drivers/wasm-session.js +67 -0
  42. package/src/drivers/wasm.js +17 -83
  43. package/src/drivers/worker-pool.js +183 -0
  44. package/src/drivers/worker-protocol.js +79 -0
  45. package/src/drivers/worker-queue.js +60 -0
  46. package/src/emit.js +339 -48
  47. package/src/entity.js +20 -22
  48. package/src/errors.js +430 -19
  49. package/src/expression.js +284 -0
  50. package/src/graph.js +64 -8
  51. package/src/index.js +48 -17
  52. package/src/introspect.js +583 -0
  53. package/src/jobs.js +843 -107
  54. package/src/json-bytes.js +58 -0
  55. package/src/live-join.js +250 -0
  56. package/src/live-nested.js +120 -0
  57. package/src/live.js +18 -4
  58. package/src/logical-rows.js +90 -0
  59. package/src/maintenance.js +175 -0
  60. package/src/migrate.js +248 -181
  61. package/src/model.js +68 -0
  62. package/src/plan.js +1119 -138
  63. package/src/pragmas.js +314 -0
  64. package/src/profile.js +151 -3
  65. package/src/query.js +1634 -323
  66. package/src/replication-format.js +115 -0
  67. package/src/replication.js +332 -0
  68. package/src/residual.js +17 -0
  69. package/src/series.js +12 -4
  70. package/src/store.js +1567 -273
  71. package/src/tracker.js +203 -29
  72. package/src/udf.js +88 -7
  73. package/types/index.d.ts +1158 -27
  74. package/types/node-pool.d.ts +28 -0
  75. package/types/node-worker.d.ts +54 -0
  76. package/types/node.d.ts +69 -2
  77. package/types/postgres.d.ts +46 -0
  78. package/types/typed.d.ts +27 -4
  79. 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: the packed vector column the ranking reads,
80
- * its declared width, the probe (a plan-time literal vector, or the
81
- * external that carries one at call time), the window the ENGINE
82
- * will apply, and the inclusive score margin of the candidate cut.
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 } | null,
101
- * project: 'document',
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
+ }