@syncular/client 0.30.12 → 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
@@ -349,6 +349,7 @@ export interface SyncClientLimits {
349
349
  }
350
350
 
351
351
  export interface SyncClientConfig {
352
+ readonly transportEnabled?: boolean;
352
353
  /** Keep rejected/conflicting local intent visible until explicit resolution. */
353
354
  readonly retainFailedCommits?: boolean;
354
355
  readonly database: ClientDatabase;
@@ -747,6 +748,7 @@ export class SyncClient {
747
748
  /** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
748
749
  readonly #previousVersion: PreviousVersionCaptureConfig | undefined;
749
750
  #securityLifecycle: SecurityLifecycle;
751
+ #transportEnabled: boolean;
750
752
  readonly #now: () => number;
751
753
  readonly #outcomeRetentionMaxEntries: number;
752
754
  readonly #retainFailedCommits: boolean;
@@ -838,6 +840,13 @@ export class SyncClient {
838
840
  #preflightBarrier: Promise<void> | undefined;
839
841
 
840
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;
841
850
  if (config.securityPreflight === true && config.encryption !== undefined) {
842
851
  throw invalidRequest(
843
852
  'securityPreflight and encryption are mutually exclusive; activateSecurity installs keys',
@@ -1225,6 +1234,7 @@ export class SyncClient {
1225
1234
  }
1226
1235
 
1227
1236
  #emitSyncNeeded(reason: 'startup' | 'hello' | WakeReason): void {
1237
+ if (!this.#transportEnabled) return;
1228
1238
  try {
1229
1239
  this.#config.onSyncNeeded?.(reason);
1230
1240
  } catch {
@@ -1240,6 +1250,7 @@ export class SyncClient {
1240
1250
  }
1241
1251
 
1242
1252
  #emitSyncIntent(intent: SyncIntent): void {
1253
+ if (!this.#transportEnabled && intent.kind !== 'none') return;
1243
1254
  try {
1244
1255
  this.#config.onSyncIntent?.(intent);
1245
1256
  } catch {
@@ -1254,6 +1265,27 @@ export class SyncClient {
1254
1265
  }
1255
1266
  }
1256
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
+
1257
1289
  /** Current fail-closed local-replica security state. */
1258
1290
  securityLifecycle(): SecurityLifecycle {
1259
1291
  return this.#securityLifecycle;
@@ -1799,8 +1831,9 @@ export class SyncClient {
1799
1831
  }
1800
1832
  const capturedAtMs = this.#now();
1801
1833
  const leaseState = this.#diagnosticLease(capturedAtMs);
1802
- const connectivity =
1803
- this.#lastRound?.status === 'succeeded'
1834
+ const connectivity = !this.#transportEnabled
1835
+ ? 'offline'
1836
+ : this.#lastRound?.status === 'succeeded'
1804
1837
  ? 'online'
1805
1838
  : this.#lastRound?.status === 'failed' &&
1806
1839
  this.#transportFailureCode(this.#lastRound.errorCode)
@@ -1833,16 +1866,12 @@ export class SyncClient {
1833
1866
  realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
1834
1867
  }),
1835
1868
  securityLifecycle: this.#securityLifecycle,
1836
- schema: {
1869
+ schema: definedObject({
1837
1870
  currentVersion: this.#config.schema.version,
1838
1871
  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
- },
1872
+ requiredVersion: this.#schemaFloor?.requiredSchemaVersion,
1873
+ latestVersion: this.#schemaFloor?.latestSchemaVersion,
1874
+ }),
1846
1875
  replica: {
1847
1876
  localRevision: getLocalRevision(this.#db).toString(),
1848
1877
  syncNeeded: this.#needsPull,
@@ -2058,10 +2087,11 @@ export class SyncClient {
2058
2087
  this.#requireActive();
2059
2088
  const task = Promise.resolve().then(fn);
2060
2089
  this.#protectedAsync.add(task);
2061
- void task.then(
2062
- () => this.#protectedAsync.delete(task),
2063
- () => this.#protectedAsync.delete(task),
2064
- );
2090
+ const settled = () => {
2091
+ if (this.#realtimeConnectPromise === task)
2092
+ this.#realtimeConnectPromise = undefined;
2093
+ };
2094
+ void task.then(settled, settled);
2065
2095
  return task;
2066
2096
  }
2067
2097
 
@@ -2098,14 +2128,12 @@ export class SyncClient {
2098
2128
  recordPendingUpload(this.#db, blobId, nowMs, options?.mediaType);
2099
2129
  });
2100
2130
  this.#enforceBlobCacheCap();
2101
- return {
2131
+ return definedObject({
2102
2132
  blobId,
2103
2133
  byteLength: bytes.length,
2104
- ...(options?.mediaType !== undefined
2105
- ? { mediaType: options.mediaType }
2106
- : {}),
2107
- ...(options?.name !== undefined ? { name: options.name } : {}),
2108
- };
2134
+ mediaType: options?.mediaType,
2135
+ name: options?.name,
2136
+ });
2109
2137
  }
2110
2138
 
2111
2139
  /** Serialize a BlobRef to the canonical string a `blob_ref` column holds. */
@@ -2139,6 +2167,7 @@ export class SyncClient {
2139
2167
  // presign configured) a signed url the client fetches directly. On a url
2140
2168
  // arm the client MUST NOT attach host auth and MUST NOT fall through:
2141
2169
  // failure => re-request, the caller's next fetchBlob mints a fresh url.
2170
+ this.#requireTransport();
2142
2171
  const response = await transport.download(blobId);
2143
2172
  let bytes: Uint8Array;
2144
2173
  if (response.kind === 'url') {
@@ -2190,6 +2219,7 @@ export class SyncClient {
2190
2219
 
2191
2220
  /** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
2192
2221
  flushBlobUploads(): Promise<void> {
2222
+ this.#requireTransport();
2193
2223
  return this.#runProtectedAsync(() => this.#flushBlobUploads());
2194
2224
  }
2195
2225
 
@@ -2318,12 +2348,9 @@ export class SyncClient {
2318
2348
  );
2319
2349
  }
2320
2350
  const allowed =
2321
- (current.status === 'conflict' &&
2351
+ ((current.status === 'conflict' || current.status === 'rejected') &&
2322
2352
  (input.resolution === 'resolved_keep_server' ||
2323
2353
  input.resolution === 'superseded')) ||
2324
- (current.status === 'rejected' &&
2325
- (input.resolution === 'superseded' ||
2326
- input.resolution === 'resolved_keep_server')) ||
2327
2354
  ((current.status === 'applied' || current.status === 'cached') &&
2328
2355
  input.resolution === 'dismissed');
2329
2356
  if (!allowed) {
@@ -2426,6 +2453,7 @@ export class SyncClient {
2426
2453
  */
2427
2454
  setPresence(scopeKey: string, doc: Record<string, unknown> | null): void {
2428
2455
  this.#requireActive();
2456
+ this.#requireTransport();
2429
2457
  const socket = this.#socket;
2430
2458
  if (socket === undefined) {
2431
2459
  throw invalidRequest(
@@ -2594,7 +2622,10 @@ export class SyncClient {
2594
2622
  }
2595
2623
  });
2596
2624
  const effects: CommandEffects = {
2597
- sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
2625
+ sync:
2626
+ this.#transportEnabled && (changed || widened)
2627
+ ? { kind: 'interactive' }
2628
+ : { kind: 'none' },
2598
2629
  };
2599
2630
  if (effects.sync.kind === 'interactive') {
2600
2631
  this.#emitSyncIntent(effects.sync);
@@ -2898,7 +2929,9 @@ export class SyncClient {
2898
2929
  mutateCommand(mutations: readonly MutationInput[]): CommandResult<string> {
2899
2930
  return {
2900
2931
  value: this.mutate(mutations),
2901
- effects: { sync: { kind: 'interactive' } },
2932
+ effects: {
2933
+ sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
2934
+ },
2902
2935
  };
2903
2936
  }
2904
2937
 
@@ -2927,17 +2960,15 @@ export class SyncClient {
2927
2960
  );
2928
2961
  }
2929
2962
  return this.#recordMutations([
2930
- {
2963
+ definedObject({
2931
2964
  table,
2932
2965
  op: 'patch',
2933
2966
  values: {
2934
2967
  ...Object.fromEntries(normalized),
2935
2968
  [pkColumn.name]: rowId,
2936
2969
  },
2937
- ...(options?.baseVersion !== undefined
2938
- ? { baseVersion: options.baseVersion }
2939
- : {}),
2940
- },
2970
+ baseVersion: options?.baseVersion,
2971
+ }),
2941
2972
  ]);
2942
2973
  }
2943
2974
 
@@ -3222,7 +3253,9 @@ export class SyncClient {
3222
3253
  ): CommandResult<string> {
3223
3254
  return {
3224
3255
  value: this.patch(table, rowId, partial, options),
3225
- effects: { sync: { kind: 'interactive' } },
3256
+ effects: {
3257
+ sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
3258
+ },
3226
3259
  };
3227
3260
  }
3228
3261
 
@@ -3346,16 +3379,14 @@ export class SyncClient {
3346
3379
  );
3347
3380
  this.#rollbackFailedCommit(commit, batch);
3348
3381
  this.#replayOutbox();
3349
- const rejection: RejectionRecord = {
3382
+ const rejection: RejectionRecord = definedObject({
3350
3383
  clientCommitId: commit.clientCommitId,
3351
3384
  opIndex: 0,
3352
3385
  code,
3353
3386
  message,
3354
3387
  retryable: false,
3355
- ...(commit.operations[0] !== undefined
3356
- ? { operation: commit.operations[0] }
3357
- : {}),
3358
- };
3388
+ operation: commit.operations[0],
3389
+ });
3359
3390
  this.#rejections.push(rejection);
3360
3391
  recordCommitOutcome(this.#db, {
3361
3392
  clientCommitId: commit.clientCommitId,
@@ -3386,9 +3417,7 @@ export class SyncClient {
3386
3417
  this.#requireActive();
3387
3418
  if (this.#syncOutstanding) {
3388
3419
  return Promise.reject(
3389
- invalidRequest(
3390
- 'sync() is already running — the core owns one loop (coalesce wake-ups)',
3391
- ),
3420
+ invalidRequest('sync() is already running; coalesce wake-ups'),
3392
3421
  );
3393
3422
  }
3394
3423
  this.#syncOutstanding = true;
@@ -3438,6 +3467,7 @@ export class SyncClient {
3438
3467
  )
3439
3468
  .finally(() => {
3440
3469
  this.#syncOutstanding = false;
3470
+ if (!this.#transportEnabled) this.disconnectRealtime();
3441
3471
  this.#endDiagnosticsDeferral();
3442
3472
  });
3443
3473
  }
@@ -3476,6 +3506,7 @@ export class SyncClient {
3476
3506
  }
3477
3507
 
3478
3508
  async #runSync(): Promise<SyncSummary> {
3509
+ this.#requireTransport();
3479
3510
  this.#roundRetryDelayMs = undefined;
3480
3511
  this.#progress.emit({
3481
3512
  attempt: ++this.#progressAttempt,
@@ -3507,7 +3538,7 @@ export class SyncClient {
3507
3538
  this.#hasBlobs &&
3508
3539
  this.#config.blobs !== undefined
3509
3540
  ) {
3510
- await this.flushBlobUploads();
3541
+ await this.#flushBlobUploads();
3511
3542
  }
3512
3543
  // §7.4.4: encode the outbox with the CURRENT codec; a commit that
3513
3544
  // cannot express itself under the new schema (a dropped column/table)
@@ -3641,6 +3672,8 @@ export class SyncClient {
3641
3672
  loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]),
3642
3673
  );
3643
3674
  last = await this.sync();
3675
+ if (!this.#transportEnabled || last.schemaFloor !== undefined)
3676
+ return last;
3644
3677
  if (
3645
3678
  maxRounds === undefined &&
3646
3679
  last.segmentRowsApplied > 0 &&
@@ -3654,7 +3687,6 @@ export class SyncClient {
3654
3687
  )
3655
3688
  )
3656
3689
  round = -1;
3657
- if (last.schemaFloor !== undefined) return last;
3658
3690
  if (
3659
3691
  last.commitsApplied === 0 &&
3660
3692
  last.segmentRowsApplied === 0 &&
@@ -3770,6 +3802,7 @@ export class SyncClient {
3770
3802
 
3771
3803
  connectRealtime(): Promise<void> {
3772
3804
  this.#requireActive();
3805
+ this.#requireTransport();
3773
3806
  if (this.#realtimePolicy === 'off') {
3774
3807
  return Promise.reject(
3775
3808
  invalidRequest("realtimePolicy is 'off'; realtime connect is refused"),
@@ -3830,12 +3863,13 @@ export class SyncClient {
3830
3863
  },
3831
3864
  });
3832
3865
  openedSocket = socket;
3866
+ if (!this.#transportEnabled) {
3867
+ socket.close();
3868
+ this.#requireTransport();
3869
+ }
3833
3870
  if (this.#securityLifecycle === 'preflight') {
3834
3871
  socket.close();
3835
- throw new ClientSyncError(
3836
- SECURITY_PREFLIGHT_REQUIRED_CODE,
3837
- 'realtime connected after the client entered security preflight',
3838
- );
3872
+ this.#requireActive();
3839
3873
  }
3840
3874
  if (generation !== this.#realtimeGeneration || !this.#started) {
3841
3875
  socket.close();
@@ -3983,6 +4017,7 @@ export class SyncClient {
3983
4017
  }
3984
4018
 
3985
4019
  async #handleRealtimeBinary(bytes: Uint8Array): Promise<void> {
4020
+ if (!this.#transportEnabled) return;
3986
4021
  if (this.#syncing) {
3987
4022
  // Fast path: a pull is mid-flight; let it win and recover the gap
3988
4023
  // itself — re-pulling is idempotent, interleaved application is not
@@ -4010,7 +4045,8 @@ export class SyncClient {
4010
4045
  }
4011
4046
 
4012
4047
  #sendAck(cursor: number): void {
4013
- this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
4048
+ if (this.#transportEnabled)
4049
+ this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
4014
4050
  }
4015
4051
 
4016
4052
  /** Ack the highest cursor that is contiguously applied for every sub. */
@@ -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;
@@ -4712,6 +4683,33 @@ export class SyncClient {
4712
4683
  );
4713
4684
  }
4714
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
+
4715
4713
  /**
4716
4714
  * Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
4717
4715
  * (no local scope-column mapping) marks the subscription `failed` and
@@ -165,6 +165,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
165
165
  if (
166
166
  closed ||
167
167
  client === undefined ||
168
+ offline ||
168
169
  client.securityLifecycle() === 'preflight'
169
170
  ) {
170
171
  return;
@@ -189,6 +190,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
189
190
  !autoSync ||
190
191
  closed ||
191
192
  client === undefined ||
193
+ offline ||
192
194
  client.securityLifecycle() === 'preflight' ||
193
195
  intent.kind === 'none'
194
196
  ) {
@@ -235,41 +237,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
235
237
  return client;
236
238
  }
237
239
 
238
- function assertOnline(): void {
239
- if (offline) {
240
- throw new ClientSyncError(
241
- 'sync.transport_failed',
242
- 'the worker transport is offline (setOffline(true))',
243
- true,
244
- );
245
- }
246
- }
247
-
248
- function gateOffline<T>(inner: (request: T) => Promise<Uint8Array>) {
249
- return (request: T): Promise<Uint8Array> => {
250
- assertOnline();
251
- return inner(request);
252
- };
253
- }
254
-
255
- /** Offline gate that preserves the §5.4 `fetchUrl` capability marker. */
256
- function gateSegmentsOffline(inner: SegmentDownloader): SegmentDownloader {
257
- const fetchUrl = inner.fetchUrl;
258
- return Object.assign(gateOffline(inner), {
259
- ...(fetchUrl !== undefined
260
- ? {
261
- fetchUrl: (
262
- url: string,
263
- onProgress?: (bytesReceived: number) => void,
264
- ) => {
265
- assertOnline();
266
- return fetchUrl(url, onProgress);
267
- },
268
- }
269
- : {}),
270
- });
271
- }
272
-
273
240
  async function init(config: WorkerInitConfig): Promise<WorkerInitResult> {
274
241
  if (client !== undefined) {
275
242
  throw new ClientSyncError(
@@ -308,11 +275,10 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
308
275
  }
309
276
  }
310
277
  }
311
- const transport = gateOffline(
278
+ const transport =
312
279
  overrides.createTransport !== undefined
313
280
  ? overrides.createTransport(config)
314
- : httpSyncTransport(config.endpoints.syncUrl, http),
315
- );
281
+ : httpSyncTransport(config.endpoints.syncUrl, http);
316
282
  const segments =
317
283
  overrides.createSegments !== undefined
318
284
  ? overrides.createSegments(config)
@@ -329,22 +295,15 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
329
295
  // `{clientId}` URL placeholder — start the client first, then attach
330
296
  // the connector through a mutable slot read at connect time.
331
297
  let realtimeConnector: RealtimeConnector | undefined;
298
+ offline = config.transportEnabled === false;
332
299
  const started = new SyncClient({
300
+ transportEnabled: !offline,
333
301
  database,
334
302
  schema: config.schema,
335
303
  transport,
336
- ...(segments !== undefined
337
- ? { segments: gateSegmentsOffline(segments) }
338
- : {}),
304
+ ...(segments !== undefined ? { segments } : {}),
339
305
  ...(blobs !== undefined ? { blobs } : {}),
340
306
  realtime: (handlers) => {
341
- if (offline) {
342
- throw new ClientSyncError(
343
- 'sync.transport_failed',
344
- 'the worker realtime channel is offline (setOffline(true))',
345
- true,
346
- );
347
- }
348
307
  if (realtimeConnector === undefined) {
349
308
  throw new ClientSyncError(
350
309
  'sync.invalid_request',
@@ -515,8 +474,12 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
515
474
  },
516
475
  setOffline: (value) => {
517
476
  offline = value;
518
- if (offline) client?.disconnectRealtime();
519
- else if (client !== undefined) {
477
+ requireClient().setTransportEnabled(!value);
478
+ if (offline) {
479
+ if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
480
+ backgroundTimer = undefined;
481
+ backgroundDue = Number.POSITIVE_INFINITY;
482
+ } else if (client !== undefined) {
520
483
  const snapshot = client.diagnosticsSnapshot();
521
484
  post({
522
485
  t: 'event',
@@ -194,6 +194,7 @@ export interface SyncClientHandleConfig {
194
194
  readonly encryption?: EncryptionKeyringConfig;
195
195
  /** Open the worker-owned replica behind the fail-closed security gate. */
196
196
  readonly securityPreflight?: boolean;
197
+ readonly transportEnabled?: boolean;
197
198
  readonly clientId?: string;
198
199
  readonly limits?: SyncClientLimits;
199
200
  readonly retainFailedCommits?: boolean;
@@ -947,6 +948,9 @@ function buildInitConfig(
947
948
  ...(config.encryption !== undefined
948
949
  ? { encryption: config.encryption }
949
950
  : {}),
951
+ ...(config.transportEnabled !== undefined
952
+ ? { transportEnabled: config.transportEnabled }
953
+ : {}),
950
954
  ...(config.securityPreflight !== undefined
951
955
  ? { securityPreflight: config.securityPreflight }
952
956
  : {}),