@syncular/client 0.30.10 → 0.30.12

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,10 +1,15 @@
1
+ export declare const RETAINED_ROWS = "_syncular_failed_rows";
1
2
  import type { ClientDatabase } from './database.js';
2
- import type { CompiledClientSchema, JsonRowValue } from './schema.js';
3
+ import type { CompiledClientSchema, CompiledClientTable, JsonRowValue } from './schema.js';
4
+ import type { RowValue } from '@syncular/core';
5
+ import type { RetainedCommitRow } from './outcomes.js';
3
6
  import type { OutboxCommit, OutboxBeforeImage } from './outbox.js';
4
7
  /** Protected failure state. The server base advances independently of intent. */
5
8
  export declare function ensureFailedOverlaySchema(db: ClientDatabase): void;
6
- export declare function retainedBaseWrite(db: ClientDatabase, table: string, rowId: string, values?: Readonly<Record<string, JsonRowValue>>, version?: number): void;
7
- export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[]): void;
8
- export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, absentIntent?: boolean): void;
9
- export declare function failedOverlayCommits(db: ClientDatabase): OutboxCommit[];
9
+ export declare function retainedBaseWrite(db: ClientDatabase, table: CompiledClientTable, rowId: string, values?: Readonly<Record<string, JsonRowValue>>, version?: number): void;
10
+ export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[], commitSeq?: number): void;
11
+ /** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
12
+ export declare function uniqueConflicts(db: ClientDatabase, table: CompiledClientTable, values: Readonly<Record<string, RowValue>>): NonNullable<RetainedCommitRow['uniqueConflicts']>;
13
+ export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, absentIntent?: boolean, rowKeys?: readonly string[]): boolean;
14
+ export declare function failedOverlayCommits(db: ClientDatabase, acknowledged?: boolean, rowKeys?: readonly string[]): OutboxCommit[];
10
15
  export declare function dropFailedRows(db: ClientDatabase, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];
@@ -1,32 +1,59 @@
1
+ export const RETAINED_ROWS = '_syncular_failed_rows';
1
2
  import { ClientSyncError } from './errors.js';
2
- import { jsonToRowValue, quoteIdent } from './schema.js';
3
+ import { mapRowValues, fromSqlValue, jsonToRowValue, quoteIdent, rowValueToJson, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
3
4
  import { deleteLocalRow, upsertLocalRow } from './apply.js';
4
5
  /** Protected failure state. The server base advances independently of intent. */
5
6
  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))`);
7
+ db.exec(`CREATE TABLE IF NOT EXISTS ${RETAINED_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,commit_seq INTEGER,PRIMARY KEY(commit_id,idx))`);
8
+ const columns = db.query(`PRAGMA table_info(${RETAINED_ROWS})`);
9
+ for (const column of ['unique_conflicts TEXT', 'commit_seq INTEGER'])
10
+ if (!columns.some((row) => row.name === column.split(' ')[0]))
11
+ db.exec(`ALTER TABLE ${RETAINED_ROWS} ADD COLUMN ${column}`);
11
12
  }
12
13
  export function retainedBaseWrite(db, table, rowId, values, version) {
13
- db.exec('UPDATE _syncular_failed_rows SET base = ?, version = ? WHERE tbl = ? AND id = ?', [
14
+ db.exec(`UPDATE ${RETAINED_ROWS} SET base=?,version=? WHERE tbl=? AND id=?`, [
14
15
  values === undefined ? null : JSON.stringify(values),
15
16
  version ?? null,
16
- table,
17
+ table.name,
17
18
  rowId,
18
19
  ]);
20
+ if (!table.indexes.some((index) => index.unique))
21
+ return;
22
+ for (const retained of db.query(`SELECT commit_id,idx,intent,unique_conflicts FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq IS NULL`, [table.name])) {
23
+ const prior = retained.unique_conflicts === null
24
+ ? []
25
+ : JSON.parse(retained.unique_conflicts);
26
+ const conflicts = prior.filter((conflict) => conflict.rowId !== rowId);
27
+ if (values !== undefined && retained.intent !== null) {
28
+ const intent = JSON.parse(retained.intent);
29
+ conflicts.push(...uniqueConflicts(db, table, mapRowValues(intent, jsonToRowValue))
30
+ .filter((conflict) => conflict.rowId === rowId)
31
+ .map((conflict) => ({
32
+ ...conflict,
33
+ serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
34
+ })));
35
+ }
36
+ db.exec(`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`, [
37
+ conflicts.length
38
+ ? JSON.stringify(conflicts.sort((a, b) => table.indexes.findIndex((index) => index.name === a.index) -
39
+ table.indexes.findIndex((index) => index.name === b.index)))
40
+ : null,
41
+ retained.commit_id,
42
+ Number(retained.idx),
43
+ ]);
44
+ }
19
45
  }
20
- export function retainFailedRows(db, commit, images) {
46
+ export function retainFailedRows(db, commit, images, commitSeq) {
21
47
  for (const [index, operation] of commit.operations.entries()) {
22
48
  const image = images.find((image) => image.opIndex === index);
49
+ if (commitSeq !== undefined && (image?.deliverySeq ?? -1) >= commitSeq)
50
+ continue;
23
51
  const initial = operation.op === 'upsert'
24
52
  ? { ...image?.values, ...operation.values }
25
53
  : undefined;
26
54
  if (!image)
27
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
55
+ throw new ClientSyncError('sync.local_corrupt', 'missing intent before-image');
56
+ db.exec(`INSERT INTO ${RETAINED_ROWS}(commit_id,idx,tbl,id,at,op,intent,base,version,commit_seq)VALUES(?,?,?,?,?,?,?,?,?,?)`, [
30
57
  commit.clientCommitId,
31
58
  index,
32
59
  operation.table,
@@ -36,42 +63,91 @@ export function retainFailedRows(db, commit, images) {
36
63
  initial ? JSON.stringify(initial) : null,
37
64
  image.values ? JSON.stringify(image.values) : null,
38
65
  image.syncVersion ?? null,
66
+ commitSeq ?? null,
39
67
  ]);
40
68
  }
41
69
  }
42
- export function restoreFailedBases(db, schema, absentIntent = false) {
70
+ /** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
71
+ export function uniqueConflicts(db, table, values) {
72
+ return table.indexes
73
+ .filter((index) => index.unique)
74
+ .flatMap((index) => db
75
+ .query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} !=? AND ${index.columns.map((column) => `${quoteIdent(column)} = ?`).join(' AND ')}`, [
76
+ toSqlValue(values[table.primaryKey] ?? null),
77
+ ...index.columns.map((column) => toSqlValue(values[column] ?? null)),
78
+ ])
79
+ .map((row) => ({
80
+ index: index.name,
81
+ columns: index.columns,
82
+ rowId: String(row[table.primaryKey]),
83
+ serverRow: Object.fromEntries(table.columns.map((column) => [
84
+ column.name,
85
+ fromSqlValue(column, row[column.name] ?? null),
86
+ ])),
87
+ serverVersion: Number(row[SYNC_VERSION_COLUMN]),
88
+ })));
89
+ }
90
+ function retainedRows(db, rowKeys) {
91
+ return db.query(`SELECT * FROM ${RETAINED_ROWS} WHERE ?1 IS NULL OR json_array(tbl,id) IN(SELECT value FROM json_each(?1)) ORDER BY commit_seq,at,commit_id,idx`, [rowKeys ? JSON.stringify(rowKeys) : null]);
92
+ }
93
+ export function restoreFailedBases(db, schema, absentIntent = false, rowKeys) {
43
94
  const seen = new Set();
44
- for (const row of db.query('SELECT * FROM _syncular_failed_rows ORDER BY at, commit_id, idx')) {
95
+ for (const row of retainedRows(db, rowKeys)) {
45
96
  const key = JSON.stringify([row.tbl, row.id]);
46
97
  if (!absentIntent && seen.has(key))
47
98
  continue;
48
99
  seen.add(key);
49
- const table = schema.tables.get(String(row.tbl));
100
+ const table = schema.tables.get(row.tbl);
50
101
  if (!table)
51
- throw new ClientSyncError('sync.unknown_table', 'retained intent references an unknown table');
102
+ throw new ClientSyncError('sync.unknown_table', 'retained table is unknown');
52
103
  const json = absentIntent ? row.intent : row.base;
104
+ if (absentIntent && row.intent !== null) {
105
+ const values = JSON.parse(row.intent);
106
+ const conflicts = uniqueConflicts(db, table, mapRowValues(values, jsonToRowValue));
107
+ // Hydrate a pre-0.30.11 failure after its winner was already imported.
108
+ // Pending and retained overlays cannot supply authoritative row evidence.
109
+ if (row.commit_seq === null && row.unique_conflicts === null) {
110
+ const authorized = conflicts.filter((conflict) => conflict.serverVersion >= 0 &&
111
+ !db.query(`SELECT 1 FROM ${RETAINED_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);
112
+ if (authorized.length)
113
+ db.exec(`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`, [
114
+ JSON.stringify(authorized.map((conflict) => ({
115
+ ...conflict,
116
+ serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
117
+ }))),
118
+ row.commit_id,
119
+ row.idx,
120
+ ]);
121
+ }
122
+ if (conflicts.length)
123
+ continue;
124
+ }
53
125
  if (absentIntent &&
54
126
  (json === null ||
55
- db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [String(row.id)]).length))
127
+ db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [row.id]).length))
56
128
  continue;
57
129
  if (json === null)
58
- deleteLocalRow(db, table, String(row.id), false);
130
+ deleteLocalRow(db, table, row.id, false);
59
131
  else {
60
132
  const values = JSON.parse(String(json));
61
133
  if (absentIntent) {
62
- const operation = JSON.parse(String(row.op));
63
- if (!table.columns.every((column) => Object.hasOwn(operation.values ?? {}, column.name)))
134
+ const operation = JSON.parse(row.op);
135
+ if (row.commit_seq === null &&
136
+ !table.columns.every((column) => Object.hasOwn(operation.values ?? {}, column.name)))
64
137
  continue;
65
138
  }
66
- upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), Number(row.version ?? -1), false);
139
+ upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), (row.version ?? -1), false);
67
140
  }
68
141
  }
142
+ return seen.size > 0;
69
143
  }
70
- export function failedOverlayCommits(db) {
144
+ export function failedOverlayCommits(db, acknowledged = false, rowKeys) {
71
145
  const commits = new Map();
72
- for (const row of db.query('SELECT * FROM _syncular_failed_rows ORDER BY at, commit_id, idx')) {
73
- const id = String(row.commit_id);
74
- const operation = JSON.parse(String(row.op));
146
+ for (const row of retainedRows(db, rowKeys)) {
147
+ if ((row.commit_seq !== null) !== acknowledged)
148
+ continue;
149
+ const id = row.commit_id;
150
+ const operation = JSON.parse(row.op);
75
151
  const prior = commits.get(id);
76
152
  if (prior)
77
153
  prior.operations.push(operation);
@@ -79,7 +155,7 @@ export function failedOverlayCommits(db) {
79
155
  commits.set(id, {
80
156
  seq: 0,
81
157
  clientCommitId: id,
82
- createdAtMs: Number(row.at),
158
+ createdAtMs: row.at,
83
159
  operations: [operation],
84
160
  });
85
161
  }
@@ -87,16 +163,22 @@ export function failedOverlayCommits(db) {
87
163
  }
88
164
  export function dropFailedRows(db, matches) {
89
165
  const ids = new Set();
90
- for (const row of db.query('SELECT * FROM _syncular_failed_rows')) {
91
- const json = row.intent ?? row.base;
92
- if (json === null)
93
- continue;
94
- const values = JSON.parse(String(json));
95
- if (matches(String(row.tbl), values))
96
- ids.add(String(row.commit_id));
166
+ for (const row of retainedRows(db)) {
167
+ for (const json of [row.intent, row.base]) {
168
+ if (json === null)
169
+ continue;
170
+ const values = JSON.parse(String(json));
171
+ if (matches(row.tbl, values))
172
+ ids.add(row.commit_id);
173
+ }
174
+ }
175
+ const dropped = [
176
+ ...failedOverlayCommits(db),
177
+ ...failedOverlayCommits(db, true),
178
+ ].filter((commit) => ids.has(commit.clientCommitId));
179
+ for (const id of ids) {
180
+ db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
181
+ db.exec("UPDATE _syncular_commit_outcomes SET operations=NULL,results='[]' WHERE client_commit_id=?", [id]);
97
182
  }
98
- 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]);
101
183
  return dropped;
102
184
  }
package/dist/outbox.d.ts CHANGED
@@ -34,12 +34,13 @@ export interface OutboxCommit {
34
34
  export interface OutboxBeforeImage {
35
35
  readonly opIndex: number;
36
36
  readonly existed: boolean;
37
+ readonly deliverySeq?: number;
37
38
  readonly syncVersion?: number;
38
39
  readonly values?: Readonly<Record<string, JsonRowValue>>;
39
40
  }
40
41
  export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number, beforeImages?: readonly OutboxBeforeImage[]): void;
41
42
  /** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
42
- export declare function listOutbox(db: ClientDatabase): OutboxCommit[];
43
+ export declare function listOutbox(db: ClientDatabase, rowKeys?: readonly string[]): OutboxCommit[];
43
44
  /** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
44
45
  export declare function iterateOutbox(db: ClientDatabase, throughSeq: number): Generator<OutboxCommit>;
45
46
  /** Routine status reads never load operation bodies. */
package/dist/outbox.js CHANGED
@@ -7,30 +7,20 @@
7
7
  */
8
8
  import { encodeSparseRow, } from '@syncular/core';
9
9
  import { ClientSyncError } from './errors.js';
10
- import { jsonToRowValue, } from './schema.js';
10
+ import { jsonToRowValue, matchesLocalScopes, } from './schema.js';
11
11
  export function appendOutboxCommit(db, clientCommitId, operations, nowMs, beforeImages = []) {
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)]);
17
- 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 (?, ?, ?, ?, ?)`, [
21
- clientCommitId,
22
- image.opIndex,
23
- image.existed ? 1 : 0,
24
- image.syncVersion ?? null,
25
- image.values === undefined ? null : JSON.stringify(image.values),
26
- ]);
27
- }
15
+ db.exec('INSERT INTO _syncular_outbox(client_commit_id,created_at_ms,operations)VALUES(?,?,?)', [clientCommitId, nowMs, JSON.stringify(operations)]);
16
+ replaceOutboxBeforeImages(db, clientCommitId, beforeImages);
28
17
  }
29
18
  /** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
30
- export function listOutbox(db) {
19
+ export function listOutbox(db, rowKeys) {
31
20
  return db
32
- .query(`SELECT seq, client_commit_id, created_at_ms, operations
33
- FROM _syncular_outbox ORDER BY seq ASC`)
21
+ .query(rowKeys === undefined
22
+ ? 'SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox ORDER BY seq ASC'
23
+ : "SELECT * FROM _syncular_outbox WHERE EXISTS(SELECT 1 FROM json_each(operations) WHERE json_array(json_extract(value,'$.table'),json_extract(value,'$.rowId')) IN(SELECT value FROM json_each(?))) ORDER BY seq", rowKeys && [JSON.stringify(rowKeys)])
34
24
  .map(decodeOutboxRow);
35
25
  }
36
26
  function decodeOutboxRow(row) {
@@ -45,8 +35,7 @@ function decodeOutboxRow(row) {
45
35
  export function* iterateOutbox(db, throughSeq) {
46
36
  let afterSeq = 0;
47
37
  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]);
38
+ 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
39
  if (rows.length === 0)
51
40
  return;
52
41
  for (const row of rows) {
@@ -58,26 +47,27 @@ export function* iterateOutbox(db, throughSeq) {
58
47
  }
59
48
  /** Routine status reads never load operation bodies. */
60
49
  export function countOutbox(db) {
61
- return db.query('SELECT COUNT(*) AS count FROM _syncular_outbox')[0]
50
+ return db.query('SELECT COUNT(*)AS count FROM _syncular_outbox')[0]
62
51
  .count;
63
52
  }
64
53
  export function deleteOutboxCommit(db, clientCommitId) {
65
- db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?', [
54
+ db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id=?', [
66
55
  clientCommitId,
67
56
  ]);
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 = ?', [
57
+ db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id=?', [clientCommitId]);
58
+ db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id=?', [
70
59
  clientCommitId,
71
60
  ]);
72
61
  }
73
62
  export function listOutboxBeforeImages(db, clientCommitId) {
74
63
  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])
64
+ .query('SELECT op_index,existed,sync_version,values_json,delivery_seq FROM _syncular_outbox_before_images WHERE client_commit_id=? ORDER BY op_index', [clientCommitId])
78
65
  .map((row) => ({
79
66
  opIndex: row.op_index,
80
67
  existed: row.existed === 1,
68
+ ...(typeof row.delivery_seq === 'number'
69
+ ? { deliverySeq: row.delivery_seq }
70
+ : {}),
81
71
  ...(typeof row.sync_version === 'number'
82
72
  ? { syncVersion: row.sync_version }
83
73
  : {}),
@@ -90,11 +80,7 @@ export function listOutboxBeforeImages(db, clientCommitId) {
90
80
  }
91
81
  export function replaceOutboxBeforeImages(db, clientCommitId, replacements) {
92
82
  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 (?, ?, ?, ?, ?)`, [
83
+ db.exec('INSERT INTO _syncular_outbox_before_images(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?) ON CONFLICT(client_commit_id,op_index)DO UPDATE SET existed=excluded.existed,sync_version=excluded.sync_version,values_json=excluded.values_json', [
98
84
  clientCommitId,
99
85
  image.opIndex,
100
86
  image.existed ? 1 : 0,
@@ -119,7 +105,7 @@ function sparseValues(table, values) {
119
105
  // bump removed (or renamed) it — the commit cannot be expressed now.
120
106
  for (const key of Object.keys(values)) {
121
107
  if (!table.columnIndex.has(key)) {
122
- throw new OutboxEncodeError(`outbox commit references column ${JSON.stringify(key)} on ${JSON.stringify(table.name)}, which the current schema no longer has (§7.4.4)`);
108
+ throw new OutboxEncodeError(`outbox column ${JSON.stringify(key)} is absent from table ${JSON.stringify(table.name)} (§7.4.4)`);
123
109
  }
124
110
  }
125
111
  return table.columns.map((column) => {
@@ -152,7 +138,7 @@ export async function encodeOutboxCommit(schema, commit, encryption, storedKeyId
152
138
  const table = schema.tables.get(op.table);
153
139
  if (table === undefined) {
154
140
  // §7.4.4: the bump removed this table — the commit cannot be encoded.
155
- throw new OutboxEncodeError(`outbox commit ${commit.clientCommitId} targets table ${JSON.stringify(op.table)}, which the current schema no longer has (§7.4.4)`);
141
+ throw new OutboxEncodeError(`outbox table ${JSON.stringify(op.table)} is absent from the current schema (§7.4.4)`);
156
142
  }
157
143
  if (op.values === undefined) {
158
144
  throw new ClientSyncError('sync.invalid_request', `outbox upsert on ${op.table}/${op.rowId} has no values`);
@@ -193,13 +179,7 @@ export function dropOutboxCommitsInScope(db, table, effective) {
193
179
  const inScope = commit.operations.some((op) => {
194
180
  if (op.table !== table.name || op.values === undefined)
195
181
  return false;
196
- return entries.every(([variable, values]) => {
197
- const column = table.scopeColumnByVariable.get(variable);
198
- if (column === undefined)
199
- return false;
200
- const value = op.values?.[column];
201
- return typeof value === 'string' && values.includes(value);
202
- });
182
+ return matchesLocalScopes(table, effective, op.values);
203
183
  });
204
184
  if (inScope) {
205
185
  deleteOutboxCommit(db, commit.clientCommitId);
@@ -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,6 @@
1
+ import { RETAINED_ROWS } from './failed-overlay.js';
1
2
  import { ClientSyncError } from './errors.js';
2
- import { jsonToRowValue, rowValueToJson } from './schema.js';
3
+ import { mapRowValues, jsonToRowValue, rowValueToJson, } from './schema.js';
3
4
  function encodeResults(results) {
4
5
  const stored = results.map((result) => {
5
6
  if (result.status !== 'conflict')
@@ -8,10 +9,7 @@ function encodeResults(results) {
8
9
  status: 'conflict',
9
10
  conflict: {
10
11
  ...result.conflict,
11
- serverRow: Object.fromEntries(Object.entries(result.conflict.serverRow).map(([key, value]) => [
12
- key,
13
- rowValueToJson(value),
14
- ])),
12
+ serverRow: mapRowValues(result.conflict.serverRow, rowValueToJson),
15
13
  },
16
14
  };
17
15
  });
@@ -28,27 +26,32 @@ function decodeResults(raw) {
28
26
  ...result.conflict,
29
27
  // Pre-column-version journal entries carry no conflictColumns.
30
28
  conflictColumns: result.conflict.conflictColumns ?? [],
31
- serverRow: Object.fromEntries(Object.entries(result.conflict.serverRow).map(([key, value]) => [
32
- key,
33
- jsonToRowValue(value),
34
- ])),
29
+ serverRow: mapRowValues(result.conflict.serverRow, jsonToRowValue),
35
30
  },
36
31
  };
37
32
  });
38
33
  }
39
34
  function parseOutcome(db, row) {
40
35
  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)])
36
+ .query(`SELECT tbl,id,intent,base,version,unique_conflicts FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL AND commit_id=? ORDER BY idx`, [row.client_commit_id])
42
37
  .map((retained) => {
43
38
  const decode = (value) => value === null
44
39
  ? null
45
40
  : Object.fromEntries(Object.entries(JSON.parse(String(value))).map(([key, value]) => [key, jsonToRowValue(value)]));
46
41
  return {
47
- table: String(retained.tbl),
48
- rowId: String(retained.id),
42
+ table: retained.tbl,
43
+ rowId: retained.id,
49
44
  localRow: decode(retained.intent),
45
+ ...(retained.unique_conflicts === null
46
+ ? {}
47
+ : {
48
+ uniqueConflicts: JSON.parse(retained.unique_conflicts).map((conflict) => ({
49
+ ...conflict,
50
+ serverRow: decode(JSON.stringify(conflict.serverRow)),
51
+ })),
52
+ }),
50
53
  serverRow: decode(retained.base),
51
- serverVersion: retained.version === null ? null : Number(retained.version),
54
+ serverVersion: retained.version === null ? null : retained.version,
52
55
  };
53
56
  });
54
57
  return {
@@ -71,9 +74,7 @@ function parseOutcome(db, row) {
71
74
  };
72
75
  }
73
76
  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')`, [
77
+ db.exec("INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')", [
77
78
  outcome.clientCommitId,
78
79
  outcome.status,
79
80
  outcome.recordedAtMs,
@@ -85,9 +86,7 @@ export function recordCommitOutcome(db, outcome) {
85
86
  return commitOutcome(db, outcome.clientCommitId);
86
87
  }
87
88
  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];
89
+ 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
90
  return row === undefined ? undefined : parseOutcome(db, row);
92
91
  }
93
92
  export function listCommitOutcomes(db, query = {}) {
@@ -98,16 +97,11 @@ export function listCommitOutcomes(db, query = {}) {
98
97
  const where = query.activeOnly
99
98
  ? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
100
99
  : '';
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]);
100
+ 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
101
  return rows.map((row) => parseOutcome(db, row));
106
102
  }
107
103
  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'`, [
104
+ db.exec("UPDATE _syncular_commit_outcomes SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'", [
111
105
  input.resolution,
112
106
  nowMs,
113
107
  input.replacementClientCommitId ?? null,
@@ -127,11 +121,9 @@ export function pruneCommitOutcomes(db, maxEntries) {
127
121
  const excess = Math.max(0, (count ?? 0) - maxEntries);
128
122
  if (excess === 0)
129
123
  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]);
124
+ 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
125
  for (const candidate of candidates) {
134
- db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq = ?', [
126
+ db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq=?', [
135
127
  candidate.seq,
136
128
  ]);
137
129
  }
@@ -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
  }