@jarenjs/db 0.72.2 → 0.72.3

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.
@@ -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. No schema migration or revision rewrite of this protocol is implicit.
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.72.2",
4
+ "version": "0.72.3",
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.72.2",
88
- "@jarenjs/json": "^0.72.2",
89
- "@jarenjs/validate": "^0.72.2"
87
+ "@jarenjs/core": "^0.72.3",
88
+ "@jarenjs/json": "^0.72.3",
89
+ "@jarenjs/validate": "^0.72.3"
90
90
  },
91
91
  "bin": {
92
92
  "jaren-db": "./src/cli.js"
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
- * history table excluded, sorted. Shape equality after a migration —
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
+ });
@@ -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('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]),
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('INSERT INTO _jaren_replica_rows (name, key, value, frontier) VALUES (?, ?, ?, ?) '
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('INSERT INTO _jaren_replica_receipts (id, payload) VALUES (?, ?)', 'run',
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
- '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) => {
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('INSERT INTO _jaren_replica (id, value) VALUES (1, ?)', 'run',
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('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]), () =>
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('SELECT payload FROM _jaren_replica_receipts WHERE id = ?', 'get', [identity]);
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('SELECT payload FROM _jaren_replica_claims WHERE id = ?', 'get', [identity]);
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(freeze(input)); }
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('INSERT INTO _jaren_replica_conflicts (id, evidence) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET evidence = excluded.evidence',
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('INSERT INTO _jaren_replica_claims (id, payload) VALUES (?, ?)', 'run', [identity, stableStringify(envelope)]);
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('SELECT min(seq) AS lo FROM _jaren_replica_outbox', 'get');
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('SELECT payload FROM _jaren_replica_outbox WHERE seq > ? ORDER BY seq LIMIT ?'),
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('SELECT name, key, value, frontier FROM _jaren_replica_rows ORDER BY name, key LIMIT ?', [config.maxOperations + 1],
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('SELECT payload FROM _jaren_replica_receipts ORDER BY id LIMIT ?', [config.maxOperations + 1],
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('SELECT id, payload FROM _jaren_replica_receipts LIMIT ?', [config.maxOperations + 1],
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('SELECT name, key, value FROM _jaren_replica_rows LIMIT ?', [config.maxOperations + 1],
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('DELETE FROM _jaren_replica_rows');
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('DELETE FROM _jaren_replica_outbox');
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('SELECT evidence FROM _jaren_replica_conflicts ORDER BY id LIMIT ?', [Math.min(limit, config.maxOperations)],
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
  };