@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/README.md CHANGED
@@ -18,6 +18,13 @@ Ordinary commits use wire version 1 until the caller supplies an acquired
18
18
  partition `logEpoch`. Set `logEpoch` after a restore rotation requires epoch
19
19
  validation.
20
20
 
21
+ Applied and cached acknowledgements drain the send queue while protected local
22
+ intent stays visible until the same row's server image arrives at that commit
23
+ sequence or later. Empty pulls and restart preserve it; later edits stack above
24
+ it. This behavior is automatic, independent of `retainFailedCommits`. The core
25
+ requests a following pull without relying on an origin realtime notification.
26
+ See [optimistic writes](https://syncular.dev/concepts-conflicts/#the-optimistic-outbox).
27
+
21
28
  ## Client-local FTS5 projections
22
29
 
23
30
  Generated schemas may attach `ftsIndexes` to a synced table. The
package/dist/apply.d.ts CHANGED
@@ -23,7 +23,7 @@ export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClient
23
23
  * (`client.decrypt_failed`) aborts before any local write, so the commit
24
24
  * never half-applies.
25
25
  */
26
- export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: ApplyTransaction): Promise<void>;
26
+ export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () => readonly string[] | undefined) => unknown): Promise<void>;
27
27
  /**
28
28
  * §5.2: the segment's column table must match the generated schema for
29
29
  * (table, schemaVersion) — order, names, types, nullability. A mismatch is
@@ -37,6 +37,10 @@ export declare function validateSegmentColumns(schema: CompiledClientSchema, tab
37
37
  * (`sync.scope_revoked`) when the table has no local mapping for a key:
38
38
  * precision or nothing, never clear-the-table.
39
39
  */
40
+ export declare function localScopePredicate(table: CompiledClientTable, effective: ScopeMap): {
41
+ sql: string;
42
+ params: string[];
43
+ } | undefined;
40
44
  export declare function deleteScopedRows(db: ClientDatabase, table: CompiledClientTable, effective: ScopeMap): void;
41
45
  /**
42
46
  * §4.8 window eviction: delete rows matching a departing unit's effective
package/dist/apply.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { RETAINED_ROWS } from './failed-overlay.js';
1
2
  import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
2
3
  /**
3
4
  * Local application of server data: `COMMIT` frames (§4.5), rows segments
@@ -6,7 +7,7 @@ import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
6
7
  */
7
8
  import { decodeRow, } from '@syncular/core';
8
9
  import { ClientSyncError, invalidRequest } from './errors.js';
9
- import { quoteIdent, SYNC_VERSION_COLUMN, toSqlValue, rowValueToJson, } from './schema.js';
10
+ import { quoteIdent, matchesLocalScopes, SYNC_VERSION_COLUMN, toSqlValue, rowValueToJson, } from './schema.js';
10
11
  function upsertSql(table) {
11
12
  const names = [
12
13
  ...table.columns.map((column) => quoteIdent(column.name)),
@@ -75,8 +76,14 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
75
76
  });
76
77
  }
77
78
  transaction(() => {
78
- restoreFailedBases(db, schema);
79
- for (const change of resolved) {
79
+ const rowKeys = frame.tables.some((name) => schema.tables.get(name)?.indexes.some((index) => index.unique))
80
+ ? undefined
81
+ : frame.changes.map((change) => JSON.stringify([frame.tables[change.tableIndex], change.rowId]));
82
+ restoreFailedBases(db, schema, false, rowKeys);
83
+ for (const [index, change] of resolved.entries()) {
84
+ const rowId = frame.changes[index].rowId;
85
+ db.exec("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')=?)", [frame.commitSeq, change.table.name, rowId]);
86
+ db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [change.table.name, rowId, frame.commitSeq]);
80
87
  if (change.op === 'delete') {
81
88
  deleteLocalRow(db, change.table, change.rowId);
82
89
  }
@@ -84,6 +91,7 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
84
91
  upsertLocalRow(db, change.table, change.values, change.rowVersion);
85
92
  }
86
93
  }
94
+ return rowKeys;
87
95
  });
88
96
  }
89
97
  /**
@@ -123,26 +131,31 @@ export function validateSegmentColumns(schema, table, segment) {
123
131
  * (`sync.scope_revoked`) when the table has no local mapping for a key:
124
132
  * precision or nothing, never clear-the-table.
125
133
  */
126
- export function deleteScopedRows(db, table, effective) {
134
+ export function localScopePredicate(table, effective) {
127
135
  const entries = Object.entries(effective);
128
- if (entries.length === 0)
136
+ if (!entries.length || entries.some(([, values]) => !values.length))
129
137
  return;
130
- const clauses = [];
131
138
  const params = [];
132
- for (const [variable, values] of entries) {
139
+ const sql = entries
140
+ .map(([variable, values]) => {
133
141
  const column = table.scopeColumnByVariable.get(variable);
134
- if (column === undefined) {
135
- throw new ClientSyncError('sync.scope_revoked', `table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§3.3 fail-closed)`);
136
- }
137
- if (values.length === 0)
138
- return;
139
- clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
142
+ if (column === undefined)
143
+ throw new ClientSyncError('sync.scope_revoked', 'table has no local scope-column mapping');
140
144
  params.push(...values);
141
- }
142
- for (const row of db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`, params)) {
145
+ return `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`;
146
+ })
147
+ .join(' AND ');
148
+ return { sql, params };
149
+ }
150
+ export function deleteScopedRows(db, table, effective) {
151
+ const scope = localScopePredicate(table, effective);
152
+ if (!scope)
153
+ return;
154
+ const { sql, params } = scope;
155
+ for (const row of db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${sql}`, params)) {
143
156
  retainedBaseWrite(db, table, String(row.id));
144
157
  }
145
- db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`, params);
158
+ db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
146
159
  }
147
160
  /**
148
161
  * §4.8 window eviction: delete rows matching a departing unit's effective
@@ -155,21 +168,10 @@ export function deleteScopedRows(db, table, effective) {
155
168
  * cache), which is automatic since the version column is per-row.
156
169
  */
157
170
  export function evictScopedRows(db, table, effective, pinnedRowIds) {
158
- const entries = Object.entries(effective);
159
- if (entries.length === 0)
171
+ const scope = localScopePredicate(table, effective);
172
+ if (!scope)
160
173
  return { remaining: false, deferred: false };
161
- const clauses = [];
162
- const params = [];
163
- for (const [variable, values] of entries) {
164
- const column = table.scopeColumnByVariable.get(variable);
165
- if (column === undefined) {
166
- throw new ClientSyncError('sync.scope_revoked', `table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§4.8/§3.3 fail-closed)`);
167
- }
168
- if (values.length === 0)
169
- return { remaining: false, deferred: false };
170
- clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
171
- params.push(...values);
172
- }
174
+ const { sql, params } = scope;
173
175
  const pk = quoteIdent(table.primaryKey);
174
176
  let pinnedClause = '';
175
177
  if (pinnedRowIds.size > 0) {
@@ -178,10 +180,16 @@ export function evictScopedRows(db, table, effective, pinnedRowIds) {
178
180
  params.push(...ids);
179
181
  }
180
182
  const target = quoteIdent(table.name);
181
- db.exec(`DELETE FROM ${target} WHERE ${pk} IN(SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024)`, params);
182
- const remaining = db.query(`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1`, params).length > 0;
183
+ for (const row of db.query(`SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL AND tbl=?`, [table.name])) {
184
+ if (!pinnedRowIds.has(row.id) &&
185
+ [row.intent, row.base].some((json) => json !== null &&
186
+ matchesLocalScopes(table, effective, JSON.parse(json))))
187
+ db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq IS NOT NULL`, [table.name, row.id]);
188
+ }
189
+ db.exec(`DELETE FROM ${target} WHERE ${pk} IN(SELECT ${pk} FROM ${target} WHERE ${sql}${pinnedClause} LIMIT 1024)`, params);
190
+ const remaining = db.query(`SELECT 1 FROM ${target} WHERE ${sql}${pinnedClause} LIMIT 1`, params).length > 0;
183
191
  const deferred = remaining ||
184
- db.query(`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')} LIMIT 1`, params.slice(0, params.length - pinnedRowIds.size)).length > 0;
192
+ db.query(`SELECT 1 FROM ${target} WHERE ${sql} LIMIT 1`, params.slice(0, params.length - pinnedRowIds.size)).length > 0;
185
193
  return { remaining, deferred };
186
194
  }
187
195
  const IMAGE_ALIAS = 'syncular_image';
@@ -199,7 +207,7 @@ function imageInvalid(detail) {
199
207
  export async function applySqliteSegment(db, schema, table, bytes, descriptor, options) {
200
208
  const withImage = db.withSqliteImage?.bind(db);
201
209
  if (withImage === undefined) {
202
- throw invalidRequest('received a sqlite segment but the database backend cannot import images (§4.2: do not advertise accept bit 2)');
210
+ throw invalidRequest('database cannot import images; disable accept bit 2 (§4.2)');
203
211
  }
204
212
  if (descriptor.table !== table.name) {
205
213
  imageInvalid(`descriptor table ${JSON.stringify(descriptor.table)}`);
@@ -339,12 +347,12 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
339
347
  if (!clearThisBlock && rows.length === 0)
340
348
  continue;
341
349
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
342
- restoreFailedBases(db, schema);
350
+ const protectedRows = restoreFailedBases(db, schema);
343
351
  if (clearThisBlock) {
344
352
  deleteScopedRows(db, table, options.effective);
345
353
  }
346
354
  for (const row of rows) {
347
- upsertLocalRow(db, table, row.values, row.serverVersion);
355
+ upsertLocalRow(db, table, row.values, row.serverVersion, protectedRows);
348
356
  applied += 1;
349
357
  if (applied % 1024 === 0)
350
358
  options.onProgress?.(applied);
package/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { RETAINED_ROWS } from './failed-overlay.js';
1
2
  import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, uniqueConflicts, } from './failed-overlay.js';
2
3
  import { ProgressEmitter, } from './progress.js';
3
4
  /**
@@ -11,7 +12,7 @@ import { ProgressEmitter, } from './progress.js';
11
12
  */
12
13
  import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
13
14
  import { EncryptError } from '@syncular/core';
14
- import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
15
+ import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, localScopePredicate, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
15
16
  import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
16
17
  import { registerDevtools } from './devtools.js';
17
18
  import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, MAX_DIAGNOSTIC_QUERY_FAILURES, } from './diagnostics.js';
@@ -25,7 +26,7 @@ import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encod
25
26
  import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
26
27
  import { assertReadOnlyQuery } from './query-guard.js';
27
28
  import { buildPreviousVersionAudit, capturePreviousVersion, clearPreviousVersionAudit, clearPreviousVersionRefusal, dropPreviousVersionContainer, loadLocalSchemaDescriptor, PREVIOUS_VERSION_CONTAINER_NAME, PREVIOUS_VERSION_MAX_LIMIT, readPreviousVersionContainer, readPreviousVersionRows, setLocalSchemaDescriptor, storedPreviousVersionAudit, storedPreviousVersionRefusal, withPreviousVersionContainer, writePreviousVersionAudit, writePreviousVersionRefusal, } from './previous-version.js';
28
- import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
29
+ import { compileClientSchema, matchesLocalScopes, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
29
30
  import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, saveSubscriptionScopeSchema, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
30
31
  import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
31
32
  /** Stable client-local error while protected operations are preflight-gated. */
@@ -221,7 +222,7 @@ export class SyncClient {
221
222
  #preflightBarrier;
222
223
  constructor(config) {
223
224
  if (config.securityPreflight === true && config.encryption !== undefined) {
224
- throw invalidRequest('securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
225
+ throw invalidRequest('securityPreflight and encryption are mutually exclusive; activateSecurity installs keys');
225
226
  }
226
227
  this.#retainFailedCommits = config.retainFailedCommits === true;
227
228
  this.#db = config.database;
@@ -287,7 +288,7 @@ export class SyncClient {
287
288
  persisted !== this.#config.clientId) {
288
289
  await this.#lease.release();
289
290
  this.#lease = undefined;
290
- throw new ClientSyncError('client.identity_mismatch', `this client database belongs to ${JSON.stringify(persisted)}; refusing to rebind it to ${JSON.stringify(this.#config.clientId)}`);
291
+ throw new ClientSyncError('client.identity_mismatch', `database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`);
291
292
  }
292
293
  this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
293
294
  if (persisted === undefined) {
@@ -319,6 +320,7 @@ export class SyncClient {
319
320
  // an application-issued sync() call.
320
321
  const startupWork = this.#schemaFloor === undefined &&
321
322
  (countOutbox(this.#db) > 0 ||
323
+ this.#db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL LIMIT 1`).length > 0 ||
322
324
  loadPendingEvictions(this.#db).length > 0 ||
323
325
  subscriptions.some((sub) => sub.status === 'active'));
324
326
  if (startupWork && this.#securityLifecycle === 'active') {
@@ -396,6 +398,7 @@ export class SyncClient {
396
398
  writePreviousVersionAudit(this.#db, buildPreviousVersionAudit(this.#schema, listOutbox(this.#db), previousVersion, this.#schema.version, this.#now()));
397
399
  }
398
400
  pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema, true);
401
+ this.#discardAcknowledgedRows();
399
402
  dropAndRecreateSyncedTables(this.#db, this.#schema);
400
403
  resetSubscriptionsForBump(this.#db);
401
404
  setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
@@ -477,6 +480,7 @@ export class SyncClient {
477
480
  this.#sweepPreviousVersionContainer();
478
481
  this.#applyBatch((batch) => {
479
482
  this.#db.transaction(() => {
483
+ this.#discardAcknowledgedRows();
480
484
  dropAndRecreateSyncedTables(this.#db, this.#schema);
481
485
  resetSubscriptionsForBump(this.#db);
482
486
  setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
@@ -1179,7 +1183,7 @@ export class SyncClient {
1179
1183
  try {
1180
1184
  result = fn(batch);
1181
1185
  if (this.#db
1182
- .query('SELECT DISTINCT tbl FROM _syncular_failed_rows')
1186
+ .query(`SELECT DISTINCT tbl FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL`)
1183
1187
  .some((row) => batch.hasTable(String(row.tbl))))
1184
1188
  batch.outcomes();
1185
1189
  if (batch.touched) {
@@ -1454,7 +1458,7 @@ export class SyncClient {
1454
1458
  const priorRejections = this.#rejections;
1455
1459
  return this.#applyBatch((batch) => {
1456
1460
  restoreFailedBases(this.#db, this.#schema);
1457
- this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id=?', [
1461
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [
1458
1462
  input.clientCommitId,
1459
1463
  ]);
1460
1464
  const resolved = persistCommitOutcomeResolution(this.#db, input, this.#now());
@@ -1569,7 +1573,7 @@ export class SyncClient {
1569
1573
  canonicalScopeJson(input.scopes) &&
1570
1574
  existing.params === input.params;
1571
1575
  if (!sameIntent) {
1572
- throw new ClientSyncError('client.subscription_intent_mismatch', 'subscribe: the subscription id is already registered for a different table, scopes, or params');
1576
+ throw new ClientSyncError('client.subscription_intent_mismatch', 'subscription id already names different table, scopes, or params');
1573
1577
  }
1574
1578
  return;
1575
1579
  }
@@ -1616,7 +1620,7 @@ export class SyncClient {
1616
1620
  this.#requireActive();
1617
1621
  const table = this.#table(base.table);
1618
1622
  if (!table.scopeColumnByVariable.has(base.variable)) {
1619
- throw invalidRequest(`setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`);
1623
+ throw invalidRequest(`${base.table}: unknown scope variable ${base.variable} (§4.8)`);
1620
1624
  }
1621
1625
  // Serialize the whole window edit: it spans an `await deriveSubId` between
1622
1626
  // db transactions, so without the chain a delta apply (or a concurrent
@@ -1983,16 +1987,7 @@ export class SyncClient {
1983
1987
  return this.#applyBatch((batch) => {
1984
1988
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
1985
1989
  restoreFailedBases(this.#db, this.#schema);
1986
- const droppedRetained = dropFailedRows(this.#db, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
1987
- for (const commit of droppedRetained) {
1988
- batch.conflicts();
1989
- batch.rejections();
1990
- this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
1991
- this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
1992
- for (const operation of commit.operations)
1993
- batch.table(operation.table);
1994
- batch.outcomes();
1995
- }
1990
+ this.#dropRetainedRows(batch, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
1996
1991
  // Doomed detection runs to fixpoint, matching the Rust core's rule:
1997
1992
  // a commit is doomed when any of its ops touches a base-matching
1998
1993
  // row. Base values only become visible here as rollbacks restore
@@ -2128,6 +2123,7 @@ export class SyncClient {
2128
2123
  this.#upgrading = true;
2129
2124
  this.#needsPull = true;
2130
2125
  batch.status();
2126
+ this.#discardAcknowledgedRows();
2131
2127
  dropAndRecreateSyncedTables(this.#db, this.#schema);
2132
2128
  resetSubscriptionsForBump(this.#db);
2133
2129
  for (const table of this.#schema.tables.values()) {
@@ -2972,6 +2968,7 @@ export class SyncClient {
2972
2968
  index += 1;
2973
2969
  }
2974
2970
  }
2971
+ const previousNeedsPull = this.#needsPull;
2975
2972
  const conflictCount = this.#conflicts.length;
2976
2973
  const rejectionCount = this.#rejections.length;
2977
2974
  let outboxCount = countOutbox(this.#db);
@@ -2988,9 +2985,10 @@ export class SyncClient {
2988
2985
  pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2989
2986
  }
2990
2987
  }, () => this.#statusSnapshot(outboxCount), (cause) => {
2988
+ this.#needsPull = previousNeedsPull;
2991
2989
  this.#conflicts.length = conflictCount;
2992
2990
  this.#rejections.length = rejectionCount;
2993
- const error = new ClientSyncError('client.outcome_persistence_failed', 'local commit outcome could not be persisted');
2991
+ const error = new ClientSyncError('client.outcome_persistence_failed', 'outcome persistence failed');
2994
2992
  error.cause = cause;
2995
2993
  throw error;
2996
2994
  });
@@ -3201,6 +3199,10 @@ export class SyncClient {
3201
3199
  if (frame.status === 'applied' || frame.status === 'cached') {
3202
3200
  // §6.3: applied and cached both drain the outbox — cached means
3203
3201
  // "already applied, you may have missed the ack".
3202
+ if (frame.commitSeq === undefined)
3203
+ throw new ClientSyncError('client.invalid_host_response', 'ACK lacks a commit sequence');
3204
+ retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId), frame.commitSeq);
3205
+ this.#setSyncNeeded(true);
3204
3206
  recordCommitOutcome(this.#db, {
3205
3207
  clientCommitId: frame.clientCommitId,
3206
3208
  status: frame.status,
@@ -3327,10 +3329,7 @@ export class SyncClient {
3327
3329
  await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
3328
3330
  this.#recordCommitChanges(batch, frame);
3329
3331
  const result = fn();
3330
- if (this.#retainFailedCommits &&
3331
- this.#db.query('SELECT 1 FROM _syncular_failed_rows LIMIT 1')
3332
- .length > 0)
3333
- this.#replayOutbox();
3332
+ this.#replayOutbox(result);
3334
3333
  return result;
3335
3334
  }));
3336
3335
  summary.commitsApplied += 1;
@@ -3375,19 +3374,9 @@ export class SyncClient {
3375
3374
  }
3376
3375
  /** Whether a fresh-bootstrap clear would remove at least one local row. */
3377
3376
  #scopedRowsExist(table, effective) {
3378
- const entries = Object.entries(effective);
3379
- if (entries.length === 0)
3380
- return false;
3381
- const clauses = [];
3382
- const params = [];
3383
- for (const [variable, values] of entries) {
3384
- const column = table.scopeColumnByVariable.get(variable);
3385
- if (column === undefined || values.length === 0)
3386
- return false;
3387
- clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
3388
- params.push(...values);
3389
- }
3390
- return (this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')} LIMIT 1`, params).length > 0);
3377
+ const scope = localScopePredicate(table, effective);
3378
+ return (scope !== undefined &&
3379
+ this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`, scope.params).length > 0);
3391
3380
  }
3392
3381
  /**
3393
3382
  * Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
@@ -3461,7 +3450,7 @@ export class SyncClient {
3461
3450
  if (frame.urlExpiresAtMs !== undefined &&
3462
3451
  frame.urlExpiresAtMs <= this.#now()) {
3463
3452
  // §5.4: MUST NOT start a fetch at/past expiry.
3464
- throw new ClientSyncError('sync.segment_expired', `signed URL for segment ${frame.segmentId} expired before fetch — re-pull mints fresh descriptors (§5.4)`, true);
3453
+ throw new ClientSyncError('sync.segment_expired', `segment download grant expired; pull again (§5.4)`, true);
3465
3454
  }
3466
3455
  bytes = await fetchUrl(frame.url, onProgress);
3467
3456
  }
@@ -3504,6 +3493,25 @@ export class SyncClient {
3504
3493
  effectiveScopes: start.effectiveScopes,
3505
3494
  status: 'active',
3506
3495
  }));
3496
+ if (completed) {
3497
+ const table = this.#table(sub.table);
3498
+ const effective = start.effectiveScopes;
3499
+ const covered = this.#db
3500
+ .query(`SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq<=?`, [sub.table, nextCursor])
3501
+ .filter((row) => [row.intent, row.base].some((json) => {
3502
+ if (json === null)
3503
+ return false;
3504
+ const values = JSON.parse(String(json));
3505
+ return matchesLocalScopes(table, effective, values);
3506
+ }));
3507
+ if (covered.length) {
3508
+ restoreFailedBases(this.#db, this.#schema);
3509
+ for (const row of covered)
3510
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [sub.table, String(row.id), nextCursor]);
3511
+ batch.table(sub.table);
3512
+ this.#replayOutbox();
3513
+ }
3514
+ }
3507
3515
  if (completed && registered !== undefined) {
3508
3516
  // A zero-row bootstrap is a window-domain transition, not a fake
3509
3517
  // row/table change (SPEC §4.8 / §7.5).
@@ -3545,17 +3553,8 @@ export class SyncClient {
3545
3553
  Object.keys(lastEffective).length > 0) {
3546
3554
  try {
3547
3555
  restoreFailedBases(this.#db, this.#schema);
3548
- const droppedRetained = dropFailedRows(this.#db, (name, values) => name === table.name &&
3549
- Object.entries(lastEffective).every(([variable, allowed]) => allowed.includes(String(values[table.scopeColumnByVariable.get(variable)]))));
3550
- for (const commit of droppedRetained) {
3551
- batch.conflicts();
3552
- batch.rejections();
3553
- this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
3554
- this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
3555
- for (const operation of commit.operations)
3556
- batch.table(operation.table);
3557
- batch.outcomes();
3558
- }
3556
+ this.#dropRetainedRows(batch, (name, values) => name === table.name &&
3557
+ matchesLocalScopes(table, lastEffective, values));
3559
3558
  deleteScopedRows(this.#db, table, lastEffective);
3560
3559
  batch.scopeMap(table, lastEffective);
3561
3560
  const pendingById = new Map(listOutbox(this.#db).map((commit) => [
@@ -3775,19 +3774,36 @@ export class SyncClient {
3775
3774
  }
3776
3775
  }
3777
3776
  /** Re-apply every pending outbox commit on top of server state (§7.1). */
3778
- #replayOutbox() {
3777
+ #discardAcknowledgedRows() {
3778
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
3779
+ }
3780
+ #dropRetainedRows(batch, matches) {
3781
+ for (const commit of dropFailedRows(this.#db, matches)) {
3782
+ batch.conflicts();
3783
+ batch.rejections();
3784
+ batch.outcomes();
3785
+ this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
3786
+ this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
3787
+ for (const op of commit.operations)
3788
+ batch.table(op.table);
3789
+ }
3790
+ }
3791
+ #replayOutbox(rowKeys) {
3792
+ const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
3779
3793
  const pending = [
3780
- ...failedOverlayCommits(this.#db),
3781
- ...listOutbox(this.#db),
3794
+ ...failedOverlayCommits(this.#db, false, rowKeys),
3795
+ ...listOutbox(this.#db, rowKeys),
3782
3796
  ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
3783
- if (pending.length === 0)
3797
+ if (pending.length === 0 && acknowledged.length === 0)
3784
3798
  return;
3785
3799
  this.#applyBatch((batch) => {
3786
3800
  this.#db.transaction(() => {
3787
- restoreFailedBases(this.#db, this.#schema);
3788
- restoreFailedBases(this.#db, this.#schema, true);
3789
- for (const commit of pending)
3790
- this.#applyOperationsLocally(commit.operations, batch, true);
3801
+ restoreFailedBases(this.#db, this.#schema, false, rowKeys);
3802
+ restoreFailedBases(this.#db, this.#schema, true, rowKeys);
3803
+ for (const commit of [...acknowledged, ...pending])
3804
+ this.#applyOperationsLocally(rowKeys === undefined
3805
+ ? commit.operations
3806
+ : commit.operations.filter((op) => rowKeys.includes(JSON.stringify([op.table, op.rowId]))), batch, true);
3791
3807
  });
3792
3808
  });
3793
3809
  }
@@ -3800,19 +3816,19 @@ export class SyncClient {
3800
3816
  #table(name) {
3801
3817
  const table = this.#schema.tables.get(name);
3802
3818
  if (table === undefined) {
3803
- throw new ClientSyncError('sync.unknown_table', `unknown local table ${JSON.stringify(name)}`);
3819
+ throw new ClientSyncError('sync.unknown_table', `unknown table ${name}`);
3804
3820
  }
3805
3821
  return table;
3806
3822
  }
3807
3823
  #requireStarted() {
3808
3824
  if (!this.#started) {
3809
- throw invalidRequest('SyncClient.start() has not completed');
3825
+ throw invalidRequest('call SyncClient.start() first');
3810
3826
  }
3811
3827
  }
3812
3828
  #requireActive() {
3813
3829
  this.#requireStarted();
3814
3830
  if (this.#securityLifecycle === 'preflight') {
3815
- throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data');
3831
+ throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'security preflight must complete before accessing protected data; call activateSecurity');
3816
3832
  }
3817
3833
  }
3818
3834
  }
@@ -1,3 +1,4 @@
1
+ export declare const RETAINED_ROWS = "_syncular_failed_rows";
1
2
  import type { ClientDatabase } from './database.js';
2
3
  import type { CompiledClientSchema, CompiledClientTable, JsonRowValue } from './schema.js';
3
4
  import type { RowValue } from '@syncular/core';
@@ -6,9 +7,9 @@ import type { OutboxCommit, OutboxBeforeImage } from './outbox.js';
6
7
  /** Protected failure state. The server base advances independently of intent. */
7
8
  export declare function ensureFailedOverlaySchema(db: ClientDatabase): void;
8
9
  export declare function retainedBaseWrite(db: ClientDatabase, table: CompiledClientTable, rowId: string, values?: Readonly<Record<string, JsonRowValue>>, version?: number): void;
9
- export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[]): void;
10
+ export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[], commitSeq?: number): void;
10
11
  /** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
11
12
  export declare function uniqueConflicts(db: ClientDatabase, table: CompiledClientTable, values: Readonly<Record<string, RowValue>>): NonNullable<RetainedCommitRow['uniqueConflicts']>;
12
- export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, absentIntent?: boolean): void;
13
- export declare function failedOverlayCommits(db: ClientDatabase): OutboxCommit[];
13
+ export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, absentIntent?: boolean, rowKeys?: readonly string[]): boolean;
14
+ export declare function failedOverlayCommits(db: ClientDatabase, acknowledged?: boolean, rowKeys?: readonly string[]): OutboxCommit[];
14
15
  export declare function dropFailedRows(db: ClientDatabase, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];