@jarenjs/db 0.34.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 +397 -0
- package/README.md +218 -0
- package/dist/types/algebra.d.ts +133 -0
- package/dist/types/app.d.ts +49 -0
- package/dist/types/capture.d.ts +85 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/dag-job.d.ts +40 -0
- package/dist/types/ddl.d.ts +170 -0
- package/dist/types/dialect.d.ts +130 -0
- package/dist/types/dialects/sqlite.d.ts +9 -0
- package/dist/types/driver.d.ts +128 -0
- package/dist/types/drivers/bun.d.ts +47 -0
- package/dist/types/drivers/node.d.ts +37 -0
- package/dist/types/drivers/wasm.d.ts +65 -0
- package/dist/types/emit-model.d.ts +44 -0
- package/dist/types/emit.d.ts +72 -0
- package/dist/types/entity.d.ts +23 -0
- package/dist/types/errors.d.ts +165 -0
- package/dist/types/graph.d.ts +28 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/jobs.d.ts +134 -0
- package/dist/types/live.d.ts +62 -0
- package/dist/types/migrate.d.ts +163 -0
- package/dist/types/model.d.ts +36 -0
- package/dist/types/patch-sql.d.ts +37 -0
- package/dist/types/plan.d.ts +119 -0
- package/dist/types/profile.d.ts +80 -0
- package/dist/types/query.d.ts +100 -0
- package/dist/types/residual.d.ts +50 -0
- package/dist/types/store.d.ts +53 -0
- package/dist/types/tracker.d.ts +43 -0
- package/dist/types/typed.d.ts +15 -0
- package/dist/types/types.d.ts +26 -0
- package/dist/types/udf.d.ts +70 -0
- package/dist/types/window.d.ts +52 -0
- package/docs/JOBS-FORMAT.md +218 -0
- package/docs/LIVE-FORMAT.md +348 -0
- package/docs/MIGRATION-FORMAT.md +302 -0
- package/docs/MODEL-FORMAT.md +928 -0
- package/package.json +81 -0
- package/schemas/jaren-migration.draft-07.schema.json +144 -0
- package/schemas/jaren-migration.schema.json +144 -0
- package/schemas/jaren-model.draft-07.schema.json +149 -0
- package/schemas/jaren-model.schema.json +149 -0
- package/src/algebra.js +105 -0
- package/src/app.js +108 -0
- package/src/capture.js +584 -0
- package/src/cli.js +264 -0
- package/src/dag-job.js +86 -0
- package/src/ddl.js +588 -0
- package/src/dialect.js +297 -0
- package/src/dialects/sqlite.js +175 -0
- package/src/driver.js +419 -0
- package/src/drivers/bun.js +101 -0
- package/src/drivers/node.js +93 -0
- package/src/drivers/wasm.js +178 -0
- package/src/emit-model.js +208 -0
- package/src/emit.js +393 -0
- package/src/entity.js +367 -0
- package/src/errors.js +173 -0
- package/src/graph.js +101 -0
- package/src/index.js +64 -0
- package/src/jobs.js +507 -0
- package/src/live.js +899 -0
- package/src/migrate.js +1411 -0
- package/src/model.js +476 -0
- package/src/patch-sql.js +150 -0
- package/src/plan.js +1038 -0
- package/src/profile.js +131 -0
- package/src/query.js +1010 -0
- package/src/residual.js +91 -0
- package/src/store.js +1422 -0
- package/src/tracker.js +776 -0
- package/src/typed.js +19 -0
- package/src/types.js +36 -0
- package/src/udf.js +132 -0
- package/src/window.js +125 -0
- package/types/app.d.ts +36 -0
- package/types/bun.d.ts +9 -0
- package/types/index.d.ts +592 -0
- package/types/node.d.ts +15 -0
- package/types/typed.d.ts +108 -0
- package/types/wasm.d.ts +5 -0
package/src/capture.js
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Change capture (D13): committed writes become an observable,
|
|
4
|
+
* ordered stream of RFC 6902 patches — derived from SQLite's own
|
|
5
|
+
* session changesets where the binding has them, from a write-path
|
|
6
|
+
* journal where it does not (`bun:sqlite` has no `createSession`), or
|
|
7
|
+
* off entirely. One diff format then runs end to end: store → patch →
|
|
8
|
+
* live query → patch → O(k) render.
|
|
9
|
+
*
|
|
10
|
+
* The pointer contract (LIVE-FORMAT §2): `/<table>/<key>/<path…>`,
|
|
11
|
+
* every token escaped per RFC 6901. A single key renders as its
|
|
12
|
+
* scalar text (integers in decimal); a composite key renders as the
|
|
13
|
+
* JSON text of its parts array. Join-table rows are tiny documents
|
|
14
|
+
* under the join table's name — membership changes are part of the
|
|
15
|
+
* stream, not a blind spot.
|
|
16
|
+
*
|
|
17
|
+
* Session facts this file is built on (probed, 3.51.2):
|
|
18
|
+
* - a changeset carries ONE NET OP PER ROW (insert+update coalesce;
|
|
19
|
+
* insert+delete vanish; a no-op update is absent), and within-table
|
|
20
|
+
* order is NOT statement order — every row op targets a distinct
|
|
21
|
+
* pointer, so application order across rows cannot matter;
|
|
22
|
+
* - `ROLLBACK TO` a savepoint removes the undone rows from the
|
|
23
|
+
* session (pinned by test — the classic caveat does NOT hold here);
|
|
24
|
+
* - a rolled-back transaction yields an empty changeset;
|
|
25
|
+
* - virtual generated columns are invisible;
|
|
26
|
+
* - an UPDATE's old record carries the primary key and the CHANGED
|
|
27
|
+
* columns only — which is exactly enough for property-level ops,
|
|
28
|
+
* and why the doc column's old/new blobs make a minimal nested
|
|
29
|
+
* diff possible (`SELECT json(?)` turns JSONB back into text).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createJSONPatch } from '@jarenjs/json/patch';
|
|
33
|
+
import { encodeJSONPointerSegment, decodeJSONPointerSegment } from '@jarenjs/json/pointer';
|
|
34
|
+
|
|
35
|
+
import { DbRuntimeError } from './errors.js';
|
|
36
|
+
import { chain } from './driver.js';
|
|
37
|
+
|
|
38
|
+
/** The persisted change log (LIVE-FORMAT §5). */
|
|
39
|
+
export const CHANGES_TABLE = '_jaren_changes';
|
|
40
|
+
export const DEFAULT_RETENTION = 1000;
|
|
41
|
+
|
|
42
|
+
//#region the binary changeset parser
|
|
43
|
+
|
|
44
|
+
const OP_INSERT = 18;
|
|
45
|
+
const OP_UPDATE = 23;
|
|
46
|
+
const OP_DELETE = 9;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse one SQLite varint (1–9 bytes, big-endian 7-bit groups, the
|
|
50
|
+
* ninth byte carrying 8 bits).
|
|
51
|
+
* @param {Uint8Array} bytes
|
|
52
|
+
* @param {number} at
|
|
53
|
+
* @returns {{ value: number, next: number }}
|
|
54
|
+
*/
|
|
55
|
+
function readVarint(bytes, at) {
|
|
56
|
+
let value = 0;
|
|
57
|
+
for (let i = 0; i < 8; i++) {
|
|
58
|
+
const byte = bytes[at + i];
|
|
59
|
+
if ((byte & 0x80) === 0) return { value: value * 128 + byte, next: at + i + 1 };
|
|
60
|
+
value = value * 128 + (byte & 0x7f);
|
|
61
|
+
}
|
|
62
|
+
return { value: value * 256 + bytes[at + 8], next: at + 9 };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const utf8 = new TextDecoder();
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read one value record. `undefined` means "not present in this
|
|
69
|
+
* record" (an unchanged column); `null` is SQL NULL.
|
|
70
|
+
* @param {Uint8Array} bytes
|
|
71
|
+
* @param {number} at
|
|
72
|
+
* @returns {{ value: any, next: number }}
|
|
73
|
+
*/
|
|
74
|
+
function readValue(bytes, at) {
|
|
75
|
+
const type = bytes[at];
|
|
76
|
+
at += 1;
|
|
77
|
+
if (type === 0) return { value: undefined, next: at };
|
|
78
|
+
if (type === 5) return { value: null, next: at };
|
|
79
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
80
|
+
if (type === 1) {
|
|
81
|
+
const big = view.getBigInt64(at);
|
|
82
|
+
// JS documents cannot produce integers beyond 2^53 through this
|
|
83
|
+
// store; a raw-SQL value beyond it converts lossily (documented)
|
|
84
|
+
return { value: Number(big), next: at + 8 };
|
|
85
|
+
}
|
|
86
|
+
if (type === 2) return { value: view.getFloat64(at), next: at + 8 };
|
|
87
|
+
const { value: length, next } = readVarint(bytes, at);
|
|
88
|
+
const slice = bytes.subarray(next, next + length);
|
|
89
|
+
if (type === 3) return { value: utf8.decode(slice), next: next + length };
|
|
90
|
+
if (type === 4) return { value: slice.slice(), next: next + length };
|
|
91
|
+
throw new DbRuntimeError('JD2050',
|
|
92
|
+
`unknown changeset value type ${type} at offset ${at - 1}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Decode a binary changeset into row operations.
|
|
97
|
+
* @param {Uint8Array} bytes
|
|
98
|
+
* @returns {{ table: string, pk: boolean[], op: 'insert'|'update'|'delete',
|
|
99
|
+
* indirect: boolean, oldValues: any[] | null, newValues: any[] | null }[]}
|
|
100
|
+
*/
|
|
101
|
+
export function parseChangeset(bytes) {
|
|
102
|
+
/** @type {any[]} */
|
|
103
|
+
const operations = [];
|
|
104
|
+
let at = 0;
|
|
105
|
+
let table = '';
|
|
106
|
+
let columnCount = 0;
|
|
107
|
+
/** @type {boolean[]} */
|
|
108
|
+
let pk = [];
|
|
109
|
+
const readRecord = () => {
|
|
110
|
+
const values = [];
|
|
111
|
+
for (let i = 0; i < columnCount; i++) {
|
|
112
|
+
const read = readValue(bytes, at);
|
|
113
|
+
values.push(read.value);
|
|
114
|
+
at = read.next;
|
|
115
|
+
}
|
|
116
|
+
return values;
|
|
117
|
+
};
|
|
118
|
+
while (at < bytes.length) {
|
|
119
|
+
const marker = bytes[at];
|
|
120
|
+
if (marker === 0x54 /* 'T' */ || marker === 0x50 /* 'P' */) {
|
|
121
|
+
at += 1;
|
|
122
|
+
const n = readVarint(bytes, at);
|
|
123
|
+
columnCount = n.value;
|
|
124
|
+
at = n.next;
|
|
125
|
+
pk = [];
|
|
126
|
+
for (let i = 0; i < columnCount; i++) pk.push(bytes[at + i] !== 0);
|
|
127
|
+
at += columnCount;
|
|
128
|
+
let end = at;
|
|
129
|
+
while (bytes[end] !== 0) end += 1;
|
|
130
|
+
table = utf8.decode(bytes.subarray(at, end));
|
|
131
|
+
at = end + 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const op = bytes[at];
|
|
135
|
+
const indirect = bytes[at + 1] !== 0;
|
|
136
|
+
at += 2;
|
|
137
|
+
if (op === OP_INSERT) {
|
|
138
|
+
operations.push({ table, pk, op: 'insert', indirect,
|
|
139
|
+
oldValues: null, newValues: readRecord() });
|
|
140
|
+
}
|
|
141
|
+
else if (op === OP_DELETE) {
|
|
142
|
+
operations.push({ table, pk, op: 'delete', indirect,
|
|
143
|
+
oldValues: readRecord(), newValues: null });
|
|
144
|
+
}
|
|
145
|
+
else if (op === OP_UPDATE) {
|
|
146
|
+
const oldValues = readRecord();
|
|
147
|
+
operations.push({ table, pk, op: 'update', indirect,
|
|
148
|
+
oldValues, newValues: readRecord() });
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
throw new DbRuntimeError('JD2050',
|
|
152
|
+
`unknown changeset op ${op} at offset ${at - 2}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return operations;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
//#endregion
|
|
159
|
+
|
|
160
|
+
//#region the pointer contract
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The key token (LIVE-FORMAT §2): a single key is its scalar text;
|
|
164
|
+
* a composite key is the JSON text of its parts array.
|
|
165
|
+
* @param {any[]} parts
|
|
166
|
+
* @returns {string}
|
|
167
|
+
*/
|
|
168
|
+
export function keyToken(parts) {
|
|
169
|
+
return parts.length === 1 ? String(parts[0]) : JSON.stringify(parts);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const pointerOf = (table, token, path = '') =>
|
|
173
|
+
`/${encodeJSONPointerSegment(table)}/${encodeJSONPointerSegment(token)}${path}`;
|
|
174
|
+
|
|
175
|
+
//#endregion
|
|
176
|
+
|
|
177
|
+
//#region translation
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @typedef {{ kind: 'collection' | 'entity' | 'join',
|
|
181
|
+
* columns: { name: string, role: 'key'|'doc'|'scalar'|'fk'|'epoch',
|
|
182
|
+
* storage?: string }[],
|
|
183
|
+
* keyIndexes: number[], docIndex: number }} TableShape
|
|
184
|
+
*/
|
|
185
|
+
|
|
186
|
+
/** Decode one stored column value to its DOCUMENT-level value. */
|
|
187
|
+
function documentValue(column, value) {
|
|
188
|
+
if (value === null || value === undefined) return value;
|
|
189
|
+
if (column.storage === 'boolean') return value === 1;
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Translate parsed row operations into RFC 6902 ops, resolving JSONB
|
|
195
|
+
* blobs through the connection (`SELECT json(?)`).
|
|
196
|
+
* @param {any} connection
|
|
197
|
+
* @param {Map<string, TableShape>} shapes
|
|
198
|
+
* @param {any[]} operations
|
|
199
|
+
* @returns {any} value-or-promise of RFC 6902 ops
|
|
200
|
+
*/
|
|
201
|
+
export function translateOperations(connection, shapes, operations) {
|
|
202
|
+
const jsonOf = (blob) => (blob === undefined || blob === null
|
|
203
|
+
? blob
|
|
204
|
+
: chain(connection.prepare('SELECT json(?) AS t'), (statement) =>
|
|
205
|
+
chain(statement.get([blob]), (row) => JSON.parse(row.t))));
|
|
206
|
+
|
|
207
|
+
/** @type {any[]} */
|
|
208
|
+
const ops = [];
|
|
209
|
+
/** Translate ONE operation, mutating `ops`; value-or-promise. The
|
|
210
|
+
* caller drives ITERATIVELY — a self-recursive walk overflowed the
|
|
211
|
+
* stack on a 10k-row transaction (found by the live-query
|
|
212
|
+
* measurement, fixed here). */
|
|
213
|
+
const translateOne = (operation) => {
|
|
214
|
+
const shape = shapes.get(operation.table);
|
|
215
|
+
if (shape === undefined) return null; // an internal table
|
|
216
|
+
const record = operation.newValues ?? operation.oldValues ?? [];
|
|
217
|
+
const token = keyToken(shape.keyIndexes.map((index) =>
|
|
218
|
+
(operation.op === 'update'
|
|
219
|
+
? operation.oldValues?.[index]
|
|
220
|
+
: record[index])));
|
|
221
|
+
|
|
222
|
+
if (shape.kind === 'join') {
|
|
223
|
+
// membership rows are tiny documents under the join table's name
|
|
224
|
+
if (operation.op === 'insert') {
|
|
225
|
+
const value = {};
|
|
226
|
+
for (let c = 0; c < shape.columns.length; c++)
|
|
227
|
+
value[shape.columns[c].name] = record[c];
|
|
228
|
+
ops.push({ op: 'add', path: pointerOf(operation.table, token), value });
|
|
229
|
+
}
|
|
230
|
+
else if (operation.op === 'delete') {
|
|
231
|
+
ops.push({ op: 'remove', path: pointerOf(operation.table, token) });
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const buildDocument = (values) => chain(
|
|
237
|
+
jsonOf(values[shape.docIndex]), (doc) => {
|
|
238
|
+
const out = doc ?? {};
|
|
239
|
+
for (let c = 0; c < shape.columns.length; c++) {
|
|
240
|
+
const column = shape.columns[c];
|
|
241
|
+
if (column.role === 'doc' || column.role === 'epoch') continue;
|
|
242
|
+
// a collection's key column is physical bookkeeping — the
|
|
243
|
+
// document carries its own key member per the model
|
|
244
|
+
if (shape.kind === 'collection' && column.role === 'key') continue;
|
|
245
|
+
const value = values[c];
|
|
246
|
+
if (value === null || value === undefined) continue;
|
|
247
|
+
out[column.name] = documentValue(column, value);
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
if (operation.op === 'insert') {
|
|
253
|
+
return chain(buildDocument(record), (doc) => {
|
|
254
|
+
ops.push({ op: 'add', path: pointerOf(operation.table, token), value: doc });
|
|
255
|
+
return null;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (operation.op === 'delete') {
|
|
259
|
+
ops.push({ op: 'remove', path: pointerOf(operation.table, token) });
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
// update: property-level ops from the CHANGED columns; the doc
|
|
263
|
+
// column diffs old against new for a minimal nested patch
|
|
264
|
+
const columnOps = () => {
|
|
265
|
+
for (let c = 0; c < shape.columns.length; c++) {
|
|
266
|
+
const column = shape.columns[c];
|
|
267
|
+
const newValue = operation.newValues[c];
|
|
268
|
+
if (newValue === undefined || column.role === 'doc') continue;
|
|
269
|
+
if (column.role === 'epoch') continue; // derived; the doc string decides
|
|
270
|
+
if (shape.kind === 'collection' && column.role === 'key') continue;
|
|
271
|
+
const oldValue = operation.oldValues[c];
|
|
272
|
+
const path = pointerOf(operation.table, token, `/${encodeJSONPointerSegment(column.name)}`);
|
|
273
|
+
if (newValue === null) {
|
|
274
|
+
if (oldValue !== null) ops.push({ op: 'remove', path });
|
|
275
|
+
}
|
|
276
|
+
else if (oldValue === null) {
|
|
277
|
+
ops.push({ op: 'add', path, value: documentValue(column, newValue) });
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
ops.push({ op: 'replace', path, value: documentValue(column, newValue) });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
const newDocBlob = operation.newValues[shape.docIndex];
|
|
285
|
+
if (newDocBlob === undefined) {
|
|
286
|
+
columnOps();
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
return chain(jsonOf(operation.oldValues[shape.docIndex]), (oldDoc) =>
|
|
290
|
+
chain(jsonOf(newDocBlob), (newDoc) => {
|
|
291
|
+
columnOps();
|
|
292
|
+
const prefix = pointerOf(operation.table, token);
|
|
293
|
+
for (const op of createJSONPatch(oldDoc ?? {}, newDoc ?? {})) {
|
|
294
|
+
ops.push({ ...op, path: `${prefix}${op.path}`,
|
|
295
|
+
...(op.from !== undefined ? { from: `${prefix}${op.from}` } : {}) });
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}));
|
|
299
|
+
};
|
|
300
|
+
let index = 0;
|
|
301
|
+
const drive = () => {
|
|
302
|
+
while (index < operations.length) {
|
|
303
|
+
const outcome = translateOne(operations[index]);
|
|
304
|
+
index += 1;
|
|
305
|
+
if (outcome instanceof Promise) return outcome.then(drive);
|
|
306
|
+
}
|
|
307
|
+
return ops;
|
|
308
|
+
};
|
|
309
|
+
return drive();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
//#endregion
|
|
313
|
+
|
|
314
|
+
//#region the capture engine
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* @param {{ connection: any, shapes: Map<string, TableShape>,
|
|
318
|
+
* mode: 'session' | 'journal',
|
|
319
|
+
* log: boolean, retention: number }} options
|
|
320
|
+
* @returns {any}
|
|
321
|
+
*/
|
|
322
|
+
export function createCaptureEngine(options) {
|
|
323
|
+
const { connection, shapes, mode } = options;
|
|
324
|
+
const dialect = connection.dialect;
|
|
325
|
+
const q = dialect.quoteIdentifier;
|
|
326
|
+
|
|
327
|
+
/** @type {Set<Function>} */
|
|
328
|
+
const observers = new Set();
|
|
329
|
+
let seq = 0;
|
|
330
|
+
let depth = 0;
|
|
331
|
+
/** @type {any} */
|
|
332
|
+
let session = null;
|
|
333
|
+
/** @type {any[]} */
|
|
334
|
+
let journal = [];
|
|
335
|
+
/** @type {any[]} */
|
|
336
|
+
const pendingDeliveries = [];
|
|
337
|
+
|
|
338
|
+
const logStatements = options.log ? {
|
|
339
|
+
create: dialect.ddl.createPlainTable({
|
|
340
|
+
table: CHANGES_TABLE,
|
|
341
|
+
columns: [
|
|
342
|
+
{ name: 'seq', type: dialect.typeFor('integer', 'key'), primaryKey: true },
|
|
343
|
+
{ name: 'at', type: dialect.typeFor('integer', 'key') },
|
|
344
|
+
{ name: 'source', type: dialect.typeFor('string', 'key') },
|
|
345
|
+
{ name: 'patch', type: dialect.typeFor('string', 'key') },
|
|
346
|
+
],
|
|
347
|
+
}),
|
|
348
|
+
insert: `INSERT INTO ${q(CHANGES_TABLE)} `
|
|
349
|
+
+ `(${['seq', 'at', 'source', 'patch'].map(q).join(', ')}) `
|
|
350
|
+
+ `VALUES (${[1, 2, 3, 4].map((i) => dialect.parameterRef(i, 'v')).join(', ')})`,
|
|
351
|
+
prune: `DELETE FROM ${q(CHANGES_TABLE)} WHERE ${q('seq')} <= ${dialect.parameterRef(1, 'v')}`,
|
|
352
|
+
highest: `SELECT MAX(${q('seq')}) AS ${q('n')} FROM ${q(CHANGES_TABLE)}`,
|
|
353
|
+
read: `SELECT ${['seq', 'at', 'source', 'patch'].map(q).join(', ')} `
|
|
354
|
+
+ `FROM ${q(CHANGES_TABLE)} WHERE ${q('seq')} > ${dialect.parameterRef(1, 'v')} `
|
|
355
|
+
+ `ORDER BY ${q('seq')}`,
|
|
356
|
+
} : null;
|
|
357
|
+
|
|
358
|
+
const ready = logStatements === null
|
|
359
|
+
? null
|
|
360
|
+
: chain(connection.exec(logStatements.create), () =>
|
|
361
|
+
chain(connection.prepare(logStatements.highest), (statement) =>
|
|
362
|
+
chain(statement.get([]), (row) => {
|
|
363
|
+
seq = Number(row?.n ?? 0) || 0;
|
|
364
|
+
return null;
|
|
365
|
+
})));
|
|
366
|
+
|
|
367
|
+
/** A patch value must be JSON: a `{ ...doc, m: undefined }` write
|
|
368
|
+
* stores the member ABSENT, and the journal must record the JSON
|
|
369
|
+
* reality, not the JavaScript artifact. `undefined`/`null` sentinels
|
|
370
|
+
* pass through untouched. */
|
|
371
|
+
const jsonReality = (doc) => (doc === undefined || doc === null
|
|
372
|
+
? doc
|
|
373
|
+
: JSON.parse(JSON.stringify(doc)));
|
|
374
|
+
|
|
375
|
+
/** Journal-mode emission from the write paths. */
|
|
376
|
+
const record = (table, keyParts, before, after) => {
|
|
377
|
+
if (mode !== 'journal' || depth === 0) return;
|
|
378
|
+
journal.push({ table, keyParts,
|
|
379
|
+
before: jsonReality(before), after: jsonReality(after) });
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const journalOps = () => {
|
|
383
|
+
// ONE NET OP PER ROW (§2), the same discipline sessions get for
|
|
384
|
+
// free: fold every entry for one (table, key) into first-before /
|
|
385
|
+
// last-after, so insert+update coalesces, insert+delete vanishes,
|
|
386
|
+
// and an update back to the original emits nothing. Found by the
|
|
387
|
+
// wasm parity suite — the original differential script never wrote
|
|
388
|
+
// the same row twice in one transaction.
|
|
389
|
+
/** @type {Map<string, any>} */
|
|
390
|
+
const netted = new Map();
|
|
391
|
+
/** @type {string[]} */
|
|
392
|
+
const order = [];
|
|
393
|
+
for (const entry of journal) {
|
|
394
|
+
const token = keyToken(entry.keyParts);
|
|
395
|
+
const key = `${entry.table}\u0000${token}`;
|
|
396
|
+
const existing = netted.get(key);
|
|
397
|
+
if (existing === undefined) {
|
|
398
|
+
netted.set(key, {
|
|
399
|
+
table: entry.table, token,
|
|
400
|
+
before: entry.before, after: entry.after,
|
|
401
|
+
});
|
|
402
|
+
order.push(key);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
existing.after = entry.after;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/** @type {any[]} */
|
|
409
|
+
const ops = [];
|
|
410
|
+
for (const key of order) {
|
|
411
|
+
const entry = netted.get(key);
|
|
412
|
+
const prefix = pointerOf(entry.table, entry.token);
|
|
413
|
+
if (entry.before === null && entry.after === null) continue;
|
|
414
|
+
if (entry.before === null) {
|
|
415
|
+
ops.push({ op: 'add', path: prefix, value: entry.after });
|
|
416
|
+
}
|
|
417
|
+
else if (entry.after === null) {
|
|
418
|
+
ops.push({ op: 'remove', path: prefix });
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
for (const op of createJSONPatch(entry.before, entry.after)) {
|
|
422
|
+
ops.push({ ...op, path: `${prefix}${op.path}`,
|
|
423
|
+
...(op.from !== undefined ? { from: `${prefix}${op.from}` } : {}) });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return ops;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
/** Collect this commit's patch (inside the transaction). */
|
|
431
|
+
const collect = () => {
|
|
432
|
+
if (mode === 'session') {
|
|
433
|
+
const changeset = session.changeset();
|
|
434
|
+
session.close();
|
|
435
|
+
session = null;
|
|
436
|
+
if (changeset.length === 0) return [];
|
|
437
|
+
return translateOperations(connection, shapes, parseChangeset(changeset));
|
|
438
|
+
}
|
|
439
|
+
const ops = journalOps();
|
|
440
|
+
journal = [];
|
|
441
|
+
return ops;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const persist = (patch, at) => {
|
|
445
|
+
if (logStatements === null || patch.length === 0) return null;
|
|
446
|
+
seq += 1;
|
|
447
|
+
const mySeq = seq;
|
|
448
|
+
return chain(connection.prepare(logStatements.insert), (insert) =>
|
|
449
|
+
chain(insert.run([mySeq, at, mode, JSON.stringify(patch)]), () =>
|
|
450
|
+
chain(connection.prepare(logStatements.prune), (prune) =>
|
|
451
|
+
chain(prune.run([mySeq - options.retention]), () => null))));
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const deliver = () => {
|
|
455
|
+
while (pendingDeliveries.length > 0) {
|
|
456
|
+
const delivery = pendingDeliveries.shift();
|
|
457
|
+
for (const observer of [...observers]) {
|
|
458
|
+
// error isolation: a throwing observer must never affect the
|
|
459
|
+
// write (the app.observe discipline)
|
|
460
|
+
try {
|
|
461
|
+
observer(delivery);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
// deliberately swallowed; the write already committed
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Run `fn` inside the capture scope: the OUTERMOST scope opens a
|
|
472
|
+
* session (or journal buffer) plus a transaction, translates and
|
|
473
|
+
* persists inside it, and delivers to observers after commit.
|
|
474
|
+
*/
|
|
475
|
+
const wrap = (fn) => {
|
|
476
|
+
if (depth > 0) return fn();
|
|
477
|
+
depth = 1;
|
|
478
|
+
if (mode === 'session') session = connection.session();
|
|
479
|
+
else journal = [];
|
|
480
|
+
const cleanupFailure = () => {
|
|
481
|
+
depth = 0;
|
|
482
|
+
if (session !== null) {
|
|
483
|
+
session.close();
|
|
484
|
+
session = null;
|
|
485
|
+
}
|
|
486
|
+
journal = [];
|
|
487
|
+
};
|
|
488
|
+
let outcome;
|
|
489
|
+
try {
|
|
490
|
+
outcome = connection.transaction(() =>
|
|
491
|
+
chain(fn(), (result) =>
|
|
492
|
+
chain(collect(), (patch) => {
|
|
493
|
+
if (patch.length === 0) return { result, delivery: null };
|
|
494
|
+
const at = Date.now();
|
|
495
|
+
return chain(persist(patch, at), () => ({
|
|
496
|
+
result,
|
|
497
|
+
delivery: {
|
|
498
|
+
seq: logStatements === null ? (seq += 1) : seq,
|
|
499
|
+
at,
|
|
500
|
+
source: mode,
|
|
501
|
+
collections: [...new Set(patch.map(
|
|
502
|
+
(op) => decodeJSONPointerSegment(op.path.split('/')[1]))),
|
|
503
|
+
],
|
|
504
|
+
patch,
|
|
505
|
+
},
|
|
506
|
+
}));
|
|
507
|
+
})));
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
cleanupFailure();
|
|
511
|
+
throw error;
|
|
512
|
+
}
|
|
513
|
+
const finish = (bundle) => {
|
|
514
|
+
depth = 0;
|
|
515
|
+
if (bundle.delivery !== null) pendingDeliveries.push(bundle.delivery);
|
|
516
|
+
deliver();
|
|
517
|
+
return bundle.result;
|
|
518
|
+
};
|
|
519
|
+
if (outcome instanceof Promise) {
|
|
520
|
+
return outcome.then(finish, (error) => {
|
|
521
|
+
cleanupFailure();
|
|
522
|
+
throw error;
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
return finish(outcome);
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* A NESTED transaction scope: in journal mode, records buffered by
|
|
530
|
+
* an inner transaction that rolls back (and whose failure the
|
|
531
|
+
* caller CATCHES) must vanish exactly as a session drops rows
|
|
532
|
+
* undone by ROLLBACK TO — checkpoint the buffer and truncate on
|
|
533
|
+
* failure.
|
|
534
|
+
*/
|
|
535
|
+
const nest = (fn) => {
|
|
536
|
+
if (depth === 0) return wrap(fn);
|
|
537
|
+
if (mode !== 'journal') return fn();
|
|
538
|
+
const mark = journal.length;
|
|
539
|
+
let outcome;
|
|
540
|
+
try {
|
|
541
|
+
outcome = fn();
|
|
542
|
+
}
|
|
543
|
+
catch (error) {
|
|
544
|
+
journal.length = mark;
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
if (outcome instanceof Promise) {
|
|
548
|
+
return outcome.then((value) => value, (error) => {
|
|
549
|
+
journal.length = mark;
|
|
550
|
+
throw error;
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return outcome;
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
mode,
|
|
558
|
+
ready,
|
|
559
|
+
wrap,
|
|
560
|
+
nest,
|
|
561
|
+
record,
|
|
562
|
+
observe(fn) {
|
|
563
|
+
if (typeof fn !== 'function')
|
|
564
|
+
throw new TypeError('observe needs a function');
|
|
565
|
+
observers.add(fn);
|
|
566
|
+
return () => observers.delete(fn);
|
|
567
|
+
},
|
|
568
|
+
changesSince(after) {
|
|
569
|
+
if (logStatements === null) {
|
|
570
|
+
throw new DbRuntimeError('JD2051',
|
|
571
|
+
'the change log is not enabled — open the store with capture.log');
|
|
572
|
+
}
|
|
573
|
+
return chain(connection.prepare(logStatements.read), (statement) =>
|
|
574
|
+
chain(statement.all([after]), (rows) => rows.map((row) => ({
|
|
575
|
+
seq: Number(row.seq),
|
|
576
|
+
at: Number(row.at),
|
|
577
|
+
source: String(row.source),
|
|
578
|
+
patch: JSON.parse(row.patch),
|
|
579
|
+
}))));
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
//#endregion
|