@syncular/client 0.30.15 → 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/src/client.ts CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  decodeRow,
34
34
  decodeRowsSegment,
35
35
  encodeMessage,
36
+ encodeRow,
37
+ encodeSparseRow,
36
38
  encodePresencePublish,
37
39
  MessageStreamScanner,
38
40
  PROTOCOL_WIRE_VERSION,
@@ -52,6 +54,7 @@ import {
52
54
  type WakeReason,
53
55
  } from '@syncular/core';
54
56
  import { EncryptError } from '@syncular/core';
57
+ import { EncodeError } from '@syncular/core';
55
58
  import {
56
59
  applyCommitFrame,
57
60
  applyRowsSegment,
@@ -166,8 +169,10 @@ import {
166
169
  type CommitOutcome,
167
170
  type CommitOutcomeQuery,
168
171
  type ConflictRecord,
172
+ isStoredOperation,
169
173
  listCommitOutcomes,
170
174
  persistCommitOutcomeResolution,
175
+ persistedCommitOutcome,
171
176
  pruneCommitOutcomes,
172
177
  type RejectionRecord,
173
178
  type ResolveCommitOutcomeInput,
@@ -210,6 +215,7 @@ import {
210
215
  ensureLocalSyncedSchema,
211
216
  fromSqlValue,
212
217
  coerceSqlRepresentation,
218
+ localColumnType,
213
219
  type JsonRowValue,
214
220
  jsonToRowValue,
215
221
  LOCAL_SCHEMA_VERSION_KEY,
@@ -234,6 +240,7 @@ import {
234
240
  type SubscriptionRecord,
235
241
  saveSubscription,
236
242
  setMeta,
243
+ SUBSCRIPTIONS_TABLE,
237
244
  } from './state';
238
245
  import type {
239
246
  RealtimeConnector,
@@ -336,6 +343,15 @@ export interface SyncSummary {
336
343
  readonly deferredCommits?: number;
337
344
  }
338
345
 
346
+ /**
347
+ * The result of `syncUntilIdle`. The report aggregates every round that ran;
348
+ * `budgetExhausted` is true when the round budget ran out with the client not
349
+ * idle. An exhausted run is a partial success, never idle.
350
+ */
351
+ export interface SyncIdleResult extends SyncSummary {
352
+ readonly budgetExhausted: boolean;
353
+ }
354
+
339
355
  export interface SyncClientLimits {
340
356
  readonly limitCommits?: number;
341
357
  readonly limitSnapshotRows?: number;
@@ -352,6 +368,22 @@ export interface SyncClientLimits {
352
368
  * deleted to satisfy this cap. Defaults to 1,000.
353
369
  */
354
370
  readonly outcomeRetentionMaxEntries?: number;
371
+ /** §7.1: maximum whole commits in one push request. Default: unbounded. */
372
+ readonly maxPushCommitsPerRequest?: number;
373
+ /**
374
+ * §7.1: maximum operations across the push commits in one request. Default
375
+ * 500. A first commit that alone exceeds it is a typed
376
+ * `client.push_request_too_large` capacity error and stays queued.
377
+ */
378
+ readonly maxPushOperationsPerRequest?: number;
379
+ /**
380
+ * §7.1: maximum bytes of the complete encoded SSP2 request (8-byte
381
+ * envelope, request header, push commits, pull header, subscriptions,
382
+ * framing, and any §5.11 ciphertext). Default: unbounded. A request whose
383
+ * fixed frames alone exceed it, or whose first commit alone does not fit,
384
+ * raises `client.push_request_too_large` and stays queued.
385
+ */
386
+ readonly maxPushRequestBytes?: number;
355
387
  }
356
388
 
357
389
  export interface SyncClientConfig {
@@ -540,6 +572,70 @@ export interface QuerySnapshot<Row = SqlRow> {
540
572
  readonly coverage: CoverageSnapshot;
541
573
  }
542
574
 
575
+ /** §7.5: one read-only statement in an atomic snapshot read. */
576
+ export interface SnapshotStatement {
577
+ readonly sql: string;
578
+ readonly params?: readonly SqlValue[];
579
+ }
580
+
581
+ /**
582
+ * §7.5: the catch-up state of one subscription as persisted in the snapshot,
583
+ * never the mutable owner cache. `unknown` means the client does not currently
584
+ * hold the subscription (never registered, forgotten, or removed) and carries
585
+ * no fabricated table or cursor.
586
+ */
587
+ export type SubscriptionCatchup =
588
+ | { readonly state: 'unknown'; readonly id: string }
589
+ | {
590
+ readonly state: 'known';
591
+ readonly id: string;
592
+ readonly table: string;
593
+ readonly status: 'active' | 'revoked' | 'failed';
594
+ readonly cursor: number;
595
+ readonly hasResumeToken: boolean;
596
+ /** True only for `active` with `cursor >= 0` and no resume token. */
597
+ readonly bootstrapComplete: boolean;
598
+ /** True only for `active` while a resume token remains or `cursor < 0`. */
599
+ readonly knownPendingPages: boolean;
600
+ readonly effectiveScopes?: unknown;
601
+ readonly reasonCode?: string;
602
+ };
603
+
604
+ /**
605
+ * §7.5: delivery status for one client commit id. `pending` while the id still
606
+ * has an outbox entry (outbox precedence), otherwise the persisted-journal
607
+ * outcome, otherwise an explicit `unknown` (never held, or pruned by
608
+ * retention). The outcome omits the owner-derived `retainedRows` images.
609
+ */
610
+ export type CommitDelivery =
611
+ | { readonly status: 'pending'; readonly clientCommitId: string }
612
+ | {
613
+ readonly status: 'known';
614
+ readonly clientCommitId: string;
615
+ readonly outcome: CommitOutcome;
616
+ }
617
+ | { readonly status: 'unknown'; readonly clientCommitId: string };
618
+
619
+ /** §7.5: one atomic read request over a single SQLite read transaction. */
620
+ export interface SnapshotReadRequest {
621
+ readonly statements: readonly SnapshotStatement[];
622
+ readonly coverage?: readonly WindowCoverage[];
623
+ readonly subscriptions?: readonly string[];
624
+ readonly commitIds?: readonly string[];
625
+ /** §7.5 owner: a failed owned read is reported in `queryFailures`. */
626
+ readonly owner?: QueryOwner;
627
+ }
628
+
629
+ /** §7.5: one atomic read result: one revision, every statement's rows, the
630
+ * requested coverage, catch-up states, and deliveries. */
631
+ export interface SnapshotRead<Row = SqlRow> {
632
+ readonly revision: LocalRevision;
633
+ readonly queries: readonly (readonly Row[])[];
634
+ readonly coverage: CoverageSnapshot;
635
+ readonly subscriptions: readonly SubscriptionCatchup[];
636
+ readonly deliveries: readonly CommitDelivery[];
637
+ }
638
+
543
639
  /**
544
640
  * True iff `unit` is windowed-in AND its bootstrap completed (§4.8 I3):
545
641
  * registered and not pending. A unit with zero server rows still becomes
@@ -564,6 +660,11 @@ const ACCEPT_SIGNED_URLS = 1 << 3;
564
660
  * Never a wire code (§10.3) — surfaced through the rejection channel.
565
661
  */
566
662
  const OUTBOX_INCOMPATIBLE_CODE = 'sync.outbox_incompatible';
663
+ /** §7.4.4: static text for a persisted commit the current codec refuses. */
664
+ const LEGACY_VALUE_REFUSAL_MESSAGE =
665
+ 'the persisted commit carries values the current codec refuses';
666
+ /** §7.4.4: bounded reason on every intrinsic value drop. */
667
+ const INVALID_STORED_VALUES_REASON = 'invalid_stored_values';
567
668
 
568
669
  /** §6.1 per-request operation cap (matches the server's shipped default —
569
670
  * `sync.too_many_operations` above it). The push half sends whole commits in
@@ -719,6 +820,7 @@ function realtimeErrorCode(error: unknown): string | undefined {
719
820
  export type ClientSnapshotMethods = Pick<
720
821
  SyncClient,
721
822
  | 'querySnapshot'
823
+ | 'snapshotRead'
722
824
  | 'statusSnapshot'
723
825
  | 'diagnosticsSnapshot'
724
826
  | 'conflicts'
@@ -855,6 +957,26 @@ export class SyncClient {
855
957
  throw invalidRequest('transportEnabled must be a boolean');
856
958
  }
857
959
  this.#transportEnabled = config.transportEnabled !== false;
960
+ for (const [name, value] of [
961
+ ['maxPushCommitsPerRequest', config.limits?.maxPushCommitsPerRequest],
962
+ [
963
+ 'maxPushOperationsPerRequest',
964
+ config.limits?.maxPushOperationsPerRequest,
965
+ ],
966
+ ['maxPushRequestBytes', config.limits?.maxPushRequestBytes],
967
+ ] as const) {
968
+ if (
969
+ value !== undefined &&
970
+ (!Number.isInteger(value) || value < 1 || value > 0xffffffff)
971
+ ) {
972
+ throw new ClientSyncError(
973
+ 'sync.invalid_request',
974
+ 'push limit must be an integer in 1..=4294967295',
975
+ false,
976
+ { limit: name },
977
+ );
978
+ }
979
+ }
858
980
  if (config.securityPreflight === true && config.encryption !== undefined) {
859
981
  throw invalidRequest(
860
982
  'securityPreflight and encryption are mutually exclusive; activateSecurity installs keys',
@@ -922,57 +1044,142 @@ export class SyncClient {
922
1044
  this.#lease = await lock.acquire(
923
1045
  this.#config.lockName ?? 'syncular-leader',
924
1046
  );
925
- // Bookkeeping must exist before we inspect the persisted schema marker.
926
- // Do not materialize new app indexes/FTS projections yet: on a version
927
- // bump they may reference columns that only exist after the reset.
928
- ensureLocalBookkeepingSchema(this.#db);
929
- if (this.#hasBlobs) ensureBlobSchema(this.#db);
930
- this.#db.transaction(() => {
931
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
932
- });
933
- const activeFailures = activeFailureRecords(
934
- listCommitOutcomes(this.#db, { activeOnly: true }),
935
- );
936
- this.#conflicts = activeFailures.conflicts;
937
- this.#rejections = activeFailures.rejections;
938
- const persisted = getMeta(this.#db, 'clientId');
939
- if (
940
- persisted !== undefined &&
941
- this.#config.clientId !== undefined &&
942
- persisted !== this.#config.clientId
943
- ) {
1047
+ // §7.4.2: the entire synchronous storage phase below (marker guard,
1048
+ // identity and bookkeeping writes, schema reset/replay, container
1049
+ // reconciliation, subscription pruning) runs as one deferred transaction
1050
+ // whose first statement is the marker read. That read pins SQLite's
1051
+ // snapshot before any write: a replica another process upgraded after the
1052
+ // read can never be written over (its snapshot fails with
1053
+ // BUSY/BUSY_SNAPSHOT), and one upgraded before the read refuses with
1054
+ // `client.schema_downgrade`. The batch emits its change event only after
1055
+ // the commit. The onUpgrading callback still reports reset intent.
1056
+ let subscriptions!: ReturnType<typeof pruneUnknownSubscriptions>;
1057
+ try {
1058
+ this.#applyBatch(() => {
1059
+ // §7.4.1/§7.4.2: this is the transaction's first read, so it pins the
1060
+ // snapshot every write below commits under. `undefined` is an absent
1061
+ // marker (fresh/legacy); a non-canonical value, a `_syncular_meta` that
1062
+ // is not a table, or a duplicated marker row is unreadable state.
1063
+ let marker: number | undefined;
1064
+ try {
1065
+ const meta = this.#db.query(
1066
+ "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
1067
+ )[0];
1068
+ if (meta !== undefined) {
1069
+ if (meta.type !== 'table')
1070
+ throw new Error('invalid metadata table');
1071
+ const rows = this.#db.query(
1072
+ 'SELECT value FROM _syncular_meta WHERE key = ?',
1073
+ [LOCAL_SCHEMA_VERSION_KEY],
1074
+ );
1075
+ // A duplicated marker cannot be resolved. Picking one row could
1076
+ // select an older version and drive a destructive reset over a
1077
+ // newer replica.
1078
+ if (rows.length > 1) throw new Error('invalid schema marker');
1079
+ const row = rows[0];
1080
+ if (
1081
+ row === undefined &&
1082
+ this.#db.query(
1083
+ "SELECT 1 FROM _syncular_meta WHERE key = 'localSchemaDescriptor' LIMIT 1",
1084
+ ).length > 0
1085
+ ) {
1086
+ throw new Error('missing paired schema marker');
1087
+ }
1088
+ if (row !== undefined) {
1089
+ const value = row.value;
1090
+ const version = Number(value);
1091
+ if (
1092
+ typeof value !== 'string' ||
1093
+ !Number.isInteger(version) ||
1094
+ version < 1 ||
1095
+ version > 2147483647 ||
1096
+ String(version) !== value
1097
+ ) {
1098
+ throw new Error('invalid schema marker');
1099
+ }
1100
+ marker = version;
1101
+ }
1102
+ }
1103
+ } catch (error) {
1104
+ // A recognized SQLite storage failure (including BUSY/LOCKED
1105
+ // contention on the marker read) stays classified; only an invalid
1106
+ // or otherwise unreadable marker is `sync.local_corrupt`.
1107
+ const failure = classifySqliteFailure(error);
1108
+ if (failure.code !== undefined) throw failure.error;
1109
+ throw new ClientSyncError(
1110
+ 'sync.local_corrupt',
1111
+ 'persisted local schema marker is unreadable or invalid',
1112
+ );
1113
+ }
1114
+ if (marker !== undefined && marker > this.#schema.version) {
1115
+ throw new ClientSyncError(
1116
+ 'client.schema_downgrade',
1117
+ 'persisted local schema is newer than the requested schema',
1118
+ false,
1119
+ {
1120
+ persistedVersion: marker,
1121
+ requestedVersion: this.#schema.version,
1122
+ },
1123
+ );
1124
+ }
1125
+ // New app indexes may reference columns that only exist after the
1126
+ // reset.
1127
+ ensureLocalBookkeepingSchema(this.#db);
1128
+ if (this.#hasBlobs) ensureBlobSchema(this.#db);
1129
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
1130
+ const activeFailures = activeFailureRecords(
1131
+ listCommitOutcomes(this.#db, { activeOnly: true }),
1132
+ );
1133
+ this.#conflicts = activeFailures.conflicts;
1134
+ this.#rejections = activeFailures.rejections;
1135
+ const persisted = getMeta(this.#db, 'clientId');
1136
+ if (
1137
+ persisted !== undefined &&
1138
+ this.#config.clientId !== undefined &&
1139
+ persisted !== this.#config.clientId
1140
+ ) {
1141
+ throw new ClientSyncError(
1142
+ 'client.identity_mismatch',
1143
+ `database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`,
1144
+ );
1145
+ }
1146
+ this.#clientId =
1147
+ persisted ?? this.#config.clientId ?? crypto.randomUUID();
1148
+ if (persisted === undefined) {
1149
+ setMeta(this.#db, 'clientId', this.#clientId);
1150
+ }
1151
+ // §7.3.5: restore the persisted lease so leaseState survives restart.
1152
+ const leaseJson = getMeta(this.#db, 'leaseState');
1153
+ if (leaseJson !== undefined) {
1154
+ this.#leaseState = JSON.parse(leaseJson) as LeaseState;
1155
+ }
1156
+ // §7.4.2 trigger 1: the persisted local schema version is lower than
1157
+ // the generated version this client ships — run the wipe/re-bootstrap
1158
+ // reset before the first sync round. A fresh install (no marker) is
1159
+ // treated as already at the generated version.
1160
+ this.#detectAndResetSchema(marker);
1161
+ if (marker === this.#schema.version) {
1162
+ this.#replayOutbox(undefined, true, false);
1163
+ }
1164
+ // RFC 0005 D9: a container with missing/stale metadata, or past its
1165
+ // TTL, is discarded at boot before anything can read it; the capture
1166
+ // above already ran against this transaction's validated marker. The
1167
+ // orphan/stale sweep is unconditional; the TTL is aware-binary hygiene
1168
+ // only.
1169
+ this.#reconcilePreviousVersionAtBoot();
1170
+ // A registration remains app intent only while its table exists in the
1171
+ // running schema. Removed-table registrations would poison every pull.
1172
+ subscriptions = pruneUnknownSubscriptions(
1173
+ this.#db,
1174
+ loadSubscriptions(this.#db),
1175
+ this.#schema,
1176
+ );
1177
+ });
1178
+ } catch (error) {
944
1179
  await this.#lease.release();
945
1180
  this.#lease = undefined;
946
- throw new ClientSyncError(
947
- 'client.identity_mismatch',
948
- `database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`,
949
- );
1181
+ throw classifySqliteFailure(error).error;
950
1182
  }
951
- this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
952
- if (persisted === undefined) {
953
- setMeta(this.#db, 'clientId', this.#clientId);
954
- }
955
- // §7.3.5: restore the persisted lease so leaseState survives restart.
956
- const leaseJson = getMeta(this.#db, 'leaseState');
957
- if (leaseJson !== undefined) {
958
- this.#leaseState = JSON.parse(leaseJson) as LeaseState;
959
- }
960
- // §7.4.2 trigger 1: the persisted local schema version differs from the
961
- // generated version this client ships — run the wipe/re-bootstrap reset
962
- // before the first sync round. A fresh install (no marker) is treated as
963
- // already at the generated version.
964
- this.#detectAndResetSchema();
965
- // RFC 0005 D9: a container with missing/stale metadata, or past its TTL, is
966
- // discarded at boot before anything can read it. The orphan/stale sweep is
967
- // unconditional; the TTL is aware-binary hygiene only.
968
- this.#reconcilePreviousVersionAtBoot();
969
- // A registration remains app intent only while its table exists in the
970
- // running schema. Removed-table registrations would poison every pull.
971
- const subscriptions = pruneUnknownSubscriptions(
972
- this.#db,
973
- loadSubscriptions(this.#db),
974
- this.#schema,
975
- );
976
1183
  this.#started = true;
977
1184
  // A persisted active subscription needs one catch-up round on every open:
978
1185
  // realtime only covers changes after the socket connects, and an
@@ -1012,12 +1219,14 @@ export class SyncClient {
1012
1219
 
1013
1220
  /**
1014
1221
  * §7.4.1/§7.4.2: compare the generated schema version to the persisted
1015
- * marker and run the §7.4.3 reset when they differ. Idempotent by the
1222
+ * marker and run the §7.4.3 reset when it increases. Idempotent by the
1016
1223
  * marker — a mid-reset crash re-runs the reset on the next boot.
1224
+ *
1225
+ * The startup phase's single transaction already pinned and validated the
1226
+ * marker, so the shape chosen here commits under that snapshot.
1017
1227
  */
1018
- #detectAndResetSchema(): void {
1019
- const markerJson = getMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY);
1020
- if (markerJson === undefined) {
1228
+ #detectAndResetSchema(marker: number | undefined): void {
1229
+ if (marker === undefined) {
1021
1230
  // Fresh install: the tables just created match the running code.
1022
1231
  this.#db.transaction(() => {
1023
1232
  ensureLocalSyncedSchema(this.#db, this.#schema);
@@ -1031,7 +1240,6 @@ export class SyncClient {
1031
1240
  });
1032
1241
  return;
1033
1242
  }
1034
- const marker = Number(markerJson);
1035
1243
  if (marker === this.#schema.version) {
1036
1244
  // Same-version opens remain self-healing for absent tables/indexes, and
1037
1245
  // RFC 0005 D1 backfills the descriptor for a database first opened by
@@ -1088,6 +1296,7 @@ export class SyncClient {
1088
1296
  this.#discardAcknowledgedRows();
1089
1297
  dropAndRecreateSyncedTables(this.#db, this.#schema);
1090
1298
  resetSubscriptionsForBump(this.#db);
1299
+ this.#replayOutbox();
1091
1300
  setMeta(
1092
1301
  this.#db,
1093
1302
  LOCAL_SCHEMA_VERSION_KEY,
@@ -1099,11 +1308,10 @@ export class SyncClient {
1099
1308
  // Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
1100
1309
  for (const table of this.#schema.tables.values()) batch.table(table.name);
1101
1310
  });
1102
- // The stop state is over: this client now ships a servable schema. The
1103
- // outbox is re-applied optimistically over the (now empty) tables so
1104
- // pending offline writes stay visible across the bump (§7.4.5).
1311
+ // The stop state is over: this client now ships a servable schema.
1312
+ // The reset transaction already replayed pending intent before committing
1313
+ // its marker and visible rows (§7.4.5).
1105
1314
  this.#setSchemaFloor(undefined);
1106
- this.#replayOutbox();
1107
1315
  }
1108
1316
 
1109
1317
  /**
@@ -1186,23 +1394,38 @@ export class SyncClient {
1186
1394
  return [];
1187
1395
  }
1188
1396
  const subscriptions = loadSubscriptions(this.#db);
1189
- const pending = listOutbox(this.#db);
1190
1397
  this.#setUpgrading(true);
1191
1398
  // RFC 0005 D5: the log-epoch reset is not a schema bump. It performs the
1192
1399
  // orphan sweep but captures no new shadow.
1193
1400
  this.#sweepPreviousVersionContainer();
1194
- this.#applyBatch((batch) => {
1195
- this.#db.transaction(() => {
1196
- this.#discardAcknowledgedRows();
1197
- dropAndRecreateSyncedTables(this.#db, this.#schema);
1198
- resetSubscriptionsForBump(this.#db);
1199
- setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
1200
- for (const commit of pending) {
1201
- this.#applyOperationsLocally(commit.operations, batch);
1202
- }
1203
- });
1204
- for (const table of this.#schema.tables.values()) batch.table(table.name);
1205
- });
1401
+ const priorRejections = [...this.#rejections];
1402
+ this.#applyBatch(
1403
+ (batch) => {
1404
+ this.#db.transaction(() => {
1405
+ this.#discardAcknowledgedRows();
1406
+ dropAndRecreateSyncedTables(this.#db, this.#schema);
1407
+ resetSubscriptionsForBump(this.#db);
1408
+ setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
1409
+ // §7.1/§7.4.4: recover codec-invalid persisted values in this reset
1410
+ // transaction before they replay, so the reset boundary never fails on
1411
+ // a commit an earlier version accepted.
1412
+ this.#dropUnencodableOutbox();
1413
+ for (const commit of listOutbox(this.#db)) {
1414
+ this.#applyOperationsLocally(commit.operations, batch);
1415
+ }
1416
+ });
1417
+ for (const table of this.#schema.tables.values())
1418
+ batch.table(table.name);
1419
+ },
1420
+ undefined,
1421
+ (error) => {
1422
+ // A failure after the recovery drop, including the reset's revision or
1423
+ // COMMIT, rolls the database back: mirror that for the in-memory
1424
+ // rejection cache so no dropped commit is published.
1425
+ this.#rejections = priorRejections;
1426
+ throw error;
1427
+ },
1428
+ );
1206
1429
  this.#localResetEpoch += 1;
1207
1430
  this.#setSyncNeeded(true);
1208
1431
  this.#emitSyncNeeded('startup');
@@ -1419,20 +1642,154 @@ export class SyncClient {
1419
1642
  * Reactive integrations use this instead of composing `query()` and
1420
1643
  * `windowState()` across separate worker/IPC calls.
1421
1644
  */
1422
- querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row> {
1645
+ querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row>;
1646
+ querySnapshot(spec: QueryReadSpec): QuerySnapshot {
1647
+ const read = this.#readSnapshot({
1648
+ statements: [
1649
+ {
1650
+ sql: spec.sql,
1651
+ ...(spec.params !== undefined ? { params: spec.params } : {}),
1652
+ },
1653
+ ],
1654
+ ...(spec.coverage !== undefined ? { coverage: spec.coverage } : {}),
1655
+ ...(spec.owner !== undefined ? { owner: spec.owner } : {}),
1656
+ });
1657
+ const rows = read.queries[0];
1658
+ if (read.queries.length !== 1 || rows === undefined) {
1659
+ throw new ClientSyncError(
1660
+ 'client.failed',
1661
+ 'querySnapshot produced no statement result',
1662
+ );
1663
+ }
1664
+ return {
1665
+ revision: read.revision,
1666
+ rows,
1667
+ coverage: read.coverage,
1668
+ };
1669
+ }
1670
+
1671
+ /**
1672
+ * §7.5: resolve several read-only statements, window coverage, subscription
1673
+ * catch-up states, and commit deliveries from one SQLite read transaction
1674
+ * and one revision. A host that needs those together reads them together;
1675
+ * every statement passes the read-only guard and any failure rolls the whole
1676
+ * read back.
1677
+ */
1678
+ snapshotRead<Row = SqlRow>(spec: SnapshotReadRequest): SnapshotRead<Row>;
1679
+ snapshotRead(spec: SnapshotReadRequest): SnapshotRead {
1680
+ return this.#readSnapshot(spec);
1681
+ }
1682
+
1683
+ /** §7.5: one subscription's persisted catch-up state (never the owner cache). */
1684
+ subscriptionCatchup(id: string): SubscriptionCatchup {
1685
+ return this.#readSnapshot({ statements: [], subscriptions: [id] })
1686
+ .subscriptions[0]!;
1687
+ }
1688
+
1689
+ /** §7.5: one client commit's delivery status: pending, its retained outcome,
1690
+ * or an explicit unknown (never held, or pruned by retention). */
1691
+ commitDelivery(id: string): CommitDelivery {
1692
+ return this.#readSnapshot({ statements: [], commitIds: [id] })
1693
+ .deliveries[0]!;
1694
+ }
1695
+
1696
+ /**
1697
+ * §7.5: the one atomic read primitive. Every public read (`querySnapshot`,
1698
+ * `snapshotRead`, `subscriptionCatchup`, `commitDelivery`) enters here, so
1699
+ * the read-only guard, transaction, revision, coverage, catch-up, delivery,
1700
+ * and owner-failure observation exist once.
1701
+ */
1702
+ #readSnapshot(request: SnapshotReadRequest): SnapshotRead {
1423
1703
  this.#requireActive();
1424
- const owner = spec.owner;
1425
- let snapshot: QuerySnapshot<Row>;
1704
+ const owner = request.owner;
1705
+ const catchupOf = (id: string): SubscriptionCatchup => {
1706
+ function corrupt(): never {
1707
+ throw new ClientSyncError(
1708
+ 'sync.local_corrupt',
1709
+ 'persisted subscription state is invalid',
1710
+ );
1711
+ }
1712
+ const row = this.#db.query(
1713
+ `SELECT tbl,status,cursor,bootstrap_state,effective_scopes,reason_code FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`,
1714
+ [id],
1715
+ )[0];
1716
+ if (row === undefined) return { state: 'unknown', id };
1717
+ const table = row.tbl;
1718
+ const status = row.status;
1719
+ const cursor = row.cursor;
1720
+ if (
1721
+ typeof table !== 'string' ||
1722
+ (status !== 'active' && status !== 'revoked' && status !== 'failed') ||
1723
+ typeof cursor !== 'number' ||
1724
+ !Number.isSafeInteger(cursor)
1725
+ ) {
1726
+ corrupt();
1727
+ }
1728
+ const token = row.bootstrap_state;
1729
+ if (token !== null && typeof token !== 'string') corrupt();
1730
+ const rawScopes = row.effective_scopes;
1731
+ let effectiveScopes: unknown;
1732
+ if (rawScopes !== null) {
1733
+ if (typeof rawScopes !== 'string') corrupt();
1734
+ try {
1735
+ effectiveScopes = JSON.parse(rawScopes);
1736
+ if (effectiveScopes === null) effectiveScopes = undefined;
1737
+ else if (
1738
+ typeof effectiveScopes !== 'object' ||
1739
+ Array.isArray(effectiveScopes) ||
1740
+ !Object.values(effectiveScopes).every(
1741
+ (values) =>
1742
+ Array.isArray(values) &&
1743
+ values.every((value) => typeof value === 'string'),
1744
+ )
1745
+ )
1746
+ corrupt();
1747
+ } catch {
1748
+ corrupt();
1749
+ }
1750
+ }
1751
+ const reasonCode = row.reason_code;
1752
+ if (reasonCode !== null && typeof reasonCode !== 'string') corrupt();
1753
+ const active = status === 'active';
1754
+ const hasResumeToken = token !== null;
1755
+ return {
1756
+ state: 'known',
1757
+ id,
1758
+ table,
1759
+ status,
1760
+ cursor,
1761
+ hasResumeToken,
1762
+ bootstrapComplete: active && cursor >= 0 && !hasResumeToken,
1763
+ knownPendingPages: active && (hasResumeToken || cursor < 0),
1764
+ ...(effectiveScopes !== undefined ? { effectiveScopes } : {}),
1765
+ ...(reasonCode !== null ? { reasonCode } : {}),
1766
+ };
1767
+ };
1768
+ const deliveryOf = (id: string): CommitDelivery => {
1769
+ const pending =
1770
+ this.#db.query(
1771
+ `SELECT 1 FROM ${OUTBOX_TABLE} WHERE client_commit_id=? LIMIT 1`,
1772
+ [id],
1773
+ ).length > 0;
1774
+ if (pending) return { status: 'pending', clientCommitId: id };
1775
+ const outcome = persistedCommitOutcome(this.#db, id);
1776
+ return outcome === undefined
1777
+ ? { status: 'unknown', clientCommitId: id }
1778
+ : { status: 'known', clientCommitId: id, outcome };
1779
+ };
1780
+ let read: SnapshotRead;
1426
1781
  try {
1427
- assertReadOnlyQuery(spec.sql);
1428
- snapshot = this.#db.transaction(() => {
1782
+ for (const statement of request.statements) {
1783
+ assertReadOnlyQuery(statement.sql);
1784
+ }
1785
+ read = this.#db.transaction(() => {
1429
1786
  const revision = getLocalRevision(this.#db);
1430
- const rows = stripSyncColumns(
1431
- this.#db.query(spec.sql, spec.params),
1432
- ) as unknown as readonly Row[];
1787
+ const queries = request.statements.map((statement) =>
1788
+ stripSyncColumns(this.#db.query(statement.sql, statement.params)),
1789
+ );
1433
1790
  const pending: WindowUnitRef[] = [];
1434
1791
  const missing: WindowUnitRef[] = [];
1435
- for (const requested of spec.coverage ?? []) {
1792
+ for (const requested of request.coverage ?? []) {
1436
1793
  const baseKey = windowBaseKey(requested.base);
1437
1794
  const state = this.windowState(requested.base);
1438
1795
  for (const unit of new Set(requested.units)) {
@@ -1443,19 +1800,26 @@ export class SyncClient {
1443
1800
  }
1444
1801
  return {
1445
1802
  revision,
1446
- rows,
1803
+ queries,
1447
1804
  coverage: {
1448
1805
  complete: pending.length === 0 && missing.length === 0,
1449
1806
  pending,
1450
1807
  missing,
1451
1808
  },
1809
+ subscriptions: (request.subscriptions ?? []).map(catchupOf),
1810
+ deliveries: (request.commitIds ?? []).map(deliveryOf),
1452
1811
  };
1453
1812
  });
1454
1813
  } catch (error) {
1455
1814
  const failure = classifySqliteFailure(error);
1456
- const sqliteCode = failure.sqliteCode;
1457
- const code = failure.code ?? 'client.query_failed';
1458
1815
  if (owner !== undefined) {
1816
+ const code: DiagnosticQueryFailure['code'] =
1817
+ failure.code ??
1818
+ (error instanceof ClientSyncError &&
1819
+ error.code === 'sync.local_corrupt'
1820
+ ? 'sync.local_corrupt'
1821
+ : 'client.query_failed');
1822
+ const sqliteCode = failure.sqliteCode;
1459
1823
  const tables = [...new Set(owner.tables)].sort();
1460
1824
  const previous = this.#queryFailures.get(owner.id);
1461
1825
  if (
@@ -1471,7 +1835,7 @@ export class SyncClient {
1471
1835
  id: owner.id,
1472
1836
  tables,
1473
1837
  code,
1474
- sqliteCode: sqliteCode,
1838
+ sqliteCode,
1475
1839
  atMs: this.#now(),
1476
1840
  }),
1477
1841
  );
@@ -1488,7 +1852,7 @@ export class SyncClient {
1488
1852
  if (owner !== undefined && this.#queryFailures.delete(owner.id)) {
1489
1853
  this.#emitDiagnostics();
1490
1854
  }
1491
- return snapshot;
1855
+ return read;
1492
1856
  }
1493
1857
 
1494
1858
  // -- previous-version context (RFC 0005) ----------------------------------
@@ -1532,6 +1896,13 @@ export class SyncClient {
1532
1896
  refusal?.reason ?? 'no-previous-descriptor',
1533
1897
  );
1534
1898
  }
1899
+ if (record.currentVersion !== currentVersion) {
1900
+ this.#discardPreviousVersion();
1901
+ return this.#previousVersionUnavailable(
1902
+ currentVersion,
1903
+ 'no-previous-descriptor',
1904
+ );
1905
+ }
1535
1906
  if (
1536
1907
  nowMs - record.createdAtMs >
1537
1908
  previousVersionMaxAgeMs(this.#config.previousVersionContext)
@@ -2873,6 +3244,21 @@ export class SyncClient {
2873
3244
  });
2874
3245
  }
2875
3246
  const pkValue = requireRowId(json[table.primaryKey]);
3247
+ // §7.1: declared-type validation runs before the commit is recorded, so a
3248
+ // refused value leaves no outbox entry, no optimistic row, no revision
3249
+ // and no observation event.
3250
+ const refusal = this.#operationValuesError(table, json);
3251
+ if (refusal !== undefined)
3252
+ // §7.5: the same structured shape the native authoring APIs return — a
3253
+ // static message, and the dynamic cause in `details.legacyCause`.
3254
+ throw new ClientSyncError(
3255
+ 'sync.invalid_request',
3256
+ 'the authoring request is invalid',
3257
+ false,
3258
+ {
3259
+ legacyCause: `sync.invalid_request: table ${table.name}: ${refusal}`,
3260
+ },
3261
+ );
2876
3262
  return definedObject({
2877
3263
  table: mutation.table,
2878
3264
  rowId: pkValue,
@@ -3296,9 +3682,14 @@ export class SyncClient {
3296
3682
  * incompatible`); its purely-optimistic rows are undone (§7.2). Returns
3297
3683
  * the encoded push frames index-aligned with the surviving `outbox`.
3298
3684
  */
3299
- async #encodeOutboxForPush(): Promise<{
3685
+ async #encodeOutboxForPush(
3686
+ header: RequestFrame,
3687
+ pull: RequestFrame,
3688
+ byteCap?: number,
3689
+ ): Promise<{
3300
3690
  pushFrames: RequestFrame[];
3301
3691
  outbox: OutboxCommit[];
3692
+ frameBytes: number[];
3302
3693
  deferred: number;
3303
3694
  }> {
3304
3695
  // Pin before the first encryption await: mutations can append while a
@@ -3308,26 +3699,38 @@ export class SyncClient {
3308
3699
  )[0]!;
3309
3700
  const pendingCount = bounds.count as number;
3310
3701
  const throughSeq = (bounds.last_seq as number | null) ?? 0;
3702
+ const maxCommits = this.#config.limits?.maxPushCommitsPerRequest;
3703
+ const maxOps =
3704
+ this.#config.limits?.maxPushOperationsPerRequest ?? MAX_OPS_PER_REQUEST;
3705
+ // A request must start with REQ_HEADER and carry PULL_HEADER; those two
3706
+ // frames make a valid probe whose encoded size cancels out of the
3707
+ // per-commit delta measured against the canonical encoder.
3708
+ const baseBytes =
3709
+ byteCap === undefined
3710
+ ? 0
3711
+ : encodeMessage({
3712
+ wireVersion: PROTOCOL_WIRE_VERSION,
3713
+ msgKind: 'request',
3714
+ frames: [header, pull],
3715
+ }).length;
3311
3716
  const pushFrames: RequestFrame[] = [];
3312
3717
  const outbox: OutboxCommit[] = [];
3718
+ const frameBytes: number[] = [];
3313
3719
  let deferred = 0;
3314
3720
  let ops = 0;
3721
+ let bytes = 0;
3315
3722
  let processed = 0;
3316
3723
  for (const commit of iterateOutbox(this.#db, throughSeq)) {
3317
- // §6.1 splitBatch: whole commits in commit order, stopping before the
3318
- // per-request operation cap. A first commit that alone exceeds the cap
3319
- // is sent alone — the server rejects it loudly rather than the queue
3320
- // wedging silently. Deferred commits stay queued for the next round.
3321
- if (
3322
- outbox.length > 0 &&
3323
- ops + commit.operations.length > MAX_OPS_PER_REQUEST
3324
- ) {
3724
+ // §6.1/§7.1: the commit-count and operation caps are hard. A first
3725
+ // commit that alone exceeds the operation cap is a typed capacity error
3726
+ // and stays queued; a later one defers with the complete suffix.
3727
+ if (maxCommits !== undefined && pushFrames.length >= maxCommits) {
3325
3728
  deferred = pendingCount - processed;
3326
3729
  break;
3327
3730
  }
3328
- processed += 1;
3731
+ let pushFrame: RequestFrame;
3329
3732
  try {
3330
- pushFrames.push(
3733
+ pushFrame =
3331
3734
  // §5.11: encrypted columns are encrypted at this encode-at-send
3332
3735
  // seam before the row codec serializes them. The stored local row
3333
3736
  // supplies an ABSENT key-id selector (§6.1 presence set first,
@@ -3349,23 +3752,99 @@ export class SyncClient {
3349
3752
  ? value
3350
3753
  : undefined;
3351
3754
  },
3352
- ),
3353
- );
3354
- outbox.push(commit);
3355
- ops += commit.operations.length;
3755
+ );
3356
3756
  } catch (error) {
3357
3757
  if (error instanceof OutboxEncodeError) {
3358
3758
  this.#dropIncompatibleCommit(commit, error.message);
3759
+ processed += 1;
3359
3760
  continue;
3360
3761
  }
3361
3762
  if (error instanceof EncryptError) {
3362
3763
  this.#dropIncompatibleCommit(commit, error.message, error.code);
3764
+ processed += 1;
3765
+ continue;
3766
+ }
3767
+ // §7.1/§7.4.4: a persisted value the current codec refuses (an earlier
3768
+ // version accepted it) is an incompatible commit, never a wedged queue.
3769
+ if (
3770
+ error instanceof EncodeError ||
3771
+ (error instanceof ClientSyncError &&
3772
+ error.code === 'sync.invalid_request')
3773
+ ) {
3774
+ this.#dropIncompatibleCommit(
3775
+ commit,
3776
+ LEGACY_VALUE_REFUSAL_MESSAGE,
3777
+ OUTBOX_INCOMPATIBLE_CODE,
3778
+ true,
3779
+ INVALID_STORED_VALUES_REASON,
3780
+ );
3781
+ processed += 1;
3363
3782
  continue;
3364
3783
  }
3365
3784
  throw error;
3366
3785
  }
3786
+ // The operation cap is checked after the encode/drop so an unencodable
3787
+ // head is dropped as a durable rejection first, matching the native
3788
+ // prepass priority.
3789
+ if (ops + commit.operations.length > maxOps) {
3790
+ if (pushFrames.length === 0) {
3791
+ throw this.#pushCapacityError(
3792
+ 'operations',
3793
+ maxOps,
3794
+ commit.operations.length,
3795
+ commit.clientCommitId,
3796
+ );
3797
+ }
3798
+ deferred = pendingCount - processed;
3799
+ break;
3800
+ }
3801
+ const frame =
3802
+ byteCap === undefined
3803
+ ? 0
3804
+ : encodeMessage({
3805
+ wireVersion: PROTOCOL_WIRE_VERSION,
3806
+ msgKind: 'request',
3807
+ frames: [header, pushFrame, pull],
3808
+ }).length - baseBytes;
3809
+ // The head is always encoded so the caller can report the full
3810
+ // projected request size with the final header and subscription bytes;
3811
+ // a later commit defers once it no longer fits.
3812
+ if (
3813
+ byteCap !== undefined &&
3814
+ pushFrames.length > 0 &&
3815
+ bytes + frame > byteCap
3816
+ ) {
3817
+ deferred = pendingCount - processed;
3818
+ break;
3819
+ }
3820
+ pushFrames.push(pushFrame);
3821
+ outbox.push(commit);
3822
+ frameBytes.push(frame);
3823
+ ops += commit.operations.length;
3824
+ bytes += frame;
3825
+ processed += 1;
3367
3826
  }
3368
- return { pushFrames, outbox, deferred };
3827
+ return { pushFrames, outbox, frameBytes, deferred };
3828
+ }
3829
+
3830
+ /** §7.1: a configured push capacity limit cannot carry this request. */
3831
+ #pushCapacityError(
3832
+ kind: 'operations' | 'bytes',
3833
+ limit: number,
3834
+ size: number,
3835
+ clientCommitId?: string,
3836
+ ): ClientSyncError {
3837
+ return new ClientSyncError(
3838
+ 'client.push_request_too_large',
3839
+ 'the push request exceeds a configured capacity limit',
3840
+ false,
3841
+ definedObject({
3842
+ kind,
3843
+ limit,
3844
+ size,
3845
+ ...(clientCommitId !== undefined ? { clientCommitId } : {}),
3846
+ }),
3847
+ );
3369
3848
  }
3370
3849
 
3371
3850
  /**
@@ -3378,37 +3857,56 @@ export class SyncClient {
3378
3857
  commit: OutboxCommit,
3379
3858
  message: string,
3380
3859
  code: string = OUTBOX_INCOMPATIBLE_CODE,
3860
+ replay = true,
3861
+ reason?: string,
3862
+ classifiedFailures?: ReadonlySet<string>,
3381
3863
  ): void {
3382
- this.#applyBatch((batch) => {
3383
- if (this.#retainFailedCommits && code === 'client.encrypt_failed')
3384
- retainFailedRows(
3385
- this.#db,
3386
- commit,
3387
- listOutboxBeforeImages(this.#db, commit.clientCommitId),
3388
- );
3389
- this.#rollbackFailedCommit(commit, batch);
3390
- this.#replayOutbox();
3391
- const rejection: RejectionRecord = definedObject({
3392
- clientCommitId: commit.clientCommitId,
3393
- opIndex: 0,
3394
- code,
3395
- message,
3396
- retryable: false,
3397
- operation: commit.operations[0],
3398
- });
3399
- this.#rejections.push(rejection);
3400
- recordCommitOutcome(this.#db, {
3401
- clientCommitId: commit.clientCommitId,
3402
- status: 'rejected',
3403
- recordedAtMs: this.#now(),
3404
- results: [{ status: 'error', rejection }],
3405
- operations: commit.operations,
3406
- });
3407
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
3408
- batch.status();
3409
- batch.rejections();
3410
- batch.outcomes();
3411
- });
3864
+ const priorRejections = [...this.#rejections];
3865
+ this.#applyBatch(
3866
+ (batch) => {
3867
+ if (this.#retainFailedCommits && code === 'client.encrypt_failed')
3868
+ retainFailedRows(
3869
+ this.#db,
3870
+ commit,
3871
+ listOutboxBeforeImages(this.#db, commit.clientCommitId),
3872
+ );
3873
+ this.#rollbackFailedCommit(commit, batch, classifiedFailures);
3874
+ if (replay) this.#replayOutbox();
3875
+ // §7.2/§7.4.4: the journal holds the operation envelope only when its
3876
+ // stored shape is representable. A dropped legacy commit whose envelope
3877
+ // the journal cannot carry keeps its durable commitId, code and a bounded
3878
+ // `details.reason` instead of an opaque rewritten value.
3879
+ const representable = commit.operations.every(isStoredOperation);
3880
+ const rejection: RejectionRecord = definedObject({
3881
+ clientCommitId: commit.clientCommitId,
3882
+ opIndex: 0,
3883
+ code,
3884
+ message,
3885
+ retryable: false,
3886
+ ...(representable ? { operation: commit.operations[0] } : {}),
3887
+ ...(reason === undefined ? {} : { details: { reason } }),
3888
+ });
3889
+ this.#rejections.push(rejection);
3890
+ recordCommitOutcome(this.#db, {
3891
+ clientCommitId: commit.clientCommitId,
3892
+ status: 'rejected',
3893
+ recordedAtMs: this.#now(),
3894
+ results: [{ status: 'error', rejection }],
3895
+ ...(representable ? { operations: commit.operations } : {}),
3896
+ });
3897
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
3898
+ batch.status();
3899
+ batch.rejections();
3900
+ batch.outcomes();
3901
+ },
3902
+ undefined,
3903
+ (error) => {
3904
+ // The drop's own outcome write or publication failed: mirror the
3905
+ // rollback so no rejection the journal did not keep stays in memory.
3906
+ this.#rejections = priorRejections;
3907
+ throw error;
3908
+ },
3909
+ );
3412
3910
  }
3413
3911
 
3414
3912
  // -- sync -------------------------------------------------------------------
@@ -3540,6 +4038,34 @@ export class SyncClient {
3540
4038
  try {
3541
4039
  await this.#drainPendingEvictions();
3542
4040
  const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
4041
+ // §7.4.4: encode the outbox with the CURRENT codec; a commit that
4042
+ // cannot express itself under the new schema (a dropped column/table)
4043
+ // is removed from the push and surfaced as a rejection, never wedging
4044
+ // the queue. `pushFrames` and `outbox` stay index-aligned for result
4045
+ // mapping.
4046
+ const limits = this.#config.limits;
4047
+ const byteCap = limits?.maxPushRequestBytes;
4048
+ const header: RequestFrame = definedObject({
4049
+ type: 'REQ_HEADER',
4050
+ clientId: this.#clientId,
4051
+ schemaVersion: this.#schema.version,
4052
+ logEpoch: logEpoch,
4053
+ });
4054
+ const pull: RequestFrame = {
4055
+ type: 'PULL_HEADER',
4056
+ limitCommits: limits?.limitCommits ?? 0,
4057
+ limitSnapshotRows: limits?.limitSnapshotRows ?? 0,
4058
+ maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
4059
+ accept: this.#acceptMask(),
4060
+ };
4061
+ const {
4062
+ pushFrames,
4063
+ outbox,
4064
+ frameBytes,
4065
+ deferred: encodedDeferred,
4066
+ } = logEpoch === undefined
4067
+ ? { pushFrames: [], outbox: [], frameBytes: [], deferred: 0 }
4068
+ : await this.#encodeOutboxForPush(header, pull, byteCap);
3543
4069
  // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
3544
4070
  // them, so the server-side existence check (§6.6) passes.
3545
4071
  if (
@@ -3549,37 +4075,16 @@ export class SyncClient {
3549
4075
  ) {
3550
4076
  await this.#flushBlobUploads();
3551
4077
  }
3552
- // §7.4.4: encode the outbox with the CURRENT codec; a commit that
3553
- // cannot express itself under the new schema (a dropped column/table)
3554
- // is removed from the push and surfaced as a rejection, never wedging
3555
- // the queue. `pushFrames` and `outbox` stay index-aligned for result
3556
- // mapping.
3557
- const { pushFrames, outbox, deferred } =
3558
- logEpoch === undefined
3559
- ? { pushFrames: [], outbox: [], deferred: 0 }
3560
- : await this.#encodeOutboxForPush();
3561
- // Captured together with the subscription state below: the response
3562
- // apply persists SUB_END cursors only while this epoch is current.
4078
+ // Captured after encoding: a subscribe/unsubscribe during the
4079
+ // encryption await must not make the final request exceed the byte
4080
+ // budget, and the reset epoch must be the one the response apply checks.
3563
4081
  const resetEpoch = this.#localResetEpoch;
3564
4082
  const subs = loadSubscriptions(this.#db).filter(
3565
4083
  (sub) => sub.status === 'active',
3566
4084
  );
3567
- const limits = this.#config.limits;
3568
- const frames: RequestFrame[] = [
3569
- definedObject({
3570
- type: 'REQ_HEADER',
3571
- clientId: this.#clientId,
3572
- schemaVersion: this.#schema.version,
3573
- logEpoch: logEpoch,
3574
- }),
3575
- ...pushFrames,
3576
- {
3577
- type: 'PULL_HEADER',
3578
- limitCommits: limits?.limitCommits ?? 0,
3579
- limitSnapshotRows: limits?.limitSnapshotRows ?? 0,
3580
- maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
3581
- accept: this.#acceptMask(),
3582
- },
4085
+ const fixedFrames: RequestFrame[] = [
4086
+ header,
4087
+ pull,
3583
4088
  ...subs.map(
3584
4089
  (sub): RequestFrame =>
3585
4090
  definedObject({
@@ -3593,6 +4098,42 @@ export class SyncClient {
3593
4098
  }),
3594
4099
  ),
3595
4100
  ];
4101
+ let admitted = pushFrames.length;
4102
+ let deferred = encodedDeferred;
4103
+ if (byteCap !== undefined) {
4104
+ // The fixed frames encode as envelope + fixed frames + END; each
4105
+ // admitted push frame adds its own (5 + payload). Trimming the tail
4106
+ // re-fits the request to the final subscription state.
4107
+ const fixedBytes = encodeMessage({
4108
+ wireVersion: PROTOCOL_WIRE_VERSION,
4109
+ msgKind: 'request',
4110
+ frames: fixedFrames,
4111
+ }).length;
4112
+ if (fixedBytes > byteCap)
4113
+ throw this.#pushCapacityError('bytes', byteCap, fixedBytes);
4114
+ let admittedBytes = 0;
4115
+ for (const size of frameBytes) admittedBytes += size;
4116
+ while (admitted > 0 && fixedBytes + admittedBytes > byteCap) {
4117
+ admitted -= 1;
4118
+ admittedBytes -= frameBytes[admitted]!;
4119
+ deferred += 1;
4120
+ }
4121
+ if (admitted === 0 && pushFrames.length > 0) {
4122
+ throw this.#pushCapacityError(
4123
+ 'bytes',
4124
+ byteCap,
4125
+ fixedBytes + frameBytes[0]!,
4126
+ outbox[0]!.clientCommitId,
4127
+ );
4128
+ }
4129
+ pushFrames.length = admitted;
4130
+ outbox.length = admitted;
4131
+ }
4132
+ const frames: RequestFrame[] = [
4133
+ fixedFrames[0]!,
4134
+ ...pushFrames,
4135
+ ...fixedFrames.slice(1),
4136
+ ];
3596
4137
  const requestBytes = encodeMessage({
3597
4138
  wireVersion: PROTOCOL_WIRE_VERSION,
3598
4139
  msgKind: 'request',
@@ -3673,16 +4214,73 @@ export class SyncClient {
3673
4214
  * Pull repeatedly until quiescent: no commits delivered, no bootstrap
3674
4215
  * pages pending, no resets to recover (§4.5 "pull again" SHOULD).
3675
4216
  */
3676
- async syncUntilIdle(maxRounds?: number): Promise<SyncSummary> {
4217
+ async syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult> {
4218
+ // §7.7: an explicit budget is a strict positive round count. Zero,
4219
+ // fractional, and out-of-range values are caller errors.
4220
+ if (
4221
+ maxRounds !== undefined &&
4222
+ (!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 0xffffffff)
4223
+ )
4224
+ throw invalidRequest('maxRounds must be a positive integer');
3677
4225
  const budget = maxRounds ?? 20;
3678
- let last: SyncSummary | undefined;
4226
+ const aggregate = {
4227
+ pushed: 0,
4228
+ applied: [] as string[],
4229
+ rejected: [] as string[],
4230
+ retryable: [] as string[],
4231
+ conflicts: [] as ConflictRecord[],
4232
+ commitsApplied: 0,
4233
+ segmentRowsApplied: 0,
4234
+ bootstrapping: [] as string[],
4235
+ resets: [] as string[],
4236
+ revoked: [] as string[],
4237
+ failed: [] as string[],
4238
+ deferredCommits: 0,
4239
+ schemaFloor: undefined as SchemaFloor | undefined,
4240
+ };
4241
+ const result = (budgetExhausted: boolean): SyncIdleResult => ({
4242
+ pushed: aggregate.pushed,
4243
+ applied: aggregate.applied,
4244
+ rejected: aggregate.rejected,
4245
+ retryable: aggregate.retryable,
4246
+ conflicts: aggregate.conflicts,
4247
+ commitsApplied: aggregate.commitsApplied,
4248
+ segmentRowsApplied: aggregate.segmentRowsApplied,
4249
+ bootstrapping: aggregate.bootstrapping,
4250
+ resets: aggregate.resets,
4251
+ revoked: aggregate.revoked,
4252
+ failed: aggregate.failed,
4253
+ ...(aggregate.deferredCommits > 0
4254
+ ? { deferredCommits: aggregate.deferredCommits }
4255
+ : {}),
4256
+ ...(aggregate.schemaFloor !== undefined
4257
+ ? { schemaFloor: aggregate.schemaFloor }
4258
+ : {}),
4259
+ budgetExhausted,
4260
+ });
3679
4261
  for (let round = 0; round < budget; round++) {
3680
4262
  const before = JSON.stringify(
3681
4263
  loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]),
3682
4264
  );
3683
- last = await this.sync();
4265
+ const last = await this.sync();
4266
+ // Historical counters accumulate; current readiness (`bootstrapping`,
4267
+ // `deferredCommits`, `schemaFloor`) describes the latest round.
4268
+ aggregate.pushed += last.pushed;
4269
+ aggregate.applied.push(...last.applied);
4270
+ aggregate.rejected.push(...last.rejected);
4271
+ aggregate.retryable.push(...last.retryable);
4272
+ aggregate.conflicts.push(...last.conflicts);
4273
+ aggregate.commitsApplied += last.commitsApplied;
4274
+ aggregate.segmentRowsApplied += last.segmentRowsApplied;
4275
+ aggregate.bootstrapping = [...last.bootstrapping];
4276
+ aggregate.resets.push(...last.resets);
4277
+ aggregate.revoked.push(...last.revoked);
4278
+ aggregate.failed.push(...last.failed);
4279
+ aggregate.deferredCommits = last.deferredCommits ?? 0;
4280
+ aggregate.schemaFloor = last.schemaFloor;
4281
+ // A disabled transport or a schema floor ends the loop, not the budget.
3684
4282
  if (!this.#transportEnabled || last.schemaFloor !== undefined)
3685
- return last;
4283
+ return result(false);
3686
4284
  if (
3687
4285
  maxRounds === undefined &&
3688
4286
  last.segmentRowsApplied > 0 &&
@@ -3704,10 +4302,10 @@ export class SyncClient {
3704
4302
  (last.deferredCommits ?? 0) === 0 &&
3705
4303
  !this.#needsPull
3706
4304
  ) {
3707
- return last;
4305
+ return result(false);
3708
4306
  }
3709
4307
  }
3710
- throw invalidRequest('sync did not reach idle within the round budget');
4308
+ return result(true);
3711
4309
  }
3712
4310
 
3713
4311
  /**
@@ -3734,7 +4332,7 @@ export class SyncClient {
3734
4332
  tagged.set(request, 1);
3735
4333
  try {
3736
4334
  socket.sendBytes(tagged);
3737
- } catch (error) {
4335
+ } catch {
3738
4336
  this.#pendingRound = undefined;
3739
4337
  // The socket could not carry the round: record the same loss the
3740
4338
  // Rust core records when its realtime round call fails (§8.8).
@@ -3742,8 +4340,9 @@ export class SyncClient {
3742
4340
  reject(
3743
4341
  new ClientSyncError(
3744
4342
  'sync.transport_failed',
3745
- `socket round send failed: ${error instanceof Error ? error.message : String(error)}`,
4343
+ 'socket round send failed',
3746
4344
  true,
4345
+ { causeKind: 'unknown' },
3747
4346
  ),
3748
4347
  );
3749
4348
  }
@@ -3765,8 +4364,9 @@ export class SyncClient {
3765
4364
  }
3766
4365
  throw new ClientSyncError(
3767
4366
  'sync.transport_failed',
3768
- `transport round failed: ${error instanceof Error ? error.message : String(error)}`,
4367
+ 'transport round failed',
3769
4368
  true,
4369
+ { causeKind: 'unknown' },
3770
4370
  );
3771
4371
  });
3772
4372
  }
@@ -3930,13 +4530,9 @@ export class SyncClient {
3930
4530
  let done: ReturnType<MessageStreamScanner['push']>;
3931
4531
  try {
3932
4532
  done = round.scanner.push(body);
3933
- } catch (error) {
4533
+ } catch {
3934
4534
  this.#pendingRound = undefined;
3935
- round.reject(
3936
- invalidRequest(
3937
- `malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`,
3938
- ),
3939
- );
4535
+ round.reject(invalidRequest('malformed round response stream (§8.7)'));
3940
4536
  return;
3941
4537
  }
3942
4538
  if (done === undefined) return;
@@ -4561,14 +5157,23 @@ export class SyncClient {
4561
5157
  listOutboxBeforeImages(this.#db, commit.clientCommitId),
4562
5158
  );
4563
5159
  this.#rollbackFailedCommit(commit, batch);
5160
+ // §7.2: rebasing a rejection reaches rows the same-row replay inside
5161
+ // `#rollbackFailedCommit` cannot: peers of a secondary UNIQUE index
5162
+ // (table-wide scope) and later commits over legacy before-images.
5163
+ // ACK-only groups reconcile nothing and stay zero-work.
4564
5164
  this.#replayOutbox(
4565
- commit.operations.map((operation) =>
4566
- JSON.stringify(
4567
- hasUniqueIndex(this.#table(operation.table))
4568
- ? [operation.table]
4569
- : [operation.table, operation.rowId],
4570
- ),
4571
- ),
5165
+ commit.operations.flatMap((operation) => {
5166
+ const table = this.#schema.tables.get(operation.table);
5167
+ return table === undefined
5168
+ ? []
5169
+ : [
5170
+ JSON.stringify(
5171
+ hasUniqueIndex(table)
5172
+ ? [operation.table]
5173
+ : [operation.table, operation.rowId],
5174
+ ),
5175
+ ];
5176
+ }),
4572
5177
  );
4573
5178
  });
4574
5179
  batch.status();
@@ -5136,7 +5741,11 @@ export class SyncClient {
5136
5741
  }
5137
5742
  }
5138
5743
 
5139
- #rollbackFailedCommit(commit: OutboxCommit, batch: ChangeAccumulator): void {
5744
+ #rollbackFailedCommit(
5745
+ commit: OutboxCommit,
5746
+ batch: ChangeAccumulator,
5747
+ classifiedFailures?: ReadonlySet<string>,
5748
+ ): void {
5140
5749
  const images = listOutboxBeforeImages(this.#db, commit.clientCommitId);
5141
5750
  const imageByIndex = new Map(images.map((image) => [image.opIndex, image]));
5142
5751
  const complete = commit.operations.every((_, index) =>
@@ -5175,6 +5784,10 @@ export class SyncClient {
5175
5784
  later.clientCommitId,
5176
5785
  this.#captureBeforeImages(later.operations, affectedKeys),
5177
5786
  );
5787
+ // §7.4.4: a later commit the recovery already classified refused never
5788
+ // replays. Its before-image is still rebased above, so dropping it later
5789
+ // restores the same base this rollback just restored.
5790
+ if (classifiedFailures?.has(later.clientCommitId)) continue;
5178
5791
  this.#applyOperationsLocally(affectedOperations, batch);
5179
5792
  }
5180
5793
  }
@@ -5185,7 +5798,17 @@ export class SyncClient {
5185
5798
  replay = false,
5186
5799
  ): void {
5187
5800
  for (const op of operations) {
5188
- const table = this.#table(op.table);
5801
+ // §7.4.4: the overlay replay is schema-agnostic (§0), so an operation
5802
+ // whose table the current schema removed has no local mirror to replay
5803
+ // into and is skipped here. A removed-table upsert stays durable for the
5804
+ // send-time `sync.outbox_incompatible` classification; a value-free
5805
+ // delete carries no values to classify and keeps ordinary server
5806
+ // validation. Every other call runs against the live schema and keeps the
5807
+ // loud `sync.unknown_table`.
5808
+ const table = replay
5809
+ ? this.#schema.tables.get(op.table)
5810
+ : this.#table(op.table);
5811
+ if (table === undefined) continue;
5189
5812
  let precise =
5190
5813
  batch === undefined
5191
5814
  ? false
@@ -5214,8 +5837,32 @@ export class SyncClient {
5214
5837
  if (value !== undefined) return jsonToRowValue(value);
5215
5838
  return local === undefined
5216
5839
  ? null
5217
- : fromSqlValue(column, local[column.name] ?? null);
5840
+ : replay
5841
+ ? (coerceSqlRepresentation(
5842
+ column,
5843
+ local[column.name] ?? null,
5844
+ ) as RowValue)
5845
+ : fromSqlValue(column, local[column.name] ?? null);
5218
5846
  });
5847
+ if (replay) {
5848
+ try {
5849
+ encodeRow(
5850
+ table.columns.map((column, index) => ({
5851
+ ...column,
5852
+ type:
5853
+ column.encrypted && values[index] instanceof Uint8Array
5854
+ ? 'bytes'
5855
+ : localColumnType(column),
5856
+ })),
5857
+ values,
5858
+ );
5859
+ } catch {
5860
+ throw new ClientSyncError(
5861
+ 'sync.local_corrupt',
5862
+ 'persisted optimistic row value is invalid',
5863
+ );
5864
+ }
5865
+ }
5219
5866
  // Record the row's scope keys from its scope columns (I2 refinement).
5220
5867
  if (batch !== undefined) {
5221
5868
  for (const [variable, column] of table.scopeColumnByVariable) {
@@ -5283,9 +5930,102 @@ export class SyncClient {
5283
5930
  return scope;
5284
5931
  }
5285
5932
 
5286
- #replayOutbox(scope?: OverlayScope, restoreBases = true): void {
5933
+ /**
5934
+ * §7.1: the operation's values must encode under the current schema. Returns
5935
+ * the refusal message, or `undefined` when they encode. Structural failures
5936
+ * (a programming error, never caller data) propagate unchanged.
5937
+ */
5938
+ #operationValuesError(
5939
+ table: CompiledClientTable,
5940
+ values: Readonly<Record<string, JsonRowValue>>,
5941
+ ): string | undefined {
5942
+ const declared = table.columns.map((column) => ({
5943
+ ...column,
5944
+ type: localColumnType(column),
5945
+ }));
5946
+ const sparse: Array<RowValue | undefined> = [];
5947
+ for (const column of table.columns) {
5948
+ const raw = values[column.name];
5949
+ if (raw === undefined) {
5950
+ sparse.push(undefined);
5951
+ continue;
5952
+ }
5953
+ // §7.1: the schema-agnostic outbox form serializes through JSON, which
5954
+ // turns NaN and ±Infinity into null. The authoring boundary refuses them
5955
+ // instead of recording a different value; the wire `f64` encoding itself
5956
+ // stays IEEE-754 binary64 (§2.4).
5957
+ if (
5958
+ localColumnType(column) === 'float' &&
5959
+ typeof raw === 'number' &&
5960
+ !Number.isFinite(raw)
5961
+ )
5962
+ return `column ${column.name} (float) requires a finite number value`;
5963
+ try {
5964
+ sparse.push(jsonToRowValue(raw));
5965
+ } catch (error) {
5966
+ if (error instanceof ClientSyncError)
5967
+ return `column ${column.name}: ${error.message}`;
5968
+ throw error;
5969
+ }
5970
+ }
5971
+ try {
5972
+ encodeSparseRow(declared, table.primaryKeyIndex, sparse);
5973
+ return undefined;
5974
+ } catch (error) {
5975
+ if (error instanceof EncodeError || error instanceof ClientSyncError)
5976
+ return error.message;
5977
+ throw error;
5978
+ }
5979
+ }
5980
+
5981
+ /**
5982
+ * §7.4.4: a pending commit whose own values the current codec refuses (an
5983
+ * envelope or a type an earlier version accepted) leaves the outbox as
5984
+ * `sync.outbox_incompatible` instead of failing every replay and sync. The
5985
+ * commit goes through the rejection channel; encryption and storage failures
5986
+ * never reach this path.
5987
+ */
5988
+ #dropUnencodableOutbox(): void {
5989
+ const failures: OutboxCommit[] = [];
5990
+ for (const commit of listOutbox(this.#db)) {
5991
+ const refused = commit.operations.some((op) => {
5992
+ if (op.op !== 'upsert' || op.values === undefined) return false;
5993
+ const table = this.#schema.tables.get(op.table);
5994
+ if (table === undefined) return false;
5995
+ return this.#operationValuesError(table, op.values) !== undefined;
5996
+ });
5997
+ if (refused) failures.push(commit);
5998
+ }
5999
+ // §7.4.4: every refused commit is classified before the FIFO drop pass, so
6000
+ // a rollback never replays a refused successor it has not removed yet.
6001
+ const classifiedFailures = new Set(
6002
+ failures.map((commit) => commit.clientCommitId),
6003
+ );
6004
+ for (const commit of failures)
6005
+ this.#dropIncompatibleCommit(
6006
+ commit,
6007
+ LEGACY_VALUE_REFUSAL_MESSAGE,
6008
+ OUTBOX_INCOMPATIBLE_CODE,
6009
+ false,
6010
+ INVALID_STORED_VALUES_REASON,
6011
+ classifiedFailures,
6012
+ );
6013
+ }
6014
+
6015
+ #replayOutbox(
6016
+ scope?: OverlayScope,
6017
+ restoreBases = true,
6018
+ observe = true,
6019
+ ): void {
5287
6020
  if (scope?.length === 0) return;
5288
- this.#applyBatch((batch) => {
6021
+ const replay = (batch?: ChangeAccumulator) => {
6022
+ // §7.4.4: classify value-unencodable pending commits inside the
6023
+ // reconciliation transaction, so a commit persisted by an earlier version
6024
+ // is dropped atomically with the replay it would otherwise fail. A
6025
+ // scoped (partial) reconciliation leaves the classification to these full
6026
+ // boundaries and to the send-time drop, so a partial rebuild never drops
6027
+ // a commit whose other tables are outside the scope.
6028
+ if (scope === undefined) this.#dropUnencodableOutbox();
5289
6029
  if (restoreBases) restoreFailedBases(this.#db, this.#schema, scope);
5290
6030
  const acknowledged = failedOverlayCommits(
5291
6031
  this.#db,
@@ -5308,7 +6048,18 @@ export class SyncClient {
5308
6048
  batch,
5309
6049
  true,
5310
6050
  );
5311
- });
6051
+ };
6052
+ const priorRejections = [...this.#rejections];
6053
+ try {
6054
+ if (observe) this.#applyBatch(replay);
6055
+ else this.#db.transaction(() => replay());
6056
+ } catch (error) {
6057
+ // SQLite rolled the reconciliation back: mirror that rollback for the
6058
+ // in-memory rejection cache, so a later failure in the same transaction
6059
+ // publishes no drop the durable journal did not keep.
6060
+ this.#rejections = priorRejections;
6061
+ throw error;
6062
+ }
5312
6063
  }
5313
6064
 
5314
6065
  #deleteUnreferencedBlobs(): void {