@jarenjs/db 0.72.2 → 0.73.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/docs/REPLICATION-FORMAT.md +2 -1
- package/package.json +4 -4
- package/src/dag-job.js +1 -1
- package/src/migrate.js +4 -3
- package/src/replication-tables.js +10 -0
- package/src/replication.js +31 -31
|
@@ -111,7 +111,8 @@ Operation overflow is `JD2106`; all credits are positive safe integers. Retentio
|
|
|
111
111
|
prunes only outgoing envelope history. Replay receipts, row tombstones and
|
|
112
112
|
conflict evidence remain durable. They are deliberately not claimed to be a
|
|
113
113
|
bounded total database size. Internal tables use the reserved `_jaren_replica`
|
|
114
|
-
prefix
|
|
114
|
+
prefix and are excluded from model introspection and migration shape comparisons.
|
|
115
|
+
No schema migration or revision rewrite of this protocol is implicit.
|
|
115
116
|
|
|
116
117
|
## Conflicts
|
|
117
118
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarenjs/db",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.73.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./types/index.d.ts",
|
|
@@ -84,9 +84,9 @@
|
|
|
84
84
|
"prepack": "npm run build:types"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@jarenjs/core": "^0.
|
|
88
|
-
"@jarenjs/json": "^0.
|
|
89
|
-
"@jarenjs/validate": "^0.
|
|
87
|
+
"@jarenjs/core": "^0.73.0",
|
|
88
|
+
"@jarenjs/json": "^0.73.0",
|
|
89
|
+
"@jarenjs/validate": "^0.73.0"
|
|
90
90
|
},
|
|
91
91
|
"bin": {
|
|
92
92
|
"jaren-db": "./src/cli.js"
|
package/src/dag-job.js
CHANGED
|
@@ -199,7 +199,7 @@ export function createDagJobRunner(store, options) {
|
|
|
199
199
|
compiled.taskVersions);
|
|
200
200
|
// the handler's signal reaches every task: a worker winding down
|
|
201
201
|
// inside its grace period, or a lease this attempt has lost
|
|
202
|
-
return await compiled.run(input, { runId: runKey, signal: context.signal });
|
|
202
|
+
return await compiled.run(input, { runId: runKey, signal: context.signal, drainOnAbort: true });
|
|
203
203
|
}
|
|
204
204
|
finally {
|
|
205
205
|
active.delete(runKey);
|
package/src/migrate.js
CHANGED
|
@@ -36,6 +36,7 @@ import { planQuery } from './plan.js';
|
|
|
36
36
|
import { createQueryEngine, createQueryState } from './query.js';
|
|
37
37
|
import { CHANGES_TABLE, CHANGES_STATE_TABLE } from './capture.js';
|
|
38
38
|
import { JOBS_TABLE, JOB_CHECKPOINTS_TABLE } from './jobs.js';
|
|
39
|
+
import { REPLICATION_TABLES } from './replication-tables.js';
|
|
39
40
|
import { planCollection, verifyShape, planEntity, planJoinTable } from './ddl.js';
|
|
40
41
|
import { normalizeEntities, explainMapping } from './model.js';
|
|
41
42
|
import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
|
|
@@ -79,7 +80,7 @@ export const HISTORY_TABLE = '_jaren_migrations';
|
|
|
79
80
|
* anybody's drift — the drift check skips them and the introspector
|
|
80
81
|
* does not derive them. */
|
|
81
82
|
export const ENGINE_TABLES = new Set([HISTORY_TABLE, CHANGES_TABLE, CHANGES_STATE_TABLE,
|
|
82
|
-
JOBS_TABLE, JOB_CHECKPOINTS_TABLE]);
|
|
83
|
+
JOBS_TABLE, JOB_CHECKPOINTS_TABLE, ...Object.values(REPLICATION_TABLES)]);
|
|
83
84
|
|
|
84
85
|
/**
|
|
85
86
|
* The signature-grade identity of a model SHAPE.
|
|
@@ -889,7 +890,7 @@ export function createModelShape(connection, model, expressions = undefined) {
|
|
|
889
890
|
/**
|
|
890
891
|
* The declared schema of a database, normalized for comparison: every
|
|
891
892
|
* object carrying SQL text (tables, indexes), whitespace-collapsed,
|
|
892
|
-
*
|
|
893
|
+
* engine-owned tables excluded, sorted. Shape equality after a migration —
|
|
893
894
|
* this dump versus a fresh {@link createModelShape} — is the
|
|
894
895
|
* acceptance criterion for every rebuild.
|
|
895
896
|
* @param {any} connection
|
|
@@ -900,7 +901,7 @@ export function schemaShapeOf(connection) {
|
|
|
900
901
|
return chain(connection.prepare(dialect.introspect.schemaDump()), (statement) =>
|
|
901
902
|
chain(statement.all([]), (rows) => rows
|
|
902
903
|
// the engine's own tables — history, the change log and its state
|
|
903
|
-
// row, the job queue — are never a model's drift
|
|
904
|
+
// row, the job queue and replication ledger — are never a model's drift
|
|
904
905
|
.filter((row) => !ENGINE_TABLES.has(String(row.name)) && !ENGINE_TABLES.has(String(row.owner)))
|
|
905
906
|
.map((row) => ({
|
|
906
907
|
type: String(row.type),
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Engine-owned tables shared by replication storage and schema inspection. */
|
|
3
|
+
export const REPLICATION_TABLES = Object.freeze({
|
|
4
|
+
state: '_jaren_replica',
|
|
5
|
+
rows: '_jaren_replica_rows',
|
|
6
|
+
receipts: '_jaren_replica_receipts',
|
|
7
|
+
outbox: '_jaren_replica_outbox',
|
|
8
|
+
conflicts: '_jaren_replica_conflicts',
|
|
9
|
+
claims: '_jaren_replica_claims',
|
|
10
|
+
});
|
package/src/replication.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
//@ts-check
|
|
2
2
|
/** Durable replication state shares the data transaction and capture settlement. */
|
|
3
|
-
import { stableStringify } from '@jarenjs/core/object';
|
|
3
|
+
import { stableStringify, deepFreeze } from '@jarenjs/core/object';
|
|
4
4
|
import { applyJSONPatch } from '@jarenjs/json/patch';
|
|
5
5
|
import { decodeJSONPointerSegment } from '@jarenjs/json/pointer';
|
|
6
6
|
import { utf8Length, assertItemBytes, createCursor, drainPage } from './cursor.js';
|
|
@@ -8,6 +8,7 @@ import { chain } from './driver.js';
|
|
|
8
8
|
import { DbRuntimeError } from './errors.js';
|
|
9
9
|
import { refuseCancelled } from './cancellation.js';
|
|
10
10
|
import { normalizeReplication, normalizeReplicationSnapshot, normalizeFrontier, replicationIdentity, REPLICATION_DEFAULTS } from './replication-format.js';
|
|
11
|
+
import { REPLICATION_TABLES } from './replication-tables.js';
|
|
11
12
|
|
|
12
13
|
const equal = (a, b) => stableStringify(a) === stableStringify(b);
|
|
13
14
|
const position = (frontier, id) => Object.hasOwn(frontier, id) ? frontier[id] : 0;
|
|
@@ -39,11 +40,11 @@ export function createReplicationEngine(options) {
|
|
|
39
40
|
if (config.resolver !== undefined && (typeof config.resolver?.id !== 'string' || !config.resolver.id
|
|
40
41
|
|| typeof config.resolver.resolve !== 'function')) throw new TypeError('replication.resolver needs a stable id and pure resolve function');
|
|
41
42
|
const sql = (text, method = 'run', params = []) => chain(connection.prepare(text), (s) => s[method](params));
|
|
42
|
-
const state = () => chain(sql(
|
|
43
|
-
const saveState = (value) => sql(
|
|
44
|
-
const rowState = (table, key) => chain(sql(
|
|
43
|
+
const state = () => chain(sql(`SELECT value FROM ${REPLICATION_TABLES.state} WHERE id = 1`, 'get'), (row) => JSON.parse(row.value));
|
|
44
|
+
const saveState = (value) => sql(`UPDATE ${REPLICATION_TABLES.state} SET value = ? WHERE id = 1`, 'run', [stableStringify(value)]);
|
|
45
|
+
const rowState = (table, key) => chain(sql(`SELECT value, frontier FROM ${REPLICATION_TABLES.rows} WHERE name = ? AND key = ?`, 'get', [table, key]),
|
|
45
46
|
(row) => row === undefined ? { value: null, frontier: {} } : { value: JSON.parse(row.value), frontier: JSON.parse(row.frontier) });
|
|
46
|
-
const saveRow = (operation, frontier) => sql(
|
|
47
|
+
const saveRow = (operation, frontier) => sql(`INSERT INTO ${REPLICATION_TABLES.rows} (name, key, value, frontier) VALUES (?, ?, ?, ?) `
|
|
47
48
|
+ 'ON CONFLICT(name, key) DO UPDATE SET value = excluded.value, frontier = excluded.frontier', 'run',
|
|
48
49
|
[operation.table, operation.key, stableStringify(operation.after), stableStringify(frontier)]);
|
|
49
50
|
const cancelled = (request) => refuseCancelled(request, now, {
|
|
@@ -53,7 +54,7 @@ export function createReplicationEngine(options) {
|
|
|
53
54
|
if (document.operations.length > config.maxOperations) fail('JD2106', 'replication operation bound exceeded');
|
|
54
55
|
assertItemBytes(utf8Length(stableStringify(document)), config.maxBytes);
|
|
55
56
|
};
|
|
56
|
-
const receipt = (envelope) => sql(
|
|
57
|
+
const receipt = (envelope) => sql(`INSERT INTO ${REPLICATION_TABLES.receipts} (id, payload) VALUES (?, ?)`, 'run',
|
|
57
58
|
[replicationIdentity(envelope.replica, envelope.seq), stableStringify(envelope)]);
|
|
58
59
|
// Pull one row at a time and refuse before accumulating beyond shared credits.
|
|
59
60
|
const collectBounded = async (query, params, map, request, credits) => {
|
|
@@ -70,13 +71,13 @@ export function createReplicationEngine(options) {
|
|
|
70
71
|
};
|
|
71
72
|
|
|
72
73
|
const ready = bracket(() => chain(each([
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
], (ddl) => connection.exec(ddl)), () => chain(sql(
|
|
74
|
+
`CREATE TABLE IF NOT EXISTS ${REPLICATION_TABLES.state} (id INTEGER PRIMARY KEY, value TEXT NOT NULL)`,
|
|
75
|
+
`CREATE TABLE IF NOT EXISTS ${REPLICATION_TABLES.rows} (name TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, frontier TEXT NOT NULL, PRIMARY KEY(name, key))`,
|
|
76
|
+
`CREATE TABLE IF NOT EXISTS ${REPLICATION_TABLES.receipts} (id TEXT PRIMARY KEY, payload TEXT NOT NULL)`,
|
|
77
|
+
`CREATE TABLE IF NOT EXISTS ${REPLICATION_TABLES.outbox} (seq INTEGER PRIMARY KEY, payload TEXT NOT NULL)`,
|
|
78
|
+
`CREATE TABLE IF NOT EXISTS ${REPLICATION_TABLES.conflicts} (id TEXT PRIMARY KEY, evidence TEXT NOT NULL)`,
|
|
79
|
+
`CREATE TABLE IF NOT EXISTS ${REPLICATION_TABLES.claims} (id TEXT PRIMARY KEY, payload TEXT NOT NULL)`,
|
|
80
|
+
], (ddl) => connection.exec(ddl)), () => chain(sql(`SELECT value FROM ${REPLICATION_TABLES.state} WHERE id = 1`, 'get'), (existing) => {
|
|
80
81
|
if (existing !== undefined) {
|
|
81
82
|
const saved = JSON.parse(existing.value);
|
|
82
83
|
if (saved.replica !== config.replica || saved.model !== model)
|
|
@@ -85,7 +86,7 @@ export function createReplicationEngine(options) {
|
|
|
85
86
|
}
|
|
86
87
|
return chain(rows.empty(), (empty) => {
|
|
87
88
|
if (!empty) fail('JD2105', 'initialize replication on an empty store, then import an explicit snapshot');
|
|
88
|
-
return sql(
|
|
89
|
+
return sql(`INSERT INTO ${REPLICATION_TABLES.state} (id, value) VALUES (1, ?)`, 'run',
|
|
89
90
|
[stableStringify({ replica: config.replica, model, frontier: {} })]);
|
|
90
91
|
});
|
|
91
92
|
})));
|
|
@@ -131,8 +132,8 @@ export function createReplicationEngine(options) {
|
|
|
131
132
|
bounded(envelope);
|
|
132
133
|
const frontier = joinFrontiers(saved.frontier, { [config.replica]: seq });
|
|
133
134
|
return chain(each(operations, (operation) => saveRow(operation, frontier)), () => chain(receipt(envelope), () =>
|
|
134
|
-
chain(sql(
|
|
135
|
-
chain(sql(
|
|
135
|
+
chain(sql(`INSERT INTO ${REPLICATION_TABLES.outbox} (seq, payload) VALUES (?, ?)`, 'run', [seq, stableStringify(envelope)]), () =>
|
|
136
|
+
chain(sql(`DELETE FROM ${REPLICATION_TABLES.outbox} WHERE seq <= ?`, 'run', [seq - config.retention]), () =>
|
|
136
137
|
saveState({ ...saved, frontier })))));
|
|
137
138
|
});
|
|
138
139
|
});
|
|
@@ -149,12 +150,12 @@ export function createReplicationEngine(options) {
|
|
|
149
150
|
capture.setContext({ replication: true });
|
|
150
151
|
cancelled(request);
|
|
151
152
|
const saved = await state();
|
|
152
|
-
const previous = await sql(
|
|
153
|
+
const previous = await sql(`SELECT payload FROM ${REPLICATION_TABLES.receipts} WHERE id = ?`, 'get', [identity]);
|
|
153
154
|
if (previous !== undefined) {
|
|
154
155
|
if (previous.payload !== stableStringify(envelope)) fail('JD2101', 'the envelope identity already has a different payload');
|
|
155
156
|
return { status: 'duplicate', frontier: saved.frontier, conflicts: [] };
|
|
156
157
|
}
|
|
157
|
-
const claim = await sql(
|
|
158
|
+
const claim = await sql(`SELECT payload FROM ${REPLICATION_TABLES.claims} WHERE id = ?`, 'get', [identity]);
|
|
158
159
|
if (claim !== undefined && claim.payload !== stableStringify(envelope))
|
|
159
160
|
fail('JD2101', 'a conflicted envelope identity already has a different payload');
|
|
160
161
|
const seen = position(saved.frontier, envelope.replica);
|
|
@@ -178,9 +179,8 @@ export function createReplicationEngine(options) {
|
|
|
178
179
|
resolver: config.resolver?.id ?? null, resolution: null };
|
|
179
180
|
if (config.resolver) {
|
|
180
181
|
const input = structuredClone(evidence);
|
|
181
|
-
const freeze = (value) => { if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value); } return value; };
|
|
182
182
|
let decision;
|
|
183
|
-
try { decision = config.resolver.resolve(
|
|
183
|
+
try { decision = config.resolver.resolve(deepFreeze(input)); }
|
|
184
184
|
catch (cause) { throw new DbRuntimeError('JD2103', 'the conflict resolver failed', { cause }); }
|
|
185
185
|
if (decision && typeof decision.then === 'function') {
|
|
186
186
|
Promise.resolve(decision).catch(() => {});
|
|
@@ -200,11 +200,11 @@ export function createReplicationEngine(options) {
|
|
|
200
200
|
bounded({ ...envelope, operations: chosen });
|
|
201
201
|
assertItemBytes(utf8Length(stableStringify(conflicts)), config.maxBytes);
|
|
202
202
|
for (const conflict of conflicts) {
|
|
203
|
-
await sql(
|
|
203
|
+
await sql(`INSERT INTO ${REPLICATION_TABLES.conflicts} (id, evidence) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET evidence = excluded.evidence`,
|
|
204
204
|
'run', [JSON.stringify([identity, conflict.table, conflict.key]), stableStringify(conflict)]);
|
|
205
205
|
}
|
|
206
206
|
if (conflicts.length > 0 && !config.resolver) {
|
|
207
|
-
if (claim === undefined) await sql(
|
|
207
|
+
if (claim === undefined) await sql(`INSERT INTO ${REPLICATION_TABLES.claims} (id, payload) VALUES (?, ?)`, 'run', [identity, stableStringify(envelope)]);
|
|
208
208
|
return { status: 'conflict', frontier: saved.frontier, conflicts };
|
|
209
209
|
}
|
|
210
210
|
// Canonical capture order is independent of FK topology; defer constraints
|
|
@@ -234,13 +234,13 @@ export function createReplicationEngine(options) {
|
|
|
234
234
|
cancelled(request);
|
|
235
235
|
const saved = await state();
|
|
236
236
|
const highWatermark = position(saved.frontier, config.replica);
|
|
237
|
-
const floor = await sql(
|
|
237
|
+
const floor = await sql(`SELECT min(seq) AS lo FROM ${REPLICATION_TABLES.outbox}`, 'get');
|
|
238
238
|
const earliestAvailable = floor.lo === null ? null : Number(floor.lo);
|
|
239
239
|
const bounds = { earliestAvailable, highWatermark };
|
|
240
240
|
if (after > highWatermark || after + 1 < (earliestAvailable ?? highWatermark + 1))
|
|
241
241
|
return { items: [], ...bounds, resetRequired: true, hasMore: false };
|
|
242
242
|
const cursor = createCursor({ streaming: 'row', barrier: null, signal: request.signal, deadline: request.deadline, now,
|
|
243
|
-
open: () => chain(connection.prepare(
|
|
243
|
+
open: () => chain(connection.prepare(`SELECT payload FROM ${REPLICATION_TABLES.outbox} WHERE seq > ? ORDER BY seq LIMIT ?`),
|
|
244
244
|
(statement) => statement.iterate([after, limit + 1])),
|
|
245
245
|
items: (row) => [{ envelope: JSON.parse(row.payload), bytes: utf8Length(row.payload) }] });
|
|
246
246
|
const page = await drainPage(cursor, { limit, maxBytes: Math.min(maxBytes, config.maxBytes), after,
|
|
@@ -252,9 +252,9 @@ export function createReplicationEngine(options) {
|
|
|
252
252
|
cancelled(request);
|
|
253
253
|
const saved = await state();
|
|
254
254
|
const credits = { count: 0, bytes: 0 };
|
|
255
|
-
const values = await collectBounded(
|
|
255
|
+
const values = await collectBounded(`SELECT name, key, value, frontier FROM ${REPLICATION_TABLES.rows} ORDER BY name, key LIMIT ?`, [config.maxOperations + 1],
|
|
256
256
|
(row) => ({ table: row.name, key: row.key, value: JSON.parse(row.value), frontier: JSON.parse(row.frontier) }), request, credits);
|
|
257
|
-
const receipts = await collectBounded(
|
|
257
|
+
const receipts = await collectBounded(`SELECT payload FROM ${REPLICATION_TABLES.receipts} ORDER BY id LIMIT ?`, [config.maxOperations + 1],
|
|
258
258
|
(row) => JSON.parse(row.payload), request, credits);
|
|
259
259
|
const document = normalizeReplicationSnapshot({ $replicationSnapshot: '0.1', model, frontier: saved.frontier,
|
|
260
260
|
rows: values, receipts });
|
|
@@ -285,11 +285,11 @@ export function createReplicationEngine(options) {
|
|
|
285
285
|
const saved = await state();
|
|
286
286
|
if (!dominates(document.frontier, saved.frontier)) fail('JD2105', 'reset would discard acknowledged local history');
|
|
287
287
|
const credits = { count: 0, bytes: 0 };
|
|
288
|
-
const localReceipts = await collectBounded(
|
|
288
|
+
const localReceipts = await collectBounded(`SELECT id, payload FROM ${REPLICATION_TABLES.receipts} LIMIT ?`, [config.maxOperations + 1],
|
|
289
289
|
(row) => row, request, credits);
|
|
290
290
|
for (const local of localReceipts) if (stableStringify(receipts.get(local.id)) !== local.payload)
|
|
291
291
|
fail('JD2101', 'snapshot rewrites an acknowledged envelope identity');
|
|
292
|
-
const localRows = await collectBounded(
|
|
292
|
+
const localRows = await collectBounded(`SELECT name, key, value FROM ${REPLICATION_TABLES.rows} LIMIT ?`, [config.maxOperations + 1],
|
|
293
293
|
(row) => row, request, credits);
|
|
294
294
|
const desired = new Map(document.rows.map((row) => [JSON.stringify([row.table, row.key]), row]));
|
|
295
295
|
const operations = new Map(localRows.map((row) => [JSON.stringify([row.name, row.key]),
|
|
@@ -308,10 +308,10 @@ export function createReplicationEngine(options) {
|
|
|
308
308
|
}
|
|
309
309
|
for (const operation of operations.values()) if (!equal(await rows.read(operation.table, operation.key) ?? null, operation.after))
|
|
310
310
|
fail('JD2104', 'reset did not store the requested logical state');
|
|
311
|
-
await sql(
|
|
311
|
+
await sql(`DELETE FROM ${REPLICATION_TABLES.rows}`);
|
|
312
312
|
for (const row of document.rows) await saveRow({ ...row, after: row.value }, row.frontier);
|
|
313
313
|
for (const envelope of receipts.values()) if (!localReceipts.some((local) => local.id === replicationIdentity(envelope.replica, envelope.seq))) await receipt(envelope);
|
|
314
|
-
await sql(
|
|
314
|
+
await sql(`DELETE FROM ${REPLICATION_TABLES.outbox}`);
|
|
315
315
|
await saveState({ ...saved, frontier: document.frontier });
|
|
316
316
|
cancelled(request);
|
|
317
317
|
return { status: 'reset', frontier: document.frontier };
|
|
@@ -325,7 +325,7 @@ export function createReplicationEngine(options) {
|
|
|
325
325
|
if (request.maxBytes !== undefined && (!Number.isSafeInteger(request.maxBytes) || request.maxBytes < 1))
|
|
326
326
|
throw new TypeError('conflicts.maxBytes must be a positive safe integer');
|
|
327
327
|
cancelled(request);
|
|
328
|
-
return collectBounded(
|
|
328
|
+
return collectBounded(`SELECT evidence FROM ${REPLICATION_TABLES.conflicts} ORDER BY id LIMIT ?`, [Math.min(limit, config.maxOperations)],
|
|
329
329
|
(row) => JSON.parse(row.evidence), request, { count: 0, bytes: 1 });
|
|
330
330
|
},
|
|
331
331
|
};
|