@syncular/client 0.30.13 → 0.30.14

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/dist/apply.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type OverlayScope } from './failed-overlay.js';
1
2
  /**
2
3
  * Local application of server data: `COMMIT` frames (§4.5), rows segments
3
4
  * (§5.2, §5.6), and the scope-matched delete shared by the §3.3 purge
@@ -8,7 +9,7 @@ import type { ClientDatabase } from './database.js';
8
9
  import type { EncryptionConfig } from './encryption.js';
9
10
  import { type CompiledClientSchema, type CompiledClientTable } from './schema.js';
10
11
  /** Lets the client wrap the physical write in its revision transaction. */
11
- export type ApplyTransaction = <T>(fn: () => T) => T;
12
+ export type ApplyTransaction = (fn: () => OverlayScope) => unknown;
12
13
  export declare function upsertLocalRow(db: ClientDatabase, table: CompiledClientTable, values: readonly RowValue[], syncVersion: number, serverWrite?: boolean): void;
13
14
  export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClientTable, rowId: string, serverWrite?: boolean): void;
14
15
  /**
@@ -23,7 +24,7 @@ export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClient
23
24
  * (`client.decrypt_failed`) aborts before any local write, so the commit
24
25
  * never half-applies.
25
26
  */
26
- export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () => readonly string[] | undefined) => unknown): Promise<void>;
27
+ export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () => OverlayScope) => unknown): Promise<void>;
27
28
  /**
28
29
  * §5.2: the segment's column table must match the generated schema for
29
30
  * (table, schemaVersion) — order, names, types, nullability. A mismatch is
package/dist/apply.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { hasUniqueIndex } from './schema.js';
2
+ import { OUTBOX_TABLE, OUTBOX_IMAGES, countOutbox } from './outbox.js';
1
3
  import { RETAINED_ROWS } from './failed-overlay.js';
2
4
  import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
3
5
  /**
@@ -76,13 +78,13 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
76
78
  });
77
79
  }
78
80
  transaction(() => {
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);
81
+ const scope = resolved.map((change, index) => JSON.stringify(hasUniqueIndex(change.table)
82
+ ? [change.table.name]
83
+ : [change.table.name, frame.changes[index].rowId]));
84
+ restoreFailedBases(db, schema, scope);
83
85
  for (const [index, change] of resolved.entries()) {
84
86
  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]);
87
+ db.exec(`UPDATE ${OUTBOX_IMAGES} SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM ${OUTBOX_TABLE},json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)`, [frame.commitSeq, change.table.name, rowId]);
86
88
  db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [change.table.name, rowId, frame.commitSeq]);
87
89
  if (change.op === 'delete') {
88
90
  deleteLocalRow(db, change.table, change.rowId);
@@ -91,7 +93,7 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
91
93
  upsertLocalRow(db, change.table, change.values, change.rowVersion);
92
94
  }
93
95
  }
94
- return rowKeys;
96
+ return scope;
95
97
  });
96
98
  }
97
99
  /**
@@ -147,12 +149,27 @@ export function localScopePredicate(table, effective) {
147
149
  .join(' AND ');
148
150
  return { sql, params };
149
151
  }
152
+ function scopedRows(db, table, scope) {
153
+ return scope
154
+ ? db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${scope.sql}`, scope.params)
155
+ : [];
156
+ }
157
+ function bootstrapScope(db, table, effective) {
158
+ const rows = scopedRows(db, table, localScopePredicate(table, effective));
159
+ rows.push(...db
160
+ .query(`SELECT id,base,intent FROM ${RETAINED_ROWS} WHERE tbl=?`, [
161
+ table.name,
162
+ ])
163
+ .filter((row) => [row.base, row.intent].some((json) => json !== null &&
164
+ matchesLocalScopes(table, effective, JSON.parse(json)))));
165
+ return rows.map((row) => JSON.stringify([table.name, String(row.id)]));
166
+ }
150
167
  export function deleteScopedRows(db, table, effective) {
151
168
  const scope = localScopePredicate(table, effective);
152
169
  if (!scope)
153
170
  return;
154
171
  const { sql, params } = scope;
155
- for (const row of db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${sql}`, params)) {
172
+ for (const row of scopedRows(db, table, scope)) {
156
173
  retainedBaseWrite(db, table, String(row.id));
157
174
  }
158
175
  db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
@@ -287,18 +304,37 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
287
304
  predicates.push(`${primaryKey} <= ?`);
288
305
  params.push(boundary);
289
306
  }
307
+ const replay = countOutbox(db) ||
308
+ db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? LIMIT 1`, [
309
+ table.name,
310
+ ]).length;
311
+ const rows = replay
312
+ ? db.query(`SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`, params)
313
+ : [];
290
314
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
291
- restoreFailedBases(db, schema);
315
+ const scope = !replay
316
+ ? []
317
+ : hasUniqueIndex(table)
318
+ ? [JSON.stringify([table.name])]
319
+ : [
320
+ ...(after === undefined && options.clearFirst
321
+ ? bootstrapScope(db, table, options.effective)
322
+ : []),
323
+ ...rows.map((row) => JSON.stringify([table.name, String(row[table.primaryKey])])),
324
+ ];
325
+ const protectedRows = restoreFailedBases(db, schema, scope);
292
326
  if (after === undefined && options.clearFirst)
293
327
  deleteScopedRows(db, table, options.effective);
294
328
  db.exec(`INSERT INTO ${quoteIdent(table.name)}(${insertNames.join(', ')})SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')} FROM ${source} WHERE ${predicates.join(' AND ') || 'true'} ON CONFLICT(${primaryKey})DO UPDATE SET ${updates}`, params);
295
329
  applied += Number(db.query('SELECT changes()AS n')[0]?.n ?? 0);
296
- for (const row of db.query(`SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`, params)) {
297
- retainedBaseWrite(db, table, String(row[table.primaryKey]), Object.fromEntries(table.columns.map((column) => [
298
- column.name,
299
- rowValueToJson(row[column.name]),
300
- ])), Number(row._syncular_version));
301
- }
330
+ if (protectedRows)
331
+ for (const row of rows) {
332
+ retainedBaseWrite(db, table, String(row[table.primaryKey]), Object.fromEntries(table.columns.map((column) => [
333
+ column.name,
334
+ rowValueToJson(row[column.name]),
335
+ ])), Number(row._syncular_version));
336
+ }
337
+ return scope;
302
338
  });
303
339
  options.onProgress?.(applied);
304
340
  await yieldToHost();
@@ -347,7 +383,18 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
347
383
  if (!clearThisBlock && rows.length === 0)
348
384
  continue;
349
385
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
350
- const protectedRows = restoreFailedBases(db, schema);
386
+ const scope = hasUniqueIndex(table)
387
+ ? [JSON.stringify([table.name])]
388
+ : [
389
+ ...(clearThisBlock
390
+ ? bootstrapScope(db, table, options.effective)
391
+ : []),
392
+ ...rows.map((row) => JSON.stringify([
393
+ table.name,
394
+ String(row.values[table.primaryKeyIndex]),
395
+ ])),
396
+ ];
397
+ const protectedRows = restoreFailedBases(db, schema, scope);
351
398
  if (clearThisBlock) {
352
399
  deleteScopedRows(db, table, options.effective);
353
400
  }
@@ -357,6 +404,7 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
357
404
  if (applied % 1024 === 0)
358
405
  options.onProgress?.(applied);
359
406
  }
407
+ return scope;
360
408
  });
361
409
  options.onProgress?.(applied);
362
410
  await yieldToHost();
package/dist/blob.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export declare const BLOB_COMMIT_REFS = "_syncular_blob_commit_refs";
1
2
  /**
2
3
  * Client-side blob cache + transport (SPEC.md §5.9.7).
3
4
  *
package/dist/blob.js CHANGED
@@ -1,3 +1,4 @@
1
+ export const BLOB_COMMIT_REFS = '_syncular_blob_commit_refs';
1
2
  /**
2
3
  * Client-side blob cache + transport (SPEC.md §5.9.7).
3
4
  *
@@ -70,7 +71,7 @@ export function enforceBlobCacheCap(db, schema, maxBytes) {
70
71
  if (total <= maxBytes)
71
72
  return [];
72
73
  // Oldest eligible body first, with blob ID as a stable tie.
73
- const candidates = db.query(`SELECT blob_id,byte_length FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM _syncular_blob_commit_refs)AND blob_id NOT IN(${visibleBlobIdsSql(schema)})ORDER BY created_at_ms ASC,blob_id ASC`);
74
+ const candidates = db.query(`SELECT blob_id,byte_length FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM ${BLOB_COMMIT_REFS})AND blob_id NOT IN(${visibleBlobIdsSql(schema)})ORDER BY created_at_ms ASC,blob_id ASC`);
74
75
  const evicted = [];
75
76
  db.transaction(() => {
76
77
  for (const row of candidates) {
@@ -86,7 +87,7 @@ export function enforceBlobCacheCap(db, schema, maxBytes) {
86
87
  }
87
88
  export function listPendingUploads(db) {
88
89
  return db
89
- .query('SELECT blob_id,media_type FROM(SELECT blob_id,media_type,created_at_ms FROM _syncular_blob_uploads UNION ALL SELECT DISTINCT r.blob_id,b.media_type,9223372036854775807 AS created_at_ms FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id=r.blob_id WHERE NOT EXISTS(SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id=r.blob_id))ORDER BY created_at_ms,blob_id')
90
+ .query(`SELECT blob_id,media_type FROM(SELECT blob_id,media_type,created_at_ms FROM _syncular_blob_uploads UNION ALL SELECT DISTINCT r.blob_id,b.media_type,9223372036854775807 AS created_at_ms FROM ${BLOB_COMMIT_REFS} r LEFT JOIN _syncular_blobs b ON b.blob_id=r.blob_id WHERE NOT EXISTS(SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id=r.blob_id))ORDER BY created_at_ms,blob_id`)
90
91
  .map((row) => {
91
92
  if (typeof row.blob_id !== 'string' ||
92
93
  (row.media_type !== null && typeof row.media_type !== 'string')) {
@@ -138,6 +139,6 @@ function visibleBlobIdsSql(schema) {
138
139
  : selects.join(' UNION ');
139
140
  }
140
141
  export function deleteUnreferencedCachedBlobs(db, schema) {
141
- db.exec(`DELETE FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM _syncular_blob_commit_refs)AND blob_id NOT IN(${visibleBlobIdsSql(schema)})`);
142
+ db.exec(`DELETE FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM ${BLOB_COMMIT_REFS})AND blob_id NOT IN(${visibleBlobIdsSql(schema)})`);
142
143
  }
143
144
  export { parseBlobRef, serializeBlobRef };
package/dist/client.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { BLOB_COMMIT_REFS } from './blob.js';
2
+ import { hasUniqueIndex } from './schema.js';
3
+ import { OUTCOMES_TABLE } from './outcomes.js';
4
+ import { OUTBOX_TABLE } from './outbox.js';
1
5
  import { RETAINED_ROWS } from './failed-overlay.js';
2
6
  import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, uniqueConflicts, } from './failed-overlay.js';
3
7
  import { ProgressEmitter, } from './progress.js';
@@ -478,6 +482,13 @@ export class SyncClient {
478
482
  }
479
483
  /** §2.1 reset after the server reports a different log continuity. */
480
484
  #runLogEpochReset(logEpoch) {
485
+ if (getMeta(this.#db, LOG_EPOCH_META_KEY) === undefined) {
486
+ this.#applyBatch(() => setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch));
487
+ this.#setSyncNeeded(true);
488
+ this.#emitSyncNeeded('startup');
489
+ this.#emitSyncIntent({ kind: 'interactive' });
490
+ return [];
491
+ }
481
492
  const subscriptions = loadSubscriptions(this.#db);
482
493
  const pending = listOutbox(this.#db);
483
494
  this.#setUpgrading(true);
@@ -1128,8 +1139,8 @@ export class SyncClient {
1128
1139
  try {
1129
1140
  const pageCount = Number(this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0);
1130
1141
  const pageSize = Number(this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0);
1131
- const outboxBytes = Number(this.#db.query('SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM _syncular_outbox')[0]?.bytes ?? 0);
1132
- const outcome = this.#db.query('SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM _syncular_commit_outcomes')[0];
1142
+ const outboxBytes = Number(this.#db.query(`SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM ${OUTBOX_TABLE}`)[0]?.bytes ?? 0);
1143
+ const outcome = this.#db.query(`SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM ${OUTCOMES_TABLE}`)[0];
1133
1144
  const blobBytes = this.#hasBlobs
1134
1145
  ? Number(this.#db.query('SELECT COALESCE(SUM(byte_length),0)AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
1135
1146
  : 0;
@@ -1925,7 +1936,7 @@ export class SyncClient {
1925
1936
  catch {
1926
1937
  continue;
1927
1938
  }
1928
- this.#db.exec('INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)', [clientCommitId, blobId, blobId]);
1939
+ this.#db.exec(`INSERT OR IGNORE INTO ${BLOB_COMMIT_REFS}(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)`, [clientCommitId, blobId, blobId]);
1929
1940
  }
1930
1941
  }
1931
1942
  try {
@@ -2250,7 +2261,7 @@ export class SyncClient {
2250
2261
  async #encodeOutboxForPush() {
2251
2262
  // Pin before the first encryption await: mutations can append while a
2252
2263
  // round is encoding, and belong to the next request.
2253
- const bounds = this.#db.query('SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM _syncular_outbox')[0];
2264
+ const bounds = this.#db.query(`SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM ${OUTBOX_TABLE}`)[0];
2254
2265
  const pendingCount = bounds.count;
2255
2266
  const throughSeq = bounds.last_seq ?? 0;
2256
2267
  const pushFrames = [];
@@ -3140,14 +3151,7 @@ export class SyncClient {
3140
3151
  }
3141
3152
  }
3142
3153
  finally {
3143
- try {
3144
- // §7.1: local reads see outbox state applied optimistically — replay
3145
- // the still-pending commits on top of the freshly applied server state.
3146
- this.#replayOutbox();
3147
- }
3148
- finally {
3149
- this.#endDiagnosticsDeferral();
3150
- }
3154
+ this.#endDiagnosticsDeferral();
3151
3155
  }
3152
3156
  if (errorFrame !== undefined)
3153
3157
  throw errorFrame;
@@ -3276,13 +3280,16 @@ export class SyncClient {
3276
3280
  if (this.#retainFailedCommits)
3277
3281
  retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
3278
3282
  this.#rollbackFailedCommit(commit, batch);
3283
+ this.#replayOutbox(commit.operations.map((operation) => JSON.stringify(hasUniqueIndex(this.#table(operation.table))
3284
+ ? [operation.table]
3285
+ : [operation.table, operation.rowId])));
3279
3286
  });
3280
3287
  batch.status();
3281
3288
  summary.rejected.push(frame.clientCommitId);
3282
3289
  return true;
3283
3290
  }
3284
3291
  #outboxCommitExists(clientCommitId) {
3285
- return (this.#db.query('SELECT 1 FROM _syncular_outbox WHERE client_commit_id=? LIMIT 1', [clientCommitId]).length > 0);
3292
+ return (this.#db.query(`SELECT 1 FROM ${OUTBOX_TABLE} WHERE client_commit_id=? LIMIT 1`, [clientCommitId]).length > 0);
3286
3293
  }
3287
3294
  #decodeServerRow(tableName, payload) {
3288
3295
  if (tableName === undefined)
@@ -3320,7 +3327,7 @@ export class SyncClient {
3320
3327
  await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
3321
3328
  this.#recordCommitChanges(batch, frame);
3322
3329
  const result = fn();
3323
- this.#replayOutbox(result);
3330
+ this.#replayOutbox(result, false);
3324
3331
  return result;
3325
3332
  }));
3326
3333
  summary.commitsApplied += 1;
@@ -3381,7 +3388,7 @@ export class SyncClient {
3381
3388
  (clearFirst && this.#scopedRowsExist(table, effective)))
3382
3389
  batch.table(table.name);
3383
3390
  const result = fn();
3384
- this.#replayOutbox();
3391
+ this.#replayOutbox(result, false);
3385
3392
  return result;
3386
3393
  }),
3387
3394
  };
@@ -3513,11 +3520,12 @@ export class SyncClient {
3513
3520
  return matchesLocalScopes(table, effective, values);
3514
3521
  }));
3515
3522
  if (covered.length) {
3516
- restoreFailedBases(this.#db, this.#schema);
3523
+ const scope = covered.map((row) => JSON.stringify([sub.table, String(row.id)]));
3524
+ restoreFailedBases(this.#db, this.#schema, scope);
3517
3525
  for (const row of covered)
3518
3526
  this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [sub.table, String(row.id), nextCursor]);
3519
3527
  batch.table(sub.table);
3520
- this.#replayOutbox();
3528
+ this.#replayOutbox(scope, false);
3521
3529
  }
3522
3530
  }
3523
3531
  if (completed && registered !== undefined) {
@@ -3560,9 +3568,11 @@ export class SyncClient {
3560
3568
  if (lastEffective !== undefined &&
3561
3569
  Object.keys(lastEffective).length > 0) {
3562
3570
  try {
3563
- restoreFailedBases(this.#db, this.#schema);
3564
- this.#dropRetainedRows(batch, (name, values) => name === table.name &&
3565
- matchesLocalScopes(table, lastEffective, values));
3571
+ const scope = [
3572
+ JSON.stringify([table.name]),
3573
+ ...this.#dropRetainedRows(batch, (name, values) => name === table.name &&
3574
+ matchesLocalScopes(table, lastEffective, values)),
3575
+ ];
3566
3576
  deleteScopedRows(this.#db, table, lastEffective);
3567
3577
  batch.scopeMap(table, lastEffective);
3568
3578
  const pendingById = new Map(listOutbox(this.#db).map((commit) => [
@@ -3600,6 +3610,7 @@ export class SyncClient {
3600
3610
  batch.rejections();
3601
3611
  batch.outcomes();
3602
3612
  }
3613
+ this.#replayOutbox(scope);
3603
3614
  this.#deleteUnreferencedBlobs();
3604
3615
  }
3605
3616
  catch (error) {
@@ -3772,7 +3783,7 @@ export class SyncClient {
3772
3783
  ? OPTIMISTIC_VERSION
3773
3784
  : local[SYNC_VERSION_COLUMN];
3774
3785
  if (replay &&
3775
- table.indexes.some((index) => index.unique) &&
3786
+ hasUniqueIndex(table) &&
3776
3787
  uniqueConflicts(this.#db, table, Object.fromEntries(table.columns.map((column, index) => [
3777
3788
  column.name,
3778
3789
  values[index] ?? null,
@@ -3786,33 +3797,35 @@ export class SyncClient {
3786
3797
  this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
3787
3798
  }
3788
3799
  #dropRetainedRows(batch, matches) {
3789
- for (const commit of dropFailedRows(this.#db, matches)) {
3800
+ const scope = [];
3801
+ for (const commit of dropFailedRows(this.#db, this.#schema, matches)) {
3790
3802
  batch.conflicts();
3791
3803
  batch.rejections();
3792
3804
  batch.outcomes();
3793
3805
  this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
3794
3806
  this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
3795
- for (const op of commit.operations)
3807
+ for (const op of commit.operations) {
3796
3808
  batch.table(op.table);
3809
+ scope.push(JSON.stringify([op.table, op.rowId]));
3810
+ }
3797
3811
  }
3812
+ return scope;
3798
3813
  }
3799
- #replayOutbox(rowKeys) {
3800
- const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
3801
- const pending = [
3802
- ...failedOverlayCommits(this.#db, false, rowKeys),
3803
- ...listOutbox(this.#db, rowKeys),
3804
- ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
3805
- if (pending.length === 0 && acknowledged.length === 0)
3814
+ #replayOutbox(scope, restoreBases = true) {
3815
+ if (scope?.length === 0)
3806
3816
  return;
3807
3817
  this.#applyBatch((batch) => {
3808
- this.#db.transaction(() => {
3809
- restoreFailedBases(this.#db, this.#schema, false, rowKeys);
3810
- restoreFailedBases(this.#db, this.#schema, true, rowKeys);
3811
- for (const commit of [...acknowledged, ...pending])
3812
- this.#applyOperationsLocally(rowKeys === undefined
3813
- ? commit.operations
3814
- : commit.operations.filter((op) => rowKeys.includes(JSON.stringify([op.table, op.rowId]))), batch, true);
3815
- });
3818
+ if (restoreBases)
3819
+ restoreFailedBases(this.#db, this.#schema, scope);
3820
+ const acknowledged = failedOverlayCommits(this.#db, true, scope, this.#schema);
3821
+ const pending = [
3822
+ ...failedOverlayCommits(this.#db, false, scope, this.#schema),
3823
+ ...listOutbox(this.#db, scope),
3824
+ ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
3825
+ for (const commit of [...acknowledged, ...pending])
3826
+ this.#applyOperationsLocally(commit.operations.filter((op) => scope === undefined ||
3827
+ scope.includes(JSON.stringify([op.table, op.rowId])) ||
3828
+ scope.includes(JSON.stringify([op.table]))), batch, true);
3816
3829
  });
3817
3830
  }
3818
3831
  #deleteUnreferencedBlobs() {
@@ -10,6 +10,9 @@ export declare function retainedBaseWrite(db: ClientDatabase, table: CompiledCli
10
10
  export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[], commitSeq?: number): void;
11
11
  /** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
12
12
  export declare function uniqueConflicts(db: ClientDatabase, table: CompiledClientTable, values: Readonly<Record<string, RowValue>>): NonNullable<RetainedCommitRow['uniqueConflicts']>;
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[];
15
- export declare function dropFailedRows(db: ClientDatabase, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];
13
+ /** Exact row keys are JSON [table,id]; table scopes include unique-key peers. */
14
+ export type OverlayScope = readonly string[];
15
+ export declare function overlayScopePredicate(table: string, id: string): string;
16
+ export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, scope?: OverlayScope): boolean;
17
+ export declare function failedOverlayCommits(db: ClientDatabase, acknowledged?: boolean, scope?: OverlayScope, schema?: CompiledClientSchema): OutboxCommit[];
18
+ export declare function dropFailedRows(db: ClientDatabase, schema: CompiledClientSchema, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];
@@ -1,3 +1,6 @@
1
+ import { hasUniqueIndex } from './schema.js';
2
+ import { OUTCOMES_TABLE } from './outcomes.js';
3
+ import { OUTBOX_TABLE } from './outbox.js';
1
4
  export const RETAINED_ROWS = '_syncular_failed_rows';
2
5
  import { ClientSyncError } from './errors.js';
3
6
  import { mapRowValues, fromSqlValue, jsonToRowValue, quoteIdent, rowValueToJson, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
@@ -17,7 +20,7 @@ export function retainedBaseWrite(db, table, rowId, values, version) {
17
20
  table.name,
18
21
  rowId,
19
22
  ]);
20
- if (!table.indexes.some((index) => index.unique))
23
+ if (!hasUniqueIndex(table))
21
24
  return;
22
25
  for (const retained of db.query(`SELECT commit_id,idx,intent,unique_conflicts FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq IS NULL`, [table.name])) {
23
26
  const prior = retained.unique_conflicts === null
@@ -87,28 +90,54 @@ export function uniqueConflicts(db, table, values) {
87
90
  serverVersion: Number(row[SYNC_VERSION_COLUMN]),
88
91
  })));
89
92
  }
90
- function retainedRows(db, rowKeys) {
91
- return db.query(`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`, [rowKeys ? JSON.stringify(rowKeys) : null]);
93
+ export function overlayScopePredicate(table, id) {
94
+ 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))`;
92
95
  }
93
- export function restoreFailedBases(db, schema, absentIntent = false, rowKeys) {
96
+ function retainedRows(db, scope, acknowledged) {
97
+ return db.query(`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`, [scope ? JSON.stringify(scope) : null]);
98
+ }
99
+ function retainedTable(schema, name) {
100
+ const table = schema.tables.get(name);
101
+ if (!table)
102
+ throw new ClientSyncError('sync.unknown_table', 'retained table is unknown');
103
+ return table;
104
+ }
105
+ export function restoreFailedBases(db, schema, scope) {
94
106
  const seen = new Set();
95
- for (const row of retainedRows(db, rowKeys)) {
107
+ for (const row of retainedRows(db, scope)) {
96
108
  const key = JSON.stringify([row.tbl, row.id]);
97
- if (!absentIntent && seen.has(key))
109
+ if (seen.has(key))
98
110
  continue;
99
111
  seen.add(key);
100
- const table = schema.tables.get(row.tbl);
101
- if (!table)
102
- throw new ClientSyncError('sync.unknown_table', 'retained table is unknown');
103
- const json = absentIntent ? row.intent : row.base;
104
- if (absentIntent && row.intent !== null) {
105
- const values = JSON.parse(row.intent);
106
- const conflicts = uniqueConflicts(db, table, mapRowValues(values, jsonToRowValue));
107
- // Hydrate a pre-0.30.11 failure after its winner was already imported.
108
- // Pending and retained overlays cannot supply authoritative row evidence.
109
- if (row.commit_seq === null && row.unique_conflicts === null) {
110
- const authorized = conflicts.filter((conflict) => conflict.serverVersion >= 0 &&
111
- !db.query(`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`, [table.name, conflict.rowId, table.name, conflict.rowId]).length);
112
+ const table = retainedTable(schema, row.tbl);
113
+ if (row.base === null)
114
+ deleteLocalRow(db, table, row.id, false);
115
+ else {
116
+ const values = JSON.parse(String(row.base));
117
+ upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), (row.version ?? -1), false);
118
+ }
119
+ }
120
+ return seen.size > 0;
121
+ }
122
+ export function failedOverlayCommits(db, acknowledged = false, scope, schema) {
123
+ const commits = new Map();
124
+ for (const row of retainedRows(db, scope, acknowledged)) {
125
+ const id = row.commit_id;
126
+ let operation = JSON.parse(row.op);
127
+ if (schema && operation.op === 'upsert') {
128
+ const table = retainedTable(schema, operation.table);
129
+ if (acknowledged) {
130
+ if (!db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId]).length)
131
+ operation = {
132
+ ...operation,
133
+ values: JSON.parse(row.intent),
134
+ };
135
+ }
136
+ else if (row.unique_conflicts === null && row.intent !== null) {
137
+ // Hydrate legacy failure evidence from authoritative unique-key peers.
138
+ const values = JSON.parse(row.intent);
139
+ const authorized = uniqueConflicts(db, table, mapRowValues(values, jsonToRowValue)).filter((conflict) => conflict.serverVersion >= 0 &&
140
+ !db.query(`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`, [table.name, conflict.rowId, table.name, conflict.rowId]).length);
112
141
  if (authorized.length)
113
142
  db.exec(`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`, [
114
143
  JSON.stringify(authorized.map((conflict) => ({
@@ -119,35 +148,7 @@ export function restoreFailedBases(db, schema, absentIntent = false, rowKeys) {
119
148
  row.idx,
120
149
  ]);
121
150
  }
122
- if (conflicts.length)
123
- continue;
124
- }
125
- if (absentIntent &&
126
- (json === null ||
127
- db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [row.id]).length))
128
- continue;
129
- if (json === null)
130
- deleteLocalRow(db, table, row.id, false);
131
- else {
132
- const values = JSON.parse(String(json));
133
- if (absentIntent) {
134
- const operation = JSON.parse(row.op);
135
- if (row.commit_seq === null &&
136
- !table.columns.every((column) => Object.hasOwn(operation.values ?? {}, column.name)))
137
- continue;
138
- }
139
- upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), (row.version ?? -1), false);
140
151
  }
141
- }
142
- return seen.size > 0;
143
- }
144
- export function failedOverlayCommits(db, acknowledged = false, rowKeys) {
145
- const commits = new Map();
146
- for (const row of retainedRows(db, rowKeys)) {
147
- if ((row.commit_seq !== null) !== acknowledged)
148
- continue;
149
- const id = row.commit_id;
150
- const operation = JSON.parse(row.op);
151
152
  const prior = commits.get(id);
152
153
  if (prior)
153
154
  prior.operations.push(operation);
@@ -161,7 +162,7 @@ export function failedOverlayCommits(db, acknowledged = false, rowKeys) {
161
162
  }
162
163
  return [...commits.values()];
163
164
  }
164
- export function dropFailedRows(db, matches) {
165
+ export function dropFailedRows(db, schema, matches) {
165
166
  const ids = new Set();
166
167
  for (const row of retainedRows(db)) {
167
168
  for (const json of [row.intent, row.base]) {
@@ -176,9 +177,10 @@ export function dropFailedRows(db, matches) {
176
177
  ...failedOverlayCommits(db),
177
178
  ...failedOverlayCommits(db, true),
178
179
  ].filter((commit) => ids.has(commit.clientCommitId));
180
+ restoreFailedBases(db, schema, dropped.flatMap((commit) => commit.operations.map((op) => JSON.stringify([op.table, op.rowId]))));
179
181
  for (const id of ids) {
180
182
  db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
181
- db.exec("UPDATE _syncular_commit_outcomes SET operations=NULL,results='[]' WHERE client_commit_id=?", [id]);
183
+ db.exec(`UPDATE ${OUTCOMES_TABLE} SET operations=NULL,results='[]' WHERE client_commit_id=?`, [id]);
182
184
  }
183
185
  return dropped;
184
186
  }
package/dist/outbox.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ export declare const OUTBOX_TABLE = "_syncular_outbox";
2
+ export declare const OUTBOX_IMAGES = "_syncular_outbox_before_images";
3
+ import { type OverlayScope } from './failed-overlay.js';
1
4
  /**
2
5
  * The durable outbox (SPEC.md §7.1) with encode-at-send (the §0 binary-push
3
6
  * outbox rule): local mutations are persisted in a schema-agnostic JSON
@@ -40,7 +43,7 @@ export interface OutboxBeforeImage {
40
43
  }
41
44
  export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number, beforeImages?: readonly OutboxBeforeImage[]): void;
42
45
  /** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
43
- export declare function listOutbox(db: ClientDatabase, rowKeys?: readonly string[]): OutboxCommit[];
46
+ export declare function listOutbox(db: ClientDatabase, scope?: OverlayScope): OutboxCommit[];
44
47
  /** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
45
48
  export declare function iterateOutbox(db: ClientDatabase, throughSeq: number): Generator<OutboxCommit>;
46
49
  /** Routine status reads never load operation bodies. */