@syncular/client 0.30.11 → 0.30.13

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,3 +1,4 @@
1
+ import { RETAINED_ROWS } from './failed-overlay';
1
2
  import {
2
3
  dropFailedRows,
3
4
  failedOverlayCommits,
@@ -51,6 +52,7 @@ import {
51
52
  applySqliteSegment,
52
53
  deleteLocalRow,
53
54
  deleteScopedRows,
55
+ localScopePredicate,
54
56
  evictScopedRows,
55
57
  yieldToHost,
56
58
  upsertLocalRow,
@@ -196,6 +198,7 @@ import {
196
198
  type CompiledClientSchema,
197
199
  type CompiledClientTable,
198
200
  compileClientSchema,
201
+ matchesLocalScopes,
199
202
  dropAndRecreateSyncedTables,
200
203
  ensureLocalBookkeepingSchema,
201
204
  ensureLocalSyncedSchema,
@@ -346,6 +349,7 @@ export interface SyncClientLimits {
346
349
  }
347
350
 
348
351
  export interface SyncClientConfig {
352
+ readonly transportEnabled?: boolean;
349
353
  /** Keep rejected/conflicting local intent visible until explicit resolution. */
350
354
  readonly retainFailedCommits?: boolean;
351
355
  readonly database: ClientDatabase;
@@ -744,6 +748,7 @@ export class SyncClient {
744
748
  /** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
745
749
  readonly #previousVersion: PreviousVersionCaptureConfig | undefined;
746
750
  #securityLifecycle: SecurityLifecycle;
751
+ #transportEnabled: boolean;
747
752
  readonly #now: () => number;
748
753
  readonly #outcomeRetentionMaxEntries: number;
749
754
  readonly #retainFailedCommits: boolean;
@@ -835,9 +840,16 @@ export class SyncClient {
835
840
  #preflightBarrier: Promise<void> | undefined;
836
841
 
837
842
  constructor(config: SyncClientConfig) {
843
+ if (
844
+ config.transportEnabled !== undefined &&
845
+ typeof config.transportEnabled !== 'boolean'
846
+ ) {
847
+ throw invalidRequest('transportEnabled must be a boolean');
848
+ }
849
+ this.#transportEnabled = config.transportEnabled !== false;
838
850
  if (config.securityPreflight === true && config.encryption !== undefined) {
839
851
  throw invalidRequest(
840
- 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight',
852
+ 'securityPreflight and encryption are mutually exclusive; activateSecurity installs keys',
841
853
  );
842
854
  }
843
855
  this.#retainFailedCommits = config.retainFailedCommits === true;
@@ -923,7 +935,7 @@ export class SyncClient {
923
935
  this.#lease = undefined;
924
936
  throw new ClientSyncError(
925
937
  'client.identity_mismatch',
926
- `this client database belongs to ${JSON.stringify(persisted)}; refusing to rebind it to ${JSON.stringify(this.#config.clientId)}`,
938
+ `database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`,
927
939
  );
928
940
  }
929
941
  this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
@@ -961,6 +973,9 @@ export class SyncClient {
961
973
  const startupWork =
962
974
  this.#schemaFloor === undefined &&
963
975
  (countOutbox(this.#db) > 0 ||
976
+ this.#db.query(
977
+ `SELECT 1 FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL LIMIT 1`,
978
+ ).length > 0 ||
964
979
  loadPendingEvictions(this.#db).length > 0 ||
965
980
  subscriptions.some((sub) => sub.status === 'active'));
966
981
  if (startupWork && this.#securityLifecycle === 'active') {
@@ -1060,6 +1075,7 @@ export class SyncClient {
1060
1075
  this.#schema,
1061
1076
  true,
1062
1077
  );
1078
+ this.#discardAcknowledgedRows();
1063
1079
  dropAndRecreateSyncedTables(this.#db, this.#schema);
1064
1080
  resetSubscriptionsForBump(this.#db);
1065
1081
  setMeta(
@@ -1160,6 +1176,7 @@ export class SyncClient {
1160
1176
  this.#sweepPreviousVersionContainer();
1161
1177
  this.#applyBatch((batch) => {
1162
1178
  this.#db.transaction(() => {
1179
+ this.#discardAcknowledgedRows();
1163
1180
  dropAndRecreateSyncedTables(this.#db, this.#schema);
1164
1181
  resetSubscriptionsForBump(this.#db);
1165
1182
  setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
@@ -1217,6 +1234,7 @@ export class SyncClient {
1217
1234
  }
1218
1235
 
1219
1236
  #emitSyncNeeded(reason: 'startup' | 'hello' | WakeReason): void {
1237
+ if (!this.#transportEnabled) return;
1220
1238
  try {
1221
1239
  this.#config.onSyncNeeded?.(reason);
1222
1240
  } catch {
@@ -1232,6 +1250,7 @@ export class SyncClient {
1232
1250
  }
1233
1251
 
1234
1252
  #emitSyncIntent(intent: SyncIntent): void {
1253
+ if (!this.#transportEnabled && intent.kind !== 'none') return;
1235
1254
  try {
1236
1255
  this.#config.onSyncIntent?.(intent);
1237
1256
  } catch {
@@ -1246,6 +1265,27 @@ export class SyncClient {
1246
1265
  }
1247
1266
  }
1248
1267
 
1268
+ /** Host-owned transport policy; the local replica remains available. */
1269
+ setTransportEnabled(enabled: boolean): void {
1270
+ this.#requireStarted();
1271
+ if (typeof enabled !== 'boolean')
1272
+ throw invalidRequest('transportEnabled must be a boolean');
1273
+ if (this.#transportEnabled === enabled) return;
1274
+ this.#transportEnabled = enabled;
1275
+ if (!enabled && !this.#syncOutstanding) this.disconnectRealtime();
1276
+ this.#emitSyncIntent(
1277
+ enabled && this.#securityLifecycle === 'active'
1278
+ ? { kind: 'interactive' }
1279
+ : { kind: 'none' },
1280
+ );
1281
+ this.#emitDiagnostics();
1282
+ }
1283
+
1284
+ #requireTransport(): void {
1285
+ if (!this.#transportEnabled)
1286
+ throw new ClientSyncError('sync.offline', 'transport gate is closed');
1287
+ }
1288
+
1249
1289
  /** Current fail-closed local-replica security state. */
1250
1290
  securityLifecycle(): SecurityLifecycle {
1251
1291
  return this.#securityLifecycle;
@@ -1791,8 +1831,9 @@ export class SyncClient {
1791
1831
  }
1792
1832
  const capturedAtMs = this.#now();
1793
1833
  const leaseState = this.#diagnosticLease(capturedAtMs);
1794
- const connectivity =
1795
- this.#lastRound?.status === 'succeeded'
1834
+ const connectivity = !this.#transportEnabled
1835
+ ? 'offline'
1836
+ : this.#lastRound?.status === 'succeeded'
1796
1837
  ? 'online'
1797
1838
  : this.#lastRound?.status === 'failed' &&
1798
1839
  this.#transportFailureCode(this.#lastRound.errorCode)
@@ -1825,16 +1866,12 @@ export class SyncClient {
1825
1866
  realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
1826
1867
  }),
1827
1868
  securityLifecycle: this.#securityLifecycle,
1828
- schema: {
1869
+ schema: definedObject({
1829
1870
  currentVersion: this.#config.schema.version,
1830
1871
  upgrading: this.#upgrading,
1831
- ...(this.#schemaFloor?.requiredSchemaVersion !== undefined
1832
- ? { requiredVersion: this.#schemaFloor.requiredSchemaVersion }
1833
- : {}),
1834
- ...(this.#schemaFloor?.latestSchemaVersion !== undefined
1835
- ? { latestVersion: this.#schemaFloor.latestSchemaVersion }
1836
- : {}),
1837
- },
1872
+ requiredVersion: this.#schemaFloor?.requiredSchemaVersion,
1873
+ latestVersion: this.#schemaFloor?.latestSchemaVersion,
1874
+ }),
1838
1875
  replica: {
1839
1876
  localRevision: getLocalRevision(this.#db).toString(),
1840
1877
  syncNeeded: this.#needsPull,
@@ -1981,7 +2018,9 @@ export class SyncClient {
1981
2018
  result = fn(batch);
1982
2019
  if (
1983
2020
  this.#db
1984
- .query('SELECT DISTINCT tbl FROM _syncular_failed_rows')
2021
+ .query(
2022
+ `SELECT DISTINCT tbl FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL`,
2023
+ )
1985
2024
  .some((row) => batch.hasTable(String(row.tbl)))
1986
2025
  )
1987
2026
  batch.outcomes();
@@ -2048,10 +2087,11 @@ export class SyncClient {
2048
2087
  this.#requireActive();
2049
2088
  const task = Promise.resolve().then(fn);
2050
2089
  this.#protectedAsync.add(task);
2051
- void task.then(
2052
- () => this.#protectedAsync.delete(task),
2053
- () => this.#protectedAsync.delete(task),
2054
- );
2090
+ const settled = () => {
2091
+ if (this.#realtimeConnectPromise === task)
2092
+ this.#realtimeConnectPromise = undefined;
2093
+ };
2094
+ void task.then(settled, settled);
2055
2095
  return task;
2056
2096
  }
2057
2097
 
@@ -2088,14 +2128,12 @@ export class SyncClient {
2088
2128
  recordPendingUpload(this.#db, blobId, nowMs, options?.mediaType);
2089
2129
  });
2090
2130
  this.#enforceBlobCacheCap();
2091
- return {
2131
+ return definedObject({
2092
2132
  blobId,
2093
2133
  byteLength: bytes.length,
2094
- ...(options?.mediaType !== undefined
2095
- ? { mediaType: options.mediaType }
2096
- : {}),
2097
- ...(options?.name !== undefined ? { name: options.name } : {}),
2098
- };
2134
+ mediaType: options?.mediaType,
2135
+ name: options?.name,
2136
+ });
2099
2137
  }
2100
2138
 
2101
2139
  /** Serialize a BlobRef to the canonical string a `blob_ref` column holds. */
@@ -2129,6 +2167,7 @@ export class SyncClient {
2129
2167
  // presign configured) a signed url the client fetches directly. On a url
2130
2168
  // arm the client MUST NOT attach host auth and MUST NOT fall through:
2131
2169
  // failure => re-request, the caller's next fetchBlob mints a fresh url.
2170
+ this.#requireTransport();
2132
2171
  const response = await transport.download(blobId);
2133
2172
  let bytes: Uint8Array;
2134
2173
  if (response.kind === 'url') {
@@ -2180,6 +2219,7 @@ export class SyncClient {
2180
2219
 
2181
2220
  /** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
2182
2221
  flushBlobUploads(): Promise<void> {
2222
+ this.#requireTransport();
2183
2223
  return this.#runProtectedAsync(() => this.#flushBlobUploads());
2184
2224
  }
2185
2225
 
@@ -2308,12 +2348,9 @@ export class SyncClient {
2308
2348
  );
2309
2349
  }
2310
2350
  const allowed =
2311
- (current.status === 'conflict' &&
2351
+ ((current.status === 'conflict' || current.status === 'rejected') &&
2312
2352
  (input.resolution === 'resolved_keep_server' ||
2313
2353
  input.resolution === 'superseded')) ||
2314
- (current.status === 'rejected' &&
2315
- (input.resolution === 'superseded' ||
2316
- input.resolution === 'resolved_keep_server')) ||
2317
2354
  ((current.status === 'applied' || current.status === 'cached') &&
2318
2355
  input.resolution === 'dismissed');
2319
2356
  if (!allowed) {
@@ -2327,7 +2364,7 @@ export class SyncClient {
2327
2364
  return this.#applyBatch(
2328
2365
  (batch) => {
2329
2366
  restoreFailedBases(this.#db, this.#schema);
2330
- this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id=?', [
2367
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [
2331
2368
  input.clientCommitId,
2332
2369
  ]);
2333
2370
  const resolved = persistCommitOutcomeResolution(
@@ -2416,6 +2453,7 @@ export class SyncClient {
2416
2453
  */
2417
2454
  setPresence(scopeKey: string, doc: Record<string, unknown> | null): void {
2418
2455
  this.#requireActive();
2456
+ this.#requireTransport();
2419
2457
  const socket = this.#socket;
2420
2458
  if (socket === undefined) {
2421
2459
  throw invalidRequest(
@@ -2475,7 +2513,7 @@ export class SyncClient {
2475
2513
  if (!sameIntent) {
2476
2514
  throw new ClientSyncError(
2477
2515
  'client.subscription_intent_mismatch',
2478
- 'subscribe: the subscription id is already registered for a different table, scopes, or params',
2516
+ 'subscription id already names different table, scopes, or params',
2479
2517
  );
2480
2518
  }
2481
2519
  return;
@@ -2533,7 +2571,7 @@ export class SyncClient {
2533
2571
  const table = this.#table(base.table);
2534
2572
  if (!table.scopeColumnByVariable.has(base.variable)) {
2535
2573
  throw invalidRequest(
2536
- `setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`,
2574
+ `${base.table}: unknown scope variable ${base.variable} (§4.8)`,
2537
2575
  );
2538
2576
  }
2539
2577
  // Serialize the whole window edit: it spans an `await deriveSubId` between
@@ -2584,7 +2622,10 @@ export class SyncClient {
2584
2622
  }
2585
2623
  });
2586
2624
  const effects: CommandEffects = {
2587
- sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
2625
+ sync:
2626
+ this.#transportEnabled && (changed || widened)
2627
+ ? { kind: 'interactive' }
2628
+ : { kind: 'none' },
2588
2629
  };
2589
2630
  if (effects.sync.kind === 'interactive') {
2590
2631
  this.#emitSyncIntent(effects.sync);
@@ -2888,7 +2929,9 @@ export class SyncClient {
2888
2929
  mutateCommand(mutations: readonly MutationInput[]): CommandResult<string> {
2889
2930
  return {
2890
2931
  value: this.mutate(mutations),
2891
- effects: { sync: { kind: 'interactive' } },
2932
+ effects: {
2933
+ sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
2934
+ },
2892
2935
  };
2893
2936
  }
2894
2937
 
@@ -2917,17 +2960,15 @@ export class SyncClient {
2917
2960
  );
2918
2961
  }
2919
2962
  return this.#recordMutations([
2920
- {
2963
+ definedObject({
2921
2964
  table,
2922
2965
  op: 'patch',
2923
2966
  values: {
2924
2967
  ...Object.fromEntries(normalized),
2925
2968
  [pkColumn.name]: rowId,
2926
2969
  },
2927
- ...(options?.baseVersion !== undefined
2928
- ? { baseVersion: options.baseVersion }
2929
- : {}),
2930
- },
2970
+ baseVersion: options?.baseVersion,
2971
+ }),
2931
2972
  ]);
2932
2973
  }
2933
2974
 
@@ -2965,26 +3006,13 @@ export class SyncClient {
2965
3006
  return this.#applyBatch((batch) => {
2966
3007
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
2967
3008
  restoreFailedBases(this.#db, this.#schema);
2968
- const droppedRetained = dropFailedRows(this.#db, (table, values) =>
3009
+ this.#dropRetainedRows(batch, (table, values) =>
2969
3010
  (targetsByTable.get(table) ?? []).some((target) =>
2970
3011
  target.selectors.every((selector) =>
2971
3012
  selector.values.includes(String(values[selector.column])),
2972
3013
  ),
2973
3014
  ),
2974
3015
  );
2975
- for (const commit of droppedRetained) {
2976
- batch.conflicts();
2977
- batch.rejections();
2978
- this.#conflicts = this.#conflicts.filter(
2979
- (record) => record.clientCommitId !== commit.clientCommitId,
2980
- );
2981
- this.#rejections = this.#rejections.filter(
2982
- (record) => record.clientCommitId !== commit.clientCommitId,
2983
- );
2984
- for (const operation of commit.operations)
2985
- batch.table(operation.table);
2986
- batch.outcomes();
2987
- }
2988
3016
  // Doomed detection runs to fixpoint, matching the Rust core's rule:
2989
3017
  // a commit is doomed when any of its ops touches a base-matching
2990
3018
  // row. Base values only become visible here as rollbacks restore
@@ -3144,6 +3172,7 @@ export class SyncClient {
3144
3172
  this.#upgrading = true;
3145
3173
  this.#needsPull = true;
3146
3174
  batch.status();
3175
+ this.#discardAcknowledgedRows();
3147
3176
  dropAndRecreateSyncedTables(this.#db, this.#schema);
3148
3177
  resetSubscriptionsForBump(this.#db);
3149
3178
  for (const table of this.#schema.tables.values()) {
@@ -3224,7 +3253,9 @@ export class SyncClient {
3224
3253
  ): CommandResult<string> {
3225
3254
  return {
3226
3255
  value: this.patch(table, rowId, partial, options),
3227
- effects: { sync: { kind: 'interactive' } },
3256
+ effects: {
3257
+ sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
3258
+ },
3228
3259
  };
3229
3260
  }
3230
3261
 
@@ -3348,16 +3379,14 @@ export class SyncClient {
3348
3379
  );
3349
3380
  this.#rollbackFailedCommit(commit, batch);
3350
3381
  this.#replayOutbox();
3351
- const rejection: RejectionRecord = {
3382
+ const rejection: RejectionRecord = definedObject({
3352
3383
  clientCommitId: commit.clientCommitId,
3353
3384
  opIndex: 0,
3354
3385
  code,
3355
3386
  message,
3356
3387
  retryable: false,
3357
- ...(commit.operations[0] !== undefined
3358
- ? { operation: commit.operations[0] }
3359
- : {}),
3360
- };
3388
+ operation: commit.operations[0],
3389
+ });
3361
3390
  this.#rejections.push(rejection);
3362
3391
  recordCommitOutcome(this.#db, {
3363
3392
  clientCommitId: commit.clientCommitId,
@@ -3388,9 +3417,7 @@ export class SyncClient {
3388
3417
  this.#requireActive();
3389
3418
  if (this.#syncOutstanding) {
3390
3419
  return Promise.reject(
3391
- invalidRequest(
3392
- 'sync() is already running — the core owns one loop (coalesce wake-ups)',
3393
- ),
3420
+ invalidRequest('sync() is already running; coalesce wake-ups'),
3394
3421
  );
3395
3422
  }
3396
3423
  this.#syncOutstanding = true;
@@ -3440,6 +3467,7 @@ export class SyncClient {
3440
3467
  )
3441
3468
  .finally(() => {
3442
3469
  this.#syncOutstanding = false;
3470
+ if (!this.#transportEnabled) this.disconnectRealtime();
3443
3471
  this.#endDiagnosticsDeferral();
3444
3472
  });
3445
3473
  }
@@ -3478,6 +3506,7 @@ export class SyncClient {
3478
3506
  }
3479
3507
 
3480
3508
  async #runSync(): Promise<SyncSummary> {
3509
+ this.#requireTransport();
3481
3510
  this.#roundRetryDelayMs = undefined;
3482
3511
  this.#progress.emit({
3483
3512
  attempt: ++this.#progressAttempt,
@@ -3509,7 +3538,7 @@ export class SyncClient {
3509
3538
  this.#hasBlobs &&
3510
3539
  this.#config.blobs !== undefined
3511
3540
  ) {
3512
- await this.flushBlobUploads();
3541
+ await this.#flushBlobUploads();
3513
3542
  }
3514
3543
  // §7.4.4: encode the outbox with the CURRENT codec; a commit that
3515
3544
  // cannot express itself under the new schema (a dropped column/table)
@@ -3643,6 +3672,8 @@ export class SyncClient {
3643
3672
  loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]),
3644
3673
  );
3645
3674
  last = await this.sync();
3675
+ if (!this.#transportEnabled || last.schemaFloor !== undefined)
3676
+ return last;
3646
3677
  if (
3647
3678
  maxRounds === undefined &&
3648
3679
  last.segmentRowsApplied > 0 &&
@@ -3656,7 +3687,6 @@ export class SyncClient {
3656
3687
  )
3657
3688
  )
3658
3689
  round = -1;
3659
- if (last.schemaFloor !== undefined) return last;
3660
3690
  if (
3661
3691
  last.commitsApplied === 0 &&
3662
3692
  last.segmentRowsApplied === 0 &&
@@ -3772,6 +3802,7 @@ export class SyncClient {
3772
3802
 
3773
3803
  connectRealtime(): Promise<void> {
3774
3804
  this.#requireActive();
3805
+ this.#requireTransport();
3775
3806
  if (this.#realtimePolicy === 'off') {
3776
3807
  return Promise.reject(
3777
3808
  invalidRequest("realtimePolicy is 'off'; realtime connect is refused"),
@@ -3832,12 +3863,13 @@ export class SyncClient {
3832
3863
  },
3833
3864
  });
3834
3865
  openedSocket = socket;
3866
+ if (!this.#transportEnabled) {
3867
+ socket.close();
3868
+ this.#requireTransport();
3869
+ }
3835
3870
  if (this.#securityLifecycle === 'preflight') {
3836
3871
  socket.close();
3837
- throw new ClientSyncError(
3838
- SECURITY_PREFLIGHT_REQUIRED_CODE,
3839
- 'realtime connected after the client entered security preflight',
3840
- );
3872
+ this.#requireActive();
3841
3873
  }
3842
3874
  if (generation !== this.#realtimeGeneration || !this.#started) {
3843
3875
  socket.close();
@@ -3985,6 +4017,7 @@ export class SyncClient {
3985
4017
  }
3986
4018
 
3987
4019
  async #handleRealtimeBinary(bytes: Uint8Array): Promise<void> {
4020
+ if (!this.#transportEnabled) return;
3988
4021
  if (this.#syncing) {
3989
4022
  // Fast path: a pull is mid-flight; let it win and recover the gap
3990
4023
  // itself — re-pulling is idempotent, interleaved application is not
@@ -4012,7 +4045,8 @@ export class SyncClient {
4012
4045
  }
4013
4046
 
4014
4047
  #sendAck(cursor: number): void {
4015
- this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
4048
+ if (this.#transportEnabled)
4049
+ this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
4016
4050
  }
4017
4051
 
4018
4052
  /** Ack the highest cursor that is contiguously applied for every sub. */
@@ -4151,6 +4185,7 @@ export class SyncClient {
4151
4185
  index += 1;
4152
4186
  }
4153
4187
  }
4188
+ const previousNeedsPull = this.#needsPull;
4154
4189
  const conflictCount = this.#conflicts.length;
4155
4190
  const rejectionCount = this.#rejections.length;
4156
4191
  let outboxCount = countOutbox(this.#db);
@@ -4183,11 +4218,12 @@ export class SyncClient {
4183
4218
  },
4184
4219
  () => this.#statusSnapshot(outboxCount),
4185
4220
  (cause) => {
4221
+ this.#needsPull = previousNeedsPull;
4186
4222
  this.#conflicts.length = conflictCount;
4187
4223
  this.#rejections.length = rejectionCount;
4188
4224
  const error = new ClientSyncError(
4189
4225
  'client.outcome_persistence_failed',
4190
- 'local commit outcome could not be persisted',
4226
+ 'outcome persistence failed',
4191
4227
  );
4192
4228
  error.cause = cause;
4193
4229
  throw error;
@@ -4220,74 +4256,16 @@ export class SyncClient {
4220
4256
  await this.#applyCommit(frame, summary);
4221
4257
  }
4222
4258
  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
- }
4259
+ case 'SEGMENT_INLINE':
4280
4260
  case 'SEGMENT_REF': {
4281
4261
  if (
4282
4262
  section === undefined ||
4283
4263
  section.skip ||
4284
4264
  section.sub === undefined
4285
- ) {
4265
+ )
4286
4266
  break;
4287
- }
4288
- // §4.2: a descriptor whose mediaType was not advertised is a
4289
- // broken server — fail loud, never skip or guess.
4290
4267
  if (
4268
+ frame.type === 'SEGMENT_REF' &&
4291
4269
  frame.mediaType === 'sqlite' &&
4292
4270
  (this.#acceptMask() & ACCEPT_SQLITE) === 0
4293
4271
  ) {
@@ -4295,11 +4273,14 @@ export class SyncClient {
4295
4273
  'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)',
4296
4274
  );
4297
4275
  }
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.
4276
+ const bytes =
4277
+ frame.type === 'SEGMENT_REF'
4278
+ ? await this.#downloadSegment(frame, section.sub)
4279
+ : frame.payload;
4280
+ if (frame.type === 'SEGMENT_REF')
4281
+ this.#progress.update({ phase: 'import' });
4282
+ if (frame.type === 'SEGMENT_REF' && frame.mediaType === 'sqlite') {
4283
+ // Images are whole-table; paged image descriptors are invalid.
4303
4284
  if (
4304
4285
  frame.rowCursor !== undefined ||
4305
4286
  frame.nextRowCursor !== undefined
@@ -4323,31 +4304,33 @@ export class SyncClient {
4323
4304
  asOfCommitSeq: frame.asOfCommitSeq,
4324
4305
  scopeDigest: frame.scopeDigest,
4325
4306
  },
4326
- {
4307
+ this.#segmentApplyOptions(
4308
+ table,
4327
4309
  clearFirst,
4328
- isCurrent: () => this.#localResetEpoch === resetEpoch,
4329
- onProgress: (rowsProcessed) =>
4330
- this.#progress.update({ rowsProcessed }),
4331
4310
  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
- },
4311
+ frame.rowCount > 0,
4312
+ resetEpoch,
4313
+ ),
4346
4314
  ),
4347
4315
  section.fresh && !section.cleared,
4348
4316
  );
4349
4317
  } else {
4350
4318
  const segment = decodeRowsSegment(bytes);
4319
+ if (frame.type === 'SEGMENT_INLINE') {
4320
+ this.#progress.update({
4321
+ phase: 'import',
4322
+ subscriptionId: section.sub.id,
4323
+ table: section.sub.table,
4324
+ segmentId: undefined,
4325
+ bytesReceived: bytes.byteLength,
4326
+ bytesTotal: bytes.byteLength,
4327
+ rowsProcessed: 0,
4328
+ rowsTotal: segment.blocks.reduce(
4329
+ (n, block) => n + block.length,
4330
+ 0,
4331
+ ),
4332
+ });
4333
+ }
4351
4334
  await this.#applySegmentOrFail(
4352
4335
  section,
4353
4336
  summary,
@@ -4357,31 +4340,19 @@ export class SyncClient {
4357
4340
  this.#schema,
4358
4341
  table,
4359
4342
  segment,
4360
- {
4343
+ this.#segmentApplyOptions(
4344
+ table,
4361
4345
  clearFirst,
4362
- isCurrent: () => this.#localResetEpoch === resetEpoch,
4363
- onProgress: (rowsProcessed) =>
4364
- this.#progress.update({ rowsProcessed }),
4365
4346
  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
- },
4347
+ segment.blocks.some((block) => block.length > 0),
4348
+ resetEpoch,
4349
+ ),
4380
4350
  this.#encryption,
4381
4351
  ),
4382
4352
  section.fresh &&
4383
4353
  !section.cleared &&
4384
- frame.rowCursor === undefined,
4354
+ (frame.type === 'SEGMENT_INLINE' ||
4355
+ frame.rowCursor === undefined),
4385
4356
  );
4386
4357
  }
4387
4358
  break;
@@ -4485,6 +4456,18 @@ export class SyncClient {
4485
4456
  if (frame.status === 'applied' || frame.status === 'cached') {
4486
4457
  // §6.3: applied and cached both drain the outbox — cached means
4487
4458
  // "already applied, you may have missed the ack".
4459
+ if (frame.commitSeq === undefined)
4460
+ throw new ClientSyncError(
4461
+ 'client.invalid_host_response',
4462
+ 'ACK lacks a commit sequence',
4463
+ );
4464
+ retainFailedRows(
4465
+ this.#db,
4466
+ commit,
4467
+ listOutboxBeforeImages(this.#db, commit.clientCommitId),
4468
+ frame.commitSeq,
4469
+ );
4470
+ this.#setSyncNeeded(true);
4488
4471
  recordCommitOutcome(this.#db, {
4489
4472
  clientCommitId: frame.clientCommitId,
4490
4473
  status: frame.status,
@@ -4638,12 +4621,7 @@ export class SyncClient {
4638
4621
  this.#applyBatch((batch) => {
4639
4622
  this.#recordCommitChanges(batch, frame);
4640
4623
  const result = fn();
4641
- if (
4642
- this.#retainFailedCommits &&
4643
- this.#db.query('SELECT 1 FROM _syncular_failed_rows LIMIT 1')
4644
- .length > 0
4645
- )
4646
- this.#replayOutbox();
4624
+ this.#replayOutbox(result);
4647
4625
  return result;
4648
4626
  }),
4649
4627
  );
@@ -4695,26 +4673,43 @@ export class SyncClient {
4695
4673
 
4696
4674
  /** Whether a fresh-bootstrap clear would remove at least one local row. */
4697
4675
  #scopedRowsExist(table: CompiledClientTable, effective: ScopeMap): boolean {
4698
- const entries = Object.entries(effective);
4699
- if (entries.length === 0) return false;
4700
- const clauses: string[] = [];
4701
- const params: string[] = [];
4702
- for (const [variable, values] of entries) {
4703
- const column = table.scopeColumnByVariable.get(variable);
4704
- if (column === undefined || values.length === 0) return false;
4705
- clauses.push(
4706
- `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
4707
- );
4708
- params.push(...values);
4709
- }
4676
+ const scope = localScopePredicate(table, effective);
4710
4677
  return (
4678
+ scope !== undefined &&
4711
4679
  this.#db.query(
4712
- `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')} LIMIT 1`,
4713
- params,
4680
+ `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`,
4681
+ scope.params,
4714
4682
  ).length > 0
4715
4683
  );
4716
4684
  }
4717
4685
 
4686
+ /** Shared transaction, reset fence and progress for both segment formats. */
4687
+ #segmentApplyOptions(
4688
+ table: CompiledClientTable,
4689
+ clearFirst: boolean,
4690
+ effective: ScopeMap,
4691
+ hasRows: boolean,
4692
+ resetEpoch: number,
4693
+ ): Parameters<typeof applyRowsSegment>[4] {
4694
+ return {
4695
+ clearFirst,
4696
+ effective,
4697
+ isCurrent: () => this.#localResetEpoch === resetEpoch,
4698
+ onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
4699
+ transaction: (fn) =>
4700
+ this.#applyBatch((batch) => {
4701
+ if (
4702
+ hasRows ||
4703
+ (clearFirst && this.#scopedRowsExist(table, effective))
4704
+ )
4705
+ batch.table(table.name);
4706
+ const result = fn();
4707
+ this.#replayOutbox();
4708
+ return result;
4709
+ }),
4710
+ };
4711
+ }
4712
+
4718
4713
  /**
4719
4714
  * Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
4720
4715
  * (no local scope-column mapping) marks the subscription `failed` and
@@ -4817,7 +4812,7 @@ export class SyncClient {
4817
4812
  // §5.4: MUST NOT start a fetch at/past expiry.
4818
4813
  throw new ClientSyncError(
4819
4814
  'sync.segment_expired',
4820
- `signed URL for segment ${frame.segmentId} expired before fetch — re-pull mints fresh descriptors (§5.4)`,
4815
+ `segment download grant expired; pull again (§5.4)`,
4821
4816
  true,
4822
4817
  );
4823
4818
  }
@@ -4875,6 +4870,34 @@ export class SyncClient {
4875
4870
  status: 'active',
4876
4871
  }),
4877
4872
  );
4873
+ if (completed) {
4874
+ const table = this.#table(sub.table);
4875
+ const effective = start.effectiveScopes;
4876
+ const covered = this.#db
4877
+ .query(
4878
+ `SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq<=?`,
4879
+ [sub.table, nextCursor],
4880
+ )
4881
+ .filter((row) =>
4882
+ [row.intent, row.base].some((json) => {
4883
+ if (json === null) return false;
4884
+ const values: Record<string, JsonRowValue> = JSON.parse(
4885
+ String(json),
4886
+ );
4887
+ return matchesLocalScopes(table, effective, values);
4888
+ }),
4889
+ );
4890
+ if (covered.length) {
4891
+ restoreFailedBases(this.#db, this.#schema);
4892
+ for (const row of covered)
4893
+ this.#db.exec(
4894
+ `DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`,
4895
+ [sub.table, String(row.id), nextCursor],
4896
+ );
4897
+ batch.table(sub.table);
4898
+ this.#replayOutbox();
4899
+ }
4900
+ }
4878
4901
  if (completed && registered !== undefined) {
4879
4902
  // A zero-row bootstrap is a window-domain transition, not a fake
4880
4903
  // row/table change (SPEC §4.8 / §7.5).
@@ -4923,29 +4946,12 @@ export class SyncClient {
4923
4946
  ) {
4924
4947
  try {
4925
4948
  restoreFailedBases(this.#db, this.#schema);
4926
- const droppedRetained = dropFailedRows(
4927
- this.#db,
4949
+ this.#dropRetainedRows(
4950
+ batch,
4928
4951
  (name, values) =>
4929
4952
  name === table.name &&
4930
- Object.entries(lastEffective).every(([variable, allowed]) =>
4931
- allowed.includes(
4932
- String(values[table.scopeColumnByVariable.get(variable)!]),
4933
- ),
4934
- ),
4953
+ matchesLocalScopes(table, lastEffective, values),
4935
4954
  );
4936
- for (const commit of droppedRetained) {
4937
- batch.conflicts();
4938
- batch.rejections();
4939
- this.#conflicts = this.#conflicts.filter(
4940
- (record) => record.clientCommitId !== commit.clientCommitId,
4941
- );
4942
- this.#rejections = this.#rejections.filter(
4943
- (record) => record.clientCommitId !== commit.clientCommitId,
4944
- );
4945
- for (const operation of commit.operations)
4946
- batch.table(operation.table);
4947
- batch.outcomes();
4948
- }
4949
4955
  deleteScopedRows(this.#db, table, lastEffective);
4950
4956
  batch.scopeMap(table, lastEffective);
4951
4957
  const pendingById = new Map(
@@ -5229,18 +5235,52 @@ export class SyncClient {
5229
5235
  }
5230
5236
 
5231
5237
  /** Re-apply every pending outbox commit on top of server state (§7.1). */
5232
- #replayOutbox(): void {
5238
+ #discardAcknowledgedRows(): void {
5239
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
5240
+ }
5241
+
5242
+ #dropRetainedRows(
5243
+ batch: ChangeAccumulator,
5244
+ matches: (
5245
+ table: string,
5246
+ values: Readonly<Record<string, JsonRowValue>>,
5247
+ ) => boolean,
5248
+ ): void {
5249
+ for (const commit of dropFailedRows(this.#db, matches)) {
5250
+ batch.conflicts();
5251
+ batch.rejections();
5252
+ batch.outcomes();
5253
+ this.#conflicts = this.#conflicts.filter(
5254
+ (record) => record.clientCommitId !== commit.clientCommitId,
5255
+ );
5256
+ this.#rejections = this.#rejections.filter(
5257
+ (record) => record.clientCommitId !== commit.clientCommitId,
5258
+ );
5259
+ for (const op of commit.operations) batch.table(op.table);
5260
+ }
5261
+ }
5262
+
5263
+ #replayOutbox(rowKeys?: readonly string[]): void {
5264
+ const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
5233
5265
  const pending = [
5234
- ...failedOverlayCommits(this.#db),
5235
- ...listOutbox(this.#db),
5266
+ ...failedOverlayCommits(this.#db, false, rowKeys),
5267
+ ...listOutbox(this.#db, rowKeys),
5236
5268
  ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
5237
- if (pending.length === 0) return;
5269
+ if (pending.length === 0 && acknowledged.length === 0) return;
5238
5270
  this.#applyBatch((batch) => {
5239
5271
  this.#db.transaction(() => {
5240
- restoreFailedBases(this.#db, this.#schema);
5241
- restoreFailedBases(this.#db, this.#schema, true);
5242
- for (const commit of pending)
5243
- this.#applyOperationsLocally(commit.operations, batch, true);
5272
+ restoreFailedBases(this.#db, this.#schema, false, rowKeys);
5273
+ restoreFailedBases(this.#db, this.#schema, true, rowKeys);
5274
+ for (const commit of [...acknowledged, ...pending])
5275
+ this.#applyOperationsLocally(
5276
+ rowKeys === undefined
5277
+ ? commit.operations
5278
+ : commit.operations.filter((op) =>
5279
+ rowKeys.includes(JSON.stringify([op.table, op.rowId])),
5280
+ ),
5281
+ batch,
5282
+ true,
5283
+ );
5244
5284
  });
5245
5285
  });
5246
5286
  }
@@ -5255,17 +5295,14 @@ export class SyncClient {
5255
5295
  #table(name: string): CompiledClientTable {
5256
5296
  const table = this.#schema.tables.get(name);
5257
5297
  if (table === undefined) {
5258
- throw new ClientSyncError(
5259
- 'sync.unknown_table',
5260
- `unknown local table ${JSON.stringify(name)}`,
5261
- );
5298
+ throw new ClientSyncError('sync.unknown_table', `unknown table ${name}`);
5262
5299
  }
5263
5300
  return table;
5264
5301
  }
5265
5302
 
5266
5303
  #requireStarted(): void {
5267
5304
  if (!this.#started) {
5268
- throw invalidRequest('SyncClient.start() has not completed');
5305
+ throw invalidRequest('call SyncClient.start() first');
5269
5306
  }
5270
5307
  }
5271
5308
 
@@ -5274,7 +5311,7 @@ export class SyncClient {
5274
5311
  if (this.#securityLifecycle === 'preflight') {
5275
5312
  throw new ClientSyncError(
5276
5313
  SECURITY_PREFLIGHT_REQUIRED_CODE,
5277
- 'the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data',
5314
+ 'security preflight must complete before accessing protected data; call activateSecurity',
5278
5315
  );
5279
5316
  }
5280
5317
  }