@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/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { RETAINED_ROWS } from './failed-overlay.js';
1
2
  import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, uniqueConflicts, } from './failed-overlay.js';
2
3
  import { ProgressEmitter, } from './progress.js';
3
4
  /**
@@ -11,7 +12,7 @@ import { ProgressEmitter, } from './progress.js';
11
12
  */
12
13
  import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
13
14
  import { EncryptError } from '@syncular/core';
14
- import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
15
+ import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, localScopePredicate, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
15
16
  import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
16
17
  import { registerDevtools } from './devtools.js';
17
18
  import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, MAX_DIAGNOSTIC_QUERY_FAILURES, } from './diagnostics.js';
@@ -25,7 +26,7 @@ import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encod
25
26
  import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
26
27
  import { assertReadOnlyQuery } from './query-guard.js';
27
28
  import { buildPreviousVersionAudit, capturePreviousVersion, clearPreviousVersionAudit, clearPreviousVersionRefusal, dropPreviousVersionContainer, loadLocalSchemaDescriptor, PREVIOUS_VERSION_CONTAINER_NAME, PREVIOUS_VERSION_MAX_LIMIT, readPreviousVersionContainer, readPreviousVersionRows, setLocalSchemaDescriptor, storedPreviousVersionAudit, storedPreviousVersionRefusal, withPreviousVersionContainer, writePreviousVersionAudit, writePreviousVersionRefusal, } from './previous-version.js';
28
- import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
29
+ import { compileClientSchema, matchesLocalScopes, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
29
30
  import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, saveSubscriptionScopeSchema, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
30
31
  import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
31
32
  /** Stable client-local error while protected operations are preflight-gated. */
@@ -132,6 +133,7 @@ export class SyncClient {
132
133
  /** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
133
134
  #previousVersion;
134
135
  #securityLifecycle;
136
+ #transportEnabled;
135
137
  #now;
136
138
  #outcomeRetentionMaxEntries;
137
139
  #retainFailedCommits;
@@ -220,8 +222,13 @@ export class SyncClient {
220
222
  #protectedAsync = new Set();
221
223
  #preflightBarrier;
222
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;
223
230
  if (config.securityPreflight === true && config.encryption !== undefined) {
224
- throw invalidRequest('securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
231
+ throw invalidRequest('securityPreflight and encryption are mutually exclusive; activateSecurity installs keys');
225
232
  }
226
233
  this.#retainFailedCommits = config.retainFailedCommits === true;
227
234
  this.#db = config.database;
@@ -287,7 +294,7 @@ export class SyncClient {
287
294
  persisted !== this.#config.clientId) {
288
295
  await this.#lease.release();
289
296
  this.#lease = undefined;
290
- throw new ClientSyncError('client.identity_mismatch', `this client database belongs to ${JSON.stringify(persisted)}; refusing to rebind it to ${JSON.stringify(this.#config.clientId)}`);
297
+ throw new ClientSyncError('client.identity_mismatch', `database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`);
291
298
  }
292
299
  this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
293
300
  if (persisted === undefined) {
@@ -319,6 +326,7 @@ export class SyncClient {
319
326
  // an application-issued sync() call.
320
327
  const startupWork = this.#schemaFloor === undefined &&
321
328
  (countOutbox(this.#db) > 0 ||
329
+ this.#db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL LIMIT 1`).length > 0 ||
322
330
  loadPendingEvictions(this.#db).length > 0 ||
323
331
  subscriptions.some((sub) => sub.status === 'active'));
324
332
  if (startupWork && this.#securityLifecycle === 'active') {
@@ -396,6 +404,7 @@ export class SyncClient {
396
404
  writePreviousVersionAudit(this.#db, buildPreviousVersionAudit(this.#schema, listOutbox(this.#db), previousVersion, this.#schema.version, this.#now()));
397
405
  }
398
406
  pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema, true);
407
+ this.#discardAcknowledgedRows();
399
408
  dropAndRecreateSyncedTables(this.#db, this.#schema);
400
409
  resetSubscriptionsForBump(this.#db);
401
410
  setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
@@ -477,6 +486,7 @@ export class SyncClient {
477
486
  this.#sweepPreviousVersionContainer();
478
487
  this.#applyBatch((batch) => {
479
488
  this.#db.transaction(() => {
489
+ this.#discardAcknowledgedRows();
480
490
  dropAndRecreateSyncedTables(this.#db, this.#schema);
481
491
  resetSubscriptionsForBump(this.#db);
482
492
  setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
@@ -533,6 +543,8 @@ export class SyncClient {
533
543
  this.#syncIntentListeners.clear();
534
544
  }
535
545
  #emitSyncNeeded(reason) {
546
+ if (!this.#transportEnabled)
547
+ return;
536
548
  try {
537
549
  this.#config.onSyncNeeded?.(reason);
538
550
  }
@@ -549,6 +561,8 @@ export class SyncClient {
549
561
  }
550
562
  }
551
563
  #emitSyncIntent(intent) {
564
+ if (!this.#transportEnabled && intent.kind !== 'none')
565
+ return;
552
566
  try {
553
567
  this.#config.onSyncIntent?.(intent);
554
568
  }
@@ -564,6 +578,25 @@ export class SyncClient {
564
578
  }
565
579
  }
566
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
+ }
567
600
  /** Current fail-closed local-replica security state. */
568
601
  securityLifecycle() {
569
602
  return this.#securityLifecycle;
@@ -1024,12 +1057,14 @@ export class SyncClient {
1024
1057
  }
1025
1058
  const capturedAtMs = this.#now();
1026
1059
  const leaseState = this.#diagnosticLease(capturedAtMs);
1027
- const connectivity = this.#lastRound?.status === 'succeeded'
1028
- ? 'online'
1029
- : this.#lastRound?.status === 'failed' &&
1030
- this.#transportFailureCode(this.#lastRound.errorCode)
1031
- ? 'offline'
1032
- : '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';
1033
1068
  const expectedOrder = new Map(expected.map((item, index) => [item.id, index]));
1034
1069
  const allSubscriptions = [...subscriptions.values()].sort((a, b) => {
1035
1070
  const aExpected = expectedOrder.get(a.id);
@@ -1053,16 +1088,12 @@ export class SyncClient {
1053
1088
  realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
1054
1089
  }),
1055
1090
  securityLifecycle: this.#securityLifecycle,
1056
- schema: {
1091
+ schema: definedObject({
1057
1092
  currentVersion: this.#config.schema.version,
1058
1093
  upgrading: this.#upgrading,
1059
- ...(this.#schemaFloor?.requiredSchemaVersion !== undefined
1060
- ? { requiredVersion: this.#schemaFloor.requiredSchemaVersion }
1061
- : {}),
1062
- ...(this.#schemaFloor?.latestSchemaVersion !== undefined
1063
- ? { latestVersion: this.#schemaFloor.latestSchemaVersion }
1064
- : {}),
1065
- },
1094
+ requiredVersion: this.#schemaFloor?.requiredSchemaVersion,
1095
+ latestVersion: this.#schemaFloor?.latestSchemaVersion,
1096
+ }),
1066
1097
  replica: {
1067
1098
  localRevision: getLocalRevision(this.#db).toString(),
1068
1099
  syncNeeded: this.#needsPull,
@@ -1179,7 +1210,7 @@ export class SyncClient {
1179
1210
  try {
1180
1211
  result = fn(batch);
1181
1212
  if (this.#db
1182
- .query('SELECT DISTINCT tbl FROM _syncular_failed_rows')
1213
+ .query(`SELECT DISTINCT tbl FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL`)
1183
1214
  .some((row) => batch.hasTable(String(row.tbl))))
1184
1215
  batch.outcomes();
1185
1216
  if (batch.touched) {
@@ -1242,7 +1273,11 @@ export class SyncClient {
1242
1273
  this.#requireActive();
1243
1274
  const task = Promise.resolve().then(fn);
1244
1275
  this.#protectedAsync.add(task);
1245
- 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);
1246
1281
  return task;
1247
1282
  }
1248
1283
  // -- blobs (§5.9) ---------------------------------------------------------
@@ -1268,14 +1303,12 @@ export class SyncClient {
1268
1303
  recordPendingUpload(this.#db, blobId, nowMs, options?.mediaType);
1269
1304
  });
1270
1305
  this.#enforceBlobCacheCap();
1271
- return {
1306
+ return definedObject({
1272
1307
  blobId,
1273
1308
  byteLength: bytes.length,
1274
- ...(options?.mediaType !== undefined
1275
- ? { mediaType: options.mediaType }
1276
- : {}),
1277
- ...(options?.name !== undefined ? { name: options.name } : {}),
1278
- };
1309
+ mediaType: options?.mediaType,
1310
+ name: options?.name,
1311
+ });
1279
1312
  }
1280
1313
  /** Serialize a BlobRef to the canonical string a `blob_ref` column holds. */
1281
1314
  blobRefString(ref) {
@@ -1305,6 +1338,7 @@ export class SyncClient {
1305
1338
  // presign configured) a signed url the client fetches directly. On a url
1306
1339
  // arm the client MUST NOT attach host auth and MUST NOT fall through:
1307
1340
  // failure => re-request, the caller's next fetchBlob mints a fresh url.
1341
+ this.#requireTransport();
1308
1342
  const response = await transport.download(blobId);
1309
1343
  let bytes;
1310
1344
  if (response.kind === 'url') {
@@ -1346,6 +1380,7 @@ export class SyncClient {
1346
1380
  }
1347
1381
  /** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
1348
1382
  flushBlobUploads() {
1383
+ this.#requireTransport();
1349
1384
  return this.#runProtectedAsync(() => this.#flushBlobUploads());
1350
1385
  }
1351
1386
  async #flushBlobUploads() {
@@ -1439,12 +1474,9 @@ export class SyncClient {
1439
1474
  else if (replacement !== undefined) {
1440
1475
  throw invalidRequest('replacementClientCommitId is valid only for superseded outcomes');
1441
1476
  }
1442
- const allowed = (current.status === 'conflict' &&
1477
+ const allowed = ((current.status === 'conflict' || current.status === 'rejected') &&
1443
1478
  (input.resolution === 'resolved_keep_server' ||
1444
1479
  input.resolution === 'superseded')) ||
1445
- (current.status === 'rejected' &&
1446
- (input.resolution === 'superseded' ||
1447
- input.resolution === 'resolved_keep_server')) ||
1448
1480
  ((current.status === 'applied' || current.status === 'cached') &&
1449
1481
  input.resolution === 'dismissed');
1450
1482
  if (!allowed) {
@@ -1454,7 +1486,7 @@ export class SyncClient {
1454
1486
  const priorRejections = this.#rejections;
1455
1487
  return this.#applyBatch((batch) => {
1456
1488
  restoreFailedBases(this.#db, this.#schema);
1457
- this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id=?', [
1489
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [
1458
1490
  input.clientCommitId,
1459
1491
  ]);
1460
1492
  const resolved = persistCommitOutcomeResolution(this.#db, input, this.#now());
@@ -1525,6 +1557,7 @@ export class SyncClient {
1525
1557
  */
1526
1558
  setPresence(scopeKey, doc) {
1527
1559
  this.#requireActive();
1560
+ this.#requireTransport();
1528
1561
  const socket = this.#socket;
1529
1562
  if (socket === undefined) {
1530
1563
  throw invalidRequest('setPresence requires a connected realtime socket (§8.6)');
@@ -1569,7 +1602,7 @@ export class SyncClient {
1569
1602
  canonicalScopeJson(input.scopes) &&
1570
1603
  existing.params === input.params;
1571
1604
  if (!sameIntent) {
1572
- throw new ClientSyncError('client.subscription_intent_mismatch', 'subscribe: the subscription id is already registered for a different table, scopes, or params');
1605
+ throw new ClientSyncError('client.subscription_intent_mismatch', 'subscription id already names different table, scopes, or params');
1573
1606
  }
1574
1607
  return;
1575
1608
  }
@@ -1616,7 +1649,7 @@ export class SyncClient {
1616
1649
  this.#requireActive();
1617
1650
  const table = this.#table(base.table);
1618
1651
  if (!table.scopeColumnByVariable.has(base.variable)) {
1619
- throw invalidRequest(`setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`);
1652
+ throw invalidRequest(`${base.table}: unknown scope variable ${base.variable} (§4.8)`);
1620
1653
  }
1621
1654
  // Serialize the whole window edit: it spans an `await deriveSubId` between
1622
1655
  // db transactions, so without the chain a delta apply (or a concurrent
@@ -1663,7 +1696,9 @@ export class SyncClient {
1663
1696
  }
1664
1697
  });
1665
1698
  const effects = {
1666
- sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
1699
+ sync: this.#transportEnabled && (changed || widened)
1700
+ ? { kind: 'interactive' }
1701
+ : { kind: 'none' },
1667
1702
  };
1668
1703
  if (effects.sync.kind === 'interactive') {
1669
1704
  this.#emitSyncIntent(effects.sync);
@@ -1918,7 +1953,9 @@ export class SyncClient {
1918
1953
  mutateCommand(mutations) {
1919
1954
  return {
1920
1955
  value: this.mutate(mutations),
1921
- effects: { sync: { kind: 'interactive' } },
1956
+ effects: {
1957
+ sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
1958
+ },
1922
1959
  };
1923
1960
  }
1924
1961
  /**
@@ -1939,17 +1976,15 @@ export class SyncClient {
1939
1976
  throw invalidRequest(`table ${compiled.name}: patch cannot change the primary key`);
1940
1977
  }
1941
1978
  return this.#recordMutations([
1942
- {
1979
+ definedObject({
1943
1980
  table,
1944
1981
  op: 'patch',
1945
1982
  values: {
1946
1983
  ...Object.fromEntries(normalized),
1947
1984
  [pkColumn.name]: rowId,
1948
1985
  },
1949
- ...(options?.baseVersion !== undefined
1950
- ? { baseVersion: options.baseVersion }
1951
- : {}),
1952
- },
1986
+ baseVersion: options?.baseVersion,
1987
+ }),
1953
1988
  ]);
1954
1989
  }
1955
1990
  /**
@@ -1983,16 +2018,7 @@ export class SyncClient {
1983
2018
  return this.#applyBatch((batch) => {
1984
2019
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
1985
2020
  restoreFailedBases(this.#db, this.#schema);
1986
- const droppedRetained = dropFailedRows(this.#db, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
1987
- for (const commit of droppedRetained) {
1988
- batch.conflicts();
1989
- batch.rejections();
1990
- this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
1991
- this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
1992
- for (const operation of commit.operations)
1993
- batch.table(operation.table);
1994
- batch.outcomes();
1995
- }
2021
+ this.#dropRetainedRows(batch, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
1996
2022
  // Doomed detection runs to fixpoint, matching the Rust core's rule:
1997
2023
  // a commit is doomed when any of its ops touches a base-matching
1998
2024
  // row. Base values only become visible here as rollbacks restore
@@ -2128,6 +2154,7 @@ export class SyncClient {
2128
2154
  this.#upgrading = true;
2129
2155
  this.#needsPull = true;
2130
2156
  batch.status();
2157
+ this.#discardAcknowledgedRows();
2131
2158
  dropAndRecreateSyncedTables(this.#db, this.#schema);
2132
2159
  resetSubscriptionsForBump(this.#db);
2133
2160
  for (const table of this.#schema.tables.values()) {
@@ -2191,7 +2218,9 @@ export class SyncClient {
2191
2218
  patchCommand(table, rowId, partial, options) {
2192
2219
  return {
2193
2220
  value: this.patch(table, rowId, partial, options),
2194
- effects: { sync: { kind: 'interactive' } },
2221
+ effects: {
2222
+ sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
2223
+ },
2195
2224
  };
2196
2225
  }
2197
2226
  // -- lease state (§7.3.5) ---------------------------------------------------
@@ -2286,16 +2315,14 @@ export class SyncClient {
2286
2315
  retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
2287
2316
  this.#rollbackFailedCommit(commit, batch);
2288
2317
  this.#replayOutbox();
2289
- const rejection = {
2318
+ const rejection = definedObject({
2290
2319
  clientCommitId: commit.clientCommitId,
2291
2320
  opIndex: 0,
2292
2321
  code,
2293
2322
  message,
2294
2323
  retryable: false,
2295
- ...(commit.operations[0] !== undefined
2296
- ? { operation: commit.operations[0] }
2297
- : {}),
2298
- };
2324
+ operation: commit.operations[0],
2325
+ });
2299
2326
  this.#rejections.push(rejection);
2300
2327
  recordCommitOutcome(this.#db, {
2301
2328
  clientCommitId: commit.clientCommitId,
@@ -2323,7 +2350,7 @@ export class SyncClient {
2323
2350
  sync() {
2324
2351
  this.#requireActive();
2325
2352
  if (this.#syncOutstanding) {
2326
- 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'));
2327
2354
  }
2328
2355
  this.#syncOutstanding = true;
2329
2356
  this.#beginDiagnosticsDeferral();
@@ -2365,6 +2392,8 @@ export class SyncClient {
2365
2392
  })
2366
2393
  .finally(() => {
2367
2394
  this.#syncOutstanding = false;
2395
+ if (!this.#transportEnabled)
2396
+ this.disconnectRealtime();
2368
2397
  this.#endDiagnosticsDeferral();
2369
2398
  });
2370
2399
  }
@@ -2397,6 +2426,7 @@ export class SyncClient {
2397
2426
  : 'client.unknown_failure';
2398
2427
  }
2399
2428
  async #runSync() {
2429
+ this.#requireTransport();
2400
2430
  this.#roundRetryDelayMs = undefined;
2401
2431
  this.#progress.emit({
2402
2432
  attempt: ++this.#progressAttempt,
@@ -2426,7 +2456,7 @@ export class SyncClient {
2426
2456
  if (logEpoch !== undefined &&
2427
2457
  this.#hasBlobs &&
2428
2458
  this.#config.blobs !== undefined) {
2429
- await this.flushBlobUploads();
2459
+ await this.#flushBlobUploads();
2430
2460
  }
2431
2461
  // §7.4.4: encode the outbox with the CURRENT codec; a commit that
2432
2462
  // cannot express itself under the new schema (a dropped column/table)
@@ -2542,6 +2572,8 @@ export class SyncClient {
2542
2572
  for (let round = 0; round < budget; round++) {
2543
2573
  const before = JSON.stringify(loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]));
2544
2574
  last = await this.sync();
2575
+ if (!this.#transportEnabled || last.schemaFloor !== undefined)
2576
+ return last;
2545
2577
  if (maxRounds === undefined &&
2546
2578
  last.segmentRowsApplied > 0 &&
2547
2579
  last.bootstrapping.length > 0 &&
@@ -2551,8 +2583,6 @@ export class SyncClient {
2551
2583
  sub.bootstrapState,
2552
2584
  ])))
2553
2585
  round = -1;
2554
- if (last.schemaFloor !== undefined)
2555
- return last;
2556
2586
  if (last.commitsApplied === 0 &&
2557
2587
  last.segmentRowsApplied === 0 &&
2558
2588
  last.bootstrapping.length === 0 &&
@@ -2644,6 +2674,7 @@ export class SyncClient {
2644
2674
  }
2645
2675
  connectRealtime() {
2646
2676
  this.#requireActive();
2677
+ this.#requireTransport();
2647
2678
  if (this.#realtimePolicy === 'off') {
2648
2679
  return Promise.reject(invalidRequest("realtimePolicy is 'off'; realtime connect is refused"));
2649
2680
  }
@@ -2699,9 +2730,13 @@ export class SyncClient {
2699
2730
  },
2700
2731
  });
2701
2732
  openedSocket = socket;
2733
+ if (!this.#transportEnabled) {
2734
+ socket.close();
2735
+ this.#requireTransport();
2736
+ }
2702
2737
  if (this.#securityLifecycle === 'preflight') {
2703
2738
  socket.close();
2704
- throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'realtime connected after the client entered security preflight');
2739
+ this.#requireActive();
2705
2740
  }
2706
2741
  if (generation !== this.#realtimeGeneration || !this.#started) {
2707
2742
  socket.close();
@@ -2838,6 +2873,8 @@ export class SyncClient {
2838
2873
  }
2839
2874
  }
2840
2875
  async #handleRealtimeBinary(bytes) {
2876
+ if (!this.#transportEnabled)
2877
+ return;
2841
2878
  if (this.#syncing) {
2842
2879
  // Fast path: a pull is mid-flight; let it win and recover the gap
2843
2880
  // itself — re-pulling is idempotent, interleaved application is not
@@ -2866,7 +2903,8 @@ export class SyncClient {
2866
2903
  });
2867
2904
  }
2868
2905
  #sendAck(cursor) {
2869
- this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
2906
+ if (this.#transportEnabled)
2907
+ this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
2870
2908
  }
2871
2909
  /** Ack the highest cursor that is contiguously applied for every sub. */
2872
2910
  #ackAfterPull() {
@@ -2972,6 +3010,7 @@ export class SyncClient {
2972
3010
  index += 1;
2973
3011
  }
2974
3012
  }
3013
+ const previousNeedsPull = this.#needsPull;
2975
3014
  const conflictCount = this.#conflicts.length;
2976
3015
  const rejectionCount = this.#rejections.length;
2977
3016
  let outboxCount = countOutbox(this.#db);
@@ -2988,9 +3027,10 @@ export class SyncClient {
2988
3027
  pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2989
3028
  }
2990
3029
  }, () => this.#statusSnapshot(outboxCount), (cause) => {
3030
+ this.#needsPull = previousNeedsPull;
2991
3031
  this.#conflicts.length = conflictCount;
2992
3032
  this.#rejections.length = rejectionCount;
2993
- const error = new ClientSyncError('client.outcome_persistence_failed', 'local commit outcome could not be persisted');
3033
+ const error = new ClientSyncError('client.outcome_persistence_failed', 'outcome persistence failed');
2994
3034
  error.cause = cause;
2995
3035
  throw error;
2996
3036
  });
@@ -3019,58 +3059,24 @@ export class SyncClient {
3019
3059
  await this.#applyCommit(frame, summary);
3020
3060
  }
3021
3061
  break;
3022
- case 'SEGMENT_INLINE': {
3023
- if (section === undefined ||
3024
- section.skip ||
3025
- section.sub === undefined) {
3026
- break;
3027
- }
3028
- const segment = decodeRowsSegment(frame.payload);
3029
- this.#progress.update({
3030
- phase: 'import',
3031
- subscriptionId: section.sub.id,
3032
- table: section.sub.table,
3033
- segmentId: undefined,
3034
- bytesReceived: frame.payload.byteLength,
3035
- bytesTotal: frame.payload.byteLength,
3036
- rowsProcessed: 0,
3037
- rowsTotal: segment.blocks.reduce((n, block) => n + block.length, 0),
3038
- });
3039
- await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
3040
- clearFirst,
3041
- isCurrent: () => this.#localResetEpoch === resetEpoch,
3042
- onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
3043
- effective,
3044
- transaction: (fn) => this.#applyBatch((batch) => {
3045
- if (segment.blocks.some((block) => block.length > 0) ||
3046
- (clearFirst &&
3047
- this.#scopedRowsExist(table, effective))) {
3048
- batch.table(table.name);
3049
- }
3050
- const result = fn();
3051
- this.#replayOutbox();
3052
- return result;
3053
- }),
3054
- }, this.#encryption), section.fresh && !section.cleared);
3055
- break;
3056
- }
3062
+ case 'SEGMENT_INLINE':
3057
3063
  case 'SEGMENT_REF': {
3058
3064
  if (section === undefined ||
3059
3065
  section.skip ||
3060
- section.sub === undefined) {
3066
+ section.sub === undefined)
3061
3067
  break;
3062
- }
3063
- // §4.2: a descriptor whose mediaType was not advertised is a
3064
- // broken server — fail loud, never skip or guess.
3065
- if (frame.mediaType === 'sqlite' &&
3068
+ if (frame.type === 'SEGMENT_REF' &&
3069
+ frame.mediaType === 'sqlite' &&
3066
3070
  (this.#acceptMask() & ACCEPT_SQLITE) === 0) {
3067
3071
  throw invalidRequest('SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
3068
3072
  }
3069
- const bytes = await this.#downloadSegment(frame, section.sub);
3070
- this.#progress.update({ phase: 'import' });
3071
- if (frame.mediaType === 'sqlite') {
3072
- // §5.3: images are whole-table — a paged descriptor is
3073
- // 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.
3074
3080
  if (frame.rowCursor !== undefined ||
3075
3081
  frame.nextRowCursor !== undefined) {
3076
3082
  throw invalidRequest('sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
@@ -3080,43 +3086,26 @@ export class SyncClient {
3080
3086
  rowCount: frame.rowCount,
3081
3087
  asOfCommitSeq: frame.asOfCommitSeq,
3082
3088
  scopeDigest: frame.scopeDigest,
3083
- }, {
3084
- clearFirst,
3085
- isCurrent: () => this.#localResetEpoch === resetEpoch,
3086
- onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
3087
- effective,
3088
- transaction: (fn) => this.#applyBatch((batch) => {
3089
- if (frame.rowCount > 0 ||
3090
- (clearFirst &&
3091
- this.#scopedRowsExist(table, effective))) {
3092
- batch.table(table.name);
3093
- }
3094
- const result = fn();
3095
- this.#replayOutbox();
3096
- return result;
3097
- }),
3098
- }), section.fresh && !section.cleared);
3089
+ }, this.#segmentApplyOptions(table, clearFirst, effective, frame.rowCount > 0, resetEpoch)), section.fresh && !section.cleared);
3099
3090
  }
3100
3091
  else {
3101
3092
  const segment = decodeRowsSegment(bytes);
3102
- await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
3103
- clearFirst,
3104
- isCurrent: () => this.#localResetEpoch === resetEpoch,
3105
- onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
3106
- effective,
3107
- transaction: (fn) => this.#applyBatch((batch) => {
3108
- if (segment.blocks.some((block) => block.length > 0) ||
3109
- (clearFirst &&
3110
- this.#scopedRowsExist(table, effective))) {
3111
- batch.table(table.name);
3112
- }
3113
- const result = fn();
3114
- this.#replayOutbox();
3115
- return result;
3116
- }),
3117
- }, 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 &&
3118
3106
  !section.cleared &&
3119
- frame.rowCursor === undefined);
3107
+ (frame.type === 'SEGMENT_INLINE' ||
3108
+ frame.rowCursor === undefined));
3120
3109
  }
3121
3110
  break;
3122
3111
  }
@@ -3201,6 +3190,10 @@ export class SyncClient {
3201
3190
  if (frame.status === 'applied' || frame.status === 'cached') {
3202
3191
  // §6.3: applied and cached both drain the outbox — cached means
3203
3192
  // "already applied, you may have missed the ack".
3193
+ if (frame.commitSeq === undefined)
3194
+ throw new ClientSyncError('client.invalid_host_response', 'ACK lacks a commit sequence');
3195
+ retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId), frame.commitSeq);
3196
+ this.#setSyncNeeded(true);
3204
3197
  recordCommitOutcome(this.#db, {
3205
3198
  clientCommitId: frame.clientCommitId,
3206
3199
  status: frame.status,
@@ -3327,10 +3320,7 @@ export class SyncClient {
3327
3320
  await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
3328
3321
  this.#recordCommitChanges(batch, frame);
3329
3322
  const result = fn();
3330
- if (this.#retainFailedCommits &&
3331
- this.#db.query('SELECT 1 FROM _syncular_failed_rows LIMIT 1')
3332
- .length > 0)
3333
- this.#replayOutbox();
3323
+ this.#replayOutbox(result);
3334
3324
  return result;
3335
3325
  }));
3336
3326
  summary.commitsApplied += 1;
@@ -3375,19 +3365,26 @@ export class SyncClient {
3375
3365
  }
3376
3366
  /** Whether a fresh-bootstrap clear would remove at least one local row. */
3377
3367
  #scopedRowsExist(table, effective) {
3378
- const entries = Object.entries(effective);
3379
- if (entries.length === 0)
3380
- return false;
3381
- const clauses = [];
3382
- const params = [];
3383
- for (const [variable, values] of entries) {
3384
- const column = table.scopeColumnByVariable.get(variable);
3385
- if (column === undefined || values.length === 0)
3386
- return false;
3387
- clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
3388
- params.push(...values);
3389
- }
3390
- return (this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')} LIMIT 1`, params).length > 0);
3368
+ const scope = localScopePredicate(table, effective);
3369
+ return (scope !== undefined &&
3370
+ this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`, scope.params).length > 0);
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
+ };
3391
3388
  }
3392
3389
  /**
3393
3390
  * Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
@@ -3461,7 +3458,7 @@ export class SyncClient {
3461
3458
  if (frame.urlExpiresAtMs !== undefined &&
3462
3459
  frame.urlExpiresAtMs <= this.#now()) {
3463
3460
  // §5.4: MUST NOT start a fetch at/past expiry.
3464
- throw new ClientSyncError('sync.segment_expired', `signed URL for segment ${frame.segmentId} expired before fetch — re-pull mints fresh descriptors (§5.4)`, true);
3461
+ throw new ClientSyncError('sync.segment_expired', `segment download grant expired; pull again (§5.4)`, true);
3465
3462
  }
3466
3463
  bytes = await fetchUrl(frame.url, onProgress);
3467
3464
  }
@@ -3504,6 +3501,25 @@ export class SyncClient {
3504
3501
  effectiveScopes: start.effectiveScopes,
3505
3502
  status: 'active',
3506
3503
  }));
3504
+ if (completed) {
3505
+ const table = this.#table(sub.table);
3506
+ const effective = start.effectiveScopes;
3507
+ const covered = this.#db
3508
+ .query(`SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq<=?`, [sub.table, nextCursor])
3509
+ .filter((row) => [row.intent, row.base].some((json) => {
3510
+ if (json === null)
3511
+ return false;
3512
+ const values = JSON.parse(String(json));
3513
+ return matchesLocalScopes(table, effective, values);
3514
+ }));
3515
+ if (covered.length) {
3516
+ restoreFailedBases(this.#db, this.#schema);
3517
+ for (const row of covered)
3518
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [sub.table, String(row.id), nextCursor]);
3519
+ batch.table(sub.table);
3520
+ this.#replayOutbox();
3521
+ }
3522
+ }
3507
3523
  if (completed && registered !== undefined) {
3508
3524
  // A zero-row bootstrap is a window-domain transition, not a fake
3509
3525
  // row/table change (SPEC §4.8 / §7.5).
@@ -3545,17 +3561,8 @@ export class SyncClient {
3545
3561
  Object.keys(lastEffective).length > 0) {
3546
3562
  try {
3547
3563
  restoreFailedBases(this.#db, this.#schema);
3548
- const droppedRetained = dropFailedRows(this.#db, (name, values) => name === table.name &&
3549
- Object.entries(lastEffective).every(([variable, allowed]) => allowed.includes(String(values[table.scopeColumnByVariable.get(variable)]))));
3550
- for (const commit of droppedRetained) {
3551
- batch.conflicts();
3552
- batch.rejections();
3553
- this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
3554
- this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
3555
- for (const operation of commit.operations)
3556
- batch.table(operation.table);
3557
- batch.outcomes();
3558
- }
3564
+ this.#dropRetainedRows(batch, (name, values) => name === table.name &&
3565
+ matchesLocalScopes(table, lastEffective, values));
3559
3566
  deleteScopedRows(this.#db, table, lastEffective);
3560
3567
  batch.scopeMap(table, lastEffective);
3561
3568
  const pendingById = new Map(listOutbox(this.#db).map((commit) => [
@@ -3775,19 +3782,36 @@ export class SyncClient {
3775
3782
  }
3776
3783
  }
3777
3784
  /** Re-apply every pending outbox commit on top of server state (§7.1). */
3778
- #replayOutbox() {
3785
+ #discardAcknowledgedRows() {
3786
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
3787
+ }
3788
+ #dropRetainedRows(batch, matches) {
3789
+ for (const commit of dropFailedRows(this.#db, matches)) {
3790
+ batch.conflicts();
3791
+ batch.rejections();
3792
+ batch.outcomes();
3793
+ this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
3794
+ this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
3795
+ for (const op of commit.operations)
3796
+ batch.table(op.table);
3797
+ }
3798
+ }
3799
+ #replayOutbox(rowKeys) {
3800
+ const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
3779
3801
  const pending = [
3780
- ...failedOverlayCommits(this.#db),
3781
- ...listOutbox(this.#db),
3802
+ ...failedOverlayCommits(this.#db, false, rowKeys),
3803
+ ...listOutbox(this.#db, rowKeys),
3782
3804
  ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
3783
- if (pending.length === 0)
3805
+ if (pending.length === 0 && acknowledged.length === 0)
3784
3806
  return;
3785
3807
  this.#applyBatch((batch) => {
3786
3808
  this.#db.transaction(() => {
3787
- restoreFailedBases(this.#db, this.#schema);
3788
- restoreFailedBases(this.#db, this.#schema, true);
3789
- for (const commit of pending)
3790
- this.#applyOperationsLocally(commit.operations, batch, true);
3809
+ restoreFailedBases(this.#db, this.#schema, false, rowKeys);
3810
+ restoreFailedBases(this.#db, this.#schema, true, rowKeys);
3811
+ for (const commit of [...acknowledged, ...pending])
3812
+ this.#applyOperationsLocally(rowKeys === undefined
3813
+ ? commit.operations
3814
+ : commit.operations.filter((op) => rowKeys.includes(JSON.stringify([op.table, op.rowId]))), batch, true);
3791
3815
  });
3792
3816
  });
3793
3817
  }
@@ -3800,19 +3824,19 @@ export class SyncClient {
3800
3824
  #table(name) {
3801
3825
  const table = this.#schema.tables.get(name);
3802
3826
  if (table === undefined) {
3803
- throw new ClientSyncError('sync.unknown_table', `unknown local table ${JSON.stringify(name)}`);
3827
+ throw new ClientSyncError('sync.unknown_table', `unknown table ${name}`);
3804
3828
  }
3805
3829
  return table;
3806
3830
  }
3807
3831
  #requireStarted() {
3808
3832
  if (!this.#started) {
3809
- throw invalidRequest('SyncClient.start() has not completed');
3833
+ throw invalidRequest('call SyncClient.start() first');
3810
3834
  }
3811
3835
  }
3812
3836
  #requireActive() {
3813
3837
  this.#requireStarted();
3814
3838
  if (this.#securityLifecycle === 'preflight') {
3815
- throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data');
3839
+ throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'security preflight must complete before accessing protected data; call activateSecurity');
3816
3840
  }
3817
3841
  }
3818
3842
  }