@syncular/client 0.30.13 → 0.30.15

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.
package/src/client.ts CHANGED
@@ -1,9 +1,15 @@
1
+ import { BLOB_COMMIT_REFS } from './blob';
2
+ import type { AuthorityReadPolicy, AuthoritySnapshot } from './authority';
3
+ import { hasUniqueIndex } from './schema';
4
+ import { OUTCOMES_TABLE } from './outcomes';
5
+ import { OUTBOX_TABLE } from './outbox';
1
6
  import { RETAINED_ROWS } from './failed-overlay';
2
7
  import {
3
8
  dropFailedRows,
4
9
  failedOverlayCommits,
5
10
  retainFailedRows,
6
11
  restoreFailedBases,
12
+ type OverlayScope,
7
13
  uniqueConflicts,
8
14
  } from './failed-overlay';
9
15
  import {
@@ -419,6 +425,7 @@ export interface SyncClientConfig {
419
425
  * hosts must not materialize key bytes before their preflight has passed.
420
426
  */
421
427
  readonly securityPreflight?: boolean;
428
+ readonly authorityReads?: AuthorityReadPolicy;
422
429
  /**
423
430
  * RFC 0005 previous-version context. A feature flag, default off: when
424
431
  * absent or `enabled: false`, a schema bump behaves exactly as 0.22.0 did
@@ -741,6 +748,7 @@ export type ClientSnapshotReader = {
741
748
 
742
749
  export class SyncClient {
743
750
  readonly #config: SyncClientConfig;
751
+ readonly #authorityReads: AuthorityReadPolicy | undefined;
744
752
  readonly #db: ClientDatabase;
745
753
  readonly #schema: CompiledClientSchema;
746
754
  /** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
@@ -855,6 +863,8 @@ export class SyncClient {
855
863
  this.#retainFailedCommits = config.retainFailedCommits === true;
856
864
  this.#db = config.database;
857
865
  this.#schema = compileClientSchema(config.schema);
866
+ this.#authorityReads = config.authorityReads;
867
+ this.#authorityReads?.validate(this.#schema);
858
868
  const realtimePolicy = config.realtimePolicy ?? 'optional';
859
869
  if (
860
870
  realtimePolicy !== 'required' &&
@@ -1168,6 +1178,13 @@ export class SyncClient {
1168
1178
 
1169
1179
  /** §2.1 reset after the server reports a different log continuity. */
1170
1180
  #runLogEpochReset(logEpoch: string): string[] {
1181
+ if (getMeta(this.#db, LOG_EPOCH_META_KEY) === undefined) {
1182
+ this.#applyBatch(() => setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch));
1183
+ this.#setSyncNeeded(true);
1184
+ this.#emitSyncNeeded('startup');
1185
+ this.#emitSyncIntent({ kind: 'interactive' });
1186
+ return [];
1187
+ }
1171
1188
  const subscriptions = loadSubscriptions(this.#db);
1172
1189
  const pending = listOutbox(this.#db);
1173
1190
  this.#setUpgrading(true);
@@ -1220,6 +1237,7 @@ export class SyncClient {
1220
1237
  }
1221
1238
 
1222
1239
  async close(): Promise<void> {
1240
+ this.#started = false;
1223
1241
  this.#emitSyncIntent({ kind: 'none' });
1224
1242
  this.#devtoolsUnregister?.();
1225
1243
  this.#devtoolsUnregister = undefined;
@@ -1227,7 +1245,6 @@ export class SyncClient {
1227
1245
  this.#abortPendingRound('client closed mid-round');
1228
1246
  await this.#lease?.release();
1229
1247
  this.#lease = undefined;
1230
- this.#started = false;
1231
1248
  this.#progress.clear();
1232
1249
  this.#syncNeededListeners.clear();
1233
1250
  this.#syncIntentListeners.clear();
@@ -1386,6 +1403,17 @@ export class SyncClient {
1386
1403
  return getLocalRevision(this.#db);
1387
1404
  }
1388
1405
 
1406
+ /** Read the creation-time plain authority policy, including preflight. */
1407
+ authoritySnapshot(): AuthoritySnapshot {
1408
+ this.#requireStarted();
1409
+ if (!this.#authorityReads || arguments.length)
1410
+ throw new ClientSyncError(
1411
+ 'client.authority_read_forbidden',
1412
+ 'authority read denied',
1413
+ );
1414
+ return this.#authorityReads.snapshot(this.#db, this.#schema);
1415
+ }
1416
+
1389
1417
  /**
1390
1418
  * Read rows, window answerability, and revision from one SQLite snapshot.
1391
1419
  * Reactive integrations use this instead of composing `query()` and
@@ -1406,28 +1434,11 @@ export class SyncClient {
1406
1434
  const missing: WindowUnitRef[] = [];
1407
1435
  for (const requested of spec.coverage ?? []) {
1408
1436
  const baseKey = windowBaseKey(requested.base);
1409
- const live = new Map(
1410
- loadWindowUnits(this.#db, baseKey).map((entry) => [
1411
- entry.unit,
1412
- entry.subId,
1413
- ]),
1414
- );
1437
+ const state = this.windowState(requested.base);
1415
1438
  for (const unit of new Set(requested.units)) {
1416
- const subId = live.get(unit);
1417
1439
  const ref = { baseKey, unit };
1418
- if (subId === undefined) {
1419
- missing.push(ref);
1420
- continue;
1421
- }
1422
- const sub = getSubscription(this.#db, subId);
1423
- if (
1424
- sub === undefined ||
1425
- sub.status !== 'active' ||
1426
- sub.cursor < 0 ||
1427
- sub.bootstrapState !== undefined
1428
- ) {
1429
- pending.push(ref);
1430
- }
1440
+ if (!state.units.includes(unit)) missing.push(ref);
1441
+ else if (state.pending.includes(unit)) pending.push(ref);
1431
1442
  }
1432
1443
  }
1433
1444
  return {
@@ -1805,7 +1816,7 @@ export class SyncClient {
1805
1816
  item.table.length === 0
1806
1817
  ) {
1807
1818
  throw invalidRequest(
1808
- 'diagnosticsSnapshot expected subscriptions require non-empty id and table strings',
1819
+ 'diagnosticsSnapshot needs subscription id and table',
1809
1820
  );
1810
1821
  }
1811
1822
  const registeredSubscription = subscriptions.get(item.id);
@@ -1917,11 +1928,11 @@ export class SyncClient {
1917
1928
  );
1918
1929
  const outboxBytes = Number(
1919
1930
  this.#db.query(
1920
- 'SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM _syncular_outbox',
1931
+ `SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM ${OUTBOX_TABLE}`,
1921
1932
  )[0]?.bytes ?? 0,
1922
1933
  );
1923
1934
  const outcome = this.#db.query(
1924
- 'SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM _syncular_commit_outcomes',
1935
+ `SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM ${OUTCOMES_TABLE}`,
1925
1936
  )[0];
1926
1937
  const blobBytes = this.#hasBlobs
1927
1938
  ? Number(
@@ -2894,7 +2905,7 @@ export class SyncClient {
2894
2905
  continue;
2895
2906
  }
2896
2907
  this.#db.exec(
2897
- 'INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)',
2908
+ `INSERT OR IGNORE INTO ${BLOB_COMMIT_REFS}(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)`,
2898
2909
  [clientCommitId, blobId, blobId],
2899
2910
  );
2900
2911
  }
@@ -3158,9 +3169,7 @@ export class SyncClient {
3158
3169
  };
3159
3170
  }
3160
3171
  if (this.#schemaFloor !== undefined) {
3161
- throw invalidRequest(
3162
- 'local rebootstrap cannot bypass an active schema-floor stop; update the application first',
3163
- );
3172
+ throw invalidRequest('local rebootstrap needs a schema-floor update');
3164
3173
  }
3165
3174
 
3166
3175
  const pending = listOutbox(this.#db);
@@ -3295,7 +3304,7 @@ export class SyncClient {
3295
3304
  // Pin before the first encryption await: mutations can append while a
3296
3305
  // round is encoding, and belong to the next request.
3297
3306
  const bounds = this.#db.query(
3298
- 'SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM _syncular_outbox',
3307
+ `SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM ${OUTBOX_TABLE}`,
3299
3308
  )[0]!;
3300
3309
  const pendingCount = bounds.count as number;
3301
3310
  const throughSeq = (bounds.last_seq as number | null) ?? 0;
@@ -4398,13 +4407,7 @@ export class SyncClient {
4398
4407
  if (errorFrame !== undefined) break;
4399
4408
  }
4400
4409
  } finally {
4401
- try {
4402
- // §7.1: local reads see outbox state applied optimistically — replay
4403
- // the still-pending commits on top of the freshly applied server state.
4404
- this.#replayOutbox();
4405
- } finally {
4406
- this.#endDiagnosticsDeferral();
4407
- }
4410
+ this.#endDiagnosticsDeferral();
4408
4411
  }
4409
4412
 
4410
4413
  if (errorFrame !== undefined) throw errorFrame;
@@ -4558,6 +4561,15 @@ export class SyncClient {
4558
4561
  listOutboxBeforeImages(this.#db, commit.clientCommitId),
4559
4562
  );
4560
4563
  this.#rollbackFailedCommit(commit, batch);
4564
+ this.#replayOutbox(
4565
+ commit.operations.map((operation) =>
4566
+ JSON.stringify(
4567
+ hasUniqueIndex(this.#table(operation.table))
4568
+ ? [operation.table]
4569
+ : [operation.table, operation.rowId],
4570
+ ),
4571
+ ),
4572
+ );
4561
4573
  });
4562
4574
  batch.status();
4563
4575
  summary.rejected.push(frame.clientCommitId);
@@ -4567,7 +4579,7 @@ export class SyncClient {
4567
4579
  #outboxCommitExists(clientCommitId: string): boolean {
4568
4580
  return (
4569
4581
  this.#db.query(
4570
- 'SELECT 1 FROM _syncular_outbox WHERE client_commit_id=? LIMIT 1',
4582
+ `SELECT 1 FROM ${OUTBOX_TABLE} WHERE client_commit_id=? LIMIT 1`,
4571
4583
  [clientCommitId],
4572
4584
  ).length > 0
4573
4585
  );
@@ -4621,7 +4633,7 @@ export class SyncClient {
4621
4633
  this.#applyBatch((batch) => {
4622
4634
  this.#recordCommitChanges(batch, frame);
4623
4635
  const result = fn();
4624
- this.#replayOutbox(result);
4636
+ this.#replayOutbox(result, false);
4625
4637
  return result;
4626
4638
  }),
4627
4639
  );
@@ -4704,7 +4716,7 @@ export class SyncClient {
4704
4716
  )
4705
4717
  batch.table(table.name);
4706
4718
  const result = fn();
4707
- this.#replayOutbox();
4719
+ this.#replayOutbox(result, false);
4708
4720
  return result;
4709
4721
  }),
4710
4722
  };
@@ -4888,14 +4900,17 @@ export class SyncClient {
4888
4900
  }),
4889
4901
  );
4890
4902
  if (covered.length) {
4891
- restoreFailedBases(this.#db, this.#schema);
4903
+ const scope = covered.map((row) =>
4904
+ JSON.stringify([sub.table, String(row.id)]),
4905
+ );
4906
+ restoreFailedBases(this.#db, this.#schema, scope);
4892
4907
  for (const row of covered)
4893
4908
  this.#db.exec(
4894
4909
  `DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`,
4895
4910
  [sub.table, String(row.id), nextCursor],
4896
4911
  );
4897
4912
  batch.table(sub.table);
4898
- this.#replayOutbox();
4913
+ this.#replayOutbox(scope, false);
4899
4914
  }
4900
4915
  }
4901
4916
  if (completed && registered !== undefined) {
@@ -4945,13 +4960,15 @@ export class SyncClient {
4945
4960
  Object.keys(lastEffective).length > 0
4946
4961
  ) {
4947
4962
  try {
4948
- restoreFailedBases(this.#db, this.#schema);
4949
- this.#dropRetainedRows(
4950
- batch,
4951
- (name, values) =>
4952
- name === table.name &&
4953
- matchesLocalScopes(table, lastEffective, values),
4954
- );
4963
+ const scope = [
4964
+ JSON.stringify([table.name]),
4965
+ ...this.#dropRetainedRows(
4966
+ batch,
4967
+ (name, values) =>
4968
+ name === table.name &&
4969
+ matchesLocalScopes(table, lastEffective, values),
4970
+ ),
4971
+ ];
4955
4972
  deleteScopedRows(this.#db, table, lastEffective);
4956
4973
  batch.scopeMap(table, lastEffective);
4957
4974
  const pendingById = new Map(
@@ -4997,6 +5014,7 @@ export class SyncClient {
4997
5014
  batch.rejections();
4998
5015
  batch.outcomes();
4999
5016
  }
5017
+ this.#replayOutbox(scope);
5000
5018
  this.#deleteUnreferencedBlobs();
5001
5019
  } catch (error) {
5002
5020
  if (
@@ -5217,7 +5235,7 @@ export class SyncClient {
5217
5235
  : (local[SYNC_VERSION_COLUMN] as number);
5218
5236
  if (
5219
5237
  replay &&
5220
- table.indexes.some((index) => index.unique) &&
5238
+ hasUniqueIndex(table) &&
5221
5239
  uniqueConflicts(
5222
5240
  this.#db,
5223
5241
  table,
@@ -5245,8 +5263,9 @@ export class SyncClient {
5245
5263
  table: string,
5246
5264
  values: Readonly<Record<string, JsonRowValue>>,
5247
5265
  ) => boolean,
5248
- ): void {
5249
- for (const commit of dropFailedRows(this.#db, matches)) {
5266
+ ): string[] {
5267
+ const scope: string[] = [];
5268
+ for (const commit of dropFailedRows(this.#db, this.#schema, matches)) {
5250
5269
  batch.conflicts();
5251
5270
  batch.rejections();
5252
5271
  batch.outcomes();
@@ -5256,32 +5275,39 @@ export class SyncClient {
5256
5275
  this.#rejections = this.#rejections.filter(
5257
5276
  (record) => record.clientCommitId !== commit.clientCommitId,
5258
5277
  );
5259
- for (const op of commit.operations) batch.table(op.table);
5278
+ for (const op of commit.operations) {
5279
+ batch.table(op.table);
5280
+ scope.push(JSON.stringify([op.table, op.rowId]));
5281
+ }
5260
5282
  }
5283
+ return scope;
5261
5284
  }
5262
5285
 
5263
- #replayOutbox(rowKeys?: readonly string[]): void {
5264
- const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
5265
- const pending = [
5266
- ...failedOverlayCommits(this.#db, false, rowKeys),
5267
- ...listOutbox(this.#db, rowKeys),
5268
- ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
5269
- if (pending.length === 0 && acknowledged.length === 0) return;
5286
+ #replayOutbox(scope?: OverlayScope, restoreBases = true): void {
5287
+ if (scope?.length === 0) return;
5270
5288
  this.#applyBatch((batch) => {
5271
- this.#db.transaction(() => {
5272
- restoreFailedBases(this.#db, this.#schema, false, rowKeys);
5273
- restoreFailedBases(this.#db, this.#schema, true, rowKeys);
5274
- for (const commit of [...acknowledged, ...pending])
5275
- this.#applyOperationsLocally(
5276
- rowKeys === undefined
5277
- ? commit.operations
5278
- : commit.operations.filter((op) =>
5279
- rowKeys.includes(JSON.stringify([op.table, op.rowId])),
5280
- ),
5281
- batch,
5282
- true,
5283
- );
5284
- });
5289
+ if (restoreBases) restoreFailedBases(this.#db, this.#schema, scope);
5290
+ const acknowledged = failedOverlayCommits(
5291
+ this.#db,
5292
+ true,
5293
+ scope,
5294
+ this.#schema,
5295
+ );
5296
+ const pending = [
5297
+ ...failedOverlayCommits(this.#db, false, scope, this.#schema),
5298
+ ...listOutbox(this.#db, scope),
5299
+ ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
5300
+ for (const commit of [...acknowledged, ...pending])
5301
+ this.#applyOperationsLocally(
5302
+ commit.operations.filter(
5303
+ (op) =>
5304
+ scope === undefined ||
5305
+ scope.includes(JSON.stringify([op.table, op.rowId])) ||
5306
+ scope.includes(JSON.stringify([op.table])),
5307
+ ),
5308
+ batch,
5309
+ true,
5310
+ );
5285
5311
  });
5286
5312
  }
5287
5313
 
@@ -5302,7 +5328,7 @@ export class SyncClient {
5302
5328
 
5303
5329
  #requireStarted(): void {
5304
5330
  if (!this.#started) {
5305
- throw invalidRequest('call SyncClient.start() first');
5331
+ throw invalidRequest('call start() first');
5306
5332
  }
5307
5333
  }
5308
5334
 
@@ -5311,7 +5337,7 @@ export class SyncClient {
5311
5337
  if (this.#securityLifecycle === 'preflight') {
5312
5338
  throw new ClientSyncError(
5313
5339
  SECURITY_PREFLIGHT_REQUIRED_CODE,
5314
- 'security preflight must complete before accessing protected data; call activateSecurity',
5340
+ 'protected reads require activateSecurity',
5315
5341
  );
5316
5342
  }
5317
5343
  }
@@ -1,3 +1,6 @@
1
+ import { hasUniqueIndex } from './schema';
2
+ import { OUTCOMES_TABLE } from './outcomes';
3
+ import { OUTBOX_TABLE } from './outbox';
1
4
  export const RETAINED_ROWS = '_syncular_failed_rows';
2
5
  import { ClientSyncError } from './errors';
3
6
  import type { ClientDatabase } from './database';
@@ -48,7 +51,7 @@ export function retainedBaseWrite(
48
51
  table.name,
49
52
  rowId,
50
53
  ]);
51
- if (!table.indexes.some((index) => index.unique)) return;
54
+ if (!hasUniqueIndex(table)) return;
52
55
  for (const retained of db.query(
53
56
  `SELECT commit_id,idx,intent,unique_conflicts FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq IS NULL`,
54
57
  [table.name],
@@ -164,90 +167,51 @@ export function uniqueConflicts(
164
167
  );
165
168
  }
166
169
 
167
- function retainedRows(db: ClientDatabase, rowKeys?: readonly string[]) {
170
+ /** Exact row keys are JSON [table,id]; table scopes include unique-key peers. */
171
+ export type OverlayScope = readonly string[];
172
+
173
+ export function overlayScopePredicate(table: string, id: string): string {
174
+ return `?1 IS NULL OR json_array(${table},${id}) IN(SELECT value FROM json_each(?1)) OR json_array(${table}) IN(SELECT value FROM json_each(?1))`;
175
+ }
176
+
177
+ function retainedRows(
178
+ db: ClientDatabase,
179
+ scope?: OverlayScope,
180
+ acknowledged?: boolean,
181
+ ) {
168
182
  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],
183
+ `SELECT * FROM ${RETAINED_ROWS} WHERE (${overlayScopePredicate('tbl', 'id')})${acknowledged === undefined ? '' : ` AND commit_seq IS${acknowledged ? ' NOT' : ''} NULL`} ORDER BY commit_seq,at,commit_id,idx`,
184
+ [scope ? JSON.stringify(scope) : null],
171
185
  );
172
186
  }
173
187
 
188
+ function retainedTable(
189
+ schema: CompiledClientSchema,
190
+ name: string,
191
+ ): CompiledClientTable {
192
+ const table = schema.tables.get(name);
193
+ if (!table)
194
+ throw new ClientSyncError(
195
+ 'sync.unknown_table',
196
+ 'retained table is unknown',
197
+ );
198
+ return table;
199
+ }
200
+
174
201
  export function restoreFailedBases(
175
202
  db: ClientDatabase,
176
203
  schema: CompiledClientSchema,
177
- absentIntent = false,
178
- rowKeys?: readonly string[],
204
+ scope?: OverlayScope,
179
205
  ): boolean {
180
206
  const seen = new Set<string>();
181
- for (const row of retainedRows(db, rowKeys)) {
207
+ for (const row of retainedRows(db, scope)) {
182
208
  const key = JSON.stringify([row.tbl, row.id]);
183
- if (!absentIntent && seen.has(key)) continue;
209
+ if (seen.has(key)) continue;
184
210
  seen.add(key);
185
- const table = schema.tables.get(row.tbl as string);
186
- if (!table)
187
- throw new ClientSyncError(
188
- 'sync.unknown_table',
189
- 'retained table is unknown',
190
- );
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
- }
229
- if (
230
- absentIntent &&
231
- (json === null ||
232
- db.query(
233
- `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
234
- [row.id as string],
235
- ).length)
236
- )
237
- continue;
238
- if (json === null) deleteLocalRow(db, table, row.id as string, false);
211
+ const table = retainedTable(schema, row.tbl as string);
212
+ if (row.base === null) deleteLocalRow(db, table, row.id as string, false);
239
213
  else {
240
- const values: Record<string, JsonRowValue> = JSON.parse(String(json));
241
- if (absentIntent) {
242
- const operation: OutboxOperation = JSON.parse(row.op as string);
243
- if (
244
- row.commit_seq === null &&
245
- !table.columns.every((column) =>
246
- Object.hasOwn(operation.values ?? {}, column.name),
247
- )
248
- )
249
- continue;
250
- }
214
+ const values: Record<string, JsonRowValue> = JSON.parse(String(row.base));
251
215
  upsertLocalRow(
252
216
  db,
253
217
  table,
@@ -265,7 +229,8 @@ export function restoreFailedBases(
265
229
  export function failedOverlayCommits(
266
230
  db: ClientDatabase,
267
231
  acknowledged = false,
268
- rowKeys?: readonly string[],
232
+ scope?: OverlayScope,
233
+ schema?: CompiledClientSchema,
269
234
  ): OutboxCommit[] {
270
235
  const commits = new Map<
271
236
  string,
@@ -276,10 +241,55 @@ export function failedOverlayCommits(
276
241
  operations: OutboxOperation[];
277
242
  }
278
243
  >();
279
- for (const row of retainedRows(db, rowKeys)) {
280
- if ((row.commit_seq !== null) !== acknowledged) continue;
244
+ for (const row of retainedRows(db, scope, acknowledged)) {
281
245
  const id = row.commit_id as string;
282
- const operation: OutboxOperation = JSON.parse(row.op as string);
246
+ let operation: OutboxOperation = JSON.parse(row.op as string);
247
+ if (schema && operation.op === 'upsert') {
248
+ const table = retainedTable(schema, operation.table);
249
+ if (acknowledged) {
250
+ if (
251
+ !db.query(
252
+ `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
253
+ [operation.rowId],
254
+ ).length
255
+ )
256
+ operation = {
257
+ ...operation,
258
+ values: JSON.parse(row.intent as string),
259
+ };
260
+ } else if (row.unique_conflicts === null && row.intent !== null) {
261
+ // Hydrate legacy failure evidence from authoritative unique-key peers.
262
+ const values: Record<string, JsonRowValue> = JSON.parse(
263
+ row.intent as string,
264
+ );
265
+ const authorized = uniqueConflicts(
266
+ db,
267
+ table,
268
+ mapRowValues(values, jsonToRowValue),
269
+ ).filter(
270
+ (conflict) =>
271
+ conflict.serverVersion >= 0 &&
272
+ !db.query(
273
+ `SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? UNION ALL SELECT 1 FROM ${OUTBOX_TABLE},json_each(operations)WHERE json_extract(json_each.value,'$.table')=? AND json_extract(json_each.value,'$.rowId')=? LIMIT 1`,
274
+ [table.name, conflict.rowId, table.name, conflict.rowId],
275
+ ).length,
276
+ );
277
+ if (authorized.length)
278
+ db.exec(
279
+ `UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`,
280
+ [
281
+ JSON.stringify(
282
+ authorized.map((conflict) => ({
283
+ ...conflict,
284
+ serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
285
+ })),
286
+ ),
287
+ row.commit_id as string,
288
+ row.idx as number,
289
+ ],
290
+ );
291
+ }
292
+ }
283
293
  const prior = commits.get(id);
284
294
  if (prior) prior.operations.push(operation);
285
295
  else
@@ -295,6 +305,7 @@ export function failedOverlayCommits(
295
305
 
296
306
  export function dropFailedRows(
297
307
  db: ClientDatabase,
308
+ schema: CompiledClientSchema,
298
309
  matches: (
299
310
  table: string,
300
311
  values: Readonly<Record<string, JsonRowValue>>,
@@ -312,10 +323,17 @@ export function dropFailedRows(
312
323
  ...failedOverlayCommits(db),
313
324
  ...failedOverlayCommits(db, true),
314
325
  ].filter((commit) => ids.has(commit.clientCommitId));
326
+ restoreFailedBases(
327
+ db,
328
+ schema,
329
+ dropped.flatMap((commit) =>
330
+ commit.operations.map((op) => JSON.stringify([op.table, op.rowId])),
331
+ ),
332
+ );
315
333
  for (const id of ids) {
316
334
  db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
317
335
  db.exec(
318
- "UPDATE _syncular_commit_outcomes SET operations=NULL,results='[]' WHERE client_commit_id=?",
336
+ `UPDATE ${OUTCOMES_TABLE} SET operations=NULL,results='[]' WHERE client_commit_id=?`,
319
337
  [id],
320
338
  );
321
339
  }
package/src/index.ts CHANGED
@@ -8,6 +8,12 @@
8
8
  * (`worker-host`) and the RPC protocol types are root exports — they
9
9
  * import no SQLite.
10
10
  */
11
+ export {
12
+ defineAuthorityReads,
13
+ type AuthorityReadPolicy,
14
+ type AuthorityReadDeclaration,
15
+ type AuthoritySnapshot,
16
+ } from './authority';
11
17
  export * from './apply';
12
18
  export * from './availability';
13
19
  export * from './blob';