@jarenjs/db 0.66.1 → 0.72.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/src/migrate.js CHANGED
@@ -32,6 +32,8 @@ import { compileJsltStylesheet } from '@jarenjs/json/jslt';
32
32
  import { DbCompileError } from './errors.js';
33
33
  import { chain, toPromise } from './driver.js';
34
34
  import { normalizeModel } from './store.js';
35
+ import { planQuery } from './plan.js';
36
+ import { createQueryEngine, createQueryState } from './query.js';
35
37
  import { CHANGES_TABLE, CHANGES_STATE_TABLE } from './capture.js';
36
38
  import { JOBS_TABLE, JOB_CHECKPOINTS_TABLE } from './jobs.js';
37
39
  import { planCollection, verifyShape, planEntity, planJoinTable } from './ddl.js';
@@ -1012,6 +1014,21 @@ export function compareShapeToModel(driver, connection, model, registerFunctions
1012
1014
  }));
1013
1015
  }
1014
1016
 
1017
+ /**
1018
+ * Count can use the existing provider planner without assuming an intermediate
1019
+ * schema. A native plan proves both row selection and item cardinality. Typed
1020
+ * aggregates keep ordered engine folds until their current shape is declared.
1021
+ */
1022
+ function assertionProvider(query, collection, shape) {
1023
+ if (shape !== '$count') return null;
1024
+ const operand = query.$count;
1025
+ const document = { $count: typeof operand === 'string' && operand.startsWith('$[*]')
1026
+ ? { $for: { row: '$[*]' }, $return: `$row${operand.slice(4)}` } : operand };
1027
+ const source = { collection, schema: { type: 'object' }, columnByCanonical: new Map(), indexes: [] };
1028
+ const planned = planQuery(document, source);
1029
+ return planned.mode === 'native' && planned.plan?.aggregate?.fn === 'count' ? document : null;
1030
+ }
1031
+
1015
1032
  /**
1016
1033
  * The batched row walk shared by transforms and post-validation:
1017
1034
  * `SELECT rowid, json(doc) ... WHERE rowid > ? ORDER BY rowid LIMIT ?`
@@ -1247,17 +1264,28 @@ function runSteps(connection, migration, options) {
1247
1264
  migrationId: migration.id,
1248
1265
  compileJslt: compileJsltStylesheet,
1249
1266
  compileQuery: compileJsonQuery,
1267
+ assertionBounds: options.assertionBounds,
1250
1268
  });
1251
1269
  const assertOver = operation.assert;
1252
1270
  const readDoc = (row) => (assertionMapping === null
1253
1271
  ? JSON.parse(row.doc)
1254
1272
  : mergeEntityRow(assertionMapping, row, 'doc'));
1255
1273
 
1274
+ const provider = assertionMapping === null ? assertionProvider(current.assert, current.collection, operation.shape) : null;
1275
+ options.onAssertionPlan?.({ ...operation.plan, ...(provider === null ? {} : {
1276
+ strategy: 'provider', reason: 'the existing query planner proves a native count without assuming an intermediate schema',
1277
+ }) });
1278
+ if (provider !== null) {
1279
+ const engine = createQueryEngine({ connection, state: createQueryState(),
1280
+ collection: { name: current.collection, schema: { type: 'object' }, docPath: '' },
1281
+ physicalPlan: { table: current.collection, keyColumn: 'key', docColumn: 'doc', columnByCanonical: new Map() },
1282
+ });
1283
+ return chain(engine.execute(provider, { strict: true }), operation.accept);
1284
+ }
1285
+
1256
1286
  if (operation.fold !== null) {
1257
- // an associative aggregate: each batch is answered by the engine
1258
- // and the partial answers combine, so the collection is never
1259
- // held. The whole-collection read this replaces is the one
1260
- // statement a cross-document assertion used to cost.
1287
+ // Consume operand items in row order through the query engine's
1288
+ // shared state: regrouping floating-point batch totals is unsound.
1261
1289
  let accumulated = operation.fold.start();
1262
1290
  let folded = 0;
1263
1291
  return chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
@@ -1604,6 +1632,8 @@ export function migrate(target, migrations, options) {
1604
1632
  'migrate needs { baseline }: the model the store was first created with '
1605
1633
  + '(the chain anchor and the shadow starting shape)');
1606
1634
  const batchSize = options.batchSize ?? 500;
1635
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1)
1636
+ throw new TypeError('batchSize must be a positive safe integer');
1607
1637
  const runtime = resolveRuntime(options.runtime);
1608
1638
  /**
1609
1639
  * The cancellation boundary: between migrations, between steps and
@@ -1621,6 +1651,7 @@ export function migrate(target, migrations, options) {
1621
1651
  batchSize,
1622
1652
  assertionBounds: normalizeAssertionBounds(options.assertionBounds),
1623
1653
  onProgress: options.onProgress,
1654
+ onAssertionPlan: options.onAssertionPlan,
1624
1655
  registerFunctions: options.registerFunctions,
1625
1656
  // the host's declared index-expression functions ride to every
1626
1657
  // planner and every connection this run opens — the shadow's
@@ -1740,7 +1771,15 @@ export function migrate(target, migrations, options) {
1740
1771
  }
1741
1772
  else if (migrationStep.kind === 'jslt')
1742
1773
  rendered.push(`-- jslt transform over '${migrationStep.collection}'`);
1743
- else rendered.push(`-- assert over '${migrationStep.collection}'`);
1774
+ else {
1775
+ const operation = compileDocumentStep(migrationStep, migration.steps.indexOf(migrationStep), {
1776
+ migrationId: migration.id, compileJslt: compileJsltStylesheet, compileQuery: compileJsonQuery,
1777
+ assertionBounds: runOptions.assertionBounds,
1778
+ });
1779
+ const strategy = assertionProvider(migrationStep.assert, migrationStep.collection, operation.shape) === null
1780
+ ? operation.strategy : 'provider';
1781
+ rendered.push(`-- assert over '${migrationStep.collection}' (${strategy}; ${operation.reason})`);
1782
+ }
1744
1783
  }
1745
1784
  const jsltCollections = [...new Set(migration.steps
1746
1785
  .filter((s) => s.kind === 'jslt').map((s) => s.collection))];
@@ -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
+ }