@syncular/client 0.30.16 → 0.31.0

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
@@ -14,8 +14,9 @@ import { ProgressEmitter, } from './progress.js';
14
14
  * ownership behind `LeaderLock`. One combined push+pull request per
15
15
  * `sync()` round (§7.2); local reads go straight to the database.
16
16
  */
17
- import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
17
+ import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodeRow, encodeSparseRow, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
18
18
  import { EncryptError } from '@syncular/core';
19
+ import { EncodeError } from '@syncular/core';
19
20
  import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, localScopePredicate, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
20
21
  import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
21
22
  import { registerDevtools } from './devtools.js';
@@ -27,11 +28,11 @@ import { compileLocalDataPurge, localDataPurgeMetaKey, localDataPurgeTargetMatch
27
28
  import { compileLocalDataRebootstrap, localDataRebootstrapMetaKey, } from './local-rebootstrap.js';
28
29
  import { decodeLocalDataRebootstrapReceipt, encodeLocalDataRebootstrapReceipt, } from './local-rebootstrap-receipt.js';
29
30
  import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, iterateOutbox, countOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
30
- import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
31
+ import { activeFailureRecords, isStoredOperation, listCommitOutcomes, persistCommitOutcomeResolution, persistedCommitOutcome, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
31
32
  import { assertReadOnlyQuery } from './query-guard.js';
32
33
  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';
33
- 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';
34
- import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, saveSubscriptionScopeSchema, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
34
+ import { compileClientSchema, matchesLocalScopes, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, localColumnType, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
35
+ import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, saveSubscriptionScopeSchema, resetSubscriptionsForBump, saveSubscription, setMeta, SUBSCRIPTIONS_TABLE, } from './state.js';
35
36
  import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
36
37
  /** Stable client-local error while protected operations are preflight-gated. */
37
38
  export const SECURITY_PREFLIGHT_REQUIRED_CODE = 'client.security_preflight_required';
@@ -56,6 +57,10 @@ const ACCEPT_SIGNED_URLS = 1 << 3;
56
57
  * Never a wire code (§10.3) — surfaced through the rejection channel.
57
58
  */
58
59
  const OUTBOX_INCOMPATIBLE_CODE = 'sync.outbox_incompatible';
60
+ /** §7.4.4: static text for a persisted commit the current codec refuses. */
61
+ const LEGACY_VALUE_REFUSAL_MESSAGE = 'the persisted commit carries values the current codec refuses';
62
+ /** §7.4.4: bounded reason on every intrinsic value drop. */
63
+ const INVALID_STORED_VALUES_REASON = 'invalid_stored_values';
59
64
  /** §6.1 per-request operation cap (matches the server's shipped default —
60
65
  * `sync.too_many_operations` above it). The push half sends whole commits in
61
66
  * commit order up to this cap and defers the rest to the next round. */
@@ -232,6 +237,19 @@ export class SyncClient {
232
237
  throw invalidRequest('transportEnabled must be a boolean');
233
238
  }
234
239
  this.#transportEnabled = config.transportEnabled !== false;
240
+ for (const [name, value] of [
241
+ ['maxPushCommitsPerRequest', config.limits?.maxPushCommitsPerRequest],
242
+ [
243
+ 'maxPushOperationsPerRequest',
244
+ config.limits?.maxPushOperationsPerRequest,
245
+ ],
246
+ ['maxPushRequestBytes', config.limits?.maxPushRequestBytes],
247
+ ]) {
248
+ if (value !== undefined &&
249
+ (!Number.isInteger(value) || value < 1 || value > 0xffffffff)) {
250
+ throw new ClientSyncError('sync.invalid_request', 'push limit must be an integer in 1..=4294967295', false, { limit: name });
251
+ }
252
+ }
235
253
  if (config.securityPreflight === true && config.encryption !== undefined) {
236
254
  throw invalidRequest('securityPreflight and encryption are mutually exclusive; activateSecurity installs keys');
237
255
  }
@@ -283,47 +301,117 @@ export class SyncClient {
283
301
  return;
284
302
  const lock = this.#config.leaderLock ?? singleOwnerLock();
285
303
  this.#lease = await lock.acquire(this.#config.lockName ?? 'syncular-leader');
286
- // Bookkeeping must exist before we inspect the persisted schema marker.
287
- // Do not materialize new app indexes/FTS projections yet: on a version
288
- // bump they may reference columns that only exist after the reset.
289
- ensureLocalBookkeepingSchema(this.#db);
290
- if (this.#hasBlobs)
291
- ensureBlobSchema(this.#db);
292
- this.#db.transaction(() => {
293
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
294
- });
295
- const activeFailures = activeFailureRecords(listCommitOutcomes(this.#db, { activeOnly: true }));
296
- this.#conflicts = activeFailures.conflicts;
297
- this.#rejections = activeFailures.rejections;
298
- const persisted = getMeta(this.#db, 'clientId');
299
- if (persisted !== undefined &&
300
- this.#config.clientId !== undefined &&
301
- persisted !== this.#config.clientId) {
304
+ // §7.4.2: the entire synchronous storage phase below (marker guard,
305
+ // identity and bookkeeping writes, schema reset/replay, container
306
+ // reconciliation, subscription pruning) runs as one deferred transaction
307
+ // whose first statement is the marker read. That read pins SQLite's
308
+ // snapshot before any write: a replica another process upgraded after the
309
+ // read can never be written over (its snapshot fails with
310
+ // BUSY/BUSY_SNAPSHOT), and one upgraded before the read refuses with
311
+ // `client.schema_downgrade`. The batch emits its change event only after
312
+ // the commit. The onUpgrading callback still reports reset intent.
313
+ let subscriptions;
314
+ try {
315
+ this.#applyBatch(() => {
316
+ // §7.4.1/§7.4.2: this is the transaction's first read, so it pins the
317
+ // snapshot every write below commits under. `undefined` is an absent
318
+ // marker (fresh/legacy); a non-canonical value, a `_syncular_meta` that
319
+ // is not a table, or a duplicated marker row is unreadable state.
320
+ let marker;
321
+ try {
322
+ const meta = this.#db.query("SELECT type FROM sqlite_master WHERE name = '_syncular_meta'")[0];
323
+ if (meta !== undefined) {
324
+ if (meta.type !== 'table')
325
+ throw new Error('invalid metadata table');
326
+ const rows = this.#db.query('SELECT value FROM _syncular_meta WHERE key = ?', [LOCAL_SCHEMA_VERSION_KEY]);
327
+ // A duplicated marker cannot be resolved. Picking one row could
328
+ // select an older version and drive a destructive reset over a
329
+ // newer replica.
330
+ if (rows.length > 1)
331
+ throw new Error('invalid schema marker');
332
+ const row = rows[0];
333
+ if (row === undefined &&
334
+ this.#db.query("SELECT 1 FROM _syncular_meta WHERE key = 'localSchemaDescriptor' LIMIT 1").length > 0) {
335
+ throw new Error('missing paired schema marker');
336
+ }
337
+ if (row !== undefined) {
338
+ const value = row.value;
339
+ const version = Number(value);
340
+ if (typeof value !== 'string' ||
341
+ !Number.isInteger(version) ||
342
+ version < 1 ||
343
+ version > 2147483647 ||
344
+ String(version) !== value) {
345
+ throw new Error('invalid schema marker');
346
+ }
347
+ marker = version;
348
+ }
349
+ }
350
+ }
351
+ catch (error) {
352
+ // A recognized SQLite storage failure (including BUSY/LOCKED
353
+ // contention on the marker read) stays classified; only an invalid
354
+ // or otherwise unreadable marker is `sync.local_corrupt`.
355
+ const failure = classifySqliteFailure(error);
356
+ if (failure.code !== undefined)
357
+ throw failure.error;
358
+ throw new ClientSyncError('sync.local_corrupt', 'persisted local schema marker is unreadable or invalid');
359
+ }
360
+ if (marker !== undefined && marker > this.#schema.version) {
361
+ throw new ClientSyncError('client.schema_downgrade', 'persisted local schema is newer than the requested schema', false, {
362
+ persistedVersion: marker,
363
+ requestedVersion: this.#schema.version,
364
+ });
365
+ }
366
+ // New app indexes may reference columns that only exist after the
367
+ // reset.
368
+ ensureLocalBookkeepingSchema(this.#db);
369
+ if (this.#hasBlobs)
370
+ ensureBlobSchema(this.#db);
371
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
372
+ const activeFailures = activeFailureRecords(listCommitOutcomes(this.#db, { activeOnly: true }));
373
+ this.#conflicts = activeFailures.conflicts;
374
+ this.#rejections = activeFailures.rejections;
375
+ const persisted = getMeta(this.#db, 'clientId');
376
+ if (persisted !== undefined &&
377
+ this.#config.clientId !== undefined &&
378
+ persisted !== this.#config.clientId) {
379
+ throw new ClientSyncError('client.identity_mismatch', `database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`);
380
+ }
381
+ this.#clientId =
382
+ persisted ?? this.#config.clientId ?? crypto.randomUUID();
383
+ if (persisted === undefined) {
384
+ setMeta(this.#db, 'clientId', this.#clientId);
385
+ }
386
+ // §7.3.5: restore the persisted lease so leaseState survives restart.
387
+ const leaseJson = getMeta(this.#db, 'leaseState');
388
+ if (leaseJson !== undefined) {
389
+ this.#leaseState = JSON.parse(leaseJson);
390
+ }
391
+ // §7.4.2 trigger 1: the persisted local schema version is lower than
392
+ // the generated version this client ships — run the wipe/re-bootstrap
393
+ // reset before the first sync round. A fresh install (no marker) is
394
+ // treated as already at the generated version.
395
+ this.#detectAndResetSchema(marker);
396
+ if (marker === this.#schema.version) {
397
+ this.#replayOutbox(undefined, true, false);
398
+ }
399
+ // RFC 0005 D9: a container with missing/stale metadata, or past its
400
+ // TTL, is discarded at boot before anything can read it; the capture
401
+ // above already ran against this transaction's validated marker. The
402
+ // orphan/stale sweep is unconditional; the TTL is aware-binary hygiene
403
+ // only.
404
+ this.#reconcilePreviousVersionAtBoot();
405
+ // A registration remains app intent only while its table exists in the
406
+ // running schema. Removed-table registrations would poison every pull.
407
+ subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema);
408
+ });
409
+ }
410
+ catch (error) {
302
411
  await this.#lease.release();
303
412
  this.#lease = undefined;
304
- throw new ClientSyncError('client.identity_mismatch', `database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`);
305
- }
306
- this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
307
- if (persisted === undefined) {
308
- setMeta(this.#db, 'clientId', this.#clientId);
309
- }
310
- // §7.3.5: restore the persisted lease so leaseState survives restart.
311
- const leaseJson = getMeta(this.#db, 'leaseState');
312
- if (leaseJson !== undefined) {
313
- this.#leaseState = JSON.parse(leaseJson);
314
- }
315
- // §7.4.2 trigger 1: the persisted local schema version differs from the
316
- // generated version this client ships — run the wipe/re-bootstrap reset
317
- // before the first sync round. A fresh install (no marker) is treated as
318
- // already at the generated version.
319
- this.#detectAndResetSchema();
320
- // RFC 0005 D9: a container with missing/stale metadata, or past its TTL, is
321
- // discarded at boot before anything can read it. The orphan/stale sweep is
322
- // unconditional; the TTL is aware-binary hygiene only.
323
- this.#reconcilePreviousVersionAtBoot();
324
- // A registration remains app intent only while its table exists in the
325
- // running schema. Removed-table registrations would poison every pull.
326
- const subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema);
413
+ throw classifySqliteFailure(error).error;
414
+ }
327
415
  this.#started = true;
328
416
  // A persisted active subscription needs one catch-up round on every open:
329
417
  // realtime only covers changes after the socket connects, and an
@@ -359,12 +447,14 @@ export class SyncClient {
359
447
  }
360
448
  /**
361
449
  * §7.4.1/§7.4.2: compare the generated schema version to the persisted
362
- * marker and run the §7.4.3 reset when they differ. Idempotent by the
450
+ * marker and run the §7.4.3 reset when it increases. Idempotent by the
363
451
  * marker — a mid-reset crash re-runs the reset on the next boot.
452
+ *
453
+ * The startup phase's single transaction already pinned and validated the
454
+ * marker, so the shape chosen here commits under that snapshot.
364
455
  */
365
- #detectAndResetSchema() {
366
- const markerJson = getMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY);
367
- if (markerJson === undefined) {
456
+ #detectAndResetSchema(marker) {
457
+ if (marker === undefined) {
368
458
  // Fresh install: the tables just created match the running code.
369
459
  this.#db.transaction(() => {
370
460
  ensureLocalSyncedSchema(this.#db, this.#schema);
@@ -374,7 +464,6 @@ export class SyncClient {
374
464
  });
375
465
  return;
376
466
  }
377
- const marker = Number(markerJson);
378
467
  if (marker === this.#schema.version) {
379
468
  // Same-version opens remain self-healing for absent tables/indexes, and
380
469
  // RFC 0005 D1 backfills the descriptor for a database first opened by
@@ -414,6 +503,7 @@ export class SyncClient {
414
503
  this.#discardAcknowledgedRows();
415
504
  dropAndRecreateSyncedTables(this.#db, this.#schema);
416
505
  resetSubscriptionsForBump(this.#db);
506
+ this.#replayOutbox();
417
507
  setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
418
508
  setLocalSchemaDescriptor(this.#db, this.#schema);
419
509
  saveSubscriptionScopeSchema(this.#db, this.#schema);
@@ -422,11 +512,10 @@ export class SyncClient {
422
512
  for (const table of this.#schema.tables.values())
423
513
  batch.table(table.name);
424
514
  });
425
- // The stop state is over: this client now ships a servable schema. The
426
- // outbox is re-applied optimistically over the (now empty) tables so
427
- // pending offline writes stay visible across the bump (§7.4.5).
515
+ // The stop state is over: this client now ships a servable schema.
516
+ // The reset transaction already replayed pending intent before committing
517
+ // its marker and visible rows (§7.4.5).
428
518
  this.#setSchemaFloor(undefined);
429
- this.#replayOutbox();
430
519
  }
431
520
  /**
432
521
  * RFC 0005 D5 steps 2-5: capture the previous-version context into its own
@@ -493,23 +582,33 @@ export class SyncClient {
493
582
  return [];
494
583
  }
495
584
  const subscriptions = loadSubscriptions(this.#db);
496
- const pending = listOutbox(this.#db);
497
585
  this.#setUpgrading(true);
498
586
  // RFC 0005 D5: the log-epoch reset is not a schema bump. It performs the
499
587
  // orphan sweep but captures no new shadow.
500
588
  this.#sweepPreviousVersionContainer();
589
+ const priorRejections = [...this.#rejections];
501
590
  this.#applyBatch((batch) => {
502
591
  this.#db.transaction(() => {
503
592
  this.#discardAcknowledgedRows();
504
593
  dropAndRecreateSyncedTables(this.#db, this.#schema);
505
594
  resetSubscriptionsForBump(this.#db);
506
595
  setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
507
- for (const commit of pending) {
596
+ // §7.1/§7.4.4: recover codec-invalid persisted values in this reset
597
+ // transaction before they replay, so the reset boundary never fails on
598
+ // a commit an earlier version accepted.
599
+ this.#dropUnencodableOutbox();
600
+ for (const commit of listOutbox(this.#db)) {
508
601
  this.#applyOperationsLocally(commit.operations, batch);
509
602
  }
510
603
  });
511
604
  for (const table of this.#schema.tables.values())
512
605
  batch.table(table.name);
606
+ }, undefined, (error) => {
607
+ // A failure after the recovery drop, including the reset's revision or
608
+ // COMMIT, rolls the database back: mirror that for the in-memory
609
+ // rejection cache so no dropped commit is published.
610
+ this.#rejections = priorRejections;
611
+ throw error;
513
612
  });
514
613
  this.#localResetEpoch += 1;
515
614
  this.#setSyncNeeded(true);
@@ -706,23 +805,126 @@ export class SyncClient {
706
805
  throw new ClientSyncError('client.authority_read_forbidden', 'authority read denied');
707
806
  return this.#authorityReads.snapshot(this.#db, this.#schema);
708
807
  }
808
+ querySnapshot(spec) {
809
+ const read = this.#readSnapshot({
810
+ statements: [
811
+ {
812
+ sql: spec.sql,
813
+ ...(spec.params !== undefined ? { params: spec.params } : {}),
814
+ },
815
+ ],
816
+ ...(spec.coverage !== undefined ? { coverage: spec.coverage } : {}),
817
+ ...(spec.owner !== undefined ? { owner: spec.owner } : {}),
818
+ });
819
+ const rows = read.queries[0];
820
+ if (read.queries.length !== 1 || rows === undefined) {
821
+ throw new ClientSyncError('client.failed', 'querySnapshot produced no statement result');
822
+ }
823
+ return {
824
+ revision: read.revision,
825
+ rows,
826
+ coverage: read.coverage,
827
+ };
828
+ }
829
+ snapshotRead(spec) {
830
+ return this.#readSnapshot(spec);
831
+ }
832
+ /** §7.5: one subscription's persisted catch-up state (never the owner cache). */
833
+ subscriptionCatchup(id) {
834
+ return this.#readSnapshot({ statements: [], subscriptions: [id] })
835
+ .subscriptions[0];
836
+ }
837
+ /** §7.5: one client commit's delivery status: pending, its retained outcome,
838
+ * or an explicit unknown (never held, or pruned by retention). */
839
+ commitDelivery(id) {
840
+ return this.#readSnapshot({ statements: [], commitIds: [id] })
841
+ .deliveries[0];
842
+ }
709
843
  /**
710
- * Read rows, window answerability, and revision from one SQLite snapshot.
711
- * Reactive integrations use this instead of composing `query()` and
712
- * `windowState()` across separate worker/IPC calls.
844
+ * §7.5: the one atomic read primitive. Every public read (`querySnapshot`,
845
+ * `snapshotRead`, `subscriptionCatchup`, `commitDelivery`) enters here, so
846
+ * the read-only guard, transaction, revision, coverage, catch-up, delivery,
847
+ * and owner-failure observation exist once.
713
848
  */
714
- querySnapshot(spec) {
849
+ #readSnapshot(request) {
715
850
  this.#requireActive();
716
- const owner = spec.owner;
717
- let snapshot;
851
+ const owner = request.owner;
852
+ const catchupOf = (id) => {
853
+ function corrupt() {
854
+ throw new ClientSyncError('sync.local_corrupt', 'persisted subscription state is invalid');
855
+ }
856
+ const row = this.#db.query(`SELECT tbl,status,cursor,bootstrap_state,effective_scopes,reason_code FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [id])[0];
857
+ if (row === undefined)
858
+ return { state: 'unknown', id };
859
+ const table = row.tbl;
860
+ const status = row.status;
861
+ const cursor = row.cursor;
862
+ if (typeof table !== 'string' ||
863
+ (status !== 'active' && status !== 'revoked' && status !== 'failed') ||
864
+ typeof cursor !== 'number' ||
865
+ !Number.isSafeInteger(cursor)) {
866
+ corrupt();
867
+ }
868
+ const token = row.bootstrap_state;
869
+ if (token !== null && typeof token !== 'string')
870
+ corrupt();
871
+ const rawScopes = row.effective_scopes;
872
+ let effectiveScopes;
873
+ if (rawScopes !== null) {
874
+ if (typeof rawScopes !== 'string')
875
+ corrupt();
876
+ try {
877
+ effectiveScopes = JSON.parse(rawScopes);
878
+ if (effectiveScopes === null)
879
+ effectiveScopes = undefined;
880
+ else if (typeof effectiveScopes !== 'object' ||
881
+ Array.isArray(effectiveScopes) ||
882
+ !Object.values(effectiveScopes).every((values) => Array.isArray(values) &&
883
+ values.every((value) => typeof value === 'string')))
884
+ corrupt();
885
+ }
886
+ catch {
887
+ corrupt();
888
+ }
889
+ }
890
+ const reasonCode = row.reason_code;
891
+ if (reasonCode !== null && typeof reasonCode !== 'string')
892
+ corrupt();
893
+ const active = status === 'active';
894
+ const hasResumeToken = token !== null;
895
+ return {
896
+ state: 'known',
897
+ id,
898
+ table,
899
+ status,
900
+ cursor,
901
+ hasResumeToken,
902
+ bootstrapComplete: active && cursor >= 0 && !hasResumeToken,
903
+ knownPendingPages: active && (hasResumeToken || cursor < 0),
904
+ ...(effectiveScopes !== undefined ? { effectiveScopes } : {}),
905
+ ...(reasonCode !== null ? { reasonCode } : {}),
906
+ };
907
+ };
908
+ const deliveryOf = (id) => {
909
+ const pending = this.#db.query(`SELECT 1 FROM ${OUTBOX_TABLE} WHERE client_commit_id=? LIMIT 1`, [id]).length > 0;
910
+ if (pending)
911
+ return { status: 'pending', clientCommitId: id };
912
+ const outcome = persistedCommitOutcome(this.#db, id);
913
+ return outcome === undefined
914
+ ? { status: 'unknown', clientCommitId: id }
915
+ : { status: 'known', clientCommitId: id, outcome };
916
+ };
917
+ let read;
718
918
  try {
719
- assertReadOnlyQuery(spec.sql);
720
- snapshot = this.#db.transaction(() => {
919
+ for (const statement of request.statements) {
920
+ assertReadOnlyQuery(statement.sql);
921
+ }
922
+ read = this.#db.transaction(() => {
721
923
  const revision = getLocalRevision(this.#db);
722
- const rows = stripSyncColumns(this.#db.query(spec.sql, spec.params));
924
+ const queries = request.statements.map((statement) => stripSyncColumns(this.#db.query(statement.sql, statement.params)));
723
925
  const pending = [];
724
926
  const missing = [];
725
- for (const requested of spec.coverage ?? []) {
927
+ for (const requested of request.coverage ?? []) {
726
928
  const baseKey = windowBaseKey(requested.base);
727
929
  const state = this.windowState(requested.base);
728
930
  for (const unit of new Set(requested.units)) {
@@ -735,20 +937,26 @@ export class SyncClient {
735
937
  }
736
938
  return {
737
939
  revision,
738
- rows,
940
+ queries,
739
941
  coverage: {
740
942
  complete: pending.length === 0 && missing.length === 0,
741
943
  pending,
742
944
  missing,
743
945
  },
946
+ subscriptions: (request.subscriptions ?? []).map(catchupOf),
947
+ deliveries: (request.commitIds ?? []).map(deliveryOf),
744
948
  };
745
949
  });
746
950
  }
747
951
  catch (error) {
748
952
  const failure = classifySqliteFailure(error);
749
- const sqliteCode = failure.sqliteCode;
750
- const code = failure.code ?? 'client.query_failed';
751
953
  if (owner !== undefined) {
954
+ const code = failure.code ??
955
+ (error instanceof ClientSyncError &&
956
+ error.code === 'sync.local_corrupt'
957
+ ? 'sync.local_corrupt'
958
+ : 'client.query_failed');
959
+ const sqliteCode = failure.sqliteCode;
752
960
  const tables = [...new Set(owner.tables)].sort();
753
961
  const previous = this.#queryFailures.get(owner.id);
754
962
  if (previous === undefined ||
@@ -760,7 +968,7 @@ export class SyncClient {
760
968
  id: owner.id,
761
969
  tables,
762
970
  code,
763
- sqliteCode: sqliteCode,
971
+ sqliteCode,
764
972
  atMs: this.#now(),
765
973
  }));
766
974
  for (const id of this.#queryFailures.keys()) {
@@ -776,7 +984,7 @@ export class SyncClient {
776
984
  if (owner !== undefined && this.#queryFailures.delete(owner.id)) {
777
985
  this.#emitDiagnostics();
778
986
  }
779
- return snapshot;
987
+ return read;
780
988
  }
781
989
  // -- previous-version context (RFC 0005) ----------------------------------
782
990
  /**
@@ -810,6 +1018,10 @@ export class SyncClient {
810
1018
  const refusal = storedPreviousVersionRefusal(this.#db);
811
1019
  return this.#previousVersionUnavailable(currentVersion, refusal?.reason ?? 'no-previous-descriptor');
812
1020
  }
1021
+ if (record.currentVersion !== currentVersion) {
1022
+ this.#discardPreviousVersion();
1023
+ return this.#previousVersionUnavailable(currentVersion, 'no-previous-descriptor');
1024
+ }
813
1025
  if (nowMs - record.createdAtMs >
814
1026
  previousVersionMaxAgeMs(this.#config.previousVersionContext)) {
815
1027
  this.#discardPreviousVersion();
@@ -1905,6 +2117,16 @@ export class SyncClient {
1905
2117
  });
1906
2118
  }
1907
2119
  const pkValue = requireRowId(json[table.primaryKey]);
2120
+ // §7.1: declared-type validation runs before the commit is recorded, so a
2121
+ // refused value leaves no outbox entry, no optimistic row, no revision
2122
+ // and no observation event.
2123
+ const refusal = this.#operationValuesError(table, json);
2124
+ if (refusal !== undefined)
2125
+ // §7.5: the same structured shape the native authoring APIs return — a
2126
+ // static message, and the dynamic cause in `details.legacyCause`.
2127
+ throw new ClientSyncError('sync.invalid_request', 'the authoring request is invalid', false, {
2128
+ legacyCause: `sync.invalid_request: table ${table.name}: ${refusal}`,
2129
+ });
1908
2130
  return definedObject({
1909
2131
  table: mutation.table,
1910
2132
  rowId: pkValue,
@@ -2257,61 +2479,123 @@ export class SyncClient {
2257
2479
  * incompatible`); its purely-optimistic rows are undone (§7.2). Returns
2258
2480
  * the encoded push frames index-aligned with the surviving `outbox`.
2259
2481
  */
2260
- async #encodeOutboxForPush() {
2482
+ async #encodeOutboxForPush(header, pull, byteCap) {
2261
2483
  // Pin before the first encryption await: mutations can append while a
2262
2484
  // round is encoding, and belong to the next request.
2263
2485
  const bounds = this.#db.query(`SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM ${OUTBOX_TABLE}`)[0];
2264
2486
  const pendingCount = bounds.count;
2265
2487
  const throughSeq = bounds.last_seq ?? 0;
2488
+ const maxCommits = this.#config.limits?.maxPushCommitsPerRequest;
2489
+ const maxOps = this.#config.limits?.maxPushOperationsPerRequest ?? MAX_OPS_PER_REQUEST;
2490
+ // A request must start with REQ_HEADER and carry PULL_HEADER; those two
2491
+ // frames make a valid probe whose encoded size cancels out of the
2492
+ // per-commit delta measured against the canonical encoder.
2493
+ const baseBytes = byteCap === undefined
2494
+ ? 0
2495
+ : encodeMessage({
2496
+ wireVersion: PROTOCOL_WIRE_VERSION,
2497
+ msgKind: 'request',
2498
+ frames: [header, pull],
2499
+ }).length;
2266
2500
  const pushFrames = [];
2267
2501
  const outbox = [];
2502
+ const frameBytes = [];
2268
2503
  let deferred = 0;
2269
2504
  let ops = 0;
2505
+ let bytes = 0;
2270
2506
  let processed = 0;
2271
2507
  for (const commit of iterateOutbox(this.#db, throughSeq)) {
2272
- // §6.1 splitBatch: whole commits in commit order, stopping before the
2273
- // per-request operation cap. A first commit that alone exceeds the cap
2274
- // is sent alone — the server rejects it loudly rather than the queue
2275
- // wedging silently. Deferred commits stay queued for the next round.
2276
- if (outbox.length > 0 &&
2277
- ops + commit.operations.length > MAX_OPS_PER_REQUEST) {
2508
+ // §6.1/§7.1: the commit-count and operation caps are hard. A first
2509
+ // commit that alone exceeds the operation cap is a typed capacity error
2510
+ // and stays queued; a later one defers with the complete suffix.
2511
+ if (maxCommits !== undefined && pushFrames.length >= maxCommits) {
2278
2512
  deferred = pendingCount - processed;
2279
2513
  break;
2280
2514
  }
2281
- processed += 1;
2515
+ let pushFrame;
2282
2516
  try {
2283
- pushFrames.push(
2284
- // §5.11: encrypted columns are encrypted at this encode-at-send
2285
- // seam before the row codec serializes them. The stored local row
2286
- // supplies an ABSENT key-id selector (§6.1 presence set first,
2287
- // stored row second); an unresolvable key becomes a durable
2288
- // rejection for that commit, never a sync() throw.
2289
- await encodeOutboxCommit(this.#schema, commit, this.#encryption, (table, rowId) => {
2290
- const selector = this.#encryption?.keyIdColumns?.[table.name];
2291
- if (selector === undefined)
2292
- return undefined;
2293
- const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId])[0];
2294
- const value = row?.value;
2295
- return typeof value === 'string' && value.length > 0
2296
- ? value
2297
- : undefined;
2298
- }));
2299
- outbox.push(commit);
2300
- ops += commit.operations.length;
2517
+ pushFrame =
2518
+ // §5.11: encrypted columns are encrypted at this encode-at-send
2519
+ // seam before the row codec serializes them. The stored local row
2520
+ // supplies an ABSENT key-id selector (§6.1 presence set first,
2521
+ // stored row second); an unresolvable key becomes a durable
2522
+ // rejection for that commit, never a sync() throw.
2523
+ await encodeOutboxCommit(this.#schema, commit, this.#encryption, (table, rowId) => {
2524
+ const selector = this.#encryption?.keyIdColumns?.[table.name];
2525
+ if (selector === undefined)
2526
+ return undefined;
2527
+ const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId])[0];
2528
+ const value = row?.value;
2529
+ return typeof value === 'string' && value.length > 0
2530
+ ? value
2531
+ : undefined;
2532
+ });
2301
2533
  }
2302
2534
  catch (error) {
2303
2535
  if (error instanceof OutboxEncodeError) {
2304
2536
  this.#dropIncompatibleCommit(commit, error.message);
2537
+ processed += 1;
2305
2538
  continue;
2306
2539
  }
2307
2540
  if (error instanceof EncryptError) {
2308
2541
  this.#dropIncompatibleCommit(commit, error.message, error.code);
2542
+ processed += 1;
2543
+ continue;
2544
+ }
2545
+ // §7.1/§7.4.4: a persisted value the current codec refuses (an earlier
2546
+ // version accepted it) is an incompatible commit, never a wedged queue.
2547
+ if (error instanceof EncodeError ||
2548
+ (error instanceof ClientSyncError &&
2549
+ error.code === 'sync.invalid_request')) {
2550
+ this.#dropIncompatibleCommit(commit, LEGACY_VALUE_REFUSAL_MESSAGE, OUTBOX_INCOMPATIBLE_CODE, true, INVALID_STORED_VALUES_REASON);
2551
+ processed += 1;
2309
2552
  continue;
2310
2553
  }
2311
2554
  throw error;
2312
2555
  }
2556
+ // The operation cap is checked after the encode/drop so an unencodable
2557
+ // head is dropped as a durable rejection first, matching the native
2558
+ // prepass priority.
2559
+ if (ops + commit.operations.length > maxOps) {
2560
+ if (pushFrames.length === 0) {
2561
+ throw this.#pushCapacityError('operations', maxOps, commit.operations.length, commit.clientCommitId);
2562
+ }
2563
+ deferred = pendingCount - processed;
2564
+ break;
2565
+ }
2566
+ const frame = byteCap === undefined
2567
+ ? 0
2568
+ : encodeMessage({
2569
+ wireVersion: PROTOCOL_WIRE_VERSION,
2570
+ msgKind: 'request',
2571
+ frames: [header, pushFrame, pull],
2572
+ }).length - baseBytes;
2573
+ // The head is always encoded so the caller can report the full
2574
+ // projected request size with the final header and subscription bytes;
2575
+ // a later commit defers once it no longer fits.
2576
+ if (byteCap !== undefined &&
2577
+ pushFrames.length > 0 &&
2578
+ bytes + frame > byteCap) {
2579
+ deferred = pendingCount - processed;
2580
+ break;
2581
+ }
2582
+ pushFrames.push(pushFrame);
2583
+ outbox.push(commit);
2584
+ frameBytes.push(frame);
2585
+ ops += commit.operations.length;
2586
+ bytes += frame;
2587
+ processed += 1;
2313
2588
  }
2314
- return { pushFrames, outbox, deferred };
2589
+ return { pushFrames, outbox, frameBytes, deferred };
2590
+ }
2591
+ /** §7.1: a configured push capacity limit cannot carry this request. */
2592
+ #pushCapacityError(kind, limit, size, clientCommitId) {
2593
+ return new ClientSyncError('client.push_request_too_large', 'the push request exceeds a configured capacity limit', false, definedObject({
2594
+ kind,
2595
+ limit,
2596
+ size,
2597
+ ...(clientCommitId !== undefined ? { clientCommitId } : {}),
2598
+ }));
2315
2599
  }
2316
2600
  /**
2317
2601
  * §7.4.4: drop a commit that cannot re-encode after a bump, mirroring the
@@ -2319,19 +2603,27 @@ export class SyncClient {
2319
2603
  * purely-optimistic rows are undone, and a rejection record is raised.
2320
2604
  * §5.11: the same drop carries an encode-seam `client.encrypt_failed`.
2321
2605
  */
2322
- #dropIncompatibleCommit(commit, message, code = OUTBOX_INCOMPATIBLE_CODE) {
2606
+ #dropIncompatibleCommit(commit, message, code = OUTBOX_INCOMPATIBLE_CODE, replay = true, reason, classifiedFailures) {
2607
+ const priorRejections = [...this.#rejections];
2323
2608
  this.#applyBatch((batch) => {
2324
2609
  if (this.#retainFailedCommits && code === 'client.encrypt_failed')
2325
2610
  retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
2326
- this.#rollbackFailedCommit(commit, batch);
2327
- this.#replayOutbox();
2611
+ this.#rollbackFailedCommit(commit, batch, classifiedFailures);
2612
+ if (replay)
2613
+ this.#replayOutbox();
2614
+ // §7.2/§7.4.4: the journal holds the operation envelope only when its
2615
+ // stored shape is representable. A dropped legacy commit whose envelope
2616
+ // the journal cannot carry keeps its durable commitId, code and a bounded
2617
+ // `details.reason` instead of an opaque rewritten value.
2618
+ const representable = commit.operations.every(isStoredOperation);
2328
2619
  const rejection = definedObject({
2329
2620
  clientCommitId: commit.clientCommitId,
2330
2621
  opIndex: 0,
2331
2622
  code,
2332
2623
  message,
2333
2624
  retryable: false,
2334
- operation: commit.operations[0],
2625
+ ...(representable ? { operation: commit.operations[0] } : {}),
2626
+ ...(reason === undefined ? {} : { details: { reason } }),
2335
2627
  });
2336
2628
  this.#rejections.push(rejection);
2337
2629
  recordCommitOutcome(this.#db, {
@@ -2339,12 +2631,17 @@ export class SyncClient {
2339
2631
  status: 'rejected',
2340
2632
  recordedAtMs: this.#now(),
2341
2633
  results: [{ status: 'error', rejection }],
2342
- operations: commit.operations,
2634
+ ...(representable ? { operations: commit.operations } : {}),
2343
2635
  });
2344
2636
  pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2345
2637
  batch.status();
2346
2638
  batch.rejections();
2347
2639
  batch.outcomes();
2640
+ }, undefined, (error) => {
2641
+ // The drop's own outcome write or publication failed: mirror the
2642
+ // rollback so no rejection the journal did not keep stays in memory.
2643
+ this.#rejections = priorRejections;
2644
+ throw error;
2348
2645
  });
2349
2646
  }
2350
2647
  // -- sync -------------------------------------------------------------------
@@ -2461,6 +2758,29 @@ export class SyncClient {
2461
2758
  try {
2462
2759
  await this.#drainPendingEvictions();
2463
2760
  const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
2761
+ // §7.4.4: encode the outbox with the CURRENT codec; a commit that
2762
+ // cannot express itself under the new schema (a dropped column/table)
2763
+ // is removed from the push and surfaced as a rejection, never wedging
2764
+ // the queue. `pushFrames` and `outbox` stay index-aligned for result
2765
+ // mapping.
2766
+ const limits = this.#config.limits;
2767
+ const byteCap = limits?.maxPushRequestBytes;
2768
+ const header = definedObject({
2769
+ type: 'REQ_HEADER',
2770
+ clientId: this.#clientId,
2771
+ schemaVersion: this.#schema.version,
2772
+ logEpoch: logEpoch,
2773
+ });
2774
+ const pull = {
2775
+ type: 'PULL_HEADER',
2776
+ limitCommits: limits?.limitCommits ?? 0,
2777
+ limitSnapshotRows: limits?.limitSnapshotRows ?? 0,
2778
+ maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
2779
+ accept: this.#acceptMask(),
2780
+ };
2781
+ const { pushFrames, outbox, frameBytes, deferred: encodedDeferred, } = logEpoch === undefined
2782
+ ? { pushFrames: [], outbox: [], frameBytes: [], deferred: 0 }
2783
+ : await this.#encodeOutboxForPush(header, pull, byteCap);
2464
2784
  // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
2465
2785
  // them, so the server-side existence check (§6.6) passes.
2466
2786
  if (logEpoch !== undefined &&
@@ -2468,34 +2788,14 @@ export class SyncClient {
2468
2788
  this.#config.blobs !== undefined) {
2469
2789
  await this.#flushBlobUploads();
2470
2790
  }
2471
- // §7.4.4: encode the outbox with the CURRENT codec; a commit that
2472
- // cannot express itself under the new schema (a dropped column/table)
2473
- // is removed from the push and surfaced as a rejection, never wedging
2474
- // the queue. `pushFrames` and `outbox` stay index-aligned for result
2475
- // mapping.
2476
- const { pushFrames, outbox, deferred } = logEpoch === undefined
2477
- ? { pushFrames: [], outbox: [], deferred: 0 }
2478
- : await this.#encodeOutboxForPush();
2479
- // Captured together with the subscription state below: the response
2480
- // apply persists SUB_END cursors only while this epoch is current.
2791
+ // Captured after encoding: a subscribe/unsubscribe during the
2792
+ // encryption await must not make the final request exceed the byte
2793
+ // budget, and the reset epoch must be the one the response apply checks.
2481
2794
  const resetEpoch = this.#localResetEpoch;
2482
2795
  const subs = loadSubscriptions(this.#db).filter((sub) => sub.status === 'active');
2483
- const limits = this.#config.limits;
2484
- const frames = [
2485
- definedObject({
2486
- type: 'REQ_HEADER',
2487
- clientId: this.#clientId,
2488
- schemaVersion: this.#schema.version,
2489
- logEpoch: logEpoch,
2490
- }),
2491
- ...pushFrames,
2492
- {
2493
- type: 'PULL_HEADER',
2494
- limitCommits: limits?.limitCommits ?? 0,
2495
- limitSnapshotRows: limits?.limitSnapshotRows ?? 0,
2496
- maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
2497
- accept: this.#acceptMask(),
2498
- },
2796
+ const fixedFrames = [
2797
+ header,
2798
+ pull,
2499
2799
  ...subs.map((sub) => definedObject({
2500
2800
  type: 'SUBSCRIPTION',
2501
2801
  id: sub.id,
@@ -2506,6 +2806,38 @@ export class SyncClient {
2506
2806
  bootstrapState: sub.bootstrapState,
2507
2807
  })),
2508
2808
  ];
2809
+ let admitted = pushFrames.length;
2810
+ let deferred = encodedDeferred;
2811
+ if (byteCap !== undefined) {
2812
+ // The fixed frames encode as envelope + fixed frames + END; each
2813
+ // admitted push frame adds its own (5 + payload). Trimming the tail
2814
+ // re-fits the request to the final subscription state.
2815
+ const fixedBytes = encodeMessage({
2816
+ wireVersion: PROTOCOL_WIRE_VERSION,
2817
+ msgKind: 'request',
2818
+ frames: fixedFrames,
2819
+ }).length;
2820
+ if (fixedBytes > byteCap)
2821
+ throw this.#pushCapacityError('bytes', byteCap, fixedBytes);
2822
+ let admittedBytes = 0;
2823
+ for (const size of frameBytes)
2824
+ admittedBytes += size;
2825
+ while (admitted > 0 && fixedBytes + admittedBytes > byteCap) {
2826
+ admitted -= 1;
2827
+ admittedBytes -= frameBytes[admitted];
2828
+ deferred += 1;
2829
+ }
2830
+ if (admitted === 0 && pushFrames.length > 0) {
2831
+ throw this.#pushCapacityError('bytes', byteCap, fixedBytes + frameBytes[0], outbox[0].clientCommitId);
2832
+ }
2833
+ pushFrames.length = admitted;
2834
+ outbox.length = admitted;
2835
+ }
2836
+ const frames = [
2837
+ fixedFrames[0],
2838
+ ...pushFrames,
2839
+ ...fixedFrames.slice(1),
2840
+ ];
2509
2841
  const requestBytes = encodeMessage({
2510
2842
  wireVersion: PROTOCOL_WIRE_VERSION,
2511
2843
  msgKind: 'request',
@@ -2577,13 +2909,68 @@ export class SyncClient {
2577
2909
  * pages pending, no resets to recover (§4.5 "pull again" SHOULD).
2578
2910
  */
2579
2911
  async syncUntilIdle(maxRounds) {
2912
+ // §7.7: an explicit budget is a strict positive round count. Zero,
2913
+ // fractional, and out-of-range values are caller errors.
2914
+ if (maxRounds !== undefined &&
2915
+ (!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 0xffffffff))
2916
+ throw invalidRequest('maxRounds must be a positive integer');
2580
2917
  const budget = maxRounds ?? 20;
2581
- let last;
2918
+ const aggregate = {
2919
+ pushed: 0,
2920
+ applied: [],
2921
+ rejected: [],
2922
+ retryable: [],
2923
+ conflicts: [],
2924
+ commitsApplied: 0,
2925
+ segmentRowsApplied: 0,
2926
+ bootstrapping: [],
2927
+ resets: [],
2928
+ revoked: [],
2929
+ failed: [],
2930
+ deferredCommits: 0,
2931
+ schemaFloor: undefined,
2932
+ };
2933
+ const result = (budgetExhausted) => ({
2934
+ pushed: aggregate.pushed,
2935
+ applied: aggregate.applied,
2936
+ rejected: aggregate.rejected,
2937
+ retryable: aggregate.retryable,
2938
+ conflicts: aggregate.conflicts,
2939
+ commitsApplied: aggregate.commitsApplied,
2940
+ segmentRowsApplied: aggregate.segmentRowsApplied,
2941
+ bootstrapping: aggregate.bootstrapping,
2942
+ resets: aggregate.resets,
2943
+ revoked: aggregate.revoked,
2944
+ failed: aggregate.failed,
2945
+ ...(aggregate.deferredCommits > 0
2946
+ ? { deferredCommits: aggregate.deferredCommits }
2947
+ : {}),
2948
+ ...(aggregate.schemaFloor !== undefined
2949
+ ? { schemaFloor: aggregate.schemaFloor }
2950
+ : {}),
2951
+ budgetExhausted,
2952
+ });
2582
2953
  for (let round = 0; round < budget; round++) {
2583
2954
  const before = JSON.stringify(loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]));
2584
- last = await this.sync();
2955
+ const last = await this.sync();
2956
+ // Historical counters accumulate; current readiness (`bootstrapping`,
2957
+ // `deferredCommits`, `schemaFloor`) describes the latest round.
2958
+ aggregate.pushed += last.pushed;
2959
+ aggregate.applied.push(...last.applied);
2960
+ aggregate.rejected.push(...last.rejected);
2961
+ aggregate.retryable.push(...last.retryable);
2962
+ aggregate.conflicts.push(...last.conflicts);
2963
+ aggregate.commitsApplied += last.commitsApplied;
2964
+ aggregate.segmentRowsApplied += last.segmentRowsApplied;
2965
+ aggregate.bootstrapping = [...last.bootstrapping];
2966
+ aggregate.resets.push(...last.resets);
2967
+ aggregate.revoked.push(...last.revoked);
2968
+ aggregate.failed.push(...last.failed);
2969
+ aggregate.deferredCommits = last.deferredCommits ?? 0;
2970
+ aggregate.schemaFloor = last.schemaFloor;
2971
+ // A disabled transport or a schema floor ends the loop, not the budget.
2585
2972
  if (!this.#transportEnabled || last.schemaFloor !== undefined)
2586
- return last;
2973
+ return result(false);
2587
2974
  if (maxRounds === undefined &&
2588
2975
  last.segmentRowsApplied > 0 &&
2589
2976
  last.bootstrapping.length > 0 &&
@@ -2599,10 +2986,10 @@ export class SyncClient {
2599
2986
  last.resets.length === 0 &&
2600
2987
  (last.deferredCommits ?? 0) === 0 &&
2601
2988
  !this.#needsPull) {
2602
- return last;
2989
+ return result(false);
2603
2990
  }
2604
2991
  }
2605
- throw invalidRequest('sync did not reach idle within the round budget');
2992
+ return result(true);
2606
2993
  }
2607
2994
  /**
2608
2995
  * One request/response round trip (§8.7): over the socket whenever it
@@ -2629,12 +3016,12 @@ export class SyncClient {
2629
3016
  try {
2630
3017
  socket.sendBytes(tagged);
2631
3018
  }
2632
- catch (error) {
3019
+ catch {
2633
3020
  this.#pendingRound = undefined;
2634
3021
  // The socket could not carry the round: record the same loss the
2635
3022
  // Rust core records when its realtime round call fails (§8.8).
2636
3023
  this.#setRealtimeState('lost', 'sync.transport_failed');
2637
- reject(new ClientSyncError('sync.transport_failed', `socket round send failed: ${error instanceof Error ? error.message : String(error)}`, true));
3024
+ reject(new ClientSyncError('sync.transport_failed', 'socket round send failed', true, { causeKind: 'unknown' }));
2638
3025
  }
2639
3026
  });
2640
3027
  }
@@ -2650,7 +3037,7 @@ export class SyncClient {
2650
3037
  typeof error?.code === 'string') {
2651
3038
  throw error;
2652
3039
  }
2653
- throw new ClientSyncError('sync.transport_failed', `transport round failed: ${error instanceof Error ? error.message : String(error)}`, true);
3040
+ throw new ClientSyncError('sync.transport_failed', 'transport round failed', true, { causeKind: 'unknown' });
2654
3041
  });
2655
3042
  }
2656
3043
  /** Abort the in-flight socket round (socket closed or disconnected). */
@@ -2795,9 +3182,9 @@ export class SyncClient {
2795
3182
  try {
2796
3183
  done = round.scanner.push(body);
2797
3184
  }
2798
- catch (error) {
3185
+ catch {
2799
3186
  this.#pendingRound = undefined;
2800
- round.reject(invalidRequest(`malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`));
3187
+ round.reject(invalidRequest('malformed round response stream (§8.7)'));
2801
3188
  return;
2802
3189
  }
2803
3190
  if (done === undefined)
@@ -3279,9 +3666,20 @@ export class SyncClient {
3279
3666
  if (this.#retainFailedCommits)
3280
3667
  retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
3281
3668
  this.#rollbackFailedCommit(commit, batch);
3282
- this.#replayOutbox(commit.operations.map((operation) => JSON.stringify(hasUniqueIndex(this.#table(operation.table))
3283
- ? [operation.table]
3284
- : [operation.table, operation.rowId])));
3669
+ // §7.2: rebasing a rejection reaches rows the same-row replay inside
3670
+ // `#rollbackFailedCommit` cannot: peers of a secondary UNIQUE index
3671
+ // (table-wide scope) and later commits over legacy before-images.
3672
+ // ACK-only groups reconcile nothing and stay zero-work.
3673
+ this.#replayOutbox(commit.operations.flatMap((operation) => {
3674
+ const table = this.#schema.tables.get(operation.table);
3675
+ return table === undefined
3676
+ ? []
3677
+ : [
3678
+ JSON.stringify(hasUniqueIndex(table)
3679
+ ? [operation.table]
3680
+ : [operation.table, operation.rowId]),
3681
+ ];
3682
+ }));
3285
3683
  });
3286
3684
  batch.status();
3287
3685
  summary.rejected.push(frame.clientCommitId);
@@ -3697,7 +4095,7 @@ export class SyncClient {
3697
4095
  }
3698
4096
  }
3699
4097
  }
3700
- #rollbackFailedCommit(commit, batch) {
4098
+ #rollbackFailedCommit(commit, batch, classifiedFailures) {
3701
4099
  const images = listOutboxBeforeImages(this.#db, commit.clientCommitId);
3702
4100
  const imageByIndex = new Map(images.map((image) => [image.opIndex, image]));
3703
4101
  const complete = commit.operations.every((_, index) => imageByIndex.has(index));
@@ -3729,12 +4127,28 @@ export class SyncClient {
3729
4127
  if (affectedOperations.length === 0)
3730
4128
  continue;
3731
4129
  replaceOutboxBeforeImages(this.#db, later.clientCommitId, this.#captureBeforeImages(later.operations, affectedKeys));
4130
+ // §7.4.4: a later commit the recovery already classified refused never
4131
+ // replays. Its before-image is still rebased above, so dropping it later
4132
+ // restores the same base this rollback just restored.
4133
+ if (classifiedFailures?.has(later.clientCommitId))
4134
+ continue;
3732
4135
  this.#applyOperationsLocally(affectedOperations, batch);
3733
4136
  }
3734
4137
  }
3735
4138
  #applyOperationsLocally(operations, batch, replay = false) {
3736
4139
  for (const op of operations) {
3737
- const table = this.#table(op.table);
4140
+ // §7.4.4: the overlay replay is schema-agnostic (§0), so an operation
4141
+ // whose table the current schema removed has no local mirror to replay
4142
+ // into and is skipped here. A removed-table upsert stays durable for the
4143
+ // send-time `sync.outbox_incompatible` classification; a value-free
4144
+ // delete carries no values to classify and keeps ordinary server
4145
+ // validation. Every other call runs against the live schema and keeps the
4146
+ // loud `sync.unknown_table`.
4147
+ const table = replay
4148
+ ? this.#schema.tables.get(op.table)
4149
+ : this.#table(op.table);
4150
+ if (table === undefined)
4151
+ continue;
3738
4152
  let precise = batch === undefined
3739
4153
  ? false
3740
4154
  : this.#recordStoredRowScopes(batch, table, op.rowId);
@@ -3762,8 +4176,23 @@ export class SyncClient {
3762
4176
  return jsonToRowValue(value);
3763
4177
  return local === undefined
3764
4178
  ? null
3765
- : fromSqlValue(column, local[column.name] ?? null);
4179
+ : replay
4180
+ ? coerceSqlRepresentation(column, local[column.name] ?? null)
4181
+ : fromSqlValue(column, local[column.name] ?? null);
3766
4182
  });
4183
+ if (replay) {
4184
+ try {
4185
+ encodeRow(table.columns.map((column, index) => ({
4186
+ ...column,
4187
+ type: column.encrypted && values[index] instanceof Uint8Array
4188
+ ? 'bytes'
4189
+ : localColumnType(column),
4190
+ })), values);
4191
+ }
4192
+ catch {
4193
+ throw new ClientSyncError('sync.local_corrupt', 'persisted optimistic row value is invalid');
4194
+ }
4195
+ }
3767
4196
  // Record the row's scope keys from its scope columns (I2 refinement).
3768
4197
  if (batch !== undefined) {
3769
4198
  for (const [variable, column] of table.scopeColumnByVariable) {
@@ -3810,10 +4239,89 @@ export class SyncClient {
3810
4239
  }
3811
4240
  return scope;
3812
4241
  }
3813
- #replayOutbox(scope, restoreBases = true) {
4242
+ /**
4243
+ * §7.1: the operation's values must encode under the current schema. Returns
4244
+ * the refusal message, or `undefined` when they encode. Structural failures
4245
+ * (a programming error, never caller data) propagate unchanged.
4246
+ */
4247
+ #operationValuesError(table, values) {
4248
+ const declared = table.columns.map((column) => ({
4249
+ ...column,
4250
+ type: localColumnType(column),
4251
+ }));
4252
+ const sparse = [];
4253
+ for (const column of table.columns) {
4254
+ const raw = values[column.name];
4255
+ if (raw === undefined) {
4256
+ sparse.push(undefined);
4257
+ continue;
4258
+ }
4259
+ // §7.1: the schema-agnostic outbox form serializes through JSON, which
4260
+ // turns NaN and ±Infinity into null. The authoring boundary refuses them
4261
+ // instead of recording a different value; the wire `f64` encoding itself
4262
+ // stays IEEE-754 binary64 (§2.4).
4263
+ if (localColumnType(column) === 'float' &&
4264
+ typeof raw === 'number' &&
4265
+ !Number.isFinite(raw))
4266
+ return `column ${column.name} (float) requires a finite number value`;
4267
+ try {
4268
+ sparse.push(jsonToRowValue(raw));
4269
+ }
4270
+ catch (error) {
4271
+ if (error instanceof ClientSyncError)
4272
+ return `column ${column.name}: ${error.message}`;
4273
+ throw error;
4274
+ }
4275
+ }
4276
+ try {
4277
+ encodeSparseRow(declared, table.primaryKeyIndex, sparse);
4278
+ return undefined;
4279
+ }
4280
+ catch (error) {
4281
+ if (error instanceof EncodeError || error instanceof ClientSyncError)
4282
+ return error.message;
4283
+ throw error;
4284
+ }
4285
+ }
4286
+ /**
4287
+ * §7.4.4: a pending commit whose own values the current codec refuses (an
4288
+ * envelope or a type an earlier version accepted) leaves the outbox as
4289
+ * `sync.outbox_incompatible` instead of failing every replay and sync. The
4290
+ * commit goes through the rejection channel; encryption and storage failures
4291
+ * never reach this path.
4292
+ */
4293
+ #dropUnencodableOutbox() {
4294
+ const failures = [];
4295
+ for (const commit of listOutbox(this.#db)) {
4296
+ const refused = commit.operations.some((op) => {
4297
+ if (op.op !== 'upsert' || op.values === undefined)
4298
+ return false;
4299
+ const table = this.#schema.tables.get(op.table);
4300
+ if (table === undefined)
4301
+ return false;
4302
+ return this.#operationValuesError(table, op.values) !== undefined;
4303
+ });
4304
+ if (refused)
4305
+ failures.push(commit);
4306
+ }
4307
+ // §7.4.4: every refused commit is classified before the FIFO drop pass, so
4308
+ // a rollback never replays a refused successor it has not removed yet.
4309
+ const classifiedFailures = new Set(failures.map((commit) => commit.clientCommitId));
4310
+ for (const commit of failures)
4311
+ this.#dropIncompatibleCommit(commit, LEGACY_VALUE_REFUSAL_MESSAGE, OUTBOX_INCOMPATIBLE_CODE, false, INVALID_STORED_VALUES_REASON, classifiedFailures);
4312
+ }
4313
+ #replayOutbox(scope, restoreBases = true, observe = true) {
3814
4314
  if (scope?.length === 0)
3815
4315
  return;
3816
- this.#applyBatch((batch) => {
4316
+ const replay = (batch) => {
4317
+ // §7.4.4: classify value-unencodable pending commits inside the
4318
+ // reconciliation transaction, so a commit persisted by an earlier version
4319
+ // is dropped atomically with the replay it would otherwise fail. A
4320
+ // scoped (partial) reconciliation leaves the classification to these full
4321
+ // boundaries and to the send-time drop, so a partial rebuild never drops
4322
+ // a commit whose other tables are outside the scope.
4323
+ if (scope === undefined)
4324
+ this.#dropUnencodableOutbox();
3817
4325
  if (restoreBases)
3818
4326
  restoreFailedBases(this.#db, this.#schema, scope);
3819
4327
  const acknowledged = failedOverlayCommits(this.#db, true, scope, this.#schema);
@@ -3825,7 +4333,21 @@ export class SyncClient {
3825
4333
  this.#applyOperationsLocally(commit.operations.filter((op) => scope === undefined ||
3826
4334
  scope.includes(JSON.stringify([op.table, op.rowId])) ||
3827
4335
  scope.includes(JSON.stringify([op.table]))), batch, true);
3828
- });
4336
+ };
4337
+ const priorRejections = [...this.#rejections];
4338
+ try {
4339
+ if (observe)
4340
+ this.#applyBatch(replay);
4341
+ else
4342
+ this.#db.transaction(() => replay());
4343
+ }
4344
+ catch (error) {
4345
+ // SQLite rolled the reconciliation back: mirror that rollback for the
4346
+ // in-memory rejection cache, so a later failure in the same transaction
4347
+ // publishes no drop the durable journal did not keep.
4348
+ this.#rejections = priorRejections;
4349
+ throw error;
4350
+ }
3829
4351
  }
3830
4352
  #deleteUnreferencedBlobs() {
3831
4353
  if (!this.#hasBlobs)