@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/dist/client.d.ts CHANGED
@@ -104,6 +104,7 @@ export interface SyncClientLimits {
104
104
  readonly outcomeRetentionMaxEntries?: number;
105
105
  }
106
106
  export interface SyncClientConfig {
107
+ readonly transportEnabled?: boolean;
107
108
  /** Keep rejected/conflicting local intent visible until explicit resolution. */
108
109
  readonly retainFailedCommits?: boolean;
109
110
  readonly database: ClientDatabase;
@@ -295,6 +296,8 @@ export declare class SyncClient {
295
296
  /** Acquire leadership, create local tables, resolve the clientId. */
296
297
  start(): Promise<void>;
297
298
  close(): Promise<void>;
299
+ /** Host-owned transport policy; the local replica remains available. */
300
+ setTransportEnabled(enabled: boolean): void;
298
301
  /** Current fail-closed local-replica security state. */
299
302
  securityLifecycle(): SecurityLifecycle;
300
303
  /**
package/dist/client.js CHANGED
@@ -133,6 +133,7 @@ export class SyncClient {
133
133
  /** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
134
134
  #previousVersion;
135
135
  #securityLifecycle;
136
+ #transportEnabled;
136
137
  #now;
137
138
  #outcomeRetentionMaxEntries;
138
139
  #retainFailedCommits;
@@ -221,6 +222,11 @@ export class SyncClient {
221
222
  #protectedAsync = new Set();
222
223
  #preflightBarrier;
223
224
  constructor(config) {
225
+ if (config.transportEnabled !== undefined &&
226
+ typeof config.transportEnabled !== 'boolean') {
227
+ throw invalidRequest('transportEnabled must be a boolean');
228
+ }
229
+ this.#transportEnabled = config.transportEnabled !== false;
224
230
  if (config.securityPreflight === true && config.encryption !== undefined) {
225
231
  throw invalidRequest('securityPreflight and encryption are mutually exclusive; activateSecurity installs keys');
226
232
  }
@@ -537,6 +543,8 @@ export class SyncClient {
537
543
  this.#syncIntentListeners.clear();
538
544
  }
539
545
  #emitSyncNeeded(reason) {
546
+ if (!this.#transportEnabled)
547
+ return;
540
548
  try {
541
549
  this.#config.onSyncNeeded?.(reason);
542
550
  }
@@ -553,6 +561,8 @@ export class SyncClient {
553
561
  }
554
562
  }
555
563
  #emitSyncIntent(intent) {
564
+ if (!this.#transportEnabled && intent.kind !== 'none')
565
+ return;
556
566
  try {
557
567
  this.#config.onSyncIntent?.(intent);
558
568
  }
@@ -568,6 +578,25 @@ export class SyncClient {
568
578
  }
569
579
  }
570
580
  }
581
+ /** Host-owned transport policy; the local replica remains available. */
582
+ setTransportEnabled(enabled) {
583
+ this.#requireStarted();
584
+ if (typeof enabled !== 'boolean')
585
+ throw invalidRequest('transportEnabled must be a boolean');
586
+ if (this.#transportEnabled === enabled)
587
+ return;
588
+ this.#transportEnabled = enabled;
589
+ if (!enabled && !this.#syncOutstanding)
590
+ this.disconnectRealtime();
591
+ this.#emitSyncIntent(enabled && this.#securityLifecycle === 'active'
592
+ ? { kind: 'interactive' }
593
+ : { kind: 'none' });
594
+ this.#emitDiagnostics();
595
+ }
596
+ #requireTransport() {
597
+ if (!this.#transportEnabled)
598
+ throw new ClientSyncError('sync.offline', 'transport gate is closed');
599
+ }
571
600
  /** Current fail-closed local-replica security state. */
572
601
  securityLifecycle() {
573
602
  return this.#securityLifecycle;
@@ -1028,12 +1057,14 @@ export class SyncClient {
1028
1057
  }
1029
1058
  const capturedAtMs = this.#now();
1030
1059
  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';
1060
+ const connectivity = !this.#transportEnabled
1061
+ ? 'offline'
1062
+ : this.#lastRound?.status === 'succeeded'
1063
+ ? 'online'
1064
+ : this.#lastRound?.status === 'failed' &&
1065
+ this.#transportFailureCode(this.#lastRound.errorCode)
1066
+ ? 'offline'
1067
+ : 'unknown';
1037
1068
  const expectedOrder = new Map(expected.map((item, index) => [item.id, index]));
1038
1069
  const allSubscriptions = [...subscriptions.values()].sort((a, b) => {
1039
1070
  const aExpected = expectedOrder.get(a.id);
@@ -1057,16 +1088,12 @@ export class SyncClient {
1057
1088
  realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
1058
1089
  }),
1059
1090
  securityLifecycle: this.#securityLifecycle,
1060
- schema: {
1091
+ schema: definedObject({
1061
1092
  currentVersion: this.#config.schema.version,
1062
1093
  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
- },
1094
+ requiredVersion: this.#schemaFloor?.requiredSchemaVersion,
1095
+ latestVersion: this.#schemaFloor?.latestSchemaVersion,
1096
+ }),
1070
1097
  replica: {
1071
1098
  localRevision: getLocalRevision(this.#db).toString(),
1072
1099
  syncNeeded: this.#needsPull,
@@ -1246,7 +1273,11 @@ export class SyncClient {
1246
1273
  this.#requireActive();
1247
1274
  const task = Promise.resolve().then(fn);
1248
1275
  this.#protectedAsync.add(task);
1249
- void task.then(() => this.#protectedAsync.delete(task), () => this.#protectedAsync.delete(task));
1276
+ const settled = () => {
1277
+ if (this.#realtimeConnectPromise === task)
1278
+ this.#realtimeConnectPromise = undefined;
1279
+ };
1280
+ void task.then(settled, settled);
1250
1281
  return task;
1251
1282
  }
1252
1283
  // -- blobs (§5.9) ---------------------------------------------------------
@@ -1272,14 +1303,12 @@ export class SyncClient {
1272
1303
  recordPendingUpload(this.#db, blobId, nowMs, options?.mediaType);
1273
1304
  });
1274
1305
  this.#enforceBlobCacheCap();
1275
- return {
1306
+ return definedObject({
1276
1307
  blobId,
1277
1308
  byteLength: bytes.length,
1278
- ...(options?.mediaType !== undefined
1279
- ? { mediaType: options.mediaType }
1280
- : {}),
1281
- ...(options?.name !== undefined ? { name: options.name } : {}),
1282
- };
1309
+ mediaType: options?.mediaType,
1310
+ name: options?.name,
1311
+ });
1283
1312
  }
1284
1313
  /** Serialize a BlobRef to the canonical string a `blob_ref` column holds. */
1285
1314
  blobRefString(ref) {
@@ -1309,6 +1338,7 @@ export class SyncClient {
1309
1338
  // presign configured) a signed url the client fetches directly. On a url
1310
1339
  // arm the client MUST NOT attach host auth and MUST NOT fall through:
1311
1340
  // failure => re-request, the caller's next fetchBlob mints a fresh url.
1341
+ this.#requireTransport();
1312
1342
  const response = await transport.download(blobId);
1313
1343
  let bytes;
1314
1344
  if (response.kind === 'url') {
@@ -1350,6 +1380,7 @@ export class SyncClient {
1350
1380
  }
1351
1381
  /** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
1352
1382
  flushBlobUploads() {
1383
+ this.#requireTransport();
1353
1384
  return this.#runProtectedAsync(() => this.#flushBlobUploads());
1354
1385
  }
1355
1386
  async #flushBlobUploads() {
@@ -1443,12 +1474,9 @@ export class SyncClient {
1443
1474
  else if (replacement !== undefined) {
1444
1475
  throw invalidRequest('replacementClientCommitId is valid only for superseded outcomes');
1445
1476
  }
1446
- const allowed = (current.status === 'conflict' &&
1477
+ const allowed = ((current.status === 'conflict' || current.status === 'rejected') &&
1447
1478
  (input.resolution === 'resolved_keep_server' ||
1448
1479
  input.resolution === 'superseded')) ||
1449
- (current.status === 'rejected' &&
1450
- (input.resolution === 'superseded' ||
1451
- input.resolution === 'resolved_keep_server')) ||
1452
1480
  ((current.status === 'applied' || current.status === 'cached') &&
1453
1481
  input.resolution === 'dismissed');
1454
1482
  if (!allowed) {
@@ -1529,6 +1557,7 @@ export class SyncClient {
1529
1557
  */
1530
1558
  setPresence(scopeKey, doc) {
1531
1559
  this.#requireActive();
1560
+ this.#requireTransport();
1532
1561
  const socket = this.#socket;
1533
1562
  if (socket === undefined) {
1534
1563
  throw invalidRequest('setPresence requires a connected realtime socket (§8.6)');
@@ -1667,7 +1696,9 @@ export class SyncClient {
1667
1696
  }
1668
1697
  });
1669
1698
  const effects = {
1670
- sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
1699
+ sync: this.#transportEnabled && (changed || widened)
1700
+ ? { kind: 'interactive' }
1701
+ : { kind: 'none' },
1671
1702
  };
1672
1703
  if (effects.sync.kind === 'interactive') {
1673
1704
  this.#emitSyncIntent(effects.sync);
@@ -1922,7 +1953,9 @@ export class SyncClient {
1922
1953
  mutateCommand(mutations) {
1923
1954
  return {
1924
1955
  value: this.mutate(mutations),
1925
- effects: { sync: { kind: 'interactive' } },
1956
+ effects: {
1957
+ sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
1958
+ },
1926
1959
  };
1927
1960
  }
1928
1961
  /**
@@ -1943,17 +1976,15 @@ export class SyncClient {
1943
1976
  throw invalidRequest(`table ${compiled.name}: patch cannot change the primary key`);
1944
1977
  }
1945
1978
  return this.#recordMutations([
1946
- {
1979
+ definedObject({
1947
1980
  table,
1948
1981
  op: 'patch',
1949
1982
  values: {
1950
1983
  ...Object.fromEntries(normalized),
1951
1984
  [pkColumn.name]: rowId,
1952
1985
  },
1953
- ...(options?.baseVersion !== undefined
1954
- ? { baseVersion: options.baseVersion }
1955
- : {}),
1956
- },
1986
+ baseVersion: options?.baseVersion,
1987
+ }),
1957
1988
  ]);
1958
1989
  }
1959
1990
  /**
@@ -2187,7 +2218,9 @@ export class SyncClient {
2187
2218
  patchCommand(table, rowId, partial, options) {
2188
2219
  return {
2189
2220
  value: this.patch(table, rowId, partial, options),
2190
- effects: { sync: { kind: 'interactive' } },
2221
+ effects: {
2222
+ sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
2223
+ },
2191
2224
  };
2192
2225
  }
2193
2226
  // -- lease state (§7.3.5) ---------------------------------------------------
@@ -2282,16 +2315,14 @@ export class SyncClient {
2282
2315
  retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
2283
2316
  this.#rollbackFailedCommit(commit, batch);
2284
2317
  this.#replayOutbox();
2285
- const rejection = {
2318
+ const rejection = definedObject({
2286
2319
  clientCommitId: commit.clientCommitId,
2287
2320
  opIndex: 0,
2288
2321
  code,
2289
2322
  message,
2290
2323
  retryable: false,
2291
- ...(commit.operations[0] !== undefined
2292
- ? { operation: commit.operations[0] }
2293
- : {}),
2294
- };
2324
+ operation: commit.operations[0],
2325
+ });
2295
2326
  this.#rejections.push(rejection);
2296
2327
  recordCommitOutcome(this.#db, {
2297
2328
  clientCommitId: commit.clientCommitId,
@@ -2319,7 +2350,7 @@ export class SyncClient {
2319
2350
  sync() {
2320
2351
  this.#requireActive();
2321
2352
  if (this.#syncOutstanding) {
2322
- return Promise.reject(invalidRequest('sync() is already running — the core owns one loop (coalesce wake-ups)'));
2353
+ return Promise.reject(invalidRequest('sync() is already running; coalesce wake-ups'));
2323
2354
  }
2324
2355
  this.#syncOutstanding = true;
2325
2356
  this.#beginDiagnosticsDeferral();
@@ -2361,6 +2392,8 @@ export class SyncClient {
2361
2392
  })
2362
2393
  .finally(() => {
2363
2394
  this.#syncOutstanding = false;
2395
+ if (!this.#transportEnabled)
2396
+ this.disconnectRealtime();
2364
2397
  this.#endDiagnosticsDeferral();
2365
2398
  });
2366
2399
  }
@@ -2393,6 +2426,7 @@ export class SyncClient {
2393
2426
  : 'client.unknown_failure';
2394
2427
  }
2395
2428
  async #runSync() {
2429
+ this.#requireTransport();
2396
2430
  this.#roundRetryDelayMs = undefined;
2397
2431
  this.#progress.emit({
2398
2432
  attempt: ++this.#progressAttempt,
@@ -2422,7 +2456,7 @@ export class SyncClient {
2422
2456
  if (logEpoch !== undefined &&
2423
2457
  this.#hasBlobs &&
2424
2458
  this.#config.blobs !== undefined) {
2425
- await this.flushBlobUploads();
2459
+ await this.#flushBlobUploads();
2426
2460
  }
2427
2461
  // §7.4.4: encode the outbox with the CURRENT codec; a commit that
2428
2462
  // cannot express itself under the new schema (a dropped column/table)
@@ -2538,6 +2572,8 @@ export class SyncClient {
2538
2572
  for (let round = 0; round < budget; round++) {
2539
2573
  const before = JSON.stringify(loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]));
2540
2574
  last = await this.sync();
2575
+ if (!this.#transportEnabled || last.schemaFloor !== undefined)
2576
+ return last;
2541
2577
  if (maxRounds === undefined &&
2542
2578
  last.segmentRowsApplied > 0 &&
2543
2579
  last.bootstrapping.length > 0 &&
@@ -2547,8 +2583,6 @@ export class SyncClient {
2547
2583
  sub.bootstrapState,
2548
2584
  ])))
2549
2585
  round = -1;
2550
- if (last.schemaFloor !== undefined)
2551
- return last;
2552
2586
  if (last.commitsApplied === 0 &&
2553
2587
  last.segmentRowsApplied === 0 &&
2554
2588
  last.bootstrapping.length === 0 &&
@@ -2640,6 +2674,7 @@ export class SyncClient {
2640
2674
  }
2641
2675
  connectRealtime() {
2642
2676
  this.#requireActive();
2677
+ this.#requireTransport();
2643
2678
  if (this.#realtimePolicy === 'off') {
2644
2679
  return Promise.reject(invalidRequest("realtimePolicy is 'off'; realtime connect is refused"));
2645
2680
  }
@@ -2695,9 +2730,13 @@ export class SyncClient {
2695
2730
  },
2696
2731
  });
2697
2732
  openedSocket = socket;
2733
+ if (!this.#transportEnabled) {
2734
+ socket.close();
2735
+ this.#requireTransport();
2736
+ }
2698
2737
  if (this.#securityLifecycle === 'preflight') {
2699
2738
  socket.close();
2700
- throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'realtime connected after the client entered security preflight');
2739
+ this.#requireActive();
2701
2740
  }
2702
2741
  if (generation !== this.#realtimeGeneration || !this.#started) {
2703
2742
  socket.close();
@@ -2834,6 +2873,8 @@ export class SyncClient {
2834
2873
  }
2835
2874
  }
2836
2875
  async #handleRealtimeBinary(bytes) {
2876
+ if (!this.#transportEnabled)
2877
+ return;
2837
2878
  if (this.#syncing) {
2838
2879
  // Fast path: a pull is mid-flight; let it win and recover the gap
2839
2880
  // itself — re-pulling is idempotent, interleaved application is not
@@ -2862,7 +2903,8 @@ export class SyncClient {
2862
2903
  });
2863
2904
  }
2864
2905
  #sendAck(cursor) {
2865
- this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
2906
+ if (this.#transportEnabled)
2907
+ this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
2866
2908
  }
2867
2909
  /** Ack the highest cursor that is contiguously applied for every sub. */
2868
2910
  #ackAfterPull() {
@@ -3017,58 +3059,24 @@ export class SyncClient {
3017
3059
  await this.#applyCommit(frame, summary);
3018
3060
  }
3019
3061
  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
- }
3062
+ case 'SEGMENT_INLINE':
3055
3063
  case 'SEGMENT_REF': {
3056
3064
  if (section === undefined ||
3057
3065
  section.skip ||
3058
- section.sub === undefined) {
3066
+ section.sub === undefined)
3059
3067
  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' &&
3068
+ if (frame.type === 'SEGMENT_REF' &&
3069
+ frame.mediaType === 'sqlite' &&
3064
3070
  (this.#acceptMask() & ACCEPT_SQLITE) === 0) {
3065
3071
  throw invalidRequest('SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
3066
3072
  }
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.
3073
+ const bytes = frame.type === 'SEGMENT_REF'
3074
+ ? await this.#downloadSegment(frame, section.sub)
3075
+ : frame.payload;
3076
+ if (frame.type === 'SEGMENT_REF')
3077
+ this.#progress.update({ phase: 'import' });
3078
+ if (frame.type === 'SEGMENT_REF' && frame.mediaType === 'sqlite') {
3079
+ // Images are whole-table; paged image descriptors are invalid.
3072
3080
  if (frame.rowCursor !== undefined ||
3073
3081
  frame.nextRowCursor !== undefined) {
3074
3082
  throw invalidRequest('sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
@@ -3078,43 +3086,26 @@ export class SyncClient {
3078
3086
  rowCount: frame.rowCount,
3079
3087
  asOfCommitSeq: frame.asOfCommitSeq,
3080
3088
  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);
3089
+ }, this.#segmentApplyOptions(table, clearFirst, effective, frame.rowCount > 0, resetEpoch)), section.fresh && !section.cleared);
3097
3090
  }
3098
3091
  else {
3099
3092
  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 &&
3093
+ if (frame.type === 'SEGMENT_INLINE') {
3094
+ this.#progress.update({
3095
+ phase: 'import',
3096
+ subscriptionId: section.sub.id,
3097
+ table: section.sub.table,
3098
+ segmentId: undefined,
3099
+ bytesReceived: bytes.byteLength,
3100
+ bytesTotal: bytes.byteLength,
3101
+ rowsProcessed: 0,
3102
+ rowsTotal: segment.blocks.reduce((n, block) => n + block.length, 0),
3103
+ });
3104
+ }
3105
+ 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
3106
  !section.cleared &&
3117
- frame.rowCursor === undefined);
3107
+ (frame.type === 'SEGMENT_INLINE' ||
3108
+ frame.rowCursor === undefined));
3118
3109
  }
3119
3110
  break;
3120
3111
  }
@@ -3378,6 +3369,23 @@ export class SyncClient {
3378
3369
  return (scope !== undefined &&
3379
3370
  this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`, scope.params).length > 0);
3380
3371
  }
3372
+ /** Shared transaction, reset fence and progress for both segment formats. */
3373
+ #segmentApplyOptions(table, clearFirst, effective, hasRows, resetEpoch) {
3374
+ return {
3375
+ clearFirst,
3376
+ effective,
3377
+ isCurrent: () => this.#localResetEpoch === resetEpoch,
3378
+ onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
3379
+ transaction: (fn) => this.#applyBatch((batch) => {
3380
+ if (hasRows ||
3381
+ (clearFirst && this.#scopedRowsExist(table, effective)))
3382
+ batch.table(table.name);
3383
+ const result = fn();
3384
+ this.#replayOutbox();
3385
+ return result;
3386
+ }),
3387
+ };
3388
+ }
3381
3389
  /**
3382
3390
  * Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
3383
3391
  * (no local scope-column mapping) marks the subscription `failed` and
@@ -94,6 +94,7 @@ export function startSyncWorker(overrides = {}) {
94
94
  autoSyncScheduled = false;
95
95
  if (closed ||
96
96
  client === undefined ||
97
+ offline ||
97
98
  client.securityLifecycle() === 'preflight') {
98
99
  return;
99
100
  }
@@ -116,6 +117,7 @@ export function startSyncWorker(overrides = {}) {
116
117
  if (!autoSync ||
117
118
  closed ||
118
119
  client === undefined ||
120
+ offline ||
119
121
  client.securityLifecycle() === 'preflight' ||
120
122
  intent.kind === 'none') {
121
123
  return;
@@ -156,31 +158,6 @@ export function startSyncWorker(overrides = {}) {
156
158
  }
157
159
  return client;
158
160
  }
159
- function assertOnline() {
160
- if (offline) {
161
- throw new ClientSyncError('sync.transport_failed', 'the worker transport is offline (setOffline(true))', true);
162
- }
163
- }
164
- function gateOffline(inner) {
165
- return (request) => {
166
- assertOnline();
167
- return inner(request);
168
- };
169
- }
170
- /** Offline gate that preserves the §5.4 `fetchUrl` capability marker. */
171
- function gateSegmentsOffline(inner) {
172
- const fetchUrl = inner.fetchUrl;
173
- return Object.assign(gateOffline(inner), {
174
- ...(fetchUrl !== undefined
175
- ? {
176
- fetchUrl: (url, onProgress) => {
177
- assertOnline();
178
- return fetchUrl(url, onProgress);
179
- },
180
- }
181
- : {}),
182
- });
183
- }
184
161
  async function init(config) {
185
162
  if (client !== undefined) {
186
163
  throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
@@ -215,9 +192,9 @@ export function startSyncWorker(overrides = {}) {
215
192
  }
216
193
  }
217
194
  }
218
- const transport = gateOffline(overrides.createTransport !== undefined
195
+ const transport = overrides.createTransport !== undefined
219
196
  ? overrides.createTransport(config)
220
- : httpSyncTransport(config.endpoints.syncUrl, http));
197
+ : httpSyncTransport(config.endpoints.syncUrl, http);
221
198
  const segments = overrides.createSegments !== undefined
222
199
  ? overrides.createSegments(config)
223
200
  : config.endpoints.segmentsUrl !== undefined
@@ -231,18 +208,15 @@ export function startSyncWorker(overrides = {}) {
231
208
  // `{clientId}` URL placeholder — start the client first, then attach
232
209
  // the connector through a mutable slot read at connect time.
233
210
  let realtimeConnector;
211
+ offline = config.transportEnabled === false;
234
212
  const started = new SyncClient({
213
+ transportEnabled: !offline,
235
214
  database,
236
215
  schema: config.schema,
237
216
  transport,
238
- ...(segments !== undefined
239
- ? { segments: gateSegmentsOffline(segments) }
240
- : {}),
217
+ ...(segments !== undefined ? { segments } : {}),
241
218
  ...(blobs !== undefined ? { blobs } : {}),
242
219
  realtime: (handlers) => {
243
- if (offline) {
244
- throw new ClientSyncError('sync.transport_failed', 'the worker realtime channel is offline (setOffline(true))', true);
245
- }
246
220
  if (realtimeConnector === undefined) {
247
221
  throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
248
222
  }
@@ -399,8 +373,13 @@ export function startSyncWorker(overrides = {}) {
399
373
  },
400
374
  setOffline: (value) => {
401
375
  offline = value;
402
- if (offline)
403
- client?.disconnectRealtime();
376
+ requireClient().setTransportEnabled(!value);
377
+ if (offline) {
378
+ if (backgroundTimer !== undefined)
379
+ clearTimeout(backgroundTimer);
380
+ backgroundTimer = undefined;
381
+ backgroundDue = Number.POSITIVE_INFINITY;
382
+ }
404
383
  else if (client !== undefined) {
405
384
  const snapshot = client.diagnosticsSnapshot();
406
385
  post({
@@ -85,6 +85,7 @@ export interface SyncClientHandleConfig {
85
85
  readonly encryption?: EncryptionKeyringConfig;
86
86
  /** Open the worker-owned replica behind the fail-closed security gate. */
87
87
  readonly securityPreflight?: boolean;
88
+ readonly transportEnabled?: boolean;
88
89
  readonly clientId?: string;
89
90
  readonly limits?: SyncClientLimits;
90
91
  readonly retainFailedCommits?: boolean;
@@ -566,6 +566,9 @@ function buildInitConfig(config, headers = config.headers) {
566
566
  ...(config.encryption !== undefined
567
567
  ? { encryption: config.encryption }
568
568
  : {}),
569
+ ...(config.transportEnabled !== undefined
570
+ ? { transportEnabled: config.transportEnabled }
571
+ : {}),
569
572
  ...(config.securityPreflight !== undefined
570
573
  ? { securityPreflight: config.securityPreflight }
571
574
  : {}),
@@ -78,6 +78,7 @@ export interface WorkerInitConfig {
78
78
  readonly encryption?: EncryptionKeyringConfig;
79
79
  /** Open the worker-owned replica behind the fail-closed security gate. */
80
80
  readonly securityPreflight?: boolean;
81
+ readonly transportEnabled?: boolean;
81
82
  readonly clientId?: string;
82
83
  readonly limits?: SyncClientLimits;
83
84
  readonly retainFailedCommits?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.30.12",
3
+ "version": "0.30.13",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -89,10 +89,10 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.30.12"
92
+ "@syncular/core": "0.30.13"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.30.12",
95
+ "@syncular/server": "0.30.13",
96
96
  "playwright": "1.63.0",
97
97
  "@electric-sql/pglite": "^0.5.4"
98
98
  }