@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,115 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Portable logical transactions. Canonical payloads are also collision-free receipts. */
|
|
3
|
+
import { stableStringify } from '@jarenjs/core/object';
|
|
4
|
+
import { DbCompileError } from './errors.js';
|
|
5
|
+
|
|
6
|
+
export const REPLICATION_VERSION = '0.1';
|
|
7
|
+
export const REPLICATION_DEFAULTS = Object.freeze({ retention: 1000, maxOperations: 10000, maxBytes: 4194304 });
|
|
8
|
+
|
|
9
|
+
const invalid = (reason) => { throw new DbCompileError('JD0060', reason); };
|
|
10
|
+
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
const id = (value) => typeof value === 'string' && value.length > 0 && value.length <= 128;
|
|
12
|
+
const sequence = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
13
|
+
const members = (value, names) => record(value) && Object.keys(value).length === names.length
|
|
14
|
+
&& names.every((name) => Object.hasOwn(value, name));
|
|
15
|
+
|
|
16
|
+
/** Normalize a causal frontier without interpreting host-issued replica names. @param {any} value */
|
|
17
|
+
export function normalizeFrontier(value) {
|
|
18
|
+
jsonValue(value);
|
|
19
|
+
if (!record(value) || Object.entries(value).some(([key, seq]) => !id(key) || !sequence(seq)))
|
|
20
|
+
invalid('a frontier maps non-empty replica ids to non-negative safe sequences');
|
|
21
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** JSON identity is unambiguous even when replica ids contain separators. @param {string} replica @param {number} seq */
|
|
25
|
+
export function replicationIdentity(replica, seq) {
|
|
26
|
+
if (!id(replica) || !sequence(seq) || seq === 0) invalid('an envelope identity needs a replica and positive safe sequence');
|
|
27
|
+
return JSON.stringify([replica, seq]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Reject values whose JSON encoding would silently discard or alter information. */
|
|
31
|
+
function jsonValue(value, seen = new Set()) {
|
|
32
|
+
if (seen.size > 128) invalid('replication JSON exceeds the maximum nesting depth');
|
|
33
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
|
34
|
+
if (typeof value === 'number' && Number.isFinite(value)) return;
|
|
35
|
+
if (typeof value !== 'object' || seen.has(value)) invalid('replication values must be finite, acyclic JSON');
|
|
36
|
+
if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
|
|
37
|
+
invalid('replication values must be plain JSON objects');
|
|
38
|
+
seen.add(value);
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
if (Reflect.ownKeys(value).length !== value.length + 1) invalid('replication arrays contain only dense indexed values');
|
|
41
|
+
for (let i = 0; i < value.length; i++) {
|
|
42
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(i));
|
|
43
|
+
if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) invalid('replication arrays contain only data values');
|
|
44
|
+
jsonValue(descriptor.value, seen);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
49
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
50
|
+
if (typeof key !== 'string' || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value'))
|
|
51
|
+
invalid('replication objects must have enumerable data members');
|
|
52
|
+
jsonValue(descriptor.value, seen);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
seen.delete(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Validate and detach a versioned envelope; array order is part of its identity. @param {any} document */
|
|
59
|
+
export function normalizeReplication(document) {
|
|
60
|
+
jsonValue(document);
|
|
61
|
+
if (!members(document, ['$replication', 'replica', 'seq', 'frontier', 'model', 'operations']))
|
|
62
|
+
invalid('a replication envelope has exactly $replication, replica, seq, frontier, model and operations');
|
|
63
|
+
if (document.$replication !== REPLICATION_VERSION) invalid('unsupported replication version');
|
|
64
|
+
replicationIdentity(document.replica, document.seq);
|
|
65
|
+
const frontier = normalizeFrontier(document.frontier);
|
|
66
|
+
if ((Object.hasOwn(frontier, document.replica) ? frontier[document.replica] : 0) !== document.seq - 1)
|
|
67
|
+
invalid('the sender frontier must immediately precede the envelope sequence');
|
|
68
|
+
if (typeof document.model !== 'string' || document.model.length === 0) invalid('model revision must be non-empty');
|
|
69
|
+
if (!Array.isArray(document.operations) || document.operations.length === 0) invalid('an envelope contains at least one operation');
|
|
70
|
+
const rows = new Set();
|
|
71
|
+
for (const operation of document.operations) {
|
|
72
|
+
if (!members(operation, ['table', 'key', 'before', 'after'])
|
|
73
|
+
|| typeof operation.table !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(operation.table)
|
|
74
|
+
|| typeof operation.key !== 'string'
|
|
75
|
+
|| !(operation.before === null || record(operation.before))
|
|
76
|
+
|| !(operation.after === null || record(operation.after))) invalid('invalid logical row operation');
|
|
77
|
+
const key = JSON.stringify([operation.table, operation.key]);
|
|
78
|
+
if (rows.has(key)) invalid('a transaction contains at most one net operation per row');
|
|
79
|
+
rows.add(key);
|
|
80
|
+
if (stableStringify(operation.before) === stableStringify(operation.after)) invalid('a logical operation must change its row');
|
|
81
|
+
}
|
|
82
|
+
return JSON.parse(stableStringify({ ...document, frontier }));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Deterministic JSON authoring shared by the DB pen and receipt comparison. @param {any} document @returns {string} */
|
|
86
|
+
export function encodeReplication(document) {
|
|
87
|
+
return stableStringify(normalizeReplication(document));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A reset carries state, causality and collision receipts as one bounded document. @param {any} document */
|
|
91
|
+
export function normalizeReplicationSnapshot(document) {
|
|
92
|
+
jsonValue(document);
|
|
93
|
+
if (!members(document, ['$replicationSnapshot', 'model', 'frontier', 'rows', 'receipts'])
|
|
94
|
+
|| document.$replicationSnapshot !== REPLICATION_VERSION || typeof document.model !== 'string' || !document.model
|
|
95
|
+
|| !Array.isArray(document.rows) || !Array.isArray(document.receipts)) invalid('invalid replication snapshot');
|
|
96
|
+
normalizeFrontier(document.frontier);
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
for (const row of document.rows) {
|
|
99
|
+
if (!members(row, ['table', 'key', 'value', 'frontier']) || typeof row.table !== 'string'
|
|
100
|
+
|| !/^[A-Za-z_][A-Za-z0-9_]*$/.test(row.table) || typeof row.key !== 'string'
|
|
101
|
+
|| !(row.value === null || record(row.value))) invalid('invalid snapshot row');
|
|
102
|
+
normalizeFrontier(row.frontier);
|
|
103
|
+
const key = JSON.stringify([row.table, row.key]);
|
|
104
|
+
if (seen.has(key)) invalid('duplicate snapshot row');
|
|
105
|
+
seen.add(key);
|
|
106
|
+
}
|
|
107
|
+
seen.clear();
|
|
108
|
+
for (const receipt of document.receipts) {
|
|
109
|
+
normalizeReplication(receipt);
|
|
110
|
+
const key = replicationIdentity(receipt.replica, receipt.seq);
|
|
111
|
+
if (seen.has(key)) invalid('duplicate snapshot receipt');
|
|
112
|
+
seen.add(key);
|
|
113
|
+
}
|
|
114
|
+
return JSON.parse(stableStringify(document));
|
|
115
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Durable replication state shares the data transaction and capture settlement. */
|
|
3
|
+
import { stableStringify } from '@jarenjs/core/object';
|
|
4
|
+
import { applyJSONPatch } from '@jarenjs/json/patch';
|
|
5
|
+
import { decodeJSONPointerSegment } from '@jarenjs/json/pointer';
|
|
6
|
+
import { utf8Length, assertItemBytes, createCursor, drainPage } from './cursor.js';
|
|
7
|
+
import { chain } from './driver.js';
|
|
8
|
+
import { DbRuntimeError } from './errors.js';
|
|
9
|
+
import { refuseCancelled } from './cancellation.js';
|
|
10
|
+
import { normalizeReplication, normalizeReplicationSnapshot, normalizeFrontier, replicationIdentity, REPLICATION_DEFAULTS } from './replication-format.js';
|
|
11
|
+
|
|
12
|
+
const equal = (a, b) => stableStringify(a) === stableStringify(b);
|
|
13
|
+
const position = (frontier, id) => Object.hasOwn(frontier, id) ? frontier[id] : 0;
|
|
14
|
+
const dominates = (a, b) => Object.entries(b).every(([id, seq]) => position(a, id) >= seq);
|
|
15
|
+
const fail = (code, reason) => { throw new DbRuntimeError(code, reason); };
|
|
16
|
+
const joinFrontiers = (a, b) => normalizeFrontier(Object.fromEntries([...new Set([...Object.keys(a), ...Object.keys(b)])]
|
|
17
|
+
.map((id) => [id, Math.max(position(a, id), position(b, id))])));
|
|
18
|
+
const each = (items, fn) => {
|
|
19
|
+
let i = 0;
|
|
20
|
+
const next = () => {
|
|
21
|
+
while (i < items.length) {
|
|
22
|
+
const value = fn(items[i++]);
|
|
23
|
+
if (value instanceof Promise) return value.then(next);
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
return next();
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** @param {any} options */
|
|
31
|
+
export function createReplicationEngine(options) {
|
|
32
|
+
const { connection, rows, capture, model, now, bracket } = options;
|
|
33
|
+
const config = { ...REPLICATION_DEFAULTS, ...options.config };
|
|
34
|
+
replicationIdentity(config.replica, 1);
|
|
35
|
+
for (const member of ['retention', 'maxOperations', 'maxBytes']) {
|
|
36
|
+
if (!Number.isSafeInteger(config[member]) || config[member] < 1)
|
|
37
|
+
throw new TypeError(`replication.${member} must be a positive safe integer`);
|
|
38
|
+
}
|
|
39
|
+
if (config.resolver !== undefined && (typeof config.resolver?.id !== 'string' || !config.resolver.id
|
|
40
|
+
|| typeof config.resolver.resolve !== 'function')) throw new TypeError('replication.resolver needs a stable id and pure resolve function');
|
|
41
|
+
const sql = (text, method = 'run', params = []) => chain(connection.prepare(text), (s) => s[method](params));
|
|
42
|
+
const state = () => chain(sql('SELECT value FROM _jaren_replica WHERE id = 1', 'get'), (row) => JSON.parse(row.value));
|
|
43
|
+
const saveState = (value) => sql('UPDATE _jaren_replica SET value = ? WHERE id = 1', 'run', [stableStringify(value)]);
|
|
44
|
+
const rowState = (table, key) => chain(sql('SELECT value, frontier FROM _jaren_replica_rows WHERE name = ? AND key = ?', 'get', [table, key]),
|
|
45
|
+
(row) => row === undefined ? { value: null, frontier: {} } : { value: JSON.parse(row.value), frontier: JSON.parse(row.frontier) });
|
|
46
|
+
const saveRow = (operation, frontier) => sql('INSERT INTO _jaren_replica_rows (name, key, value, frontier) VALUES (?, ?, ?, ?) '
|
|
47
|
+
+ 'ON CONFLICT(name, key) DO UPDATE SET value = excluded.value, frontier = excluded.frontier', 'run',
|
|
48
|
+
[operation.table, operation.key, stableStringify(operation.after), stableStringify(frontier)]);
|
|
49
|
+
const cancelled = (request) => refuseCancelled(request, now, {
|
|
50
|
+
abortCode: 'JD2072', aborted: 'the next replication operation', passed: 'the next replication operation', ran: 'the transaction is not acknowledged',
|
|
51
|
+
});
|
|
52
|
+
const bounded = (document) => {
|
|
53
|
+
if (document.operations.length > config.maxOperations) fail('JD2106', 'replication operation bound exceeded');
|
|
54
|
+
assertItemBytes(utf8Length(stableStringify(document)), config.maxBytes);
|
|
55
|
+
};
|
|
56
|
+
const receipt = (envelope) => sql('INSERT INTO _jaren_replica_receipts (id, payload) VALUES (?, ?)', 'run',
|
|
57
|
+
[replicationIdentity(envelope.replica, envelope.seq), stableStringify(envelope)]);
|
|
58
|
+
// Pull one row at a time and refuse before accumulating beyond shared credits.
|
|
59
|
+
const collectBounded = async (query, params, map, request, credits) => {
|
|
60
|
+
const cursor = createCursor({ streaming: 'row', barrier: null, signal: request?.signal, deadline: request?.deadline, now,
|
|
61
|
+
open: () => chain(connection.prepare(query), (statement) => statement.iterate(params)), items: (row) => [map(row)] });
|
|
62
|
+
const result = [];
|
|
63
|
+
for await (const item of cursor) {
|
|
64
|
+
if (++credits.count > config.maxOperations) fail('JD2106', 'replication read exceeds its row capacity');
|
|
65
|
+
credits.bytes += utf8Length(stableStringify(item)) + 1;
|
|
66
|
+
assertItemBytes(credits.bytes, Math.min(request?.maxBytes ?? config.maxBytes, config.maxBytes));
|
|
67
|
+
result.push(item);
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const ready = bracket(() => chain(each([
|
|
73
|
+
'CREATE TABLE IF NOT EXISTS _jaren_replica (id INTEGER PRIMARY KEY, value TEXT NOT NULL)',
|
|
74
|
+
'CREATE TABLE IF NOT EXISTS _jaren_replica_rows (name TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, frontier TEXT NOT NULL, PRIMARY KEY(name, key))',
|
|
75
|
+
'CREATE TABLE IF NOT EXISTS _jaren_replica_receipts (id TEXT PRIMARY KEY, payload TEXT NOT NULL)',
|
|
76
|
+
'CREATE TABLE IF NOT EXISTS _jaren_replica_outbox (seq INTEGER PRIMARY KEY, payload TEXT NOT NULL)',
|
|
77
|
+
'CREATE TABLE IF NOT EXISTS _jaren_replica_conflicts (id TEXT PRIMARY KEY, evidence TEXT NOT NULL)',
|
|
78
|
+
'CREATE TABLE IF NOT EXISTS _jaren_replica_claims (id TEXT PRIMARY KEY, payload TEXT NOT NULL)',
|
|
79
|
+
], (ddl) => connection.exec(ddl)), () => chain(sql('SELECT value FROM _jaren_replica WHERE id = 1', 'get'), (existing) => {
|
|
80
|
+
if (existing !== undefined) {
|
|
81
|
+
const saved = JSON.parse(existing.value);
|
|
82
|
+
if (saved.replica !== config.replica || saved.model !== model)
|
|
83
|
+
fail('JD2102', 'the durable replica identity or model revision disagrees with this open');
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return chain(rows.empty(), (empty) => {
|
|
87
|
+
if (!empty) fail('JD2105', 'initialize replication on an empty store, then import an explicit snapshot');
|
|
88
|
+
return sql('INSERT INTO _jaren_replica (id, value) VALUES (1, ?)', 'run',
|
|
89
|
+
[stableStringify({ replica: config.replica, model, frontier: {} })]);
|
|
90
|
+
});
|
|
91
|
+
})));
|
|
92
|
+
|
|
93
|
+
/** Convert net capture patches against durable before-images, inside commit. */
|
|
94
|
+
const commit = (patch, context) => {
|
|
95
|
+
if (patch.length === 0) return null;
|
|
96
|
+
const grouped = new Map();
|
|
97
|
+
for (const operation of patch) {
|
|
98
|
+
const segments = operation.path.split('/').slice(1).map(decodeJSONPointerSegment);
|
|
99
|
+
const identity = JSON.stringify(segments.slice(0, 2));
|
|
100
|
+
if (!grouped.has(identity)) grouped.set(identity, { table: segments[0], key: segments[1], patch: [] });
|
|
101
|
+
const prefix = operation.path.split('/').slice(0, 3).join('/');
|
|
102
|
+
grouped.get(identity).patch.push({ ...operation, path: operation.path.slice(prefix.length),
|
|
103
|
+
...(operation.from === undefined ? {} : { from: operation.from.slice(prefix.length) }) });
|
|
104
|
+
}
|
|
105
|
+
if (grouped.size > config.maxOperations) fail('JD2106', 'replication operation bound exceeded');
|
|
106
|
+
if (context?.replication === true) {
|
|
107
|
+
// Applying metadata has already installed the intended final images.
|
|
108
|
+
// Any undeclared cascade must roll back with the envelope, never escape
|
|
109
|
+
// its receipt and leave an acknowledged row silently missing.
|
|
110
|
+
return each([...grouped.values()], (entry) => chain(rowState(entry.table, entry.key), (expected) =>
|
|
111
|
+
chain(rows.read(entry.table, entry.key), (actual) => {
|
|
112
|
+
if (!equal(actual ?? null, expected.value)) fail('JD2104', 'replication caused an undeclared row side effect');
|
|
113
|
+
})));
|
|
114
|
+
}
|
|
115
|
+
return chain(state(), (saved) => {
|
|
116
|
+
const operations = [];
|
|
117
|
+
return chain(each([...grouped.values()].sort((a, b) => {
|
|
118
|
+
const x = JSON.stringify([a.table, a.key]); const y = JSON.stringify([b.table, b.key]);
|
|
119
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
120
|
+
}), (entry) => chain(rowState(entry.table, entry.key), (old) => {
|
|
121
|
+
const after = entry.patch.length === 1 && entry.patch[0].op === 'remove' && entry.patch[0].path === ''
|
|
122
|
+
? null : applyJSONPatch(old.value, entry.patch);
|
|
123
|
+
return chain(rows.read(entry.table, entry.key), (actual) => {
|
|
124
|
+
if (!equal(actual ?? null, after)) fail('JD2104', 'capture disagrees with the durable replica before-image');
|
|
125
|
+
operations.push({ table: entry.table, key: entry.key, before: old.value, after });
|
|
126
|
+
});
|
|
127
|
+
})), () => {
|
|
128
|
+
const seq = position(saved.frontier, config.replica) + 1;
|
|
129
|
+
const envelope = normalizeReplication({ $replication: '0.1', replica: config.replica, seq,
|
|
130
|
+
frontier: saved.frontier, model, operations });
|
|
131
|
+
bounded(envelope);
|
|
132
|
+
const frontier = joinFrontiers(saved.frontier, { [config.replica]: seq });
|
|
133
|
+
return chain(each(operations, (operation) => saveRow(operation, frontier)), () => chain(receipt(envelope), () =>
|
|
134
|
+
chain(sql('INSERT INTO _jaren_replica_outbox (seq, payload) VALUES (?, ?)', 'run', [seq, stableStringify(envelope)]), () =>
|
|
135
|
+
chain(sql('DELETE FROM _jaren_replica_outbox WHERE seq <= ?', 'run', [seq - config.retention]), () =>
|
|
136
|
+
saveState({ ...saved, frontier })))));
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** All checks precede writes; conflict evidence commits without advancing a frontier. */
|
|
142
|
+
const apply = (input, request, transaction) => {
|
|
143
|
+
const envelope = normalizeReplication(input);
|
|
144
|
+
bounded(envelope);
|
|
145
|
+
cancelled(request);
|
|
146
|
+
if (envelope.model !== model) fail('JD2102', 'replication model revision mismatch');
|
|
147
|
+
const identity = replicationIdentity(envelope.replica, envelope.seq);
|
|
148
|
+
return transaction(async () => {
|
|
149
|
+
capture.setContext({ replication: true });
|
|
150
|
+
cancelled(request);
|
|
151
|
+
const saved = await state();
|
|
152
|
+
const previous = await sql('SELECT payload FROM _jaren_replica_receipts WHERE id = ?', 'get', [identity]);
|
|
153
|
+
if (previous !== undefined) {
|
|
154
|
+
if (previous.payload !== stableStringify(envelope)) fail('JD2101', 'the envelope identity already has a different payload');
|
|
155
|
+
return { status: 'duplicate', frontier: saved.frontier, conflicts: [] };
|
|
156
|
+
}
|
|
157
|
+
const claim = await sql('SELECT payload FROM _jaren_replica_claims WHERE id = ?', 'get', [identity]);
|
|
158
|
+
if (claim !== undefined && claim.payload !== stableStringify(envelope))
|
|
159
|
+
fail('JD2101', 'a conflicted envelope identity already has a different payload');
|
|
160
|
+
const seen = position(saved.frontier, envelope.replica);
|
|
161
|
+
if (envelope.seq <= seen) fail('JD2105', 'a snapshot covers this envelope but its receipt is unavailable; reset is required');
|
|
162
|
+
if (envelope.seq !== seen + 1 || !dominates(saved.frontier, envelope.frontier))
|
|
163
|
+
fail('JD2100', 'a sequence or causal gap requires missing history or an explicit reset');
|
|
164
|
+
if (envelope.replica === config.replica) fail('JD2101', 'a replica cannot accept an unknown envelope under its own identity');
|
|
165
|
+
const conflicts = [];
|
|
166
|
+
const chosen = [];
|
|
167
|
+
for (const operation of envelope.operations) {
|
|
168
|
+
cancelled(request);
|
|
169
|
+
const local = await rowState(operation.table, operation.key);
|
|
170
|
+
const actual = await rows.read(operation.table, operation.key) ?? null;
|
|
171
|
+
if (!equal(actual, local.value)) fail('JD2104', 'a row was written outside its replication history');
|
|
172
|
+
let after = operation.after;
|
|
173
|
+
if (!equal(actual, operation.before) && !equal(actual, operation.after)) {
|
|
174
|
+
if (dominates(envelope.frontier, local.frontier)) fail('JD2104', 'the operation before-image disagrees with its causal base');
|
|
175
|
+
const evidence = { envelope: identity, table: operation.table, key: operation.key,
|
|
176
|
+
base: operation.before, local: { value: actual, frontier: local.frontier },
|
|
177
|
+
remote: { value: operation.after, replica: envelope.replica, seq: envelope.seq, frontier: envelope.frontier },
|
|
178
|
+
resolver: config.resolver?.id ?? null, resolution: null };
|
|
179
|
+
if (config.resolver) {
|
|
180
|
+
const input = structuredClone(evidence);
|
|
181
|
+
const freeze = (value) => { if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value); } return value; };
|
|
182
|
+
let decision;
|
|
183
|
+
try { decision = config.resolver.resolve(freeze(input)); }
|
|
184
|
+
catch (cause) { throw new DbRuntimeError('JD2103', 'the conflict resolver failed', { cause }); }
|
|
185
|
+
if (decision && typeof decision.then === 'function') {
|
|
186
|
+
Promise.resolve(decision).catch(() => {});
|
|
187
|
+
fail('JD2103', 'a conflict resolver must be synchronous');
|
|
188
|
+
}
|
|
189
|
+
if (!decision || !['local', 'remote', 'merged'].includes(decision.action))
|
|
190
|
+
fail('JD2103', 'a pure resolver must return local, remote or merged synchronously');
|
|
191
|
+
after = decision.action === 'local' ? actual : decision.action === 'remote' ? operation.after : decision.value;
|
|
192
|
+
// Reuse the document grammar for merged values, including finite JSON.
|
|
193
|
+
if (!equal(after, actual)) normalizeReplication({ ...envelope, operations: [{ ...operation, before: actual, after }] });
|
|
194
|
+
evidence.resolution = { action: decision.action, value: after };
|
|
195
|
+
}
|
|
196
|
+
conflicts.push(evidence);
|
|
197
|
+
}
|
|
198
|
+
chosen.push({ ...operation, before: actual, after });
|
|
199
|
+
}
|
|
200
|
+
bounded({ ...envelope, operations: chosen });
|
|
201
|
+
assertItemBytes(utf8Length(stableStringify(conflicts)), config.maxBytes);
|
|
202
|
+
for (const conflict of conflicts) {
|
|
203
|
+
await sql('INSERT INTO _jaren_replica_conflicts (id, evidence) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET evidence = excluded.evidence',
|
|
204
|
+
'run', [JSON.stringify([identity, conflict.table, conflict.key]), stableStringify(conflict)]);
|
|
205
|
+
}
|
|
206
|
+
if (conflicts.length > 0 && !config.resolver) {
|
|
207
|
+
if (claim === undefined) await sql('INSERT INTO _jaren_replica_claims (id, payload) VALUES (?, ?)', 'run', [identity, stableStringify(envelope)]);
|
|
208
|
+
return { status: 'conflict', frontier: saved.frontier, conflicts };
|
|
209
|
+
}
|
|
210
|
+
// Canonical capture order is independent of FK topology; defer constraints
|
|
211
|
+
// until all rows of the logical transaction have reached their final state.
|
|
212
|
+
await connection.exec(connection.dialect.tx.deferForeignKeys);
|
|
213
|
+
for (const operation of chosen) {
|
|
214
|
+
cancelled(request);
|
|
215
|
+
if (!equal(operation.before, operation.after)) await rows.write(operation);
|
|
216
|
+
}
|
|
217
|
+
for (const operation of chosen) {
|
|
218
|
+
if (!equal(await rows.read(operation.table, operation.key) ?? null, operation.after))
|
|
219
|
+
fail('JD2104', 'the stored logical row differs from the requested replicated value');
|
|
220
|
+
}
|
|
221
|
+
const frontier = joinFrontiers(saved.frontier, { [envelope.replica]: envelope.seq });
|
|
222
|
+
for (const operation of chosen) await saveRow(operation, frontier);
|
|
223
|
+
await receipt(envelope);
|
|
224
|
+
await saveState({ ...saved, frontier });
|
|
225
|
+
cancelled(request);
|
|
226
|
+
return { status: 'applied', frontier, conflicts };
|
|
227
|
+
}, request);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const page = async (request = {}) => {
|
|
231
|
+
const { after = 0, limit = 100, maxBytes = config.maxBytes } = request;
|
|
232
|
+
if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1
|
|
233
|
+
|| !Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new TypeError('replication.page needs safe after, limit and maxBytes bounds');
|
|
234
|
+
cancelled(request);
|
|
235
|
+
const saved = await state();
|
|
236
|
+
const highWatermark = position(saved.frontier, config.replica);
|
|
237
|
+
const floor = await sql('SELECT min(seq) AS lo FROM _jaren_replica_outbox', 'get');
|
|
238
|
+
const earliestAvailable = floor.lo === null ? null : Number(floor.lo);
|
|
239
|
+
const bounds = { earliestAvailable, highWatermark };
|
|
240
|
+
if (after > highWatermark || after + 1 < (earliestAvailable ?? highWatermark + 1))
|
|
241
|
+
return { items: [], ...bounds, resetRequired: true, hasMore: false };
|
|
242
|
+
const cursor = createCursor({ streaming: 'row', barrier: null, signal: request.signal, deadline: request.deadline, now,
|
|
243
|
+
open: () => chain(connection.prepare('SELECT payload FROM _jaren_replica_outbox WHERE seq > ? ORDER BY seq LIMIT ?'),
|
|
244
|
+
(statement) => statement.iterate([after, limit + 1])),
|
|
245
|
+
items: (row) => [{ envelope: JSON.parse(row.payload), bytes: utf8Length(row.payload) }] });
|
|
246
|
+
const page = await drainPage(cursor, { limit, maxBytes: Math.min(maxBytes, config.maxBytes), after,
|
|
247
|
+
sizeOf: (item) => item.bytes, continuationOf: (item) => item.envelope.seq });
|
|
248
|
+
return { items: page.items.map((item) => item.envelope), ...bounds, next: page.continuation ?? after,
|
|
249
|
+
bytes: page.items.reduce((sum, item) => sum + item.bytes, 0), resetRequired: false, hasMore: page.hasMore };
|
|
250
|
+
};
|
|
251
|
+
const snapshot = async (request = {}) => {
|
|
252
|
+
cancelled(request);
|
|
253
|
+
const saved = await state();
|
|
254
|
+
const credits = { count: 0, bytes: 0 };
|
|
255
|
+
const values = await collectBounded('SELECT name, key, value, frontier FROM _jaren_replica_rows ORDER BY name, key LIMIT ?', [config.maxOperations + 1],
|
|
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('SELECT payload FROM _jaren_replica_receipts ORDER BY id LIMIT ?', [config.maxOperations + 1],
|
|
258
|
+
(row) => JSON.parse(row.payload), request, credits);
|
|
259
|
+
const document = normalizeReplicationSnapshot({ $replicationSnapshot: '0.1', model, frontier: saved.frontier,
|
|
260
|
+
rows: values, receipts });
|
|
261
|
+
assertItemBytes(utf8Length(stableStringify(document)), config.maxBytes);
|
|
262
|
+
cancelled(request);
|
|
263
|
+
return document;
|
|
264
|
+
};
|
|
265
|
+
const reset = (input, request, transaction) => {
|
|
266
|
+
const document = normalizeReplicationSnapshot(input);
|
|
267
|
+
if (document.model !== model) fail('JD2102', 'snapshot model revision mismatch');
|
|
268
|
+
if (document.rows.length + document.receipts.length > config.maxOperations) fail('JD2106', 'snapshot exceeds the bounded reset capacity');
|
|
269
|
+
assertItemBytes(utf8Length(stableStringify(document)), config.maxBytes);
|
|
270
|
+
for (const row of document.rows) if (!dominates(document.frontier, row.frontier)) fail('JD2104', 'snapshot row is ahead of its frontier');
|
|
271
|
+
const receipts = new Map();
|
|
272
|
+
for (const envelope of document.receipts) {
|
|
273
|
+
if (envelope.model !== model || !dominates(document.frontier, { ...envelope.frontier, [envelope.replica]: envelope.seq }))
|
|
274
|
+
fail('JD2104', 'snapshot receipt is ahead of its frontier or names another model');
|
|
275
|
+
receipts.set(replicationIdentity(envelope.replica, envelope.seq), envelope);
|
|
276
|
+
}
|
|
277
|
+
// A frontier must never hide an unproven receipt gap.
|
|
278
|
+
const counts = new Map();
|
|
279
|
+
for (const envelope of receipts.values()) counts.set(envelope.replica, (counts.get(envelope.replica) ?? 0) + 1);
|
|
280
|
+
for (const [replica, seq] of Object.entries(document.frontier)) if ((counts.get(replica) ?? 0) !== seq)
|
|
281
|
+
fail('JD2104', 'snapshot receipts do not cover the complete frontier');
|
|
282
|
+
return transaction(async () => {
|
|
283
|
+
capture.setContext({ replication: true });
|
|
284
|
+
cancelled(request);
|
|
285
|
+
const saved = await state();
|
|
286
|
+
if (!dominates(document.frontier, saved.frontier)) fail('JD2105', 'reset would discard acknowledged local history');
|
|
287
|
+
const credits = { count: 0, bytes: 0 };
|
|
288
|
+
const localReceipts = await collectBounded('SELECT id, payload FROM _jaren_replica_receipts LIMIT ?', [config.maxOperations + 1],
|
|
289
|
+
(row) => row, request, credits);
|
|
290
|
+
for (const local of localReceipts) if (stableStringify(receipts.get(local.id)) !== local.payload)
|
|
291
|
+
fail('JD2101', 'snapshot rewrites an acknowledged envelope identity');
|
|
292
|
+
const localRows = await collectBounded('SELECT name, key, value FROM _jaren_replica_rows LIMIT ?', [config.maxOperations + 1],
|
|
293
|
+
(row) => row, request, credits);
|
|
294
|
+
const desired = new Map(document.rows.map((row) => [JSON.stringify([row.table, row.key]), row]));
|
|
295
|
+
const operations = new Map(localRows.map((row) => [JSON.stringify([row.name, row.key]),
|
|
296
|
+
{ table: row.name, key: row.key, before: JSON.parse(row.value), after: null }]));
|
|
297
|
+
for (const [key, row] of desired) operations.set(key, { table: row.table, key: row.key,
|
|
298
|
+
before: operations.get(key)?.before ?? null, after: row.value });
|
|
299
|
+
await connection.exec(connection.dialect.tx.deferForeignKeys);
|
|
300
|
+
for (const operation of operations.values()) {
|
|
301
|
+
cancelled(request);
|
|
302
|
+
if (!equal(await rows.read(operation.table, operation.key) ?? null, operation.before))
|
|
303
|
+
fail('JD2104', 'reset found a row outside its replication history');
|
|
304
|
+
}
|
|
305
|
+
for (const operation of operations.values()) {
|
|
306
|
+
cancelled(request);
|
|
307
|
+
if (!equal(operation.before, operation.after)) await rows.write(operation);
|
|
308
|
+
}
|
|
309
|
+
for (const operation of operations.values()) if (!equal(await rows.read(operation.table, operation.key) ?? null, operation.after))
|
|
310
|
+
fail('JD2104', 'reset did not store the requested logical state');
|
|
311
|
+
await sql('DELETE FROM _jaren_replica_rows');
|
|
312
|
+
for (const row of document.rows) await saveRow({ ...row, after: row.value }, row.frontier);
|
|
313
|
+
for (const envelope of receipts.values()) if (!localReceipts.some((local) => local.id === replicationIdentity(envelope.replica, envelope.seq))) await receipt(envelope);
|
|
314
|
+
await sql('DELETE FROM _jaren_replica_outbox');
|
|
315
|
+
await saveState({ ...saved, frontier: document.frontier });
|
|
316
|
+
cancelled(request);
|
|
317
|
+
return { status: 'reset', frontier: document.frontier };
|
|
318
|
+
}, request);
|
|
319
|
+
};
|
|
320
|
+
return { ready, commit, apply, page, snapshot, reset,
|
|
321
|
+
frontier: () => chain(state(), (saved) => saved.frontier),
|
|
322
|
+
conflicts: (request = {}) => {
|
|
323
|
+
const limit = request.limit ?? 100;
|
|
324
|
+
if (!Number.isSafeInteger(limit) || limit < 1) throw new TypeError('conflicts.limit must be a positive safe integer');
|
|
325
|
+
if (request.maxBytes !== undefined && (!Number.isSafeInteger(request.maxBytes) || request.maxBytes < 1))
|
|
326
|
+
throw new TypeError('conflicts.maxBytes must be a positive safe integer');
|
|
327
|
+
cancelled(request);
|
|
328
|
+
return collectBounded('SELECT evidence FROM _jaren_replica_conflicts ORDER BY id LIMIT ?', [Math.min(limit, config.maxOperations)],
|
|
329
|
+
(row) => JSON.parse(row.evidence), request, { count: 0, bytes: 1 });
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
package/src/residual.js
CHANGED
|
@@ -72,6 +72,23 @@ export function compileSetResidual(document, limits, operators, zoneProvider) {
|
|
|
72
72
|
return (candidates, externals) => compiled(candidates, externals);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Compile the whole document for a CURSOR's barrier: `[document]` packs
|
|
77
|
+
* the result sequence into one unambiguous item array, so a cursor
|
|
78
|
+
* hands out one item per pull whatever the engine's singleton rule
|
|
79
|
+
* would have folded — the same packing the row residual uses per row,
|
|
80
|
+
* applied to the whole set.
|
|
81
|
+
* @param {any} document
|
|
82
|
+
* @param {any} [limits]
|
|
83
|
+
* @param {{ functions?: any, extensions?: any } | null} [operators]
|
|
84
|
+
* @param {any} [zoneProvider]
|
|
85
|
+
* @returns {(candidates: any[], externals: any) => any[]} the items
|
|
86
|
+
*/
|
|
87
|
+
export function compilePackedResidual(document, limits, operators, zoneProvider) {
|
|
88
|
+
const compiled = compileJsonQuery([document], residualOptions(limits, operators, zoneProvider));
|
|
89
|
+
return (candidates, externals) => /** @type {any[]} */ (compiled(candidates, externals));
|
|
90
|
+
}
|
|
91
|
+
|
|
75
92
|
/**
|
|
76
93
|
* Compile the per-row projection for row-mode evaluation.
|
|
77
94
|
* @param {any} rowDocument - The planner's complete one-row document
|
package/src/series.js
CHANGED
|
@@ -187,6 +187,11 @@ export function seekingIndexFor(shape, column, facts, minColumns = 1) {
|
|
|
187
187
|
* to: any, toOp: string | null }> }}
|
|
188
188
|
*/
|
|
189
189
|
export function filterFacts(filter) {
|
|
190
|
+
// one comparison, two spellings: the guarded form over a member, and
|
|
191
|
+
// the bare one over a declared column that a refinement pushes. A
|
|
192
|
+
// seek reads the same column either way, so the facts must too
|
|
193
|
+
const columnOf = (pred) => (pred.p === 'colCmp' ? pred.column
|
|
194
|
+
: pred.p === 'cmp' ? pred.ref.column : null);
|
|
190
195
|
/** @type {Set<string>} */
|
|
191
196
|
const pinned = new Set();
|
|
192
197
|
/** @type {Map<string, any>} */
|
|
@@ -212,14 +217,15 @@ export function filterFacts(filter) {
|
|
|
212
217
|
if (pred.p === 'or') {
|
|
213
218
|
const columns = new Set();
|
|
214
219
|
for (const item of pred.items) {
|
|
215
|
-
|
|
216
|
-
|
|
220
|
+
const named = columnOf(item);
|
|
221
|
+
if (named === null || item.op !== 'eq') return;
|
|
222
|
+
columns.add(named);
|
|
217
223
|
}
|
|
218
224
|
if (columns.size === 1) pinned.add([...columns][0]);
|
|
219
225
|
return;
|
|
220
226
|
}
|
|
221
|
-
|
|
222
|
-
|
|
227
|
+
const column = columnOf(pred);
|
|
228
|
+
if (column === null) return;
|
|
223
229
|
const operand = 'lit' in pred.operand ? pred.operand.lit : undefined;
|
|
224
230
|
if (pred.op === 'eq') {
|
|
225
231
|
pinned.add(column);
|
|
@@ -341,6 +347,8 @@ export function seriesRecord(facts) {
|
|
|
341
347
|
to: range.to ?? null,
|
|
342
348
|
toOp: range.toOp ?? null,
|
|
343
349
|
},
|
|
350
|
+
seeks: (facts.seeks ?? []).map((s) =>
|
|
351
|
+
({ side: s.side, column: s.column, probe: s.probe, op: s.op })),
|
|
344
352
|
ladder: facts.ladder ?? null,
|
|
345
353
|
aggregates: [...(facts.aggregates ?? [])],
|
|
346
354
|
refinement: facts.refinement ?? null,
|