@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/src/client.ts CHANGED
@@ -1,9 +1,14 @@
1
+ import { BLOB_COMMIT_REFS } from './blob';
2
+ import { hasUniqueIndex } from './schema';
3
+ import { OUTCOMES_TABLE } from './outcomes';
4
+ import { OUTBOX_TABLE } from './outbox';
1
5
  import { RETAINED_ROWS } from './failed-overlay';
2
6
  import {
3
7
  dropFailedRows,
4
8
  failedOverlayCommits,
5
9
  retainFailedRows,
6
10
  restoreFailedBases,
11
+ type OverlayScope,
7
12
  uniqueConflicts,
8
13
  } from './failed-overlay';
9
14
  import {
@@ -349,6 +354,7 @@ export interface SyncClientLimits {
349
354
  }
350
355
 
351
356
  export interface SyncClientConfig {
357
+ readonly transportEnabled?: boolean;
352
358
  /** Keep rejected/conflicting local intent visible until explicit resolution. */
353
359
  readonly retainFailedCommits?: boolean;
354
360
  readonly database: ClientDatabase;
@@ -747,6 +753,7 @@ export class SyncClient {
747
753
  /** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
748
754
  readonly #previousVersion: PreviousVersionCaptureConfig | undefined;
749
755
  #securityLifecycle: SecurityLifecycle;
756
+ #transportEnabled: boolean;
750
757
  readonly #now: () => number;
751
758
  readonly #outcomeRetentionMaxEntries: number;
752
759
  readonly #retainFailedCommits: boolean;
@@ -838,6 +845,13 @@ export class SyncClient {
838
845
  #preflightBarrier: Promise<void> | undefined;
839
846
 
840
847
  constructor(config: SyncClientConfig) {
848
+ if (
849
+ config.transportEnabled !== undefined &&
850
+ typeof config.transportEnabled !== 'boolean'
851
+ ) {
852
+ throw invalidRequest('transportEnabled must be a boolean');
853
+ }
854
+ this.#transportEnabled = config.transportEnabled !== false;
841
855
  if (config.securityPreflight === true && config.encryption !== undefined) {
842
856
  throw invalidRequest(
843
857
  'securityPreflight and encryption are mutually exclusive; activateSecurity installs keys',
@@ -1159,6 +1173,13 @@ export class SyncClient {
1159
1173
 
1160
1174
  /** §2.1 reset after the server reports a different log continuity. */
1161
1175
  #runLogEpochReset(logEpoch: string): string[] {
1176
+ if (getMeta(this.#db, LOG_EPOCH_META_KEY) === undefined) {
1177
+ this.#applyBatch(() => setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch));
1178
+ this.#setSyncNeeded(true);
1179
+ this.#emitSyncNeeded('startup');
1180
+ this.#emitSyncIntent({ kind: 'interactive' });
1181
+ return [];
1182
+ }
1162
1183
  const subscriptions = loadSubscriptions(this.#db);
1163
1184
  const pending = listOutbox(this.#db);
1164
1185
  this.#setUpgrading(true);
@@ -1225,6 +1246,7 @@ export class SyncClient {
1225
1246
  }
1226
1247
 
1227
1248
  #emitSyncNeeded(reason: 'startup' | 'hello' | WakeReason): void {
1249
+ if (!this.#transportEnabled) return;
1228
1250
  try {
1229
1251
  this.#config.onSyncNeeded?.(reason);
1230
1252
  } catch {
@@ -1240,6 +1262,7 @@ export class SyncClient {
1240
1262
  }
1241
1263
 
1242
1264
  #emitSyncIntent(intent: SyncIntent): void {
1265
+ if (!this.#transportEnabled && intent.kind !== 'none') return;
1243
1266
  try {
1244
1267
  this.#config.onSyncIntent?.(intent);
1245
1268
  } catch {
@@ -1254,6 +1277,27 @@ export class SyncClient {
1254
1277
  }
1255
1278
  }
1256
1279
 
1280
+ /** Host-owned transport policy; the local replica remains available. */
1281
+ setTransportEnabled(enabled: boolean): void {
1282
+ this.#requireStarted();
1283
+ if (typeof enabled !== 'boolean')
1284
+ throw invalidRequest('transportEnabled must be a boolean');
1285
+ if (this.#transportEnabled === enabled) return;
1286
+ this.#transportEnabled = enabled;
1287
+ if (!enabled && !this.#syncOutstanding) this.disconnectRealtime();
1288
+ this.#emitSyncIntent(
1289
+ enabled && this.#securityLifecycle === 'active'
1290
+ ? { kind: 'interactive' }
1291
+ : { kind: 'none' },
1292
+ );
1293
+ this.#emitDiagnostics();
1294
+ }
1295
+
1296
+ #requireTransport(): void {
1297
+ if (!this.#transportEnabled)
1298
+ throw new ClientSyncError('sync.offline', 'transport gate is closed');
1299
+ }
1300
+
1257
1301
  /** Current fail-closed local-replica security state. */
1258
1302
  securityLifecycle(): SecurityLifecycle {
1259
1303
  return this.#securityLifecycle;
@@ -1799,8 +1843,9 @@ export class SyncClient {
1799
1843
  }
1800
1844
  const capturedAtMs = this.#now();
1801
1845
  const leaseState = this.#diagnosticLease(capturedAtMs);
1802
- const connectivity =
1803
- this.#lastRound?.status === 'succeeded'
1846
+ const connectivity = !this.#transportEnabled
1847
+ ? 'offline'
1848
+ : this.#lastRound?.status === 'succeeded'
1804
1849
  ? 'online'
1805
1850
  : this.#lastRound?.status === 'failed' &&
1806
1851
  this.#transportFailureCode(this.#lastRound.errorCode)
@@ -1833,16 +1878,12 @@ export class SyncClient {
1833
1878
  realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
1834
1879
  }),
1835
1880
  securityLifecycle: this.#securityLifecycle,
1836
- schema: {
1881
+ schema: definedObject({
1837
1882
  currentVersion: this.#config.schema.version,
1838
1883
  upgrading: this.#upgrading,
1839
- ...(this.#schemaFloor?.requiredSchemaVersion !== undefined
1840
- ? { requiredVersion: this.#schemaFloor.requiredSchemaVersion }
1841
- : {}),
1842
- ...(this.#schemaFloor?.latestSchemaVersion !== undefined
1843
- ? { latestVersion: this.#schemaFloor.latestSchemaVersion }
1844
- : {}),
1845
- },
1884
+ requiredVersion: this.#schemaFloor?.requiredSchemaVersion,
1885
+ latestVersion: this.#schemaFloor?.latestSchemaVersion,
1886
+ }),
1846
1887
  replica: {
1847
1888
  localRevision: getLocalRevision(this.#db).toString(),
1848
1889
  syncNeeded: this.#needsPull,
@@ -1888,11 +1929,11 @@ export class SyncClient {
1888
1929
  );
1889
1930
  const outboxBytes = Number(
1890
1931
  this.#db.query(
1891
- 'SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM _syncular_outbox',
1932
+ `SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM ${OUTBOX_TABLE}`,
1892
1933
  )[0]?.bytes ?? 0,
1893
1934
  );
1894
1935
  const outcome = this.#db.query(
1895
- 'SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM _syncular_commit_outcomes',
1936
+ `SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM ${OUTCOMES_TABLE}`,
1896
1937
  )[0];
1897
1938
  const blobBytes = this.#hasBlobs
1898
1939
  ? Number(
@@ -2058,10 +2099,11 @@ export class SyncClient {
2058
2099
  this.#requireActive();
2059
2100
  const task = Promise.resolve().then(fn);
2060
2101
  this.#protectedAsync.add(task);
2061
- void task.then(
2062
- () => this.#protectedAsync.delete(task),
2063
- () => this.#protectedAsync.delete(task),
2064
- );
2102
+ const settled = () => {
2103
+ if (this.#realtimeConnectPromise === task)
2104
+ this.#realtimeConnectPromise = undefined;
2105
+ };
2106
+ void task.then(settled, settled);
2065
2107
  return task;
2066
2108
  }
2067
2109
 
@@ -2098,14 +2140,12 @@ export class SyncClient {
2098
2140
  recordPendingUpload(this.#db, blobId, nowMs, options?.mediaType);
2099
2141
  });
2100
2142
  this.#enforceBlobCacheCap();
2101
- return {
2143
+ return definedObject({
2102
2144
  blobId,
2103
2145
  byteLength: bytes.length,
2104
- ...(options?.mediaType !== undefined
2105
- ? { mediaType: options.mediaType }
2106
- : {}),
2107
- ...(options?.name !== undefined ? { name: options.name } : {}),
2108
- };
2146
+ mediaType: options?.mediaType,
2147
+ name: options?.name,
2148
+ });
2109
2149
  }
2110
2150
 
2111
2151
  /** Serialize a BlobRef to the canonical string a `blob_ref` column holds. */
@@ -2139,6 +2179,7 @@ export class SyncClient {
2139
2179
  // presign configured) a signed url the client fetches directly. On a url
2140
2180
  // arm the client MUST NOT attach host auth and MUST NOT fall through:
2141
2181
  // failure => re-request, the caller's next fetchBlob mints a fresh url.
2182
+ this.#requireTransport();
2142
2183
  const response = await transport.download(blobId);
2143
2184
  let bytes: Uint8Array;
2144
2185
  if (response.kind === 'url') {
@@ -2190,6 +2231,7 @@ export class SyncClient {
2190
2231
 
2191
2232
  /** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
2192
2233
  flushBlobUploads(): Promise<void> {
2234
+ this.#requireTransport();
2193
2235
  return this.#runProtectedAsync(() => this.#flushBlobUploads());
2194
2236
  }
2195
2237
 
@@ -2318,12 +2360,9 @@ export class SyncClient {
2318
2360
  );
2319
2361
  }
2320
2362
  const allowed =
2321
- (current.status === 'conflict' &&
2363
+ ((current.status === 'conflict' || current.status === 'rejected') &&
2322
2364
  (input.resolution === 'resolved_keep_server' ||
2323
2365
  input.resolution === 'superseded')) ||
2324
- (current.status === 'rejected' &&
2325
- (input.resolution === 'superseded' ||
2326
- input.resolution === 'resolved_keep_server')) ||
2327
2366
  ((current.status === 'applied' || current.status === 'cached') &&
2328
2367
  input.resolution === 'dismissed');
2329
2368
  if (!allowed) {
@@ -2426,6 +2465,7 @@ export class SyncClient {
2426
2465
  */
2427
2466
  setPresence(scopeKey: string, doc: Record<string, unknown> | null): void {
2428
2467
  this.#requireActive();
2468
+ this.#requireTransport();
2429
2469
  const socket = this.#socket;
2430
2470
  if (socket === undefined) {
2431
2471
  throw invalidRequest(
@@ -2594,7 +2634,10 @@ export class SyncClient {
2594
2634
  }
2595
2635
  });
2596
2636
  const effects: CommandEffects = {
2597
- sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
2637
+ sync:
2638
+ this.#transportEnabled && (changed || widened)
2639
+ ? { kind: 'interactive' }
2640
+ : { kind: 'none' },
2598
2641
  };
2599
2642
  if (effects.sync.kind === 'interactive') {
2600
2643
  this.#emitSyncIntent(effects.sync);
@@ -2863,7 +2906,7 @@ export class SyncClient {
2863
2906
  continue;
2864
2907
  }
2865
2908
  this.#db.exec(
2866
- 'INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)',
2909
+ `INSERT OR IGNORE INTO ${BLOB_COMMIT_REFS}(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)`,
2867
2910
  [clientCommitId, blobId, blobId],
2868
2911
  );
2869
2912
  }
@@ -2898,7 +2941,9 @@ export class SyncClient {
2898
2941
  mutateCommand(mutations: readonly MutationInput[]): CommandResult<string> {
2899
2942
  return {
2900
2943
  value: this.mutate(mutations),
2901
- effects: { sync: { kind: 'interactive' } },
2944
+ effects: {
2945
+ sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
2946
+ },
2902
2947
  };
2903
2948
  }
2904
2949
 
@@ -2927,17 +2972,15 @@ export class SyncClient {
2927
2972
  );
2928
2973
  }
2929
2974
  return this.#recordMutations([
2930
- {
2975
+ definedObject({
2931
2976
  table,
2932
2977
  op: 'patch',
2933
2978
  values: {
2934
2979
  ...Object.fromEntries(normalized),
2935
2980
  [pkColumn.name]: rowId,
2936
2981
  },
2937
- ...(options?.baseVersion !== undefined
2938
- ? { baseVersion: options.baseVersion }
2939
- : {}),
2940
- },
2982
+ baseVersion: options?.baseVersion,
2983
+ }),
2941
2984
  ]);
2942
2985
  }
2943
2986
 
@@ -3222,7 +3265,9 @@ export class SyncClient {
3222
3265
  ): CommandResult<string> {
3223
3266
  return {
3224
3267
  value: this.patch(table, rowId, partial, options),
3225
- effects: { sync: { kind: 'interactive' } },
3268
+ effects: {
3269
+ sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
3270
+ },
3226
3271
  };
3227
3272
  }
3228
3273
 
@@ -3262,7 +3307,7 @@ export class SyncClient {
3262
3307
  // Pin before the first encryption await: mutations can append while a
3263
3308
  // round is encoding, and belong to the next request.
3264
3309
  const bounds = this.#db.query(
3265
- 'SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM _syncular_outbox',
3310
+ `SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM ${OUTBOX_TABLE}`,
3266
3311
  )[0]!;
3267
3312
  const pendingCount = bounds.count as number;
3268
3313
  const throughSeq = (bounds.last_seq as number | null) ?? 0;
@@ -3346,16 +3391,14 @@ export class SyncClient {
3346
3391
  );
3347
3392
  this.#rollbackFailedCommit(commit, batch);
3348
3393
  this.#replayOutbox();
3349
- const rejection: RejectionRecord = {
3394
+ const rejection: RejectionRecord = definedObject({
3350
3395
  clientCommitId: commit.clientCommitId,
3351
3396
  opIndex: 0,
3352
3397
  code,
3353
3398
  message,
3354
3399
  retryable: false,
3355
- ...(commit.operations[0] !== undefined
3356
- ? { operation: commit.operations[0] }
3357
- : {}),
3358
- };
3400
+ operation: commit.operations[0],
3401
+ });
3359
3402
  this.#rejections.push(rejection);
3360
3403
  recordCommitOutcome(this.#db, {
3361
3404
  clientCommitId: commit.clientCommitId,
@@ -3386,9 +3429,7 @@ export class SyncClient {
3386
3429
  this.#requireActive();
3387
3430
  if (this.#syncOutstanding) {
3388
3431
  return Promise.reject(
3389
- invalidRequest(
3390
- 'sync() is already running — the core owns one loop (coalesce wake-ups)',
3391
- ),
3432
+ invalidRequest('sync() is already running; coalesce wake-ups'),
3392
3433
  );
3393
3434
  }
3394
3435
  this.#syncOutstanding = true;
@@ -3438,6 +3479,7 @@ export class SyncClient {
3438
3479
  )
3439
3480
  .finally(() => {
3440
3481
  this.#syncOutstanding = false;
3482
+ if (!this.#transportEnabled) this.disconnectRealtime();
3441
3483
  this.#endDiagnosticsDeferral();
3442
3484
  });
3443
3485
  }
@@ -3476,6 +3518,7 @@ export class SyncClient {
3476
3518
  }
3477
3519
 
3478
3520
  async #runSync(): Promise<SyncSummary> {
3521
+ this.#requireTransport();
3479
3522
  this.#roundRetryDelayMs = undefined;
3480
3523
  this.#progress.emit({
3481
3524
  attempt: ++this.#progressAttempt,
@@ -3507,7 +3550,7 @@ export class SyncClient {
3507
3550
  this.#hasBlobs &&
3508
3551
  this.#config.blobs !== undefined
3509
3552
  ) {
3510
- await this.flushBlobUploads();
3553
+ await this.#flushBlobUploads();
3511
3554
  }
3512
3555
  // §7.4.4: encode the outbox with the CURRENT codec; a commit that
3513
3556
  // cannot express itself under the new schema (a dropped column/table)
@@ -3641,6 +3684,8 @@ export class SyncClient {
3641
3684
  loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]),
3642
3685
  );
3643
3686
  last = await this.sync();
3687
+ if (!this.#transportEnabled || last.schemaFloor !== undefined)
3688
+ return last;
3644
3689
  if (
3645
3690
  maxRounds === undefined &&
3646
3691
  last.segmentRowsApplied > 0 &&
@@ -3654,7 +3699,6 @@ export class SyncClient {
3654
3699
  )
3655
3700
  )
3656
3701
  round = -1;
3657
- if (last.schemaFloor !== undefined) return last;
3658
3702
  if (
3659
3703
  last.commitsApplied === 0 &&
3660
3704
  last.segmentRowsApplied === 0 &&
@@ -3770,6 +3814,7 @@ export class SyncClient {
3770
3814
 
3771
3815
  connectRealtime(): Promise<void> {
3772
3816
  this.#requireActive();
3817
+ this.#requireTransport();
3773
3818
  if (this.#realtimePolicy === 'off') {
3774
3819
  return Promise.reject(
3775
3820
  invalidRequest("realtimePolicy is 'off'; realtime connect is refused"),
@@ -3830,12 +3875,13 @@ export class SyncClient {
3830
3875
  },
3831
3876
  });
3832
3877
  openedSocket = socket;
3878
+ if (!this.#transportEnabled) {
3879
+ socket.close();
3880
+ this.#requireTransport();
3881
+ }
3833
3882
  if (this.#securityLifecycle === 'preflight') {
3834
3883
  socket.close();
3835
- throw new ClientSyncError(
3836
- SECURITY_PREFLIGHT_REQUIRED_CODE,
3837
- 'realtime connected after the client entered security preflight',
3838
- );
3884
+ this.#requireActive();
3839
3885
  }
3840
3886
  if (generation !== this.#realtimeGeneration || !this.#started) {
3841
3887
  socket.close();
@@ -3983,6 +4029,7 @@ export class SyncClient {
3983
4029
  }
3984
4030
 
3985
4031
  async #handleRealtimeBinary(bytes: Uint8Array): Promise<void> {
4032
+ if (!this.#transportEnabled) return;
3986
4033
  if (this.#syncing) {
3987
4034
  // Fast path: a pull is mid-flight; let it win and recover the gap
3988
4035
  // itself — re-pulling is idempotent, interleaved application is not
@@ -4010,7 +4057,8 @@ export class SyncClient {
4010
4057
  }
4011
4058
 
4012
4059
  #sendAck(cursor: number): void {
4013
- this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
4060
+ if (this.#transportEnabled)
4061
+ this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
4014
4062
  }
4015
4063
 
4016
4064
  /** Ack the highest cursor that is contiguously applied for every sub. */
@@ -4220,74 +4268,16 @@ export class SyncClient {
4220
4268
  await this.#applyCommit(frame, summary);
4221
4269
  }
4222
4270
  break;
4223
- case 'SEGMENT_INLINE': {
4224
- if (
4225
- section === undefined ||
4226
- section.skip ||
4227
- section.sub === undefined
4228
- ) {
4229
- break;
4230
- }
4231
- const segment = decodeRowsSegment(frame.payload);
4232
- this.#progress.update({
4233
- phase: 'import',
4234
- subscriptionId: section.sub.id,
4235
- table: section.sub.table,
4236
- segmentId: undefined,
4237
- bytesReceived: frame.payload.byteLength,
4238
- bytesTotal: frame.payload.byteLength,
4239
- rowsProcessed: 0,
4240
- rowsTotal: segment.blocks.reduce(
4241
- (n, block) => n + block.length,
4242
- 0,
4243
- ),
4244
- });
4245
- await this.#applySegmentOrFail(
4246
- section,
4247
- summary,
4248
- (table, clearFirst, effective) =>
4249
- applyRowsSegment(
4250
- this.#db,
4251
- this.#schema,
4252
- table,
4253
- segment,
4254
- {
4255
- clearFirst,
4256
- isCurrent: () => this.#localResetEpoch === resetEpoch,
4257
- onProgress: (rowsProcessed) =>
4258
- this.#progress.update({ rowsProcessed }),
4259
- effective,
4260
- transaction: (fn) =>
4261
- this.#applyBatch((batch) => {
4262
- if (
4263
- segment.blocks.some((block) => block.length > 0) ||
4264
- (clearFirst &&
4265
- this.#scopedRowsExist(table, effective))
4266
- ) {
4267
- batch.table(table.name);
4268
- }
4269
- const result = fn();
4270
- this.#replayOutbox();
4271
- return result;
4272
- }),
4273
- },
4274
- this.#encryption,
4275
- ),
4276
- section.fresh && !section.cleared,
4277
- );
4278
- break;
4279
- }
4271
+ case 'SEGMENT_INLINE':
4280
4272
  case 'SEGMENT_REF': {
4281
4273
  if (
4282
4274
  section === undefined ||
4283
4275
  section.skip ||
4284
4276
  section.sub === undefined
4285
- ) {
4277
+ )
4286
4278
  break;
4287
- }
4288
- // §4.2: a descriptor whose mediaType was not advertised is a
4289
- // broken server — fail loud, never skip or guess.
4290
4279
  if (
4280
+ frame.type === 'SEGMENT_REF' &&
4291
4281
  frame.mediaType === 'sqlite' &&
4292
4282
  (this.#acceptMask() & ACCEPT_SQLITE) === 0
4293
4283
  ) {
@@ -4295,11 +4285,14 @@ export class SyncClient {
4295
4285
  'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)',
4296
4286
  );
4297
4287
  }
4298
- const bytes = await this.#downloadSegment(frame, section.sub);
4299
- this.#progress.update({ phase: 'import' });
4300
- if (frame.mediaType === 'sqlite') {
4301
- // §5.3: images are whole-table — a paged descriptor is
4302
- // invalid, and the image is always its table's first page.
4288
+ const bytes =
4289
+ frame.type === 'SEGMENT_REF'
4290
+ ? await this.#downloadSegment(frame, section.sub)
4291
+ : frame.payload;
4292
+ if (frame.type === 'SEGMENT_REF')
4293
+ this.#progress.update({ phase: 'import' });
4294
+ if (frame.type === 'SEGMENT_REF' && frame.mediaType === 'sqlite') {
4295
+ // Images are whole-table; paged image descriptors are invalid.
4303
4296
  if (
4304
4297
  frame.rowCursor !== undefined ||
4305
4298
  frame.nextRowCursor !== undefined
@@ -4323,31 +4316,33 @@ export class SyncClient {
4323
4316
  asOfCommitSeq: frame.asOfCommitSeq,
4324
4317
  scopeDigest: frame.scopeDigest,
4325
4318
  },
4326
- {
4319
+ this.#segmentApplyOptions(
4320
+ table,
4327
4321
  clearFirst,
4328
- isCurrent: () => this.#localResetEpoch === resetEpoch,
4329
- onProgress: (rowsProcessed) =>
4330
- this.#progress.update({ rowsProcessed }),
4331
4322
  effective,
4332
- transaction: (fn) =>
4333
- this.#applyBatch((batch) => {
4334
- if (
4335
- frame.rowCount > 0 ||
4336
- (clearFirst &&
4337
- this.#scopedRowsExist(table, effective))
4338
- ) {
4339
- batch.table(table.name);
4340
- }
4341
- const result = fn();
4342
- this.#replayOutbox();
4343
- return result;
4344
- }),
4345
- },
4323
+ frame.rowCount > 0,
4324
+ resetEpoch,
4325
+ ),
4346
4326
  ),
4347
4327
  section.fresh && !section.cleared,
4348
4328
  );
4349
4329
  } else {
4350
4330
  const segment = decodeRowsSegment(bytes);
4331
+ if (frame.type === 'SEGMENT_INLINE') {
4332
+ this.#progress.update({
4333
+ phase: 'import',
4334
+ subscriptionId: section.sub.id,
4335
+ table: section.sub.table,
4336
+ segmentId: undefined,
4337
+ bytesReceived: bytes.byteLength,
4338
+ bytesTotal: bytes.byteLength,
4339
+ rowsProcessed: 0,
4340
+ rowsTotal: segment.blocks.reduce(
4341
+ (n, block) => n + block.length,
4342
+ 0,
4343
+ ),
4344
+ });
4345
+ }
4351
4346
  await this.#applySegmentOrFail(
4352
4347
  section,
4353
4348
  summary,
@@ -4357,31 +4352,19 @@ export class SyncClient {
4357
4352
  this.#schema,
4358
4353
  table,
4359
4354
  segment,
4360
- {
4355
+ this.#segmentApplyOptions(
4356
+ table,
4361
4357
  clearFirst,
4362
- isCurrent: () => this.#localResetEpoch === resetEpoch,
4363
- onProgress: (rowsProcessed) =>
4364
- this.#progress.update({ rowsProcessed }),
4365
4358
  effective,
4366
- transaction: (fn) =>
4367
- this.#applyBatch((batch) => {
4368
- if (
4369
- segment.blocks.some((block) => block.length > 0) ||
4370
- (clearFirst &&
4371
- this.#scopedRowsExist(table, effective))
4372
- ) {
4373
- batch.table(table.name);
4374
- }
4375
- const result = fn();
4376
- this.#replayOutbox();
4377
- return result;
4378
- }),
4379
- },
4359
+ segment.blocks.some((block) => block.length > 0),
4360
+ resetEpoch,
4361
+ ),
4380
4362
  this.#encryption,
4381
4363
  ),
4382
4364
  section.fresh &&
4383
4365
  !section.cleared &&
4384
- frame.rowCursor === undefined,
4366
+ (frame.type === 'SEGMENT_INLINE' ||
4367
+ frame.rowCursor === undefined),
4385
4368
  );
4386
4369
  }
4387
4370
  break;
@@ -4427,13 +4410,7 @@ export class SyncClient {
4427
4410
  if (errorFrame !== undefined) break;
4428
4411
  }
4429
4412
  } finally {
4430
- try {
4431
- // §7.1: local reads see outbox state applied optimistically — replay
4432
- // the still-pending commits on top of the freshly applied server state.
4433
- this.#replayOutbox();
4434
- } finally {
4435
- this.#endDiagnosticsDeferral();
4436
- }
4413
+ this.#endDiagnosticsDeferral();
4437
4414
  }
4438
4415
 
4439
4416
  if (errorFrame !== undefined) throw errorFrame;
@@ -4587,6 +4564,15 @@ export class SyncClient {
4587
4564
  listOutboxBeforeImages(this.#db, commit.clientCommitId),
4588
4565
  );
4589
4566
  this.#rollbackFailedCommit(commit, batch);
4567
+ this.#replayOutbox(
4568
+ commit.operations.map((operation) =>
4569
+ JSON.stringify(
4570
+ hasUniqueIndex(this.#table(operation.table))
4571
+ ? [operation.table]
4572
+ : [operation.table, operation.rowId],
4573
+ ),
4574
+ ),
4575
+ );
4590
4576
  });
4591
4577
  batch.status();
4592
4578
  summary.rejected.push(frame.clientCommitId);
@@ -4596,7 +4582,7 @@ export class SyncClient {
4596
4582
  #outboxCommitExists(clientCommitId: string): boolean {
4597
4583
  return (
4598
4584
  this.#db.query(
4599
- 'SELECT 1 FROM _syncular_outbox WHERE client_commit_id=? LIMIT 1',
4585
+ `SELECT 1 FROM ${OUTBOX_TABLE} WHERE client_commit_id=? LIMIT 1`,
4600
4586
  [clientCommitId],
4601
4587
  ).length > 0
4602
4588
  );
@@ -4650,7 +4636,7 @@ export class SyncClient {
4650
4636
  this.#applyBatch((batch) => {
4651
4637
  this.#recordCommitChanges(batch, frame);
4652
4638
  const result = fn();
4653
- this.#replayOutbox(result);
4639
+ this.#replayOutbox(result, false);
4654
4640
  return result;
4655
4641
  }),
4656
4642
  );
@@ -4712,6 +4698,33 @@ export class SyncClient {
4712
4698
  );
4713
4699
  }
4714
4700
 
4701
+ /** Shared transaction, reset fence and progress for both segment formats. */
4702
+ #segmentApplyOptions(
4703
+ table: CompiledClientTable,
4704
+ clearFirst: boolean,
4705
+ effective: ScopeMap,
4706
+ hasRows: boolean,
4707
+ resetEpoch: number,
4708
+ ): Parameters<typeof applyRowsSegment>[4] {
4709
+ return {
4710
+ clearFirst,
4711
+ effective,
4712
+ isCurrent: () => this.#localResetEpoch === resetEpoch,
4713
+ onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
4714
+ transaction: (fn) =>
4715
+ this.#applyBatch((batch) => {
4716
+ if (
4717
+ hasRows ||
4718
+ (clearFirst && this.#scopedRowsExist(table, effective))
4719
+ )
4720
+ batch.table(table.name);
4721
+ const result = fn();
4722
+ this.#replayOutbox(result, false);
4723
+ return result;
4724
+ }),
4725
+ };
4726
+ }
4727
+
4715
4728
  /**
4716
4729
  * Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
4717
4730
  * (no local scope-column mapping) marks the subscription `failed` and
@@ -4890,14 +4903,17 @@ export class SyncClient {
4890
4903
  }),
4891
4904
  );
4892
4905
  if (covered.length) {
4893
- restoreFailedBases(this.#db, this.#schema);
4906
+ const scope = covered.map((row) =>
4907
+ JSON.stringify([sub.table, String(row.id)]),
4908
+ );
4909
+ restoreFailedBases(this.#db, this.#schema, scope);
4894
4910
  for (const row of covered)
4895
4911
  this.#db.exec(
4896
4912
  `DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`,
4897
4913
  [sub.table, String(row.id), nextCursor],
4898
4914
  );
4899
4915
  batch.table(sub.table);
4900
- this.#replayOutbox();
4916
+ this.#replayOutbox(scope, false);
4901
4917
  }
4902
4918
  }
4903
4919
  if (completed && registered !== undefined) {
@@ -4947,13 +4963,15 @@ export class SyncClient {
4947
4963
  Object.keys(lastEffective).length > 0
4948
4964
  ) {
4949
4965
  try {
4950
- restoreFailedBases(this.#db, this.#schema);
4951
- this.#dropRetainedRows(
4952
- batch,
4953
- (name, values) =>
4954
- name === table.name &&
4955
- matchesLocalScopes(table, lastEffective, values),
4956
- );
4966
+ const scope = [
4967
+ JSON.stringify([table.name]),
4968
+ ...this.#dropRetainedRows(
4969
+ batch,
4970
+ (name, values) =>
4971
+ name === table.name &&
4972
+ matchesLocalScopes(table, lastEffective, values),
4973
+ ),
4974
+ ];
4957
4975
  deleteScopedRows(this.#db, table, lastEffective);
4958
4976
  batch.scopeMap(table, lastEffective);
4959
4977
  const pendingById = new Map(
@@ -4999,6 +5017,7 @@ export class SyncClient {
4999
5017
  batch.rejections();
5000
5018
  batch.outcomes();
5001
5019
  }
5020
+ this.#replayOutbox(scope);
5002
5021
  this.#deleteUnreferencedBlobs();
5003
5022
  } catch (error) {
5004
5023
  if (
@@ -5219,7 +5238,7 @@ export class SyncClient {
5219
5238
  : (local[SYNC_VERSION_COLUMN] as number);
5220
5239
  if (
5221
5240
  replay &&
5222
- table.indexes.some((index) => index.unique) &&
5241
+ hasUniqueIndex(table) &&
5223
5242
  uniqueConflicts(
5224
5243
  this.#db,
5225
5244
  table,
@@ -5247,8 +5266,9 @@ export class SyncClient {
5247
5266
  table: string,
5248
5267
  values: Readonly<Record<string, JsonRowValue>>,
5249
5268
  ) => boolean,
5250
- ): void {
5251
- for (const commit of dropFailedRows(this.#db, matches)) {
5269
+ ): string[] {
5270
+ const scope: string[] = [];
5271
+ for (const commit of dropFailedRows(this.#db, this.#schema, matches)) {
5252
5272
  batch.conflicts();
5253
5273
  batch.rejections();
5254
5274
  batch.outcomes();
@@ -5258,32 +5278,39 @@ export class SyncClient {
5258
5278
  this.#rejections = this.#rejections.filter(
5259
5279
  (record) => record.clientCommitId !== commit.clientCommitId,
5260
5280
  );
5261
- for (const op of commit.operations) batch.table(op.table);
5281
+ for (const op of commit.operations) {
5282
+ batch.table(op.table);
5283
+ scope.push(JSON.stringify([op.table, op.rowId]));
5284
+ }
5262
5285
  }
5286
+ return scope;
5263
5287
  }
5264
5288
 
5265
- #replayOutbox(rowKeys?: readonly string[]): void {
5266
- const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
5267
- const pending = [
5268
- ...failedOverlayCommits(this.#db, false, rowKeys),
5269
- ...listOutbox(this.#db, rowKeys),
5270
- ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
5271
- if (pending.length === 0 && acknowledged.length === 0) return;
5289
+ #replayOutbox(scope?: OverlayScope, restoreBases = true): void {
5290
+ if (scope?.length === 0) return;
5272
5291
  this.#applyBatch((batch) => {
5273
- this.#db.transaction(() => {
5274
- restoreFailedBases(this.#db, this.#schema, false, rowKeys);
5275
- restoreFailedBases(this.#db, this.#schema, true, rowKeys);
5276
- for (const commit of [...acknowledged, ...pending])
5277
- this.#applyOperationsLocally(
5278
- rowKeys === undefined
5279
- ? commit.operations
5280
- : commit.operations.filter((op) =>
5281
- rowKeys.includes(JSON.stringify([op.table, op.rowId])),
5282
- ),
5283
- batch,
5284
- true,
5285
- );
5286
- });
5292
+ if (restoreBases) restoreFailedBases(this.#db, this.#schema, scope);
5293
+ const acknowledged = failedOverlayCommits(
5294
+ this.#db,
5295
+ true,
5296
+ scope,
5297
+ this.#schema,
5298
+ );
5299
+ const pending = [
5300
+ ...failedOverlayCommits(this.#db, false, scope, this.#schema),
5301
+ ...listOutbox(this.#db, scope),
5302
+ ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
5303
+ for (const commit of [...acknowledged, ...pending])
5304
+ this.#applyOperationsLocally(
5305
+ commit.operations.filter(
5306
+ (op) =>
5307
+ scope === undefined ||
5308
+ scope.includes(JSON.stringify([op.table, op.rowId])) ||
5309
+ scope.includes(JSON.stringify([op.table])),
5310
+ ),
5311
+ batch,
5312
+ true,
5313
+ );
5287
5314
  });
5288
5315
  }
5289
5316