@syncular/client 0.30.10 → 0.30.11

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.
@@ -1,21 +1,46 @@
1
1
  import { ClientSyncError } from './errors.js';
2
- import { jsonToRowValue, quoteIdent } from './schema.js';
2
+ import { mapRowValues, fromSqlValue, jsonToRowValue, quoteIdent, rowValueToJson, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
3
3
  import { deleteLocalRow, upsertLocalRow } from './apply.js';
4
4
  /** Protected failure state. The server base advances independently of intent. */
5
5
  export function ensureFailedOverlaySchema(db) {
6
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_failed_rows(
7
- commit_id TEXT NOT NULL, idx INTEGER NOT NULL,
8
- tbl TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL,
9
- op TEXT NOT NULL, intent TEXT, base TEXT, version INTEGER,
10
- PRIMARY KEY(commit_id, idx))`);
6
+ db.exec('CREATE TABLE IF NOT EXISTS _syncular_failed_rows(commit_id TEXT NOT NULL,idx INTEGER NOT NULL,tbl TEXT NOT NULL,id TEXT NOT NULL,at INTEGER NOT NULL,op TEXT NOT NULL,intent TEXT,base TEXT,version INTEGER,unique_conflicts TEXT,PRIMARY KEY(commit_id,idx))');
7
+ if (!db
8
+ .query('PRAGMA table_info(_syncular_failed_rows)')
9
+ .some((row) => row.name === 'unique_conflicts'))
10
+ db.exec('ALTER TABLE _syncular_failed_rows ADD COLUMN unique_conflicts TEXT');
11
11
  }
12
12
  export function retainedBaseWrite(db, table, rowId, values, version) {
13
- db.exec('UPDATE _syncular_failed_rows SET base = ?, version = ? WHERE tbl = ? AND id = ?', [
13
+ db.exec('UPDATE _syncular_failed_rows SET base=?,version=? WHERE tbl=? AND id=?', [
14
14
  values === undefined ? null : JSON.stringify(values),
15
15
  version ?? null,
16
- table,
16
+ table.name,
17
17
  rowId,
18
18
  ]);
19
+ if (!table.indexes.some((index) => index.unique))
20
+ return;
21
+ for (const retained of db.query('SELECT commit_id,idx,intent,unique_conflicts FROM _syncular_failed_rows WHERE tbl=?', [table.name])) {
22
+ const prior = retained.unique_conflicts === null
23
+ ? []
24
+ : JSON.parse(String(retained.unique_conflicts));
25
+ const conflicts = prior.filter((conflict) => conflict.rowId !== rowId);
26
+ if (values !== undefined && retained.intent !== null) {
27
+ const intent = JSON.parse(String(retained.intent));
28
+ conflicts.push(...uniqueConflicts(db, table, mapRowValues(intent, jsonToRowValue))
29
+ .filter((conflict) => conflict.rowId === rowId)
30
+ .map((conflict) => ({
31
+ ...conflict,
32
+ serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
33
+ })));
34
+ }
35
+ db.exec('UPDATE _syncular_failed_rows SET unique_conflicts=? WHERE commit_id=? AND idx=?', [
36
+ conflicts.length
37
+ ? JSON.stringify(conflicts.sort((a, b) => table.indexes.findIndex((index) => index.name === a.index) -
38
+ table.indexes.findIndex((index) => index.name === b.index)))
39
+ : null,
40
+ String(retained.commit_id),
41
+ Number(retained.idx),
42
+ ]);
43
+ }
19
44
  }
20
45
  export function retainFailedRows(db, commit, images) {
21
46
  for (const [index, operation] of commit.operations.entries()) {
@@ -25,8 +50,7 @@ export function retainFailedRows(db, commit, images) {
25
50
  : undefined;
26
51
  if (!image)
27
52
  throw new ClientSyncError('sync.local_corrupt', 'retained intent has no before-image');
28
- db.exec(`INSERT INTO _syncular_failed_rows(commit_id, idx, tbl, id, at, op, intent, base, version)
29
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
53
+ db.exec('INSERT INTO _syncular_failed_rows(commit_id,idx,tbl,id,at,op,intent,base,version)VALUES(?,?,?,?,?,?,?,?,?)', [
30
54
  commit.clientCommitId,
31
55
  index,
32
56
  operation.table,
@@ -39,9 +63,29 @@ export function retainFailedRows(db, commit, images) {
39
63
  ]);
40
64
  }
41
65
  }
66
+ /** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
67
+ export function uniqueConflicts(db, table, values) {
68
+ return table.indexes
69
+ .filter((index) => index.unique)
70
+ .flatMap((index) => db
71
+ .query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} !=? AND ${index.columns.map((column) => `${quoteIdent(column)} = ?`).join(' AND ')}`, [
72
+ toSqlValue(values[table.primaryKey] ?? null),
73
+ ...index.columns.map((column) => toSqlValue(values[column] ?? null)),
74
+ ])
75
+ .map((row) => ({
76
+ index: index.name,
77
+ columns: index.columns,
78
+ rowId: String(row[table.primaryKey]),
79
+ serverRow: Object.fromEntries(table.columns.map((column) => [
80
+ column.name,
81
+ fromSqlValue(column, row[column.name] ?? null),
82
+ ])),
83
+ serverVersion: Number(row[SYNC_VERSION_COLUMN]),
84
+ })));
85
+ }
42
86
  export function restoreFailedBases(db, schema, absentIntent = false) {
43
87
  const seen = new Set();
44
- for (const row of db.query('SELECT * FROM _syncular_failed_rows ORDER BY at, commit_id, idx')) {
88
+ for (const row of db.query('SELECT * FROM _syncular_failed_rows ORDER BY at,commit_id,idx')) {
45
89
  const key = JSON.stringify([row.tbl, row.id]);
46
90
  if (!absentIntent && seen.has(key))
47
91
  continue;
@@ -50,9 +94,30 @@ export function restoreFailedBases(db, schema, absentIntent = false) {
50
94
  if (!table)
51
95
  throw new ClientSyncError('sync.unknown_table', 'retained intent references an unknown table');
52
96
  const json = absentIntent ? row.intent : row.base;
97
+ if (absentIntent && row.intent !== null) {
98
+ const values = JSON.parse(String(row.intent));
99
+ const conflicts = uniqueConflicts(db, table, mapRowValues(values, jsonToRowValue));
100
+ // Hydrate a pre-0.30.11 failure after its winner was already imported.
101
+ // Pending and retained overlays cannot supply authoritative row evidence.
102
+ if (row.unique_conflicts === null) {
103
+ const authorized = conflicts.filter((conflict) => conflict.serverVersion >= 0 &&
104
+ !db.query("SELECT 1 FROM _syncular_failed_rows WHERE tbl=? AND id=? UNION ALL SELECT 1 FROM _syncular_outbox,json_each(operations)WHERE json_extract(json_each.value,'$.table')=? AND json_extract(json_each.value,'$.rowId')=? LIMIT 1", [table.name, conflict.rowId, table.name, conflict.rowId]).length);
105
+ if (authorized.length)
106
+ db.exec('UPDATE _syncular_failed_rows SET unique_conflicts=? WHERE commit_id=? AND idx=?', [
107
+ JSON.stringify(authorized.map((conflict) => ({
108
+ ...conflict,
109
+ serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
110
+ }))),
111
+ String(row.commit_id),
112
+ Number(row.idx),
113
+ ]);
114
+ }
115
+ if (conflicts.length)
116
+ continue;
117
+ }
53
118
  if (absentIntent &&
54
119
  (json === null ||
55
- db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [String(row.id)]).length))
120
+ db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [String(row.id)]).length))
56
121
  continue;
57
122
  if (json === null)
58
123
  deleteLocalRow(db, table, String(row.id), false);
@@ -69,7 +134,7 @@ export function restoreFailedBases(db, schema, absentIntent = false) {
69
134
  }
70
135
  export function failedOverlayCommits(db) {
71
136
  const commits = new Map();
72
- for (const row of db.query('SELECT * FROM _syncular_failed_rows ORDER BY at, commit_id, idx')) {
137
+ for (const row of db.query('SELECT * FROM _syncular_failed_rows ORDER BY at,commit_id,idx')) {
73
138
  const id = String(row.commit_id);
74
139
  const operation = JSON.parse(String(row.op));
75
140
  const prior = commits.get(id);
@@ -96,7 +161,9 @@ export function dropFailedRows(db, matches) {
96
161
  ids.add(String(row.commit_id));
97
162
  }
98
163
  const dropped = failedOverlayCommits(db).filter((commit) => ids.has(commit.clientCommitId));
99
- for (const id of ids)
100
- db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id = ?', [id]);
164
+ for (const id of ids) {
165
+ db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id=?', [id]);
166
+ db.exec("UPDATE _syncular_commit_outcomes SET operations=NULL,results='[]' WHERE client_commit_id=?", [id]);
167
+ }
101
168
  return dropped;
102
169
  }
package/dist/outbox.js CHANGED
@@ -12,12 +12,9 @@ export function appendOutboxCommit(db, clientCommitId, operations, nowMs, before
12
12
  if (operations.length === 0) {
13
13
  throw new ClientSyncError('sync.empty_commit', 'a local commit must carry at least one operation (§6.1)');
14
14
  }
15
- db.exec(`INSERT INTO _syncular_outbox(client_commit_id, created_at_ms, operations)
16
- VALUES (?, ?, ?)`, [clientCommitId, nowMs, JSON.stringify(operations)]);
15
+ db.exec('INSERT INTO _syncular_outbox(client_commit_id,created_at_ms,operations)VALUES(?,?,?)', [clientCommitId, nowMs, JSON.stringify(operations)]);
17
16
  for (const image of beforeImages) {
18
- db.exec(`INSERT INTO _syncular_outbox_before_images(
19
- client_commit_id, op_index, existed, sync_version, values_json
20
- ) VALUES (?, ?, ?, ?, ?)`, [
17
+ db.exec('INSERT INTO _syncular_outbox_before_images(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?)', [
21
18
  clientCommitId,
22
19
  image.opIndex,
23
20
  image.existed ? 1 : 0,
@@ -29,8 +26,7 @@ export function appendOutboxCommit(db, clientCommitId, operations, nowMs, before
29
26
  /** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
30
27
  export function listOutbox(db) {
31
28
  return db
32
- .query(`SELECT seq, client_commit_id, created_at_ms, operations
33
- FROM _syncular_outbox ORDER BY seq ASC`)
29
+ .query('SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox ORDER BY seq ASC')
34
30
  .map(decodeOutboxRow);
35
31
  }
36
32
  function decodeOutboxRow(row) {
@@ -45,8 +41,7 @@ function decodeOutboxRow(row) {
45
41
  export function* iterateOutbox(db, throughSeq) {
46
42
  let afterSeq = 0;
47
43
  while (afterSeq < throughSeq) {
48
- const rows = db.query(`SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox
49
- WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`, [afterSeq, throughSeq]);
44
+ const rows = db.query('SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32', [afterSeq, throughSeq]);
50
45
  if (rows.length === 0)
51
46
  return;
52
47
  for (const row of rows) {
@@ -58,23 +53,21 @@ export function* iterateOutbox(db, throughSeq) {
58
53
  }
59
54
  /** Routine status reads never load operation bodies. */
60
55
  export function countOutbox(db) {
61
- return db.query('SELECT COUNT(*) AS count FROM _syncular_outbox')[0]
56
+ return db.query('SELECT COUNT(*)AS count FROM _syncular_outbox')[0]
62
57
  .count;
63
58
  }
64
59
  export function deleteOutboxCommit(db, clientCommitId) {
65
- db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?', [
60
+ db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id=?', [
66
61
  clientCommitId,
67
62
  ]);
68
- db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id = ?', [clientCommitId]);
69
- db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id = ?', [
63
+ db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id=?', [clientCommitId]);
64
+ db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id=?', [
70
65
  clientCommitId,
71
66
  ]);
72
67
  }
73
68
  export function listOutboxBeforeImages(db, clientCommitId) {
74
69
  return db
75
- .query(`SELECT op_index, existed, sync_version, values_json
76
- FROM _syncular_outbox_before_images
77
- WHERE client_commit_id = ? ORDER BY op_index`, [clientCommitId])
70
+ .query('SELECT op_index,existed,sync_version,values_json FROM _syncular_outbox_before_images WHERE client_commit_id=? ORDER BY op_index', [clientCommitId])
78
71
  .map((row) => ({
79
72
  opIndex: row.op_index,
80
73
  existed: row.existed === 1,
@@ -90,11 +83,8 @@ export function listOutboxBeforeImages(db, clientCommitId) {
90
83
  }
91
84
  export function replaceOutboxBeforeImages(db, clientCommitId, replacements) {
92
85
  for (const image of replacements) {
93
- db.exec(`DELETE FROM _syncular_outbox_before_images
94
- WHERE client_commit_id = ? AND op_index = ?`, [clientCommitId, image.opIndex]);
95
- db.exec(`INSERT INTO _syncular_outbox_before_images(
96
- client_commit_id, op_index, existed, sync_version, values_json
97
- ) VALUES (?, ?, ?, ?, ?)`, [
86
+ db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id=? AND op_index=?', [clientCommitId, image.opIndex]);
87
+ db.exec('INSERT INTO _syncular_outbox_before_images(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?)', [
98
88
  clientCommitId,
99
89
  image.opIndex,
100
90
  image.existed ? 1 : 0,
@@ -51,6 +51,14 @@ export type CommitOperationOutcome = {
51
51
  readonly rejection: RejectionRecord;
52
52
  };
53
53
  export interface RetainedCommitRow {
54
+ /** Authorized different-primary-key server rows matching intended unique keys. */
55
+ readonly uniqueConflicts?: readonly {
56
+ readonly index: string;
57
+ readonly columns: readonly string[];
58
+ readonly rowId: string;
59
+ readonly serverRow: Readonly<Record<string, RowValue>>;
60
+ readonly serverVersion: number;
61
+ }[];
54
62
  readonly table: string;
55
63
  readonly rowId: string;
56
64
  /** Complete intended row, or null for a local deletion. */
package/dist/outcomes.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ClientSyncError } from './errors.js';
2
- import { jsonToRowValue, rowValueToJson } from './schema.js';
2
+ import { mapRowValues, jsonToRowValue, rowValueToJson, } from './schema.js';
3
3
  function encodeResults(results) {
4
4
  const stored = results.map((result) => {
5
5
  if (result.status !== 'conflict')
@@ -8,10 +8,7 @@ function encodeResults(results) {
8
8
  status: 'conflict',
9
9
  conflict: {
10
10
  ...result.conflict,
11
- serverRow: Object.fromEntries(Object.entries(result.conflict.serverRow).map(([key, value]) => [
12
- key,
13
- rowValueToJson(value),
14
- ])),
11
+ serverRow: mapRowValues(result.conflict.serverRow, rowValueToJson),
15
12
  },
16
13
  };
17
14
  });
@@ -28,17 +25,14 @@ function decodeResults(raw) {
28
25
  ...result.conflict,
29
26
  // Pre-column-version journal entries carry no conflictColumns.
30
27
  conflictColumns: result.conflict.conflictColumns ?? [],
31
- serverRow: Object.fromEntries(Object.entries(result.conflict.serverRow).map(([key, value]) => [
32
- key,
33
- jsonToRowValue(value),
34
- ])),
28
+ serverRow: mapRowValues(result.conflict.serverRow, jsonToRowValue),
35
29
  },
36
30
  };
37
31
  });
38
32
  }
39
33
  function parseOutcome(db, row) {
40
34
  const retainedRows = db
41
- .query('SELECT tbl, id, intent, base, version FROM _syncular_failed_rows WHERE commit_id = ? ORDER BY idx', [String(row.client_commit_id)])
35
+ .query('SELECT tbl,id,intent,base,version,unique_conflicts FROM _syncular_failed_rows WHERE commit_id=? ORDER BY idx', [String(row.client_commit_id)])
42
36
  .map((retained) => {
43
37
  const decode = (value) => value === null
44
38
  ? null
@@ -47,6 +41,14 @@ function parseOutcome(db, row) {
47
41
  table: String(retained.tbl),
48
42
  rowId: String(retained.id),
49
43
  localRow: decode(retained.intent),
44
+ ...(retained.unique_conflicts === null
45
+ ? {}
46
+ : {
47
+ uniqueConflicts: JSON.parse(String(retained.unique_conflicts)).map((conflict) => ({
48
+ ...conflict,
49
+ serverRow: decode(JSON.stringify(conflict.serverRow)),
50
+ })),
51
+ }),
50
52
  serverRow: decode(retained.base),
51
53
  serverVersion: retained.version === null ? null : Number(retained.version),
52
54
  };
@@ -71,9 +73,7 @@ function parseOutcome(db, row) {
71
73
  };
72
74
  }
73
75
  export function recordCommitOutcome(db, outcome) {
74
- db.exec(`INSERT INTO _syncular_commit_outcomes(
75
- client_commit_id, status, recorded_at_ms, results, operations, resolution
76
- ) VALUES (?, ?, ?, ?, ?, 'active')`, [
76
+ db.exec("INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')", [
77
77
  outcome.clientCommitId,
78
78
  outcome.status,
79
79
  outcome.recordedAtMs,
@@ -85,9 +85,7 @@ export function recordCommitOutcome(db, outcome) {
85
85
  return commitOutcome(db, outcome.clientCommitId);
86
86
  }
87
87
  export function commitOutcome(db, clientCommitId) {
88
- const row = db.query(`SELECT seq, client_commit_id, status, recorded_at_ms, results, operations,
89
- resolution, resolved_at_ms, replacement_client_commit_id
90
- FROM _syncular_commit_outcomes WHERE client_commit_id = ?`, [clientCommitId])[0];
88
+ const row = db.query('SELECT seq,client_commit_id,status,recorded_at_ms,results,operations,resolution,resolved_at_ms,replacement_client_commit_id FROM _syncular_commit_outcomes WHERE client_commit_id=?', [clientCommitId])[0];
91
89
  return row === undefined ? undefined : parseOutcome(db, row);
92
90
  }
93
91
  export function listCommitOutcomes(db, query = {}) {
@@ -98,16 +96,11 @@ export function listCommitOutcomes(db, query = {}) {
98
96
  const where = query.activeOnly
99
97
  ? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
100
98
  : '';
101
- const rows = db.query(`SELECT seq, client_commit_id, status, recorded_at_ms, results, operations,
102
- resolution, resolved_at_ms, replacement_client_commit_id
103
- FROM _syncular_commit_outcomes ${where}
104
- ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`, limit === undefined ? [] : [limit]);
99
+ const rows = db.query(`SELECT seq,client_commit_id,status,recorded_at_ms,results,operations,resolution,resolved_at_ms,replacement_client_commit_id FROM _syncular_commit_outcomes ${where} ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`, limit === undefined ? [] : [limit]);
105
100
  return rows.map((row) => parseOutcome(db, row));
106
101
  }
107
102
  export function persistCommitOutcomeResolution(db, input, nowMs) {
108
- db.exec(`UPDATE _syncular_commit_outcomes
109
- SET resolution = ?, resolved_at_ms = ?, replacement_client_commit_id = ?
110
- WHERE client_commit_id = ? AND resolution = 'active'`, [
103
+ db.exec("UPDATE _syncular_commit_outcomes SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'", [
111
104
  input.resolution,
112
105
  nowMs,
113
106
  input.replacementClientCommitId ?? null,
@@ -127,11 +120,9 @@ export function pruneCommitOutcomes(db, maxEntries) {
127
120
  const excess = Math.max(0, (count ?? 0) - maxEntries);
128
121
  if (excess === 0)
129
122
  return 0;
130
- const candidates = db.query(`SELECT seq FROM _syncular_commit_outcomes
131
- WHERE status IN ('applied', 'cached') OR resolution != 'active'
132
- ORDER BY seq ASC LIMIT ?`, [excess]);
123
+ const candidates = db.query("SELECT seq FROM _syncular_commit_outcomes WHERE status IN('applied','cached')OR resolution !='active' ORDER BY seq ASC LIMIT ?", [excess]);
133
124
  for (const candidate of candidates) {
134
- db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq = ?', [
125
+ db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq=?', [
135
126
  candidate.seq,
136
127
  ]);
137
128
  }
@@ -241,10 +241,10 @@ export function withPreviousVersionContainer(db, fn, removeFile = false) {
241
241
  }
242
242
  /** Read the container's own metadata record; `undefined` when absent/corrupt. */
243
243
  export function readPreviousVersionContainer(db) {
244
- const meta = db.query("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?", [CONTAINER_META_TABLE]);
244
+ const meta = db.query("SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?", [CONTAINER_META_TABLE]);
245
245
  if (meta.length === 0)
246
246
  return undefined;
247
- const raw = db.query(`SELECT record FROM ${quoteIdent(CONTAINER_META_TABLE)} WHERE id = 1`)[0]?.record;
247
+ const raw = db.query(`SELECT record FROM ${quoteIdent(CONTAINER_META_TABLE)} WHERE id=1`)[0]?.record;
248
248
  if (raw === undefined)
249
249
  return undefined;
250
250
  try {
@@ -287,11 +287,9 @@ function decodeRecord(value) {
287
287
  };
288
288
  }
289
289
  function writeRecord(db, record) {
290
- db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(CONTAINER_META_TABLE)} (
291
- id INTEGER PRIMARY KEY CHECK (id = 1),
292
- record TEXT NOT NULL)`);
290
+ db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(CONTAINER_META_TABLE)}(id INTEGER PRIMARY KEY CHECK(id=1),record TEXT NOT NULL)`);
293
291
  db.exec(`DELETE FROM ${quoteIdent(CONTAINER_META_TABLE)}`);
294
- db.exec(`INSERT INTO ${quoteIdent(CONTAINER_META_TABLE)}(id, record) VALUES (1, ?)`, [JSON.stringify(record)]);
292
+ db.exec(`INSERT INTO ${quoteIdent(CONTAINER_META_TABLE)}(id,record)VALUES(1,?)`, [JSON.stringify(record)]);
295
293
  }
296
294
  // ---------------------------------------------------------------------------
297
295
  // Bounded capture (D2 / A4)
@@ -309,9 +307,7 @@ function writeRecord(db, record) {
309
307
  */
310
308
  export function capturePreviousVersion(replica, container, oldDescriptor, newSchema, config, nowMs) {
311
309
  const discovered = new Set(replica
312
- .query(`SELECT name FROM sqlite_master WHERE type = 'table'
313
- AND name NOT LIKE '_syncular_%'
314
- AND name NOT LIKE 'sqlite_%'`)
310
+ .query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'")
315
311
  .map((row) => String(row.name)));
316
312
  // Only descriptor-known tables carry semantic types; those are the tables the
317
313
  // copy would hold, and the count the table budget bounds.
@@ -326,7 +322,7 @@ export function capturePreviousVersion(replica, container, oldDescriptor, newSch
326
322
  let measuredRows = 0;
327
323
  const perTableRows = [];
328
324
  for (const table of tables) {
329
- const probeRows = Number(replica.query(`SELECT COUNT(*) AS count FROM (SELECT 1 FROM ${quoteIdent(table.name)} LIMIT ?)`, [config.maxRows + 1])[0]?.count ?? 0);
325
+ const probeRows = Number(replica.query(`SELECT COUNT(*)AS count FROM(SELECT 1 FROM ${quoteIdent(table.name)} LIMIT ?)`, [config.maxRows + 1])[0]?.count ?? 0);
330
326
  measuredRows += probeRows;
331
327
  perTableRows.push(probeRows);
332
328
  if (measuredRows > config.maxRows)
@@ -340,13 +336,10 @@ export function capturePreviousVersion(replica, container, oldDescriptor, newSch
340
336
  .join(' + ');
341
337
  if (table.columns.length === 0)
342
338
  continue;
343
- const oversized = replica.query(`SELECT 1 AS hit FROM ${quoteIdent(table.name)}
344
- WHERE (${byteSum}) > ? LIMIT 1`, [config.maxRowBytes]);
339
+ const oversized = replica.query(`SELECT 1 AS hit FROM ${quoteIdent(table.name)} WHERE(${byteSum})>? LIMIT 1`, [config.maxRowBytes]);
345
340
  if (oversized.length > 0)
346
341
  return over(measuredRows, measuredBytes);
347
- const total = replica.query(`SELECT COALESCE(SUM(bytes), 0) AS total FROM (
348
- SELECT (${byteSum}) AS bytes FROM ${quoteIdent(table.name)} LIMIT ?
349
- )`, [perTableRows[index] ?? 0])[0]?.total;
342
+ const total = replica.query(`SELECT COALESCE(SUM(bytes),0)AS total FROM(SELECT(${byteSum})AS bytes FROM ${quoteIdent(table.name)} LIMIT ?)`, [perTableRows[index] ?? 0])[0]?.total;
350
343
  measuredBytes += Number(total ?? 0);
351
344
  if (measuredBytes > config.maxBytes)
352
345
  return over(measuredRows, measuredBytes);
@@ -354,19 +347,14 @@ export function capturePreviousVersion(replica, container, oldDescriptor, newSch
354
347
  let copiedRows = 0;
355
348
  container.transaction(() => {
356
349
  dropPreviousVersionContainer(container);
357
- container.exec(`CREATE TABLE ${quoteIdent(CONTAINER_TABLE)} (
358
- tbl TEXT NOT NULL,
359
- row_id TEXT NOT NULL,
360
- payload TEXT NOT NULL,
361
- PRIMARY KEY (tbl, row_id))`);
350
+ container.exec(`CREATE TABLE ${quoteIdent(CONTAINER_TABLE)}(tbl TEXT NOT NULL,row_id TEXT NOT NULL,payload TEXT NOT NULL,PRIMARY KEY(tbl,row_id))`);
362
351
  for (const table of tables) {
363
352
  const selectColumns = table.columns
364
353
  .map((column) => quoteIdent(column.name))
365
354
  .join(', ');
366
355
  let lastRowId = -1;
367
356
  for (;;) {
368
- const rows = replica.query(`SELECT rowid AS _rid, ${selectColumns} FROM ${quoteIdent(table.name)}
369
- WHERE rowid > ? ORDER BY rowid ASC LIMIT ?`, [lastRowId, COPY_BATCH_ROWS]);
357
+ const rows = replica.query(`SELECT rowid AS _rid,${selectColumns} FROM ${quoteIdent(table.name)} WHERE rowid>? ORDER BY rowid ASC LIMIT ?`, [lastRowId, COPY_BATCH_ROWS]);
370
358
  if (rows.length === 0)
371
359
  break;
372
360
  for (const row of rows) {
@@ -375,8 +363,7 @@ export function capturePreviousVersion(replica, container, oldDescriptor, newSch
375
363
  const value = fromSqlValue({ name: column.name, type: column.type, nullable: true }, (row[column.name] ?? null));
376
364
  payload[column.name] = rowValueToJson(value);
377
365
  }
378
- container.exec(`INSERT INTO ${quoteIdent(CONTAINER_TABLE)}(tbl, row_id, payload)
379
- VALUES (?, ?, ?)`, [table.name, rowIdText(table, row), JSON.stringify(payload)]);
366
+ container.exec(`INSERT INTO ${quoteIdent(CONTAINER_TABLE)}(tbl,row_id,payload)VALUES(?,?,?)`, [table.name, rowIdText(table, row), JSON.stringify(payload)]);
380
367
  }
381
368
  lastRowId = Number(rows[rows.length - 1]?._rid ?? lastRowId);
382
369
  copiedRows += rows.length;
@@ -431,8 +418,7 @@ export function readPreviousVersionRows(db, table, rowIds, limit) {
431
418
  const where = rowIds.length === 0
432
419
  ? ''
433
420
  : ` AND row_id IN (${rowIds.map(() => '?').join(', ')})`;
434
- const rows = db.query(`SELECT row_id, payload FROM ${quoteIdent(CONTAINER_TABLE)}
435
- WHERE tbl = ?${where} ORDER BY row_id ASC LIMIT ?`, [table.name, ...rowIds, limit + 1]);
421
+ const rows = db.query(`SELECT row_id,payload FROM ${quoteIdent(CONTAINER_TABLE)} WHERE tbl=?${where} ORDER BY row_id ASC LIMIT ?`, [table.name, ...rowIds, limit + 1]);
436
422
  const truncated = rows.length > limit;
437
423
  const visible = truncated ? rows.slice(0, limit) : rows;
438
424
  return {
@@ -455,7 +441,7 @@ export function writePreviousVersionRefusal(db, reason, measurement) {
455
441
  }
456
442
  /** Delete the durable refusal. Part of the orphan sweep and of a successful capture. */
457
443
  export function clearPreviousVersionRefusal(db) {
458
- db.exec('DELETE FROM _syncular_meta WHERE key = ?', [
444
+ db.exec('DELETE FROM _syncular_meta WHERE key=?', [
459
445
  PREVIOUS_VERSION_CONTEXT_KEY,
460
446
  ]);
461
447
  }
@@ -589,7 +575,7 @@ export function writePreviousVersionAudit(db, audit) {
589
575
  }
590
576
  /** Delete the advisory audit. Part of a discard, never of a normal bump. */
591
577
  export function clearPreviousVersionAudit(db) {
592
- db.exec('DELETE FROM _syncular_meta WHERE key = ?', [
578
+ db.exec('DELETE FROM _syncular_meta WHERE key=?', [
593
579
  PREVIOUS_VERSION_AUDIT_KEY,
594
580
  ]);
595
581
  }
package/dist/schema.d.ts CHANGED
@@ -160,3 +160,5 @@ export type JsonRowValue = string | number | boolean | null | {
160
160
  export declare function hexToBytes(hex: string): Uint8Array;
161
161
  export declare function rowValueToJson(value: RowValue): JsonRowValue;
162
162
  export declare function jsonToRowValue(value: JsonRowValue): RowValue;
163
+ /** Convert each column value without changing the row's presence set. */
164
+ export declare function mapRowValues<T, U>(values: Readonly<Record<string, T>>, convert: (value: T) => U): Record<string, U>;