@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,7 +1,22 @@
1
+ export const RETAINED_ROWS = '_syncular_failed_rows';
1
2
  import { ClientSyncError } from './errors';
2
3
  import type { ClientDatabase } from './database';
3
- import type { CompiledClientSchema, JsonRowValue } from './schema';
4
- import { jsonToRowValue, quoteIdent } from './schema';
4
+ import type {
5
+ CompiledClientSchema,
6
+ CompiledClientTable,
7
+ JsonRowValue,
8
+ } from './schema';
9
+ import {
10
+ mapRowValues,
11
+ fromSqlValue,
12
+ jsonToRowValue,
13
+ quoteIdent,
14
+ rowValueToJson,
15
+ SYNC_VERSION_COLUMN,
16
+ toSqlValue,
17
+ } from './schema';
18
+ import type { RowValue } from '@syncular/core';
19
+ import type { RetainedCommitRow } from './outcomes';
5
20
  import type {
6
21
  OutboxCommit,
7
22
  OutboxBeforeImage,
@@ -11,38 +26,83 @@ import { deleteLocalRow, upsertLocalRow } from './apply';
11
26
 
12
27
  /** Protected failure state. The server base advances independently of intent. */
13
28
  export function ensureFailedOverlaySchema(db: ClientDatabase): void {
14
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_failed_rows(
15
- commit_id TEXT NOT NULL, idx INTEGER NOT NULL,
16
- tbl TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL,
17
- op TEXT NOT NULL, intent TEXT, base TEXT, version INTEGER,
18
- PRIMARY KEY(commit_id, idx))`);
29
+ db.exec(
30
+ `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))`,
31
+ );
32
+ const columns = db.query(`PRAGMA table_info(${RETAINED_ROWS})`);
33
+ for (const column of ['unique_conflicts TEXT', 'commit_seq INTEGER'])
34
+ if (!columns.some((row) => row.name === column.split(' ')[0]))
35
+ db.exec(`ALTER TABLE ${RETAINED_ROWS} ADD COLUMN ${column}`);
19
36
  }
20
37
 
21
38
  export function retainedBaseWrite(
22
39
  db: ClientDatabase,
23
- table: string,
40
+ table: CompiledClientTable,
24
41
  rowId: string,
25
42
  values?: Readonly<Record<string, JsonRowValue>>,
26
43
  version?: number,
27
44
  ): void {
28
- db.exec(
29
- 'UPDATE _syncular_failed_rows SET base = ?, version = ? WHERE tbl = ? AND id = ?',
30
- [
31
- values === undefined ? null : JSON.stringify(values),
32
- version ?? null,
33
- table,
34
- rowId,
35
- ],
36
- );
45
+ db.exec(`UPDATE ${RETAINED_ROWS} SET base=?,version=? WHERE tbl=? AND id=?`, [
46
+ values === undefined ? null : JSON.stringify(values),
47
+ version ?? null,
48
+ table.name,
49
+ rowId,
50
+ ]);
51
+ if (!table.indexes.some((index) => index.unique)) return;
52
+ for (const retained of db.query(
53
+ `SELECT commit_id,idx,intent,unique_conflicts FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq IS NULL`,
54
+ [table.name],
55
+ )) {
56
+ const prior: readonly (Omit<
57
+ NonNullable<RetainedCommitRow['uniqueConflicts']>[number],
58
+ 'serverRow'
59
+ > & { serverRow: Readonly<Record<string, JsonRowValue>> })[] =
60
+ retained.unique_conflicts === null
61
+ ? []
62
+ : JSON.parse(retained.unique_conflicts as string);
63
+ const conflicts = prior.filter((conflict) => conflict.rowId !== rowId);
64
+ if (values !== undefined && retained.intent !== null) {
65
+ const intent: Record<string, JsonRowValue> = JSON.parse(
66
+ retained.intent as string,
67
+ );
68
+ conflicts.push(
69
+ ...uniqueConflicts(db, table, mapRowValues(intent, jsonToRowValue))
70
+ .filter((conflict) => conflict.rowId === rowId)
71
+ .map((conflict) => ({
72
+ ...conflict,
73
+ serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
74
+ })),
75
+ );
76
+ }
77
+ db.exec(
78
+ `UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`,
79
+ [
80
+ conflicts.length
81
+ ? JSON.stringify(
82
+ conflicts.sort(
83
+ (a, b) =>
84
+ table.indexes.findIndex((index) => index.name === a.index) -
85
+ table.indexes.findIndex((index) => index.name === b.index),
86
+ ),
87
+ )
88
+ : null,
89
+ retained.commit_id as string,
90
+ Number(retained.idx),
91
+ ],
92
+ );
93
+ }
37
94
  }
38
95
 
39
96
  export function retainFailedRows(
40
97
  db: ClientDatabase,
41
98
  commit: OutboxCommit,
42
99
  images: readonly OutboxBeforeImage[],
100
+ commitSeq?: number,
43
101
  ): void {
44
102
  for (const [index, operation] of commit.operations.entries()) {
45
103
  const image = images.find((image) => image.opIndex === index);
104
+ if (commitSeq !== undefined && (image?.deliverySeq ?? -1) >= commitSeq)
105
+ continue;
46
106
  const initial =
47
107
  operation.op === 'upsert'
48
108
  ? { ...image?.values, ...operation.values }
@@ -50,11 +110,10 @@ export function retainFailedRows(
50
110
  if (!image)
51
111
  throw new ClientSyncError(
52
112
  'sync.local_corrupt',
53
- 'retained intent has no before-image',
113
+ 'missing intent before-image',
54
114
  );
55
115
  db.exec(
56
- `INSERT INTO _syncular_failed_rows(commit_id, idx, tbl, id, at, op, intent, base, version)
57
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
116
+ `INSERT INTO ${RETAINED_ROWS}(commit_id,idx,tbl,id,at,op,intent,base,version,commit_seq)VALUES(?,?,?,?,?,?,?,?,?,?)`,
58
117
  [
59
118
  commit.clientCommitId,
60
119
  index,
@@ -65,45 +124,124 @@ export function retainFailedRows(
65
124
  initial ? JSON.stringify(initial) : null,
66
125
  image.values ? JSON.stringify(image.values) : null,
67
126
  image.syncVersion ?? null,
127
+ commitSeq ?? null,
68
128
  ],
69
129
  );
70
130
  }
71
131
  }
72
132
 
133
+ /** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
134
+ export function uniqueConflicts(
135
+ db: ClientDatabase,
136
+ table: CompiledClientTable,
137
+ values: Readonly<Record<string, RowValue>>,
138
+ ): NonNullable<RetainedCommitRow['uniqueConflicts']> {
139
+ return table.indexes
140
+ .filter((index) => index.unique)
141
+ .flatMap((index) =>
142
+ db
143
+ .query(
144
+ `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} !=? AND ${index.columns.map((column) => `${quoteIdent(column)} = ?`).join(' AND ')}`,
145
+ [
146
+ toSqlValue(values[table.primaryKey] ?? null),
147
+ ...index.columns.map((column) =>
148
+ toSqlValue(values[column] ?? null),
149
+ ),
150
+ ],
151
+ )
152
+ .map((row) => ({
153
+ index: index.name,
154
+ columns: index.columns,
155
+ rowId: String(row[table.primaryKey]),
156
+ serverRow: Object.fromEntries(
157
+ table.columns.map((column) => [
158
+ column.name,
159
+ fromSqlValue(column, row[column.name] ?? null),
160
+ ]),
161
+ ),
162
+ serverVersion: Number(row[SYNC_VERSION_COLUMN]),
163
+ })),
164
+ );
165
+ }
166
+
167
+ function retainedRows(db: ClientDatabase, rowKeys?: readonly string[]) {
168
+ return db.query(
169
+ `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`,
170
+ [rowKeys ? JSON.stringify(rowKeys) : null],
171
+ );
172
+ }
173
+
73
174
  export function restoreFailedBases(
74
175
  db: ClientDatabase,
75
176
  schema: CompiledClientSchema,
76
177
  absentIntent = false,
77
- ): void {
178
+ rowKeys?: readonly string[],
179
+ ): boolean {
78
180
  const seen = new Set<string>();
79
- for (const row of db.query(
80
- 'SELECT * FROM _syncular_failed_rows ORDER BY at, commit_id, idx',
81
- )) {
181
+ for (const row of retainedRows(db, rowKeys)) {
82
182
  const key = JSON.stringify([row.tbl, row.id]);
83
183
  if (!absentIntent && seen.has(key)) continue;
84
184
  seen.add(key);
85
- const table = schema.tables.get(String(row.tbl));
185
+ const table = schema.tables.get(row.tbl as string);
86
186
  if (!table)
87
187
  throw new ClientSyncError(
88
188
  'sync.unknown_table',
89
- 'retained intent references an unknown table',
189
+ 'retained table is unknown',
90
190
  );
91
191
  const json = absentIntent ? row.intent : row.base;
192
+ if (absentIntent && row.intent !== null) {
193
+ const values: Record<string, JsonRowValue> = JSON.parse(
194
+ row.intent as string,
195
+ );
196
+ const conflicts = uniqueConflicts(
197
+ db,
198
+ table,
199
+ mapRowValues(values, jsonToRowValue),
200
+ );
201
+ // Hydrate a pre-0.30.11 failure after its winner was already imported.
202
+ // Pending and retained overlays cannot supply authoritative row evidence.
203
+ if (row.commit_seq === null && row.unique_conflicts === null) {
204
+ const authorized = conflicts.filter(
205
+ (conflict) =>
206
+ conflict.serverVersion >= 0 &&
207
+ !db.query(
208
+ `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`,
209
+ [table.name, conflict.rowId, table.name, conflict.rowId],
210
+ ).length,
211
+ );
212
+ if (authorized.length)
213
+ db.exec(
214
+ `UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`,
215
+ [
216
+ JSON.stringify(
217
+ authorized.map((conflict) => ({
218
+ ...conflict,
219
+ serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
220
+ })),
221
+ ),
222
+ row.commit_id as string,
223
+ row.idx as number,
224
+ ],
225
+ );
226
+ }
227
+ if (conflicts.length) continue;
228
+ }
92
229
  if (
93
230
  absentIntent &&
94
231
  (json === null ||
95
232
  db.query(
96
- `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
97
- [String(row.id)],
233
+ `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
234
+ [row.id as string],
98
235
  ).length)
99
236
  )
100
237
  continue;
101
- if (json === null) deleteLocalRow(db, table, String(row.id), false);
238
+ if (json === null) deleteLocalRow(db, table, row.id as string, false);
102
239
  else {
103
240
  const values: Record<string, JsonRowValue> = JSON.parse(String(json));
104
241
  if (absentIntent) {
105
- const operation: OutboxOperation = JSON.parse(String(row.op));
242
+ const operation: OutboxOperation = JSON.parse(row.op as string);
106
243
  if (
244
+ row.commit_seq === null &&
107
245
  !table.columns.every((column) =>
108
246
  Object.hasOwn(operation.values ?? {}, column.name),
109
247
  )
@@ -116,14 +254,19 @@ export function restoreFailedBases(
116
254
  table.columns.map((column) =>
117
255
  jsonToRowValue(values[column.name] ?? null),
118
256
  ),
119
- Number(row.version ?? -1),
257
+ (row.version ?? -1) as number,
120
258
  false,
121
259
  );
122
260
  }
123
261
  }
262
+ return seen.size > 0;
124
263
  }
125
264
 
126
- export function failedOverlayCommits(db: ClientDatabase): OutboxCommit[] {
265
+ export function failedOverlayCommits(
266
+ db: ClientDatabase,
267
+ acknowledged = false,
268
+ rowKeys?: readonly string[],
269
+ ): OutboxCommit[] {
127
270
  const commits = new Map<
128
271
  string,
129
272
  {
@@ -133,18 +276,17 @@ export function failedOverlayCommits(db: ClientDatabase): OutboxCommit[] {
133
276
  operations: OutboxOperation[];
134
277
  }
135
278
  >();
136
- for (const row of db.query(
137
- 'SELECT * FROM _syncular_failed_rows ORDER BY at, commit_id, idx',
138
- )) {
139
- const id = String(row.commit_id);
140
- const operation: OutboxOperation = JSON.parse(String(row.op));
279
+ for (const row of retainedRows(db, rowKeys)) {
280
+ if ((row.commit_seq !== null) !== acknowledged) continue;
281
+ const id = row.commit_id as string;
282
+ const operation: OutboxOperation = JSON.parse(row.op as string);
141
283
  const prior = commits.get(id);
142
284
  if (prior) prior.operations.push(operation);
143
285
  else
144
286
  commits.set(id, {
145
287
  seq: 0,
146
288
  clientCommitId: id,
147
- createdAtMs: Number(row.at),
289
+ createdAtMs: row.at as number,
148
290
  operations: [operation],
149
291
  });
150
292
  }
@@ -159,16 +301,23 @@ export function dropFailedRows(
159
301
  ) => boolean,
160
302
  ): OutboxCommit[] {
161
303
  const ids = new Set<string>();
162
- for (const row of db.query('SELECT * FROM _syncular_failed_rows')) {
163
- const json = row.intent ?? row.base;
164
- if (json === null) continue;
165
- const values: Record<string, JsonRowValue> = JSON.parse(String(json));
166
- if (matches(String(row.tbl), values)) ids.add(String(row.commit_id));
304
+ for (const row of retainedRows(db)) {
305
+ for (const json of [row.intent, row.base]) {
306
+ if (json === null) continue;
307
+ const values: Record<string, JsonRowValue> = JSON.parse(String(json));
308
+ if (matches(row.tbl as string, values)) ids.add(row.commit_id as string);
309
+ }
310
+ }
311
+ const dropped = [
312
+ ...failedOverlayCommits(db),
313
+ ...failedOverlayCommits(db, true),
314
+ ].filter((commit) => ids.has(commit.clientCommitId));
315
+ for (const id of ids) {
316
+ db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
317
+ db.exec(
318
+ "UPDATE _syncular_commit_outcomes SET operations=NULL,results='[]' WHERE client_commit_id=?",
319
+ [id],
320
+ );
167
321
  }
168
- const dropped = failedOverlayCommits(db).filter((commit) =>
169
- ids.has(commit.clientCommitId),
170
- );
171
- for (const id of ids)
172
- db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id = ?', [id]);
173
322
  return dropped;
174
323
  }
package/src/outbox.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  type CompiledClientTable,
21
21
  type JsonRowValue,
22
22
  jsonToRowValue,
23
+ matchesLocalScopes,
23
24
  } from './schema';
24
25
 
25
26
  export interface OutboxOperation {
@@ -48,6 +49,7 @@ export interface OutboxCommit {
48
49
  export interface OutboxBeforeImage {
49
50
  readonly opIndex: number;
50
51
  readonly existed: boolean;
52
+ readonly deliverySeq?: number;
51
53
  readonly syncVersion?: number;
52
54
  readonly values?: Readonly<Record<string, JsonRowValue>>;
53
55
  }
@@ -66,32 +68,23 @@ export function appendOutboxCommit(
66
68
  );
67
69
  }
68
70
  db.exec(
69
- `INSERT INTO _syncular_outbox(client_commit_id, created_at_ms, operations)
70
- VALUES (?, ?, ?)`,
71
+ 'INSERT INTO _syncular_outbox(client_commit_id,created_at_ms,operations)VALUES(?,?,?)',
71
72
  [clientCommitId, nowMs, JSON.stringify(operations)],
72
73
  );
73
- for (const image of beforeImages) {
74
- db.exec(
75
- `INSERT INTO _syncular_outbox_before_images(
76
- client_commit_id, op_index, existed, sync_version, values_json
77
- ) VALUES (?, ?, ?, ?, ?)`,
78
- [
79
- clientCommitId,
80
- image.opIndex,
81
- image.existed ? 1 : 0,
82
- image.syncVersion ?? null,
83
- image.values === undefined ? null : JSON.stringify(image.values),
84
- ],
85
- );
86
- }
74
+ replaceOutboxBeforeImages(db, clientCommitId, beforeImages);
87
75
  }
88
76
 
89
77
  /** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
90
- export function listOutbox(db: ClientDatabase): OutboxCommit[] {
78
+ export function listOutbox(
79
+ db: ClientDatabase,
80
+ rowKeys?: readonly string[],
81
+ ): OutboxCommit[] {
91
82
  return db
92
83
  .query(
93
- `SELECT seq, client_commit_id, created_at_ms, operations
94
- FROM _syncular_outbox ORDER BY seq ASC`,
84
+ rowKeys === undefined
85
+ ? 'SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox ORDER BY seq ASC'
86
+ : "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",
87
+ rowKeys && [JSON.stringify(rowKeys)],
95
88
  )
96
89
  .map(decodeOutboxRow);
97
90
  }
@@ -113,8 +106,7 @@ export function* iterateOutbox(
113
106
  let afterSeq = 0;
114
107
  while (afterSeq < throughSeq) {
115
108
  const rows = db.query(
116
- `SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox
117
- WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`,
109
+ 'SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32',
118
110
  [afterSeq, throughSeq],
119
111
  );
120
112
  if (rows.length === 0) return;
@@ -128,7 +120,7 @@ export function* iterateOutbox(
128
120
 
129
121
  /** Routine status reads never load operation bodies. */
130
122
  export function countOutbox(db: ClientDatabase): number {
131
- return db.query('SELECT COUNT(*) AS count FROM _syncular_outbox')[0]!
123
+ return db.query('SELECT COUNT(*)AS count FROM _syncular_outbox')[0]!
132
124
  .count as number;
133
125
  }
134
126
 
@@ -136,14 +128,14 @@ export function deleteOutboxCommit(
136
128
  db: ClientDatabase,
137
129
  clientCommitId: string,
138
130
  ): void {
139
- db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?', [
131
+ db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id=?', [
140
132
  clientCommitId,
141
133
  ]);
142
134
  db.exec(
143
- 'DELETE FROM _syncular_outbox_before_images WHERE client_commit_id = ?',
135
+ 'DELETE FROM _syncular_outbox_before_images WHERE client_commit_id=?',
144
136
  [clientCommitId],
145
137
  );
146
- db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id = ?', [
138
+ db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id=?', [
147
139
  clientCommitId,
148
140
  ]);
149
141
  }
@@ -154,14 +146,15 @@ export function listOutboxBeforeImages(
154
146
  ): OutboxBeforeImage[] {
155
147
  return db
156
148
  .query(
157
- `SELECT op_index, existed, sync_version, values_json
158
- FROM _syncular_outbox_before_images
159
- WHERE client_commit_id = ? ORDER BY op_index`,
149
+ 'SELECT op_index,existed,sync_version,values_json,delivery_seq FROM _syncular_outbox_before_images WHERE client_commit_id=? ORDER BY op_index',
160
150
  [clientCommitId],
161
151
  )
162
152
  .map((row) => ({
163
153
  opIndex: row.op_index as number,
164
154
  existed: row.existed === 1,
155
+ ...(typeof row.delivery_seq === 'number'
156
+ ? { deliverySeq: row.delivery_seq }
157
+ : {}),
165
158
  ...(typeof row.sync_version === 'number'
166
159
  ? { syncVersion: row.sync_version }
167
160
  : {}),
@@ -182,14 +175,7 @@ export function replaceOutboxBeforeImages(
182
175
  ): void {
183
176
  for (const image of replacements) {
184
177
  db.exec(
185
- `DELETE FROM _syncular_outbox_before_images
186
- WHERE client_commit_id = ? AND op_index = ?`,
187
- [clientCommitId, image.opIndex],
188
- );
189
- db.exec(
190
- `INSERT INTO _syncular_outbox_before_images(
191
- client_commit_id, op_index, existed, sync_version, values_json
192
- ) VALUES (?, ?, ?, ?, ?)`,
178
+ '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',
193
179
  [
194
180
  clientCommitId,
195
181
  image.opIndex,
@@ -222,7 +208,7 @@ function sparseValues(
222
208
  for (const key of Object.keys(values)) {
223
209
  if (!table.columnIndex.has(key)) {
224
210
  throw new OutboxEncodeError(
225
- `outbox commit references column ${JSON.stringify(key)} on ${JSON.stringify(table.name)}, which the current schema no longer has (§7.4.4)`,
211
+ `outbox column ${JSON.stringify(key)} is absent from table ${JSON.stringify(table.name)} (§7.4.4)`,
226
212
  );
227
213
  }
228
214
  }
@@ -266,7 +252,7 @@ export async function encodeOutboxCommit(
266
252
  if (table === undefined) {
267
253
  // §7.4.4: the bump removed this table — the commit cannot be encoded.
268
254
  throw new OutboxEncodeError(
269
- `outbox commit ${commit.clientCommitId} targets table ${JSON.stringify(op.table)}, which the current schema no longer has (§7.4.4)`,
255
+ `outbox table ${JSON.stringify(op.table)} is absent from the current schema (§7.4.4)`,
270
256
  );
271
257
  }
272
258
  if (op.values === undefined) {
@@ -320,12 +306,7 @@ export function dropOutboxCommitsInScope(
320
306
  for (const commit of listOutbox(db)) {
321
307
  const inScope = commit.operations.some((op) => {
322
308
  if (op.table !== table.name || op.values === undefined) return false;
323
- return entries.every(([variable, values]) => {
324
- const column = table.scopeColumnByVariable.get(variable);
325
- if (column === undefined) return false;
326
- const value = op.values?.[column];
327
- return typeof value === 'string' && values.includes(value);
328
- });
309
+ return matchesLocalScopes(table, effective, op.values);
329
310
  });
330
311
  if (inScope) {
331
312
  deleteOutboxCommit(db, commit.clientCommitId);
package/src/outcomes.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { RETAINED_ROWS } from './failed-overlay';
1
2
  /**
2
3
  * Durable per-client commit outcomes.
3
4
  *
@@ -10,7 +11,12 @@ import type { RejectionDetails, RowValue } from '@syncular/core';
10
11
  import type { ClientDatabase } from './database';
11
12
  import { ClientSyncError } from './errors';
12
13
  import type { OutboxOperation } from './outbox';
13
- import { type JsonRowValue, jsonToRowValue, rowValueToJson } from './schema';
14
+ import {
15
+ mapRowValues,
16
+ type JsonRowValue,
17
+ jsonToRowValue,
18
+ rowValueToJson,
19
+ } from './schema';
14
20
 
15
21
  export interface ConflictRecord {
16
22
  readonly clientCommitId: string;
@@ -70,6 +76,14 @@ export type CommitOperationOutcome =
70
76
  };
71
77
 
72
78
  export interface RetainedCommitRow {
79
+ /** Authorized different-primary-key server rows matching intended unique keys. */
80
+ readonly uniqueConflicts?: readonly {
81
+ readonly index: string;
82
+ readonly columns: readonly string[];
83
+ readonly rowId: string;
84
+ readonly serverRow: Readonly<Record<string, RowValue>>;
85
+ readonly serverVersion: number;
86
+ }[];
73
87
  readonly table: string;
74
88
  readonly rowId: string;
75
89
  /** Complete intended row, or null for a local deletion. */
@@ -132,12 +146,7 @@ function encodeResults(results: readonly CommitOperationOutcome[]): string {
132
146
  status: 'conflict',
133
147
  conflict: {
134
148
  ...result.conflict,
135
- serverRow: Object.fromEntries(
136
- Object.entries(result.conflict.serverRow).map(([key, value]) => [
137
- key,
138
- rowValueToJson(value),
139
- ]),
140
- ),
149
+ serverRow: mapRowValues(result.conflict.serverRow, rowValueToJson),
141
150
  },
142
151
  };
143
152
  });
@@ -154,12 +163,7 @@ function decodeResults(raw: string): CommitOperationOutcome[] {
154
163
  ...result.conflict,
155
164
  // Pre-column-version journal entries carry no conflictColumns.
156
165
  conflictColumns: result.conflict.conflictColumns ?? [],
157
- serverRow: Object.fromEntries(
158
- Object.entries(result.conflict.serverRow).map(([key, value]) => [
159
- key,
160
- jsonToRowValue(value),
161
- ]),
162
- ),
166
+ serverRow: mapRowValues(result.conflict.serverRow, jsonToRowValue),
163
167
  },
164
168
  };
165
169
  });
@@ -171,8 +175,8 @@ function parseOutcome(
171
175
  ): CommitOutcome {
172
176
  const retainedRows: RetainedCommitRow[] = db
173
177
  .query(
174
- 'SELECT tbl, id, intent, base, version FROM _syncular_failed_rows WHERE commit_id = ? ORDER BY idx',
175
- [String(row.client_commit_id)],
178
+ `SELECT tbl,id,intent,base,version,unique_conflicts FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL AND commit_id=? ORDER BY idx`,
179
+ [row.client_commit_id as string],
176
180
  )
177
181
  .map((retained) => {
178
182
  const decode = (value: unknown): Record<string, RowValue> | null =>
@@ -184,12 +188,24 @@ function parseOutcome(
184
188
  ).map(([key, value]) => [key, jsonToRowValue(value)]),
185
189
  );
186
190
  return {
187
- table: String(retained.tbl),
188
- rowId: String(retained.id),
191
+ table: retained.tbl as string,
192
+ rowId: retained.id as string,
189
193
  localRow: decode(retained.intent),
194
+ ...(retained.unique_conflicts === null
195
+ ? {}
196
+ : {
197
+ uniqueConflicts: (
198
+ JSON.parse(retained.unique_conflicts as string) as NonNullable<
199
+ RetainedCommitRow['uniqueConflicts']
200
+ >
201
+ ).map((conflict) => ({
202
+ ...conflict,
203
+ serverRow: decode(JSON.stringify(conflict.serverRow))!,
204
+ })),
205
+ }),
190
206
  serverRow: decode(retained.base),
191
207
  serverVersion:
192
- retained.version === null ? null : Number(retained.version),
208
+ retained.version === null ? null : (retained.version as number),
193
209
  };
194
210
  });
195
211
  return {
@@ -217,9 +233,7 @@ export function recordCommitOutcome(
217
233
  outcome: Omit<CommitOutcome, 'sequence' | 'resolution'>,
218
234
  ): CommitOutcome {
219
235
  db.exec(
220
- `INSERT INTO _syncular_commit_outcomes(
221
- client_commit_id, status, recorded_at_ms, results, operations, resolution
222
- ) VALUES (?, ?, ?, ?, ?, 'active')`,
236
+ "INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')",
223
237
  [
224
238
  outcome.clientCommitId,
225
239
  outcome.status,
@@ -238,9 +252,7 @@ export function commitOutcome(
238
252
  clientCommitId: string,
239
253
  ): CommitOutcome | undefined {
240
254
  const row = db.query(
241
- `SELECT seq, client_commit_id, status, recorded_at_ms, results, operations,
242
- resolution, resolved_at_ms, replacement_client_commit_id
243
- FROM _syncular_commit_outcomes WHERE client_commit_id = ?`,
255
+ '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=?',
244
256
  [clientCommitId],
245
257
  )[0];
246
258
  return row === undefined ? undefined : parseOutcome(db, row);
@@ -261,10 +273,7 @@ export function listCommitOutcomes(
261
273
  ? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
262
274
  : '';
263
275
  const rows = db.query(
264
- `SELECT seq, client_commit_id, status, recorded_at_ms, results, operations,
265
- resolution, resolved_at_ms, replacement_client_commit_id
266
- FROM _syncular_commit_outcomes ${where}
267
- ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`,
276
+ `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 ?'}`,
268
277
  limit === undefined ? [] : [limit],
269
278
  );
270
279
  return rows.map((row) => parseOutcome(db, row));
@@ -276,9 +285,7 @@ export function persistCommitOutcomeResolution(
276
285
  nowMs: number,
277
286
  ): CommitOutcome | undefined {
278
287
  db.exec(
279
- `UPDATE _syncular_commit_outcomes
280
- SET resolution = ?, resolved_at_ms = ?, replacement_client_commit_id = ?
281
- WHERE client_commit_id = ? AND resolution = 'active'`,
288
+ "UPDATE _syncular_commit_outcomes SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'",
282
289
  [
283
290
  input.resolution,
284
291
  nowMs,
@@ -309,13 +316,11 @@ export function pruneCommitOutcomes(
309
316
  const excess = Math.max(0, (count ?? 0) - maxEntries);
310
317
  if (excess === 0) return 0;
311
318
  const candidates = db.query(
312
- `SELECT seq FROM _syncular_commit_outcomes
313
- WHERE status IN ('applied', 'cached') OR resolution != 'active'
314
- ORDER BY seq ASC LIMIT ?`,
319
+ "SELECT seq FROM _syncular_commit_outcomes WHERE status IN('applied','cached')OR resolution !='active' ORDER BY seq ASC LIMIT ?",
315
320
  [excess],
316
321
  );
317
322
  for (const candidate of candidates) {
318
- db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq = ?', [
323
+ db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq=?', [
319
324
  candidate.seq as number,
320
325
  ]);
321
326
  }