@syncular/client 0.30.11 → 0.30.13

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,3 +1,4 @@
1
+ export const RETAINED_ROWS = '_syncular_failed_rows';
1
2
  import { ClientSyncError } from './errors';
2
3
  import type { ClientDatabase } from './database';
3
4
  import type {
@@ -26,16 +27,12 @@ import { deleteLocalRow, upsertLocalRow } from './apply';
26
27
  /** Protected failure state. The server base advances independently of intent. */
27
28
  export function ensureFailedOverlaySchema(db: ClientDatabase): void {
28
29
  db.exec(
29
- '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))',
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))`,
30
31
  );
31
- if (
32
- !db
33
- .query('PRAGMA table_info(_syncular_failed_rows)')
34
- .some((row) => row.name === 'unique_conflicts')
35
- )
36
- db.exec(
37
- 'ALTER TABLE _syncular_failed_rows ADD COLUMN unique_conflicts TEXT',
38
- );
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}`);
39
36
  }
40
37
 
41
38
  export function retainedBaseWrite(
@@ -45,18 +42,15 @@ export function retainedBaseWrite(
45
42
  values?: Readonly<Record<string, JsonRowValue>>,
46
43
  version?: number,
47
44
  ): void {
48
- db.exec(
49
- 'UPDATE _syncular_failed_rows SET base=?,version=? WHERE tbl=? AND id=?',
50
- [
51
- values === undefined ? null : JSON.stringify(values),
52
- version ?? null,
53
- table.name,
54
- rowId,
55
- ],
56
- );
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
+ ]);
57
51
  if (!table.indexes.some((index) => index.unique)) return;
58
52
  for (const retained of db.query(
59
- 'SELECT commit_id,idx,intent,unique_conflicts FROM _syncular_failed_rows WHERE tbl=?',
53
+ `SELECT commit_id,idx,intent,unique_conflicts FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq IS NULL`,
60
54
  [table.name],
61
55
  )) {
62
56
  const prior: readonly (Omit<
@@ -65,11 +59,11 @@ export function retainedBaseWrite(
65
59
  > & { serverRow: Readonly<Record<string, JsonRowValue>> })[] =
66
60
  retained.unique_conflicts === null
67
61
  ? []
68
- : JSON.parse(String(retained.unique_conflicts));
62
+ : JSON.parse(retained.unique_conflicts as string);
69
63
  const conflicts = prior.filter((conflict) => conflict.rowId !== rowId);
70
64
  if (values !== undefined && retained.intent !== null) {
71
65
  const intent: Record<string, JsonRowValue> = JSON.parse(
72
- String(retained.intent),
66
+ retained.intent as string,
73
67
  );
74
68
  conflicts.push(
75
69
  ...uniqueConflicts(db, table, mapRowValues(intent, jsonToRowValue))
@@ -81,7 +75,7 @@ export function retainedBaseWrite(
81
75
  );
82
76
  }
83
77
  db.exec(
84
- 'UPDATE _syncular_failed_rows SET unique_conflicts=? WHERE commit_id=? AND idx=?',
78
+ `UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`,
85
79
  [
86
80
  conflicts.length
87
81
  ? JSON.stringify(
@@ -92,7 +86,7 @@ export function retainedBaseWrite(
92
86
  ),
93
87
  )
94
88
  : null,
95
- String(retained.commit_id),
89
+ retained.commit_id as string,
96
90
  Number(retained.idx),
97
91
  ],
98
92
  );
@@ -103,9 +97,12 @@ export function retainFailedRows(
103
97
  db: ClientDatabase,
104
98
  commit: OutboxCommit,
105
99
  images: readonly OutboxBeforeImage[],
100
+ commitSeq?: number,
106
101
  ): void {
107
102
  for (const [index, operation] of commit.operations.entries()) {
108
103
  const image = images.find((image) => image.opIndex === index);
104
+ if (commitSeq !== undefined && (image?.deliverySeq ?? -1) >= commitSeq)
105
+ continue;
109
106
  const initial =
110
107
  operation.op === 'upsert'
111
108
  ? { ...image?.values, ...operation.values }
@@ -113,10 +110,10 @@ export function retainFailedRows(
113
110
  if (!image)
114
111
  throw new ClientSyncError(
115
112
  'sync.local_corrupt',
116
- 'retained intent has no before-image',
113
+ 'missing intent before-image',
117
114
  );
118
115
  db.exec(
119
- 'INSERT INTO _syncular_failed_rows(commit_id,idx,tbl,id,at,op,intent,base,version)VALUES(?,?,?,?,?,?,?,?,?)',
116
+ `INSERT INTO ${RETAINED_ROWS}(commit_id,idx,tbl,id,at,op,intent,base,version,commit_seq)VALUES(?,?,?,?,?,?,?,?,?,?)`,
120
117
  [
121
118
  commit.clientCommitId,
122
119
  index,
@@ -127,6 +124,7 @@ export function retainFailedRows(
127
124
  initial ? JSON.stringify(initial) : null,
128
125
  image.values ? JSON.stringify(image.values) : null,
129
126
  image.syncVersion ?? null,
127
+ commitSeq ?? null,
130
128
  ],
131
129
  );
132
130
  }
@@ -166,28 +164,34 @@ export function uniqueConflicts(
166
164
  );
167
165
  }
168
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
+
169
174
  export function restoreFailedBases(
170
175
  db: ClientDatabase,
171
176
  schema: CompiledClientSchema,
172
177
  absentIntent = false,
173
- ): void {
178
+ rowKeys?: readonly string[],
179
+ ): boolean {
174
180
  const seen = new Set<string>();
175
- for (const row of db.query(
176
- 'SELECT * FROM _syncular_failed_rows ORDER BY at,commit_id,idx',
177
- )) {
181
+ for (const row of retainedRows(db, rowKeys)) {
178
182
  const key = JSON.stringify([row.tbl, row.id]);
179
183
  if (!absentIntent && seen.has(key)) continue;
180
184
  seen.add(key);
181
- const table = schema.tables.get(String(row.tbl));
185
+ const table = schema.tables.get(row.tbl as string);
182
186
  if (!table)
183
187
  throw new ClientSyncError(
184
188
  'sync.unknown_table',
185
- 'retained intent references an unknown table',
189
+ 'retained table is unknown',
186
190
  );
187
191
  const json = absentIntent ? row.intent : row.base;
188
192
  if (absentIntent && row.intent !== null) {
189
193
  const values: Record<string, JsonRowValue> = JSON.parse(
190
- String(row.intent),
194
+ row.intent as string,
191
195
  );
192
196
  const conflicts = uniqueConflicts(
193
197
  db,
@@ -196,18 +200,18 @@ export function restoreFailedBases(
196
200
  );
197
201
  // Hydrate a pre-0.30.11 failure after its winner was already imported.
198
202
  // Pending and retained overlays cannot supply authoritative row evidence.
199
- if (row.unique_conflicts === null) {
203
+ if (row.commit_seq === null && row.unique_conflicts === null) {
200
204
  const authorized = conflicts.filter(
201
205
  (conflict) =>
202
206
  conflict.serverVersion >= 0 &&
203
207
  !db.query(
204
- "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",
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`,
205
209
  [table.name, conflict.rowId, table.name, conflict.rowId],
206
210
  ).length,
207
211
  );
208
212
  if (authorized.length)
209
213
  db.exec(
210
- 'UPDATE _syncular_failed_rows SET unique_conflicts=? WHERE commit_id=? AND idx=?',
214
+ `UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`,
211
215
  [
212
216
  JSON.stringify(
213
217
  authorized.map((conflict) => ({
@@ -215,8 +219,8 @@ export function restoreFailedBases(
215
219
  serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
216
220
  })),
217
221
  ),
218
- String(row.commit_id),
219
- Number(row.idx),
222
+ row.commit_id as string,
223
+ row.idx as number,
220
224
  ],
221
225
  );
222
226
  }
@@ -227,16 +231,17 @@ export function restoreFailedBases(
227
231
  (json === null ||
228
232
  db.query(
229
233
  `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
230
- [String(row.id)],
234
+ [row.id as string],
231
235
  ).length)
232
236
  )
233
237
  continue;
234
- if (json === null) deleteLocalRow(db, table, String(row.id), false);
238
+ if (json === null) deleteLocalRow(db, table, row.id as string, false);
235
239
  else {
236
240
  const values: Record<string, JsonRowValue> = JSON.parse(String(json));
237
241
  if (absentIntent) {
238
- const operation: OutboxOperation = JSON.parse(String(row.op));
242
+ const operation: OutboxOperation = JSON.parse(row.op as string);
239
243
  if (
244
+ row.commit_seq === null &&
240
245
  !table.columns.every((column) =>
241
246
  Object.hasOwn(operation.values ?? {}, column.name),
242
247
  )
@@ -249,14 +254,19 @@ export function restoreFailedBases(
249
254
  table.columns.map((column) =>
250
255
  jsonToRowValue(values[column.name] ?? null),
251
256
  ),
252
- Number(row.version ?? -1),
257
+ (row.version ?? -1) as number,
253
258
  false,
254
259
  );
255
260
  }
256
261
  }
262
+ return seen.size > 0;
257
263
  }
258
264
 
259
- export function failedOverlayCommits(db: ClientDatabase): OutboxCommit[] {
265
+ export function failedOverlayCommits(
266
+ db: ClientDatabase,
267
+ acknowledged = false,
268
+ rowKeys?: readonly string[],
269
+ ): OutboxCommit[] {
260
270
  const commits = new Map<
261
271
  string,
262
272
  {
@@ -266,18 +276,17 @@ export function failedOverlayCommits(db: ClientDatabase): OutboxCommit[] {
266
276
  operations: OutboxOperation[];
267
277
  }
268
278
  >();
269
- for (const row of db.query(
270
- 'SELECT * FROM _syncular_failed_rows ORDER BY at,commit_id,idx',
271
- )) {
272
- const id = String(row.commit_id);
273
- 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);
274
283
  const prior = commits.get(id);
275
284
  if (prior) prior.operations.push(operation);
276
285
  else
277
286
  commits.set(id, {
278
287
  seq: 0,
279
288
  clientCommitId: id,
280
- createdAtMs: Number(row.at),
289
+ createdAtMs: row.at as number,
281
290
  operations: [operation],
282
291
  });
283
292
  }
@@ -292,17 +301,19 @@ export function dropFailedRows(
292
301
  ) => boolean,
293
302
  ): OutboxCommit[] {
294
303
  const ids = new Set<string>();
295
- for (const row of db.query('SELECT * FROM _syncular_failed_rows')) {
296
- const json = row.intent ?? row.base;
297
- if (json === null) continue;
298
- const values: Record<string, JsonRowValue> = JSON.parse(String(json));
299
- 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
+ }
300
310
  }
301
- const dropped = failedOverlayCommits(db).filter((commit) =>
302
- ids.has(commit.clientCommitId),
303
- );
311
+ const dropped = [
312
+ ...failedOverlayCommits(db),
313
+ ...failedOverlayCommits(db, true),
314
+ ].filter((commit) => ids.has(commit.clientCommitId));
304
315
  for (const id of ids) {
305
- db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id=?', [id]);
316
+ db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
306
317
  db.exec(
307
318
  "UPDATE _syncular_commit_outcomes SET operations=NULL,results='[]' WHERE client_commit_id=?",
308
319
  [id],
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
  }
@@ -69,25 +71,20 @@ export function appendOutboxCommit(
69
71
  'INSERT INTO _syncular_outbox(client_commit_id,created_at_ms,operations)VALUES(?,?,?)',
70
72
  [clientCommitId, nowMs, JSON.stringify(operations)],
71
73
  );
72
- for (const image of beforeImages) {
73
- db.exec(
74
- 'INSERT INTO _syncular_outbox_before_images(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?)',
75
- [
76
- clientCommitId,
77
- image.opIndex,
78
- image.existed ? 1 : 0,
79
- image.syncVersion ?? null,
80
- image.values === undefined ? null : JSON.stringify(image.values),
81
- ],
82
- );
83
- }
74
+ replaceOutboxBeforeImages(db, clientCommitId, beforeImages);
84
75
  }
85
76
 
86
77
  /** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
87
- export function listOutbox(db: ClientDatabase): OutboxCommit[] {
78
+ export function listOutbox(
79
+ db: ClientDatabase,
80
+ rowKeys?: readonly string[],
81
+ ): OutboxCommit[] {
88
82
  return db
89
83
  .query(
90
- 'SELECT seq, client_commit_id, created_at_ms, operations 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)],
91
88
  )
92
89
  .map(decodeOutboxRow);
93
90
  }
@@ -149,12 +146,15 @@ export function listOutboxBeforeImages(
149
146
  ): OutboxBeforeImage[] {
150
147
  return db
151
148
  .query(
152
- 'SELECT op_index,existed,sync_version,values_json FROM _syncular_outbox_before_images 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',
153
150
  [clientCommitId],
154
151
  )
155
152
  .map((row) => ({
156
153
  opIndex: row.op_index as number,
157
154
  existed: row.existed === 1,
155
+ ...(typeof row.delivery_seq === 'number'
156
+ ? { deliverySeq: row.delivery_seq }
157
+ : {}),
158
158
  ...(typeof row.sync_version === 'number'
159
159
  ? { syncVersion: row.sync_version }
160
160
  : {}),
@@ -175,11 +175,7 @@ export function replaceOutboxBeforeImages(
175
175
  ): void {
176
176
  for (const image of replacements) {
177
177
  db.exec(
178
- 'DELETE FROM _syncular_outbox_before_images WHERE client_commit_id=? AND op_index=?',
179
- [clientCommitId, image.opIndex],
180
- );
181
- db.exec(
182
- 'INSERT INTO _syncular_outbox_before_images(client_commit_id,op_index,existed,sync_version,values_json)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',
183
179
  [
184
180
  clientCommitId,
185
181
  image.opIndex,
@@ -212,7 +208,7 @@ function sparseValues(
212
208
  for (const key of Object.keys(values)) {
213
209
  if (!table.columnIndex.has(key)) {
214
210
  throw new OutboxEncodeError(
215
- `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)`,
216
212
  );
217
213
  }
218
214
  }
@@ -256,7 +252,7 @@ export async function encodeOutboxCommit(
256
252
  if (table === undefined) {
257
253
  // §7.4.4: the bump removed this table — the commit cannot be encoded.
258
254
  throw new OutboxEncodeError(
259
- `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)`,
260
256
  );
261
257
  }
262
258
  if (op.values === undefined) {
@@ -310,12 +306,7 @@ export function dropOutboxCommitsInScope(
310
306
  for (const commit of listOutbox(db)) {
311
307
  const inScope = commit.operations.some((op) => {
312
308
  if (op.table !== table.name || op.values === undefined) return false;
313
- return entries.every(([variable, values]) => {
314
- const column = table.scopeColumnByVariable.get(variable);
315
- if (column === undefined) return false;
316
- const value = op.values?.[column];
317
- return typeof value === 'string' && values.includes(value);
318
- });
309
+ return matchesLocalScopes(table, effective, op.values);
319
310
  });
320
311
  if (inScope) {
321
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
  *
@@ -174,8 +175,8 @@ function parseOutcome(
174
175
  ): CommitOutcome {
175
176
  const retainedRows: RetainedCommitRow[] = db
176
177
  .query(
177
- 'SELECT tbl,id,intent,base,version,unique_conflicts FROM _syncular_failed_rows WHERE commit_id=? ORDER BY idx',
178
- [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],
179
180
  )
180
181
  .map((retained) => {
181
182
  const decode = (value: unknown): Record<string, RowValue> | null =>
@@ -187,14 +188,14 @@ function parseOutcome(
187
188
  ).map(([key, value]) => [key, jsonToRowValue(value)]),
188
189
  );
189
190
  return {
190
- table: String(retained.tbl),
191
- rowId: String(retained.id),
191
+ table: retained.tbl as string,
192
+ rowId: retained.id as string,
192
193
  localRow: decode(retained.intent),
193
194
  ...(retained.unique_conflicts === null
194
195
  ? {}
195
196
  : {
196
197
  uniqueConflicts: (
197
- JSON.parse(String(retained.unique_conflicts)) as NonNullable<
198
+ JSON.parse(retained.unique_conflicts as string) as NonNullable<
198
199
  RetainedCommitRow['uniqueConflicts']
199
200
  >
200
201
  ).map((conflict) => ({
@@ -204,7 +205,7 @@ function parseOutcome(
204
205
  }),
205
206
  serverRow: decode(retained.base),
206
207
  serverVersion:
207
- retained.version === null ? null : Number(retained.version),
208
+ retained.version === null ? null : (retained.version as number),
208
209
  };
209
210
  });
210
211
  return {