@syncular/client 0.30.12 → 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/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';
@@ -133,6 +137,7 @@ export class SyncClient {
133
137
  /** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
134
138
  #previousVersion;
135
139
  #securityLifecycle;
140
+ #transportEnabled;
136
141
  #now;
137
142
  #outcomeRetentionMaxEntries;
138
143
  #retainFailedCommits;
@@ -221,6 +226,11 @@ export class SyncClient {
221
226
  #protectedAsync = new Set();
222
227
  #preflightBarrier;
223
228
  constructor(config) {
229
+ if (config.transportEnabled !== undefined &&
230
+ typeof config.transportEnabled !== 'boolean') {
231
+ throw invalidRequest('transportEnabled must be a boolean');
232
+ }
233
+ this.#transportEnabled = config.transportEnabled !== false;
224
234
  if (config.securityPreflight === true && config.encryption !== undefined) {
225
235
  throw invalidRequest('securityPreflight and encryption are mutually exclusive; activateSecurity installs keys');
226
236
  }
@@ -472,6 +482,13 @@ export class SyncClient {
472
482
  }
473
483
  /** §2.1 reset after the server reports a different log continuity. */
474
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
+ }
475
492
  const subscriptions = loadSubscriptions(this.#db);
476
493
  const pending = listOutbox(this.#db);
477
494
  this.#setUpgrading(true);
@@ -537,6 +554,8 @@ export class SyncClient {
537
554
  this.#syncIntentListeners.clear();
538
555
  }
539
556
  #emitSyncNeeded(reason) {
557
+ if (!this.#transportEnabled)
558
+ return;
540
559
  try {
541
560
  this.#config.onSyncNeeded?.(reason);
542
561
  }
@@ -553,6 +572,8 @@ export class SyncClient {
553
572
  }
554
573
  }
555
574
  #emitSyncIntent(intent) {
575
+ if (!this.#transportEnabled && intent.kind !== 'none')
576
+ return;
556
577
  try {
557
578
  this.#config.onSyncIntent?.(intent);
558
579
  }
@@ -568,6 +589,25 @@ export class SyncClient {
568
589
  }
569
590
  }
570
591
  }
592
+ /** Host-owned transport policy; the local replica remains available. */
593
+ setTransportEnabled(enabled) {
594
+ this.#requireStarted();
595
+ if (typeof enabled !== 'boolean')
596
+ throw invalidRequest('transportEnabled must be a boolean');
597
+ if (this.#transportEnabled === enabled)
598
+ return;
599
+ this.#transportEnabled = enabled;
600
+ if (!enabled && !this.#syncOutstanding)
601
+ this.disconnectRealtime();
602
+ this.#emitSyncIntent(enabled && this.#securityLifecycle === 'active'
603
+ ? { kind: 'interactive' }
604
+ : { kind: 'none' });
605
+ this.#emitDiagnostics();
606
+ }
607
+ #requireTransport() {
608
+ if (!this.#transportEnabled)
609
+ throw new ClientSyncError('sync.offline', 'transport gate is closed');
610
+ }
571
611
  /** Current fail-closed local-replica security state. */
572
612
  securityLifecycle() {
573
613
  return this.#securityLifecycle;
@@ -1028,12 +1068,14 @@ export class SyncClient {
1028
1068
  }
1029
1069
  const capturedAtMs = this.#now();
1030
1070
  const leaseState = this.#diagnosticLease(capturedAtMs);
1031
- const connectivity = this.#lastRound?.status === 'succeeded'
1032
- ? 'online'
1033
- : this.#lastRound?.status === 'failed' &&
1034
- this.#transportFailureCode(this.#lastRound.errorCode)
1035
- ? 'offline'
1036
- : 'unknown';
1071
+ const connectivity = !this.#transportEnabled
1072
+ ? 'offline'
1073
+ : this.#lastRound?.status === 'succeeded'
1074
+ ? 'online'
1075
+ : this.#lastRound?.status === 'failed' &&
1076
+ this.#transportFailureCode(this.#lastRound.errorCode)
1077
+ ? 'offline'
1078
+ : 'unknown';
1037
1079
  const expectedOrder = new Map(expected.map((item, index) => [item.id, index]));
1038
1080
  const allSubscriptions = [...subscriptions.values()].sort((a, b) => {
1039
1081
  const aExpected = expectedOrder.get(a.id);
@@ -1057,16 +1099,12 @@ export class SyncClient {
1057
1099
  realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
1058
1100
  }),
1059
1101
  securityLifecycle: this.#securityLifecycle,
1060
- schema: {
1102
+ schema: definedObject({
1061
1103
  currentVersion: this.#config.schema.version,
1062
1104
  upgrading: this.#upgrading,
1063
- ...(this.#schemaFloor?.requiredSchemaVersion !== undefined
1064
- ? { requiredVersion: this.#schemaFloor.requiredSchemaVersion }
1065
- : {}),
1066
- ...(this.#schemaFloor?.latestSchemaVersion !== undefined
1067
- ? { latestVersion: this.#schemaFloor.latestSchemaVersion }
1068
- : {}),
1069
- },
1105
+ requiredVersion: this.#schemaFloor?.requiredSchemaVersion,
1106
+ latestVersion: this.#schemaFloor?.latestSchemaVersion,
1107
+ }),
1070
1108
  replica: {
1071
1109
  localRevision: getLocalRevision(this.#db).toString(),
1072
1110
  syncNeeded: this.#needsPull,
@@ -1101,8 +1139,8 @@ export class SyncClient {
1101
1139
  try {
1102
1140
  const pageCount = Number(this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0);
1103
1141
  const pageSize = Number(this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0);
1104
- const outboxBytes = Number(this.#db.query('SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM _syncular_outbox')[0]?.bytes ?? 0);
1105
- 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];
1106
1144
  const blobBytes = this.#hasBlobs
1107
1145
  ? Number(this.#db.query('SELECT COALESCE(SUM(byte_length),0)AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
1108
1146
  : 0;
@@ -1246,7 +1284,11 @@ export class SyncClient {
1246
1284
  this.#requireActive();
1247
1285
  const task = Promise.resolve().then(fn);
1248
1286
  this.#protectedAsync.add(task);
1249
- void task.then(() => this.#protectedAsync.delete(task), () => this.#protectedAsync.delete(task));
1287
+ const settled = () => {
1288
+ if (this.#realtimeConnectPromise === task)
1289
+ this.#realtimeConnectPromise = undefined;
1290
+ };
1291
+ void task.then(settled, settled);
1250
1292
  return task;
1251
1293
  }
1252
1294
  // -- blobs (§5.9) ---------------------------------------------------------
@@ -1272,14 +1314,12 @@ export class SyncClient {
1272
1314
  recordPendingUpload(this.#db, blobId, nowMs, options?.mediaType);
1273
1315
  });
1274
1316
  this.#enforceBlobCacheCap();
1275
- return {
1317
+ return definedObject({
1276
1318
  blobId,
1277
1319
  byteLength: bytes.length,
1278
- ...(options?.mediaType !== undefined
1279
- ? { mediaType: options.mediaType }
1280
- : {}),
1281
- ...(options?.name !== undefined ? { name: options.name } : {}),
1282
- };
1320
+ mediaType: options?.mediaType,
1321
+ name: options?.name,
1322
+ });
1283
1323
  }
1284
1324
  /** Serialize a BlobRef to the canonical string a `blob_ref` column holds. */
1285
1325
  blobRefString(ref) {
@@ -1309,6 +1349,7 @@ export class SyncClient {
1309
1349
  // presign configured) a signed url the client fetches directly. On a url
1310
1350
  // arm the client MUST NOT attach host auth and MUST NOT fall through:
1311
1351
  // failure => re-request, the caller's next fetchBlob mints a fresh url.
1352
+ this.#requireTransport();
1312
1353
  const response = await transport.download(blobId);
1313
1354
  let bytes;
1314
1355
  if (response.kind === 'url') {
@@ -1350,6 +1391,7 @@ export class SyncClient {
1350
1391
  }
1351
1392
  /** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
1352
1393
  flushBlobUploads() {
1394
+ this.#requireTransport();
1353
1395
  return this.#runProtectedAsync(() => this.#flushBlobUploads());
1354
1396
  }
1355
1397
  async #flushBlobUploads() {
@@ -1443,12 +1485,9 @@ export class SyncClient {
1443
1485
  else if (replacement !== undefined) {
1444
1486
  throw invalidRequest('replacementClientCommitId is valid only for superseded outcomes');
1445
1487
  }
1446
- const allowed = (current.status === 'conflict' &&
1488
+ const allowed = ((current.status === 'conflict' || current.status === 'rejected') &&
1447
1489
  (input.resolution === 'resolved_keep_server' ||
1448
1490
  input.resolution === 'superseded')) ||
1449
- (current.status === 'rejected' &&
1450
- (input.resolution === 'superseded' ||
1451
- input.resolution === 'resolved_keep_server')) ||
1452
1491
  ((current.status === 'applied' || current.status === 'cached') &&
1453
1492
  input.resolution === 'dismissed');
1454
1493
  if (!allowed) {
@@ -1529,6 +1568,7 @@ export class SyncClient {
1529
1568
  */
1530
1569
  setPresence(scopeKey, doc) {
1531
1570
  this.#requireActive();
1571
+ this.#requireTransport();
1532
1572
  const socket = this.#socket;
1533
1573
  if (socket === undefined) {
1534
1574
  throw invalidRequest('setPresence requires a connected realtime socket (§8.6)');
@@ -1667,7 +1707,9 @@ export class SyncClient {
1667
1707
  }
1668
1708
  });
1669
1709
  const effects = {
1670
- sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
1710
+ sync: this.#transportEnabled && (changed || widened)
1711
+ ? { kind: 'interactive' }
1712
+ : { kind: 'none' },
1671
1713
  };
1672
1714
  if (effects.sync.kind === 'interactive') {
1673
1715
  this.#emitSyncIntent(effects.sync);
@@ -1894,7 +1936,7 @@ export class SyncClient {
1894
1936
  catch {
1895
1937
  continue;
1896
1938
  }
1897
- 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]);
1898
1940
  }
1899
1941
  }
1900
1942
  try {
@@ -1922,7 +1964,9 @@ export class SyncClient {
1922
1964
  mutateCommand(mutations) {
1923
1965
  return {
1924
1966
  value: this.mutate(mutations),
1925
- effects: { sync: { kind: 'interactive' } },
1967
+ effects: {
1968
+ sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
1969
+ },
1926
1970
  };
1927
1971
  }
1928
1972
  /**
@@ -1943,17 +1987,15 @@ export class SyncClient {
1943
1987
  throw invalidRequest(`table ${compiled.name}: patch cannot change the primary key`);
1944
1988
  }
1945
1989
  return this.#recordMutations([
1946
- {
1990
+ definedObject({
1947
1991
  table,
1948
1992
  op: 'patch',
1949
1993
  values: {
1950
1994
  ...Object.fromEntries(normalized),
1951
1995
  [pkColumn.name]: rowId,
1952
1996
  },
1953
- ...(options?.baseVersion !== undefined
1954
- ? { baseVersion: options.baseVersion }
1955
- : {}),
1956
- },
1997
+ baseVersion: options?.baseVersion,
1998
+ }),
1957
1999
  ]);
1958
2000
  }
1959
2001
  /**
@@ -2187,7 +2229,9 @@ export class SyncClient {
2187
2229
  patchCommand(table, rowId, partial, options) {
2188
2230
  return {
2189
2231
  value: this.patch(table, rowId, partial, options),
2190
- effects: { sync: { kind: 'interactive' } },
2232
+ effects: {
2233
+ sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
2234
+ },
2191
2235
  };
2192
2236
  }
2193
2237
  // -- lease state (§7.3.5) ---------------------------------------------------
@@ -2217,7 +2261,7 @@ export class SyncClient {
2217
2261
  async #encodeOutboxForPush() {
2218
2262
  // Pin before the first encryption await: mutations can append while a
2219
2263
  // round is encoding, and belong to the next request.
2220
- 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];
2221
2265
  const pendingCount = bounds.count;
2222
2266
  const throughSeq = bounds.last_seq ?? 0;
2223
2267
  const pushFrames = [];
@@ -2282,16 +2326,14 @@ export class SyncClient {
2282
2326
  retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
2283
2327
  this.#rollbackFailedCommit(commit, batch);
2284
2328
  this.#replayOutbox();
2285
- const rejection = {
2329
+ const rejection = definedObject({
2286
2330
  clientCommitId: commit.clientCommitId,
2287
2331
  opIndex: 0,
2288
2332
  code,
2289
2333
  message,
2290
2334
  retryable: false,
2291
- ...(commit.operations[0] !== undefined
2292
- ? { operation: commit.operations[0] }
2293
- : {}),
2294
- };
2335
+ operation: commit.operations[0],
2336
+ });
2295
2337
  this.#rejections.push(rejection);
2296
2338
  recordCommitOutcome(this.#db, {
2297
2339
  clientCommitId: commit.clientCommitId,
@@ -2319,7 +2361,7 @@ export class SyncClient {
2319
2361
  sync() {
2320
2362
  this.#requireActive();
2321
2363
  if (this.#syncOutstanding) {
2322
- return Promise.reject(invalidRequest('sync() is already running — the core owns one loop (coalesce wake-ups)'));
2364
+ return Promise.reject(invalidRequest('sync() is already running; coalesce wake-ups'));
2323
2365
  }
2324
2366
  this.#syncOutstanding = true;
2325
2367
  this.#beginDiagnosticsDeferral();
@@ -2361,6 +2403,8 @@ export class SyncClient {
2361
2403
  })
2362
2404
  .finally(() => {
2363
2405
  this.#syncOutstanding = false;
2406
+ if (!this.#transportEnabled)
2407
+ this.disconnectRealtime();
2364
2408
  this.#endDiagnosticsDeferral();
2365
2409
  });
2366
2410
  }
@@ -2393,6 +2437,7 @@ export class SyncClient {
2393
2437
  : 'client.unknown_failure';
2394
2438
  }
2395
2439
  async #runSync() {
2440
+ this.#requireTransport();
2396
2441
  this.#roundRetryDelayMs = undefined;
2397
2442
  this.#progress.emit({
2398
2443
  attempt: ++this.#progressAttempt,
@@ -2422,7 +2467,7 @@ export class SyncClient {
2422
2467
  if (logEpoch !== undefined &&
2423
2468
  this.#hasBlobs &&
2424
2469
  this.#config.blobs !== undefined) {
2425
- await this.flushBlobUploads();
2470
+ await this.#flushBlobUploads();
2426
2471
  }
2427
2472
  // §7.4.4: encode the outbox with the CURRENT codec; a commit that
2428
2473
  // cannot express itself under the new schema (a dropped column/table)
@@ -2538,6 +2583,8 @@ export class SyncClient {
2538
2583
  for (let round = 0; round < budget; round++) {
2539
2584
  const before = JSON.stringify(loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]));
2540
2585
  last = await this.sync();
2586
+ if (!this.#transportEnabled || last.schemaFloor !== undefined)
2587
+ return last;
2541
2588
  if (maxRounds === undefined &&
2542
2589
  last.segmentRowsApplied > 0 &&
2543
2590
  last.bootstrapping.length > 0 &&
@@ -2547,8 +2594,6 @@ export class SyncClient {
2547
2594
  sub.bootstrapState,
2548
2595
  ])))
2549
2596
  round = -1;
2550
- if (last.schemaFloor !== undefined)
2551
- return last;
2552
2597
  if (last.commitsApplied === 0 &&
2553
2598
  last.segmentRowsApplied === 0 &&
2554
2599
  last.bootstrapping.length === 0 &&
@@ -2640,6 +2685,7 @@ export class SyncClient {
2640
2685
  }
2641
2686
  connectRealtime() {
2642
2687
  this.#requireActive();
2688
+ this.#requireTransport();
2643
2689
  if (this.#realtimePolicy === 'off') {
2644
2690
  return Promise.reject(invalidRequest("realtimePolicy is 'off'; realtime connect is refused"));
2645
2691
  }
@@ -2695,9 +2741,13 @@ export class SyncClient {
2695
2741
  },
2696
2742
  });
2697
2743
  openedSocket = socket;
2744
+ if (!this.#transportEnabled) {
2745
+ socket.close();
2746
+ this.#requireTransport();
2747
+ }
2698
2748
  if (this.#securityLifecycle === 'preflight') {
2699
2749
  socket.close();
2700
- throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'realtime connected after the client entered security preflight');
2750
+ this.#requireActive();
2701
2751
  }
2702
2752
  if (generation !== this.#realtimeGeneration || !this.#started) {
2703
2753
  socket.close();
@@ -2834,6 +2884,8 @@ export class SyncClient {
2834
2884
  }
2835
2885
  }
2836
2886
  async #handleRealtimeBinary(bytes) {
2887
+ if (!this.#transportEnabled)
2888
+ return;
2837
2889
  if (this.#syncing) {
2838
2890
  // Fast path: a pull is mid-flight; let it win and recover the gap
2839
2891
  // itself — re-pulling is idempotent, interleaved application is not
@@ -2862,7 +2914,8 @@ export class SyncClient {
2862
2914
  });
2863
2915
  }
2864
2916
  #sendAck(cursor) {
2865
- this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
2917
+ if (this.#transportEnabled)
2918
+ this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
2866
2919
  }
2867
2920
  /** Ack the highest cursor that is contiguously applied for every sub. */
2868
2921
  #ackAfterPull() {
@@ -3017,58 +3070,24 @@ export class SyncClient {
3017
3070
  await this.#applyCommit(frame, summary);
3018
3071
  }
3019
3072
  break;
3020
- case 'SEGMENT_INLINE': {
3021
- if (section === undefined ||
3022
- section.skip ||
3023
- section.sub === undefined) {
3024
- break;
3025
- }
3026
- const segment = decodeRowsSegment(frame.payload);
3027
- this.#progress.update({
3028
- phase: 'import',
3029
- subscriptionId: section.sub.id,
3030
- table: section.sub.table,
3031
- segmentId: undefined,
3032
- bytesReceived: frame.payload.byteLength,
3033
- bytesTotal: frame.payload.byteLength,
3034
- rowsProcessed: 0,
3035
- rowsTotal: segment.blocks.reduce((n, block) => n + block.length, 0),
3036
- });
3037
- await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
3038
- clearFirst,
3039
- isCurrent: () => this.#localResetEpoch === resetEpoch,
3040
- onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
3041
- effective,
3042
- transaction: (fn) => this.#applyBatch((batch) => {
3043
- if (segment.blocks.some((block) => block.length > 0) ||
3044
- (clearFirst &&
3045
- this.#scopedRowsExist(table, effective))) {
3046
- batch.table(table.name);
3047
- }
3048
- const result = fn();
3049
- this.#replayOutbox();
3050
- return result;
3051
- }),
3052
- }, this.#encryption), section.fresh && !section.cleared);
3053
- break;
3054
- }
3073
+ case 'SEGMENT_INLINE':
3055
3074
  case 'SEGMENT_REF': {
3056
3075
  if (section === undefined ||
3057
3076
  section.skip ||
3058
- section.sub === undefined) {
3077
+ section.sub === undefined)
3059
3078
  break;
3060
- }
3061
- // §4.2: a descriptor whose mediaType was not advertised is a
3062
- // broken server — fail loud, never skip or guess.
3063
- if (frame.mediaType === 'sqlite' &&
3079
+ if (frame.type === 'SEGMENT_REF' &&
3080
+ frame.mediaType === 'sqlite' &&
3064
3081
  (this.#acceptMask() & ACCEPT_SQLITE) === 0) {
3065
3082
  throw invalidRequest('SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
3066
3083
  }
3067
- const bytes = await this.#downloadSegment(frame, section.sub);
3068
- this.#progress.update({ phase: 'import' });
3069
- if (frame.mediaType === 'sqlite') {
3070
- // §5.3: images are whole-table — a paged descriptor is
3071
- // invalid, and the image is always its table's first page.
3084
+ const bytes = frame.type === 'SEGMENT_REF'
3085
+ ? await this.#downloadSegment(frame, section.sub)
3086
+ : frame.payload;
3087
+ if (frame.type === 'SEGMENT_REF')
3088
+ this.#progress.update({ phase: 'import' });
3089
+ if (frame.type === 'SEGMENT_REF' && frame.mediaType === 'sqlite') {
3090
+ // Images are whole-table; paged image descriptors are invalid.
3072
3091
  if (frame.rowCursor !== undefined ||
3073
3092
  frame.nextRowCursor !== undefined) {
3074
3093
  throw invalidRequest('sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
@@ -3078,43 +3097,26 @@ export class SyncClient {
3078
3097
  rowCount: frame.rowCount,
3079
3098
  asOfCommitSeq: frame.asOfCommitSeq,
3080
3099
  scopeDigest: frame.scopeDigest,
3081
- }, {
3082
- clearFirst,
3083
- isCurrent: () => this.#localResetEpoch === resetEpoch,
3084
- onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
3085
- effective,
3086
- transaction: (fn) => this.#applyBatch((batch) => {
3087
- if (frame.rowCount > 0 ||
3088
- (clearFirst &&
3089
- this.#scopedRowsExist(table, effective))) {
3090
- batch.table(table.name);
3091
- }
3092
- const result = fn();
3093
- this.#replayOutbox();
3094
- return result;
3095
- }),
3096
- }), section.fresh && !section.cleared);
3100
+ }, this.#segmentApplyOptions(table, clearFirst, effective, frame.rowCount > 0, resetEpoch)), section.fresh && !section.cleared);
3097
3101
  }
3098
3102
  else {
3099
3103
  const segment = decodeRowsSegment(bytes);
3100
- await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
3101
- clearFirst,
3102
- isCurrent: () => this.#localResetEpoch === resetEpoch,
3103
- onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
3104
- effective,
3105
- transaction: (fn) => this.#applyBatch((batch) => {
3106
- if (segment.blocks.some((block) => block.length > 0) ||
3107
- (clearFirst &&
3108
- this.#scopedRowsExist(table, effective))) {
3109
- batch.table(table.name);
3110
- }
3111
- const result = fn();
3112
- this.#replayOutbox();
3113
- return result;
3114
- }),
3115
- }, this.#encryption), section.fresh &&
3104
+ if (frame.type === 'SEGMENT_INLINE') {
3105
+ this.#progress.update({
3106
+ phase: 'import',
3107
+ subscriptionId: section.sub.id,
3108
+ table: section.sub.table,
3109
+ segmentId: undefined,
3110
+ bytesReceived: bytes.byteLength,
3111
+ bytesTotal: bytes.byteLength,
3112
+ rowsProcessed: 0,
3113
+ rowsTotal: segment.blocks.reduce((n, block) => n + block.length, 0),
3114
+ });
3115
+ }
3116
+ await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, this.#segmentApplyOptions(table, clearFirst, effective, segment.blocks.some((block) => block.length > 0), resetEpoch), this.#encryption), section.fresh &&
3116
3117
  !section.cleared &&
3117
- frame.rowCursor === undefined);
3118
+ (frame.type === 'SEGMENT_INLINE' ||
3119
+ frame.rowCursor === undefined));
3118
3120
  }
3119
3121
  break;
3120
3122
  }
@@ -3149,14 +3151,7 @@ export class SyncClient {
3149
3151
  }
3150
3152
  }
3151
3153
  finally {
3152
- try {
3153
- // §7.1: local reads see outbox state applied optimistically — replay
3154
- // the still-pending commits on top of the freshly applied server state.
3155
- this.#replayOutbox();
3156
- }
3157
- finally {
3158
- this.#endDiagnosticsDeferral();
3159
- }
3154
+ this.#endDiagnosticsDeferral();
3160
3155
  }
3161
3156
  if (errorFrame !== undefined)
3162
3157
  throw errorFrame;
@@ -3285,13 +3280,16 @@ export class SyncClient {
3285
3280
  if (this.#retainFailedCommits)
3286
3281
  retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
3287
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])));
3288
3286
  });
3289
3287
  batch.status();
3290
3288
  summary.rejected.push(frame.clientCommitId);
3291
3289
  return true;
3292
3290
  }
3293
3291
  #outboxCommitExists(clientCommitId) {
3294
- 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);
3295
3293
  }
3296
3294
  #decodeServerRow(tableName, payload) {
3297
3295
  if (tableName === undefined)
@@ -3329,7 +3327,7 @@ export class SyncClient {
3329
3327
  await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
3330
3328
  this.#recordCommitChanges(batch, frame);
3331
3329
  const result = fn();
3332
- this.#replayOutbox(result);
3330
+ this.#replayOutbox(result, false);
3333
3331
  return result;
3334
3332
  }));
3335
3333
  summary.commitsApplied += 1;
@@ -3378,6 +3376,23 @@ export class SyncClient {
3378
3376
  return (scope !== undefined &&
3379
3377
  this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`, scope.params).length > 0);
3380
3378
  }
3379
+ /** Shared transaction, reset fence and progress for both segment formats. */
3380
+ #segmentApplyOptions(table, clearFirst, effective, hasRows, resetEpoch) {
3381
+ return {
3382
+ clearFirst,
3383
+ effective,
3384
+ isCurrent: () => this.#localResetEpoch === resetEpoch,
3385
+ onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
3386
+ transaction: (fn) => this.#applyBatch((batch) => {
3387
+ if (hasRows ||
3388
+ (clearFirst && this.#scopedRowsExist(table, effective)))
3389
+ batch.table(table.name);
3390
+ const result = fn();
3391
+ this.#replayOutbox(result, false);
3392
+ return result;
3393
+ }),
3394
+ };
3395
+ }
3381
3396
  /**
3382
3397
  * Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
3383
3398
  * (no local scope-column mapping) marks the subscription `failed` and
@@ -3505,11 +3520,12 @@ export class SyncClient {
3505
3520
  return matchesLocalScopes(table, effective, values);
3506
3521
  }));
3507
3522
  if (covered.length) {
3508
- 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);
3509
3525
  for (const row of covered)
3510
3526
  this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [sub.table, String(row.id), nextCursor]);
3511
3527
  batch.table(sub.table);
3512
- this.#replayOutbox();
3528
+ this.#replayOutbox(scope, false);
3513
3529
  }
3514
3530
  }
3515
3531
  if (completed && registered !== undefined) {
@@ -3552,9 +3568,11 @@ export class SyncClient {
3552
3568
  if (lastEffective !== undefined &&
3553
3569
  Object.keys(lastEffective).length > 0) {
3554
3570
  try {
3555
- restoreFailedBases(this.#db, this.#schema);
3556
- this.#dropRetainedRows(batch, (name, values) => name === table.name &&
3557
- 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
+ ];
3558
3576
  deleteScopedRows(this.#db, table, lastEffective);
3559
3577
  batch.scopeMap(table, lastEffective);
3560
3578
  const pendingById = new Map(listOutbox(this.#db).map((commit) => [
@@ -3592,6 +3610,7 @@ export class SyncClient {
3592
3610
  batch.rejections();
3593
3611
  batch.outcomes();
3594
3612
  }
3613
+ this.#replayOutbox(scope);
3595
3614
  this.#deleteUnreferencedBlobs();
3596
3615
  }
3597
3616
  catch (error) {
@@ -3764,7 +3783,7 @@ export class SyncClient {
3764
3783
  ? OPTIMISTIC_VERSION
3765
3784
  : local[SYNC_VERSION_COLUMN];
3766
3785
  if (replay &&
3767
- table.indexes.some((index) => index.unique) &&
3786
+ hasUniqueIndex(table) &&
3768
3787
  uniqueConflicts(this.#db, table, Object.fromEntries(table.columns.map((column, index) => [
3769
3788
  column.name,
3770
3789
  values[index] ?? null,
@@ -3778,33 +3797,35 @@ export class SyncClient {
3778
3797
  this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
3779
3798
  }
3780
3799
  #dropRetainedRows(batch, matches) {
3781
- for (const commit of dropFailedRows(this.#db, matches)) {
3800
+ const scope = [];
3801
+ for (const commit of dropFailedRows(this.#db, this.#schema, matches)) {
3782
3802
  batch.conflicts();
3783
3803
  batch.rejections();
3784
3804
  batch.outcomes();
3785
3805
  this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
3786
3806
  this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
3787
- for (const op of commit.operations)
3807
+ for (const op of commit.operations) {
3788
3808
  batch.table(op.table);
3809
+ scope.push(JSON.stringify([op.table, op.rowId]));
3810
+ }
3789
3811
  }
3812
+ return scope;
3790
3813
  }
3791
- #replayOutbox(rowKeys) {
3792
- const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
3793
- const pending = [
3794
- ...failedOverlayCommits(this.#db, false, rowKeys),
3795
- ...listOutbox(this.#db, rowKeys),
3796
- ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
3797
- if (pending.length === 0 && acknowledged.length === 0)
3814
+ #replayOutbox(scope, restoreBases = true) {
3815
+ if (scope?.length === 0)
3798
3816
  return;
3799
3817
  this.#applyBatch((batch) => {
3800
- this.#db.transaction(() => {
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);
3807
- });
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);
3808
3829
  });
3809
3830
  }
3810
3831
  #deleteUnreferencedBlobs() {