@syncular/client 0.30.11 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.30.11",
3
+ "version": "0.30.12",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -89,10 +89,10 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.30.11"
92
+ "@syncular/core": "0.30.12"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.30.11",
95
+ "@syncular/server": "0.30.12",
96
96
  "playwright": "1.63.0",
97
97
  "@electric-sql/pglite": "^0.5.4"
98
98
  }
package/src/apply.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { RETAINED_ROWS } from './failed-overlay';
1
2
  import { retainedBaseWrite, restoreFailedBases } from './failed-overlay';
2
3
  /**
3
4
  * Local application of server data: `COMMIT` frames (§4.5), rows segments
@@ -19,6 +20,8 @@ import {
19
20
  type CompiledClientSchema,
20
21
  type CompiledClientTable,
21
22
  quoteIdent,
23
+ matchesLocalScopes,
24
+ type JsonRowValue,
22
25
  SYNC_VERSION_COLUMN,
23
26
  toSqlValue,
24
27
  rowValueToJson,
@@ -93,7 +96,8 @@ export async function applyCommitFrame(
93
96
  schema: CompiledClientSchema,
94
97
  frame: CommitFrame,
95
98
  encryption?: EncryptionConfig,
96
- transaction: ApplyTransaction = (fn) => db.transaction(fn),
99
+ transaction: (fn: () => readonly string[] | undefined) => unknown = (fn) =>
100
+ db.transaction(fn),
97
101
  ): Promise<void> {
98
102
  type Resolved =
99
103
  | { op: 'delete'; table: CompiledClientTable; rowId: string }
@@ -138,14 +142,31 @@ export async function applyCommitFrame(
138
142
  });
139
143
  }
140
144
  transaction(() => {
141
- restoreFailedBases(db, schema);
142
- for (const change of resolved) {
145
+ const rowKeys = frame.tables.some((name) =>
146
+ schema.tables.get(name)?.indexes.some((index) => index.unique),
147
+ )
148
+ ? undefined
149
+ : frame.changes.map((change) =>
150
+ JSON.stringify([frame.tables[change.tableIndex], change.rowId]),
151
+ );
152
+ restoreFailedBases(db, schema, false, rowKeys);
153
+ for (const [index, change] of resolved.entries()) {
154
+ const rowId = frame.changes[index]!.rowId;
155
+ db.exec(
156
+ "UPDATE _syncular_outbox_before_images SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM _syncular_outbox,json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)",
157
+ [frame.commitSeq, change.table.name, rowId],
158
+ );
159
+ db.exec(
160
+ `DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`,
161
+ [change.table.name, rowId, frame.commitSeq],
162
+ );
143
163
  if (change.op === 'delete') {
144
164
  deleteLocalRow(db, change.table, change.rowId);
145
165
  } else {
146
166
  upsertLocalRow(db, change.table, change.values, change.rowVersion);
147
167
  }
148
168
  }
169
+ return rowKeys;
149
170
  });
150
171
  }
151
172
 
@@ -195,39 +216,43 @@ export function validateSegmentColumns(
195
216
  * (`sync.scope_revoked`) when the table has no local mapping for a key:
196
217
  * precision or nothing, never clear-the-table.
197
218
  */
219
+ export function localScopePredicate(
220
+ table: CompiledClientTable,
221
+ effective: ScopeMap,
222
+ ): { sql: string; params: string[] } | undefined {
223
+ const entries = Object.entries(effective);
224
+ if (!entries.length || entries.some(([, values]) => !values.length)) return;
225
+ const params: string[] = [];
226
+ const sql = entries
227
+ .map(([variable, values]) => {
228
+ const column = table.scopeColumnByVariable.get(variable);
229
+ if (column === undefined)
230
+ throw new ClientSyncError(
231
+ 'sync.scope_revoked',
232
+ 'table has no local scope-column mapping',
233
+ );
234
+ params.push(...values);
235
+ return `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`;
236
+ })
237
+ .join(' AND ');
238
+ return { sql, params };
239
+ }
240
+
198
241
  export function deleteScopedRows(
199
242
  db: ClientDatabase,
200
243
  table: CompiledClientTable,
201
244
  effective: ScopeMap,
202
245
  ): void {
203
- const entries = Object.entries(effective);
204
- if (entries.length === 0) return;
205
- const clauses: string[] = [];
206
- const params: string[] = [];
207
- for (const [variable, values] of entries) {
208
- const column = table.scopeColumnByVariable.get(variable);
209
- if (column === undefined) {
210
- throw new ClientSyncError(
211
- 'sync.scope_revoked',
212
- `table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§3.3 fail-closed)`,
213
- );
214
- }
215
- if (values.length === 0) return;
216
- clauses.push(
217
- `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
218
- );
219
- params.push(...values);
220
- }
246
+ const scope = localScopePredicate(table, effective);
247
+ if (!scope) return;
248
+ const { sql, params } = scope;
221
249
  for (const row of db.query(
222
- `SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`,
250
+ `SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${sql}`,
223
251
  params,
224
252
  )) {
225
253
  retainedBaseWrite(db, table, String(row.id));
226
254
  }
227
- db.exec(
228
- `DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`,
229
- params,
230
- );
255
+ db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
231
256
  }
232
257
 
233
258
  /**
@@ -246,24 +271,9 @@ export function evictScopedRows(
246
271
  effective: ScopeMap,
247
272
  pinnedRowIds: ReadonlySet<string>,
248
273
  ): { remaining: boolean; deferred: boolean } {
249
- const entries = Object.entries(effective);
250
- if (entries.length === 0) return { remaining: false, deferred: false };
251
- const clauses: string[] = [];
252
- const params: string[] = [];
253
- for (const [variable, values] of entries) {
254
- const column = table.scopeColumnByVariable.get(variable);
255
- if (column === undefined) {
256
- throw new ClientSyncError(
257
- 'sync.scope_revoked',
258
- `table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§4.8/§3.3 fail-closed)`,
259
- );
260
- }
261
- if (values.length === 0) return { remaining: false, deferred: false };
262
- clauses.push(
263
- `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
264
- );
265
- params.push(...values);
266
- }
274
+ const scope = localScopePredicate(table, effective);
275
+ if (!scope) return { remaining: false, deferred: false };
276
+ const { sql, params } = scope;
267
277
  const pk = quoteIdent(table.primaryKey);
268
278
  let pinnedClause = '';
269
279
  if (pinnedRowIds.size > 0) {
@@ -272,19 +282,40 @@ export function evictScopedRows(
272
282
  params.push(...ids);
273
283
  }
274
284
  const target = quoteIdent(table.name);
285
+ for (const row of db.query(
286
+ `SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL AND tbl=?`,
287
+ [table.name],
288
+ )) {
289
+ if (
290
+ !pinnedRowIds.has(row.id as string) &&
291
+ [row.intent, row.base].some(
292
+ (json) =>
293
+ json !== null &&
294
+ matchesLocalScopes(
295
+ table,
296
+ effective,
297
+ JSON.parse(json as string) as Record<string, JsonRowValue>,
298
+ ),
299
+ )
300
+ )
301
+ db.exec(
302
+ `DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq IS NOT NULL`,
303
+ [table.name, row.id as string],
304
+ );
305
+ }
275
306
  db.exec(
276
- `DELETE FROM ${target} WHERE ${pk} IN(SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024)`,
307
+ `DELETE FROM ${target} WHERE ${pk} IN(SELECT ${pk} FROM ${target} WHERE ${sql}${pinnedClause} LIMIT 1024)`,
277
308
  params,
278
309
  );
279
310
  const remaining =
280
311
  db.query(
281
- `SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1`,
312
+ `SELECT 1 FROM ${target} WHERE ${sql}${pinnedClause} LIMIT 1`,
282
313
  params,
283
314
  ).length > 0;
284
315
  const deferred =
285
316
  remaining ||
286
317
  db.query(
287
- `SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')} LIMIT 1`,
318
+ `SELECT 1 FROM ${target} WHERE ${sql} LIMIT 1`,
288
319
  params.slice(0, params.length - pinnedRowIds.size),
289
320
  ).length > 0;
290
321
  return { remaining, deferred };
@@ -329,7 +360,7 @@ export async function applySqliteSegment(
329
360
  const withImage = db.withSqliteImage?.bind(db);
330
361
  if (withImage === undefined) {
331
362
  throw invalidRequest(
332
- 'received a sqlite segment but the database backend cannot import images (§4.2: do not advertise accept bit 2)',
363
+ 'database cannot import images; disable accept bit 2 (§4.2)',
333
364
  );
334
365
  }
335
366
  if (descriptor.table !== table.name) {
@@ -506,12 +537,12 @@ export async function applyRowsSegment(
506
537
  first = false;
507
538
  if (!clearThisBlock && rows.length === 0) continue;
508
539
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
509
- restoreFailedBases(db, schema);
540
+ const protectedRows = restoreFailedBases(db, schema);
510
541
  if (clearThisBlock) {
511
542
  deleteScopedRows(db, table, options.effective);
512
543
  }
513
544
  for (const row of rows) {
514
- upsertLocalRow(db, table, row.values, row.serverVersion);
545
+ upsertLocalRow(db, table, row.values, row.serverVersion, protectedRows);
515
546
  applied += 1;
516
547
  if (applied % 1024 === 0) options.onProgress?.(applied);
517
548
  }
package/src/client.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { RETAINED_ROWS } from './failed-overlay';
1
2
  import {
2
3
  dropFailedRows,
3
4
  failedOverlayCommits,
@@ -51,6 +52,7 @@ import {
51
52
  applySqliteSegment,
52
53
  deleteLocalRow,
53
54
  deleteScopedRows,
55
+ localScopePredicate,
54
56
  evictScopedRows,
55
57
  yieldToHost,
56
58
  upsertLocalRow,
@@ -196,6 +198,7 @@ import {
196
198
  type CompiledClientSchema,
197
199
  type CompiledClientTable,
198
200
  compileClientSchema,
201
+ matchesLocalScopes,
199
202
  dropAndRecreateSyncedTables,
200
203
  ensureLocalBookkeepingSchema,
201
204
  ensureLocalSyncedSchema,
@@ -837,7 +840,7 @@ export class SyncClient {
837
840
  constructor(config: SyncClientConfig) {
838
841
  if (config.securityPreflight === true && config.encryption !== undefined) {
839
842
  throw invalidRequest(
840
- 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight',
843
+ 'securityPreflight and encryption are mutually exclusive; activateSecurity installs keys',
841
844
  );
842
845
  }
843
846
  this.#retainFailedCommits = config.retainFailedCommits === true;
@@ -923,7 +926,7 @@ export class SyncClient {
923
926
  this.#lease = undefined;
924
927
  throw new ClientSyncError(
925
928
  'client.identity_mismatch',
926
- `this client database belongs to ${JSON.stringify(persisted)}; refusing to rebind it to ${JSON.stringify(this.#config.clientId)}`,
929
+ `database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`,
927
930
  );
928
931
  }
929
932
  this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
@@ -961,6 +964,9 @@ export class SyncClient {
961
964
  const startupWork =
962
965
  this.#schemaFloor === undefined &&
963
966
  (countOutbox(this.#db) > 0 ||
967
+ this.#db.query(
968
+ `SELECT 1 FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL LIMIT 1`,
969
+ ).length > 0 ||
964
970
  loadPendingEvictions(this.#db).length > 0 ||
965
971
  subscriptions.some((sub) => sub.status === 'active'));
966
972
  if (startupWork && this.#securityLifecycle === 'active') {
@@ -1060,6 +1066,7 @@ export class SyncClient {
1060
1066
  this.#schema,
1061
1067
  true,
1062
1068
  );
1069
+ this.#discardAcknowledgedRows();
1063
1070
  dropAndRecreateSyncedTables(this.#db, this.#schema);
1064
1071
  resetSubscriptionsForBump(this.#db);
1065
1072
  setMeta(
@@ -1160,6 +1167,7 @@ export class SyncClient {
1160
1167
  this.#sweepPreviousVersionContainer();
1161
1168
  this.#applyBatch((batch) => {
1162
1169
  this.#db.transaction(() => {
1170
+ this.#discardAcknowledgedRows();
1163
1171
  dropAndRecreateSyncedTables(this.#db, this.#schema);
1164
1172
  resetSubscriptionsForBump(this.#db);
1165
1173
  setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
@@ -1981,7 +1989,9 @@ export class SyncClient {
1981
1989
  result = fn(batch);
1982
1990
  if (
1983
1991
  this.#db
1984
- .query('SELECT DISTINCT tbl FROM _syncular_failed_rows')
1992
+ .query(
1993
+ `SELECT DISTINCT tbl FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL`,
1994
+ )
1985
1995
  .some((row) => batch.hasTable(String(row.tbl)))
1986
1996
  )
1987
1997
  batch.outcomes();
@@ -2327,7 +2337,7 @@ export class SyncClient {
2327
2337
  return this.#applyBatch(
2328
2338
  (batch) => {
2329
2339
  restoreFailedBases(this.#db, this.#schema);
2330
- this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id=?', [
2340
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [
2331
2341
  input.clientCommitId,
2332
2342
  ]);
2333
2343
  const resolved = persistCommitOutcomeResolution(
@@ -2475,7 +2485,7 @@ export class SyncClient {
2475
2485
  if (!sameIntent) {
2476
2486
  throw new ClientSyncError(
2477
2487
  'client.subscription_intent_mismatch',
2478
- 'subscribe: the subscription id is already registered for a different table, scopes, or params',
2488
+ 'subscription id already names different table, scopes, or params',
2479
2489
  );
2480
2490
  }
2481
2491
  return;
@@ -2533,7 +2543,7 @@ export class SyncClient {
2533
2543
  const table = this.#table(base.table);
2534
2544
  if (!table.scopeColumnByVariable.has(base.variable)) {
2535
2545
  throw invalidRequest(
2536
- `setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`,
2546
+ `${base.table}: unknown scope variable ${base.variable} (§4.8)`,
2537
2547
  );
2538
2548
  }
2539
2549
  // Serialize the whole window edit: it spans an `await deriveSubId` between
@@ -2965,26 +2975,13 @@ export class SyncClient {
2965
2975
  return this.#applyBatch((batch) => {
2966
2976
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
2967
2977
  restoreFailedBases(this.#db, this.#schema);
2968
- const droppedRetained = dropFailedRows(this.#db, (table, values) =>
2978
+ this.#dropRetainedRows(batch, (table, values) =>
2969
2979
  (targetsByTable.get(table) ?? []).some((target) =>
2970
2980
  target.selectors.every((selector) =>
2971
2981
  selector.values.includes(String(values[selector.column])),
2972
2982
  ),
2973
2983
  ),
2974
2984
  );
2975
- for (const commit of droppedRetained) {
2976
- batch.conflicts();
2977
- batch.rejections();
2978
- this.#conflicts = this.#conflicts.filter(
2979
- (record) => record.clientCommitId !== commit.clientCommitId,
2980
- );
2981
- this.#rejections = this.#rejections.filter(
2982
- (record) => record.clientCommitId !== commit.clientCommitId,
2983
- );
2984
- for (const operation of commit.operations)
2985
- batch.table(operation.table);
2986
- batch.outcomes();
2987
- }
2988
2985
  // Doomed detection runs to fixpoint, matching the Rust core's rule:
2989
2986
  // a commit is doomed when any of its ops touches a base-matching
2990
2987
  // row. Base values only become visible here as rollbacks restore
@@ -3144,6 +3141,7 @@ export class SyncClient {
3144
3141
  this.#upgrading = true;
3145
3142
  this.#needsPull = true;
3146
3143
  batch.status();
3144
+ this.#discardAcknowledgedRows();
3147
3145
  dropAndRecreateSyncedTables(this.#db, this.#schema);
3148
3146
  resetSubscriptionsForBump(this.#db);
3149
3147
  for (const table of this.#schema.tables.values()) {
@@ -4151,6 +4149,7 @@ export class SyncClient {
4151
4149
  index += 1;
4152
4150
  }
4153
4151
  }
4152
+ const previousNeedsPull = this.#needsPull;
4154
4153
  const conflictCount = this.#conflicts.length;
4155
4154
  const rejectionCount = this.#rejections.length;
4156
4155
  let outboxCount = countOutbox(this.#db);
@@ -4183,11 +4182,12 @@ export class SyncClient {
4183
4182
  },
4184
4183
  () => this.#statusSnapshot(outboxCount),
4185
4184
  (cause) => {
4185
+ this.#needsPull = previousNeedsPull;
4186
4186
  this.#conflicts.length = conflictCount;
4187
4187
  this.#rejections.length = rejectionCount;
4188
4188
  const error = new ClientSyncError(
4189
4189
  'client.outcome_persistence_failed',
4190
- 'local commit outcome could not be persisted',
4190
+ 'outcome persistence failed',
4191
4191
  );
4192
4192
  error.cause = cause;
4193
4193
  throw error;
@@ -4485,6 +4485,18 @@ export class SyncClient {
4485
4485
  if (frame.status === 'applied' || frame.status === 'cached') {
4486
4486
  // §6.3: applied and cached both drain the outbox — cached means
4487
4487
  // "already applied, you may have missed the ack".
4488
+ if (frame.commitSeq === undefined)
4489
+ throw new ClientSyncError(
4490
+ 'client.invalid_host_response',
4491
+ 'ACK lacks a commit sequence',
4492
+ );
4493
+ retainFailedRows(
4494
+ this.#db,
4495
+ commit,
4496
+ listOutboxBeforeImages(this.#db, commit.clientCommitId),
4497
+ frame.commitSeq,
4498
+ );
4499
+ this.#setSyncNeeded(true);
4488
4500
  recordCommitOutcome(this.#db, {
4489
4501
  clientCommitId: frame.clientCommitId,
4490
4502
  status: frame.status,
@@ -4638,12 +4650,7 @@ export class SyncClient {
4638
4650
  this.#applyBatch((batch) => {
4639
4651
  this.#recordCommitChanges(batch, frame);
4640
4652
  const result = fn();
4641
- if (
4642
- this.#retainFailedCommits &&
4643
- this.#db.query('SELECT 1 FROM _syncular_failed_rows LIMIT 1')
4644
- .length > 0
4645
- )
4646
- this.#replayOutbox();
4653
+ this.#replayOutbox(result);
4647
4654
  return result;
4648
4655
  }),
4649
4656
  );
@@ -4695,22 +4702,12 @@ export class SyncClient {
4695
4702
 
4696
4703
  /** Whether a fresh-bootstrap clear would remove at least one local row. */
4697
4704
  #scopedRowsExist(table: CompiledClientTable, effective: ScopeMap): boolean {
4698
- const entries = Object.entries(effective);
4699
- if (entries.length === 0) return false;
4700
- const clauses: string[] = [];
4701
- const params: string[] = [];
4702
- for (const [variable, values] of entries) {
4703
- const column = table.scopeColumnByVariable.get(variable);
4704
- if (column === undefined || values.length === 0) return false;
4705
- clauses.push(
4706
- `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
4707
- );
4708
- params.push(...values);
4709
- }
4705
+ const scope = localScopePredicate(table, effective);
4710
4706
  return (
4707
+ scope !== undefined &&
4711
4708
  this.#db.query(
4712
- `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')} LIMIT 1`,
4713
- params,
4709
+ `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`,
4710
+ scope.params,
4714
4711
  ).length > 0
4715
4712
  );
4716
4713
  }
@@ -4817,7 +4814,7 @@ export class SyncClient {
4817
4814
  // §5.4: MUST NOT start a fetch at/past expiry.
4818
4815
  throw new ClientSyncError(
4819
4816
  'sync.segment_expired',
4820
- `signed URL for segment ${frame.segmentId} expired before fetch — re-pull mints fresh descriptors (§5.4)`,
4817
+ `segment download grant expired; pull again (§5.4)`,
4821
4818
  true,
4822
4819
  );
4823
4820
  }
@@ -4875,6 +4872,34 @@ export class SyncClient {
4875
4872
  status: 'active',
4876
4873
  }),
4877
4874
  );
4875
+ if (completed) {
4876
+ const table = this.#table(sub.table);
4877
+ const effective = start.effectiveScopes;
4878
+ const covered = this.#db
4879
+ .query(
4880
+ `SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq<=?`,
4881
+ [sub.table, nextCursor],
4882
+ )
4883
+ .filter((row) =>
4884
+ [row.intent, row.base].some((json) => {
4885
+ if (json === null) return false;
4886
+ const values: Record<string, JsonRowValue> = JSON.parse(
4887
+ String(json),
4888
+ );
4889
+ return matchesLocalScopes(table, effective, values);
4890
+ }),
4891
+ );
4892
+ if (covered.length) {
4893
+ restoreFailedBases(this.#db, this.#schema);
4894
+ for (const row of covered)
4895
+ this.#db.exec(
4896
+ `DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`,
4897
+ [sub.table, String(row.id), nextCursor],
4898
+ );
4899
+ batch.table(sub.table);
4900
+ this.#replayOutbox();
4901
+ }
4902
+ }
4878
4903
  if (completed && registered !== undefined) {
4879
4904
  // A zero-row bootstrap is a window-domain transition, not a fake
4880
4905
  // row/table change (SPEC §4.8 / §7.5).
@@ -4923,29 +4948,12 @@ export class SyncClient {
4923
4948
  ) {
4924
4949
  try {
4925
4950
  restoreFailedBases(this.#db, this.#schema);
4926
- const droppedRetained = dropFailedRows(
4927
- this.#db,
4951
+ this.#dropRetainedRows(
4952
+ batch,
4928
4953
  (name, values) =>
4929
4954
  name === table.name &&
4930
- Object.entries(lastEffective).every(([variable, allowed]) =>
4931
- allowed.includes(
4932
- String(values[table.scopeColumnByVariable.get(variable)!]),
4933
- ),
4934
- ),
4955
+ matchesLocalScopes(table, lastEffective, values),
4935
4956
  );
4936
- for (const commit of droppedRetained) {
4937
- batch.conflicts();
4938
- batch.rejections();
4939
- this.#conflicts = this.#conflicts.filter(
4940
- (record) => record.clientCommitId !== commit.clientCommitId,
4941
- );
4942
- this.#rejections = this.#rejections.filter(
4943
- (record) => record.clientCommitId !== commit.clientCommitId,
4944
- );
4945
- for (const operation of commit.operations)
4946
- batch.table(operation.table);
4947
- batch.outcomes();
4948
- }
4949
4957
  deleteScopedRows(this.#db, table, lastEffective);
4950
4958
  batch.scopeMap(table, lastEffective);
4951
4959
  const pendingById = new Map(
@@ -5229,18 +5237,52 @@ export class SyncClient {
5229
5237
  }
5230
5238
 
5231
5239
  /** Re-apply every pending outbox commit on top of server state (§7.1). */
5232
- #replayOutbox(): void {
5240
+ #discardAcknowledgedRows(): void {
5241
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
5242
+ }
5243
+
5244
+ #dropRetainedRows(
5245
+ batch: ChangeAccumulator,
5246
+ matches: (
5247
+ table: string,
5248
+ values: Readonly<Record<string, JsonRowValue>>,
5249
+ ) => boolean,
5250
+ ): void {
5251
+ for (const commit of dropFailedRows(this.#db, matches)) {
5252
+ batch.conflicts();
5253
+ batch.rejections();
5254
+ batch.outcomes();
5255
+ this.#conflicts = this.#conflicts.filter(
5256
+ (record) => record.clientCommitId !== commit.clientCommitId,
5257
+ );
5258
+ this.#rejections = this.#rejections.filter(
5259
+ (record) => record.clientCommitId !== commit.clientCommitId,
5260
+ );
5261
+ for (const op of commit.operations) batch.table(op.table);
5262
+ }
5263
+ }
5264
+
5265
+ #replayOutbox(rowKeys?: readonly string[]): void {
5266
+ const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
5233
5267
  const pending = [
5234
- ...failedOverlayCommits(this.#db),
5235
- ...listOutbox(this.#db),
5268
+ ...failedOverlayCommits(this.#db, false, rowKeys),
5269
+ ...listOutbox(this.#db, rowKeys),
5236
5270
  ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
5237
- if (pending.length === 0) return;
5271
+ if (pending.length === 0 && acknowledged.length === 0) return;
5238
5272
  this.#applyBatch((batch) => {
5239
5273
  this.#db.transaction(() => {
5240
- restoreFailedBases(this.#db, this.#schema);
5241
- restoreFailedBases(this.#db, this.#schema, true);
5242
- for (const commit of pending)
5243
- this.#applyOperationsLocally(commit.operations, batch, true);
5274
+ restoreFailedBases(this.#db, this.#schema, false, rowKeys);
5275
+ restoreFailedBases(this.#db, this.#schema, true, rowKeys);
5276
+ for (const commit of [...acknowledged, ...pending])
5277
+ this.#applyOperationsLocally(
5278
+ rowKeys === undefined
5279
+ ? commit.operations
5280
+ : commit.operations.filter((op) =>
5281
+ rowKeys.includes(JSON.stringify([op.table, op.rowId])),
5282
+ ),
5283
+ batch,
5284
+ true,
5285
+ );
5244
5286
  });
5245
5287
  });
5246
5288
  }
@@ -5255,17 +5297,14 @@ export class SyncClient {
5255
5297
  #table(name: string): CompiledClientTable {
5256
5298
  const table = this.#schema.tables.get(name);
5257
5299
  if (table === undefined) {
5258
- throw new ClientSyncError(
5259
- 'sync.unknown_table',
5260
- `unknown local table ${JSON.stringify(name)}`,
5261
- );
5300
+ throw new ClientSyncError('sync.unknown_table', `unknown table ${name}`);
5262
5301
  }
5263
5302
  return table;
5264
5303
  }
5265
5304
 
5266
5305
  #requireStarted(): void {
5267
5306
  if (!this.#started) {
5268
- throw invalidRequest('SyncClient.start() has not completed');
5307
+ throw invalidRequest('call SyncClient.start() first');
5269
5308
  }
5270
5309
  }
5271
5310
 
@@ -5274,7 +5313,7 @@ export class SyncClient {
5274
5313
  if (this.#securityLifecycle === 'preflight') {
5275
5314
  throw new ClientSyncError(
5276
5315
  SECURITY_PREFLIGHT_REQUIRED_CODE,
5277
- 'the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data',
5316
+ 'security preflight must complete before accessing protected data; call activateSecurity',
5278
5317
  );
5279
5318
  }
5280
5319
  }