@syncular/client 0.21.1 → 0.23.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
@@ -150,6 +150,30 @@ import {
150
150
  recordCommitOutcome,
151
151
  } from './outcomes';
152
152
  import { assertReadOnlyQuery } from './query-guard';
153
+ import {
154
+ buildPreviousVersionAudit,
155
+ capturePreviousVersion,
156
+ clearPreviousVersionAudit,
157
+ clearPreviousVersionRefusal,
158
+ dropPreviousVersionContainer,
159
+ loadLocalSchemaDescriptor,
160
+ PREVIOUS_VERSION_CONTAINER_NAME,
161
+ PREVIOUS_VERSION_MAX_LIMIT,
162
+ type PreviousVersionAudit,
163
+ type PreviousVersionCaptureConfig,
164
+ type PreviousVersionReason,
165
+ type PreviousVersionReadSpec,
166
+ type PreviousVersionRecord,
167
+ type PreviousVersionSnapshot,
168
+ readPreviousVersionContainer,
169
+ readPreviousVersionRows,
170
+ setLocalSchemaDescriptor,
171
+ storedPreviousVersionAudit,
172
+ storedPreviousVersionRefusal,
173
+ withPreviousVersionContainer,
174
+ writePreviousVersionAudit,
175
+ writePreviousVersionRefusal,
176
+ } from './previous-version';
153
177
  import {
154
178
  type ClientSchema,
155
179
  type CompiledClientSchema,
@@ -361,6 +385,31 @@ export interface SyncClientConfig {
361
385
  * hosts must not materialize key bytes before their preflight has passed.
362
386
  */
363
387
  readonly securityPreflight?: boolean;
388
+ /**
389
+ * RFC 0005 previous-version context. A feature flag, default off: when
390
+ * absent or `enabled: false`, a schema bump behaves exactly as 0.22.0 did
391
+ * apart from the schema-descriptor write and the unconditional orphan
392
+ * sweep. It is not a security control.
393
+ *
394
+ * When enabled, the §7.4.3 reset captures a bounded typed copy of the
395
+ * pre-reset rows and `previousVersionSnapshot()` can read it until
396
+ * coverage completes, the lease ends, a scope is revoked, the TTL passes,
397
+ * the data is purged, or the host discards it.
398
+ */
399
+ readonly previousVersionContext?: {
400
+ readonly enabled: boolean;
401
+ /** Total captured bytes; default 8 MiB. */
402
+ readonly maxBytes?: number;
403
+ /** Total captured rows; default 20,000. */
404
+ readonly maxRows?: number;
405
+ /** Captured table count; default 32. */
406
+ readonly maxTables?: number;
407
+ /** Largest single captured row in bytes; default 1 MiB. */
408
+ readonly maxRowBytes?: number;
409
+ /** Aware-binary hygiene TTL; default 24h. It does not bound an
410
+ * unaware same-schema rollback, which runs none of this code. */
411
+ readonly maxAgeMs?: number;
412
+ };
364
413
  }
365
414
 
366
415
  /** The fail-closed local-replica security lifecycle shared by every host. */
@@ -526,6 +575,78 @@ function emptySummary(pushed: number): MutableSummary {
526
575
 
527
576
  const LOG_EPOCH_META_KEY = 'logEpoch';
528
577
 
578
+ /** RFC 0005 defaults (D2/A4/D7). */
579
+ const PREVIOUS_VERSION_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
580
+ const PREVIOUS_VERSION_DEFAULT_MAX_ROWS = 20_000;
581
+ const PREVIOUS_VERSION_DEFAULT_MAX_TABLES = 32;
582
+ const PREVIOUS_VERSION_DEFAULT_MAX_ROW_BYTES = 1024 * 1024;
583
+ const PREVIOUS_VERSION_DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
584
+
585
+ function boundedBudget(
586
+ value: number | undefined,
587
+ fallback: number,
588
+ name: string,
589
+ ): number {
590
+ if (value === undefined) return fallback;
591
+ if (!Number.isSafeInteger(value) || value < 1) {
592
+ throw new ClientSyncError(
593
+ 'sync.invalid_request',
594
+ `previousVersionContext.${name} must be a positive safe integer`,
595
+ );
596
+ }
597
+ return value;
598
+ }
599
+
600
+ function resolvePreviousVersionCaptureConfig(
601
+ config: SyncClientConfig['previousVersionContext'],
602
+ ): PreviousVersionCaptureConfig | undefined {
603
+ if (config === undefined || config.enabled !== true) return undefined;
604
+ return {
605
+ maxBytes: boundedBudget(
606
+ config.maxBytes,
607
+ PREVIOUS_VERSION_DEFAULT_MAX_BYTES,
608
+ 'maxBytes',
609
+ ),
610
+ maxRows: boundedBudget(
611
+ config.maxRows,
612
+ PREVIOUS_VERSION_DEFAULT_MAX_ROWS,
613
+ 'maxRows',
614
+ ),
615
+ maxTables: boundedBudget(
616
+ config.maxTables,
617
+ PREVIOUS_VERSION_DEFAULT_MAX_TABLES,
618
+ 'maxTables',
619
+ ),
620
+ maxRowBytes: boundedBudget(
621
+ config.maxRowBytes,
622
+ PREVIOUS_VERSION_DEFAULT_MAX_ROW_BYTES,
623
+ 'maxRowBytes',
624
+ ),
625
+ };
626
+ }
627
+
628
+ function previousVersionMaxAgeMs(
629
+ config: SyncClientConfig['previousVersionContext'],
630
+ ): number {
631
+ return boundedBudget(
632
+ config?.maxAgeMs,
633
+ PREVIOUS_VERSION_DEFAULT_MAX_AGE_MS,
634
+ 'maxAgeMs',
635
+ );
636
+ }
637
+
638
+ /** RFC 0005 D7: `limit` defaults to 50 and is capped at 200. */
639
+ function resolvePreviousVersionLimit(limit: number | undefined): number {
640
+ if (limit === undefined) return 50;
641
+ if (!Number.isFinite(limit) || limit < 1) {
642
+ throw new ClientSyncError(
643
+ 'sync.invalid_request',
644
+ 'previousVersionSnapshot limit must be a positive number',
645
+ );
646
+ }
647
+ return Math.min(PREVIOUS_VERSION_MAX_LIMIT, Math.trunc(limit));
648
+ }
649
+
529
650
  function isFinalPushResult(frame: PushResultFrame): boolean {
530
651
  return (
531
652
  frame.status !== 'rejected' ||
@@ -575,6 +696,8 @@ export class SyncClient {
575
696
  readonly #schema: CompiledClientSchema;
576
697
  /** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
577
698
  #encryption: EncryptionConfig | undefined;
699
+ /** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
700
+ readonly #previousVersion: PreviousVersionCaptureConfig | undefined;
578
701
  #securityLifecycle: SecurityLifecycle;
579
702
  readonly #now: () => number;
580
703
  readonly #outcomeRetentionMaxEntries: number;
@@ -683,6 +806,13 @@ export class SyncClient {
683
806
  );
684
807
  }
685
808
  this.#outcomeRetentionMaxEntries = outcomeRetentionMaxEntries;
809
+ // RFC 0005: the container needs a sibling database file. A host without
810
+ // that capability (native bridges today) has no storage for it, so the
811
+ // feature resolves as not configured rather than pretending to capture.
812
+ this.#previousVersion =
813
+ this.#db.openSibling === undefined
814
+ ? undefined
815
+ : resolvePreviousVersionCaptureConfig(config.previousVersionContext);
686
816
  this.#hasBlobs = schemaHasBlobs(this.#schema);
687
817
  }
688
818
 
@@ -735,6 +865,10 @@ export class SyncClient {
735
865
  // before the first sync round. A fresh install (no marker) is treated as
736
866
  // already at the generated version.
737
867
  this.#detectAndResetSchema();
868
+ // RFC 0005 D9: a container with missing/stale metadata, or past its TTL, is
869
+ // discarded at boot before anything can read it. The orphan/stale sweep is
870
+ // unconditional; the TTL is aware-binary hygiene only.
871
+ this.#reconcilePreviousVersionAtBoot();
738
872
  // A registration remains app intent only while its table exists in the
739
873
  // running schema. Removed-table registrations would poison every pull.
740
874
  const subscriptions = pruneUnknownSubscriptions(
@@ -785,14 +919,26 @@ export class SyncClient {
785
919
  const markerJson = getMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY);
786
920
  if (markerJson === undefined) {
787
921
  // Fresh install: the tables just created match the running code.
788
- ensureLocalSyncedSchema(this.#db, this.#schema);
789
- setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
922
+ this.#db.transaction(() => {
923
+ ensureLocalSyncedSchema(this.#db, this.#schema);
924
+ setMeta(
925
+ this.#db,
926
+ LOCAL_SCHEMA_VERSION_KEY,
927
+ String(this.#schema.version),
928
+ );
929
+ setLocalSchemaDescriptor(this.#db, this.#schema);
930
+ });
790
931
  return;
791
932
  }
792
933
  const marker = Number(markerJson);
793
934
  if (marker === this.#schema.version) {
794
- // Same-version opens remain self-healing for absent tables/indexes.
795
- ensureLocalSyncedSchema(this.#db, this.#schema);
935
+ // Same-version opens remain self-healing for absent tables/indexes, and
936
+ // RFC 0005 D1 backfills the descriptor for a database first opened by
937
+ // 0.22.0 or earlier WITHOUT a schema bump.
938
+ this.#db.transaction(() => {
939
+ ensureLocalSyncedSchema(this.#db, this.#schema);
940
+ setLocalSchemaDescriptor(this.#db, this.#schema);
941
+ });
796
942
  return;
797
943
  }
798
944
  this.#runSchemaReset();
@@ -806,9 +952,31 @@ export class SyncClient {
806
952
  * The bump is idempotent by the marker (rewritten last).
807
953
  */
808
954
  #runSchemaReset(): void {
955
+ const previousVersion = Number(
956
+ getMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY) ?? this.#schema.version,
957
+ );
809
958
  this.#setUpgrading(true);
959
+ // D5 step 1: the container file is swept FIRST, then captured into its own
960
+ // transaction, then the reset transaction runs with the marker LAST. A
961
+ // crash before the reset commit leaves the OLD marker, so the next boot
962
+ // re-runs the reset and the sweep removes the container: idempotent by the
963
+ // existing marker, with no cross-file transaction.
964
+ this.#sweepPreviousVersionContainer();
965
+ this.#capturePreviousVersion(previousVersion);
810
966
  this.#applyBatch((batch) => {
811
967
  this.#db.transaction(() => {
968
+ if (this.#previousVersion !== undefined) {
969
+ writePreviousVersionAudit(
970
+ this.#db,
971
+ buildPreviousVersionAudit(
972
+ this.#schema,
973
+ listOutbox(this.#db),
974
+ previousVersion,
975
+ this.#schema.version,
976
+ this.#now(),
977
+ ),
978
+ );
979
+ }
812
980
  dropAndRecreateSyncedTables(this.#db, this.#schema);
813
981
  resetSubscriptionsForBump(this.#db);
814
982
  setMeta(
@@ -816,6 +984,7 @@ export class SyncClient {
816
984
  LOCAL_SCHEMA_VERSION_KEY,
817
985
  String(this.#schema.version),
818
986
  );
987
+ setLocalSchemaDescriptor(this.#db, this.#schema);
819
988
  });
820
989
  // Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
821
990
  for (const table of this.#schema.tables.values()) batch.table(table.name);
@@ -827,11 +996,84 @@ export class SyncClient {
827
996
  this.#replayOutbox();
828
997
  }
829
998
 
999
+ /**
1000
+ * RFC 0005 D5 steps 2-5: capture the previous-version context into its own
1001
+ * database file, in its own transaction, before the wipe. A refused capture
1002
+ * stores nothing and records its reason for the read surface.
1003
+ */
1004
+ #capturePreviousVersion(previousVersion: number): void {
1005
+ const config = this.#previousVersion;
1006
+ if (config === undefined) return;
1007
+ const descriptor = loadLocalSchemaDescriptor(this.#db);
1008
+ if (descriptor === undefined || descriptor.version !== previousVersion) {
1009
+ writePreviousVersionRefusal(this.#db, 'no-previous-descriptor', {
1010
+ tables: 0,
1011
+ rows: 0,
1012
+ bytes: 0,
1013
+ });
1014
+ return;
1015
+ }
1016
+ const handle = this.#db.openSibling?.(PREVIOUS_VERSION_CONTAINER_NAME);
1017
+ if (handle === undefined) return;
1018
+ let captured = false;
1019
+ try {
1020
+ const outcome = capturePreviousVersion(
1021
+ this.#db,
1022
+ handle.database,
1023
+ descriptor,
1024
+ this.#schema,
1025
+ config,
1026
+ this.#now(),
1027
+ );
1028
+ if (outcome.ok) {
1029
+ captured = true;
1030
+ clearPreviousVersionRefusal(this.#db);
1031
+ } else {
1032
+ writePreviousVersionRefusal(
1033
+ this.#db,
1034
+ outcome.reason,
1035
+ outcome.measurement,
1036
+ );
1037
+ }
1038
+ } finally {
1039
+ handle.close();
1040
+ // A refused capture must leave no container file at all.
1041
+ if (!captured) handle.removeFile();
1042
+ }
1043
+ }
1044
+
1045
+ /** D5 step 1: unconditional orphan sweep on the container file. */
1046
+ #sweepPreviousVersionContainer(): void {
1047
+ if (this.#dropPreviousVersionContainerFile()) {
1048
+ clearPreviousVersionRefusal(this.#db);
1049
+ }
1050
+ }
1051
+
1052
+ /**
1053
+ * Drop the container file when present and report whether it was. Shared by
1054
+ * the orphan sweep and the discard path; the file is gone either way.
1055
+ */
1056
+ #dropPreviousVersionContainerFile(): boolean {
1057
+ return (
1058
+ withPreviousVersionContainer(
1059
+ this.#db,
1060
+ (container) => {
1061
+ dropPreviousVersionContainer(container);
1062
+ return true;
1063
+ },
1064
+ true,
1065
+ ) === true
1066
+ );
1067
+ }
1068
+
830
1069
  /** §2.1 reset after the server reports a different log continuity. */
831
1070
  #runLogEpochReset(logEpoch: string): string[] {
832
1071
  const subscriptions = loadSubscriptions(this.#db);
833
1072
  const pending = listOutbox(this.#db);
834
1073
  this.#setUpgrading(true);
1074
+ // RFC 0005 D5: the log-epoch reset is not a schema bump. It performs the
1075
+ // orphan sweep but captures no new shadow.
1076
+ this.#sweepPreviousVersionContainer();
835
1077
  this.#applyBatch((batch) => {
836
1078
  this.#db.transaction(() => {
837
1079
  dropAndRecreateSyncedTables(this.#db, this.#schema);
@@ -1006,6 +1248,8 @@ export class SyncClient {
1006
1248
  query(sql: string, params?: readonly SqlValue[]): SqlRow[] {
1007
1249
  this.#requireActive();
1008
1250
  assertReadOnlyQuery(sql);
1251
+ // RFC 0005's container lives in a separate database file, so this
1252
+ // connection cannot see it and there is no table-access guard to run.
1009
1253
  return stripSyncColumns(this.#db.query(sql, params));
1010
1254
  }
1011
1255
 
@@ -1068,6 +1312,229 @@ export class SyncClient {
1068
1312
  });
1069
1313
  }
1070
1314
 
1315
+ // -- previous-version context (RFC 0005) ----------------------------------
1316
+
1317
+ /**
1318
+ * RFC 0005 D7: read captured rows from the previous local schema. `state` is
1319
+ * always `'previousVersion'`; the container never makes `querySnapshot()`
1320
+ * coverage complete.
1321
+ */
1322
+ previousVersionSnapshot(
1323
+ spec: PreviousVersionReadSpec,
1324
+ ): PreviousVersionSnapshot {
1325
+ this.#requireActive();
1326
+ const currentVersion = this.#schema.version;
1327
+ if (this.#previousVersion === undefined) {
1328
+ return this.#previousVersionUnavailable(currentVersion, 'not-configured');
1329
+ }
1330
+ const record = this.#readPreviousVersionRecord();
1331
+ const nowMs = this.#now();
1332
+ if (this.#previousVersionLeaseInactive(nowMs)) {
1333
+ this.#discardPreviousVersion();
1334
+ return this.#previousVersionUnavailable(currentVersion, 'lease-inactive');
1335
+ }
1336
+ if (loadSubscriptions(this.#db).some((sub) => sub.status === 'revoked')) {
1337
+ this.#discardPreviousVersion();
1338
+ return this.#previousVersionUnavailable(currentVersion, 'scope-revoked');
1339
+ }
1340
+ // Coverage completion is a live predicate: the container is normally
1341
+ // dropped by the sync path first, so a read can be the first observer.
1342
+ if (this.#previousVersionCoverageComplete()) {
1343
+ this.#discardPreviousVersion();
1344
+ return this.#previousVersionUnavailable(
1345
+ currentVersion,
1346
+ 'coverage-complete',
1347
+ );
1348
+ }
1349
+ if (record === undefined) {
1350
+ const refusal = storedPreviousVersionRefusal(this.#db);
1351
+ return this.#previousVersionUnavailable(
1352
+ currentVersion,
1353
+ refusal?.reason ?? 'no-previous-descriptor',
1354
+ );
1355
+ }
1356
+ if (
1357
+ nowMs - record.createdAtMs >
1358
+ previousVersionMaxAgeMs(this.#config.previousVersionContext)
1359
+ ) {
1360
+ this.#discardPreviousVersion();
1361
+ return this.#previousVersionUnavailable(currentVersion, 'expired');
1362
+ }
1363
+ const table = record.tables.find((entry) => entry.name === spec.table);
1364
+ if (table === undefined) {
1365
+ throw new ClientSyncError(
1366
+ 'sync.invalid_request',
1367
+ `previousVersionSnapshot names unknown previous table ${JSON.stringify(spec.table)}`,
1368
+ );
1369
+ }
1370
+ const read = withPreviousVersionContainer(this.#db, (container) =>
1371
+ readPreviousVersionRows(
1372
+ container,
1373
+ table,
1374
+ spec.rowIds?.map(String) ?? [],
1375
+ resolvePreviousVersionLimit(spec.limit),
1376
+ ),
1377
+ );
1378
+ if (read === undefined) {
1379
+ return this.#previousVersionUnavailable(
1380
+ currentVersion,
1381
+ 'no-previous-descriptor',
1382
+ );
1383
+ }
1384
+ return {
1385
+ state: 'previousVersion',
1386
+ available: true,
1387
+ previousVersion: record.previousVersion,
1388
+ currentVersion,
1389
+ rows: read.rows,
1390
+ truncated: read.truncated,
1391
+ };
1392
+ }
1393
+
1394
+ /** RFC 0005 D6: the pre-reset compatibility audit, advisory only. */
1395
+ previousVersionAudit(): PreviousVersionAudit | undefined {
1396
+ this.#requireActive();
1397
+ return storedPreviousVersionAudit(this.#db);
1398
+ }
1399
+
1400
+ /**
1401
+ * RFC 0005 A2/D9: the executable downgrade step. Drop the container file and
1402
+ * both metadata records, and report whether anything was present.
1403
+ *
1404
+ * Authorized cleanup, so it is gated only on `start()` like
1405
+ * `purgeLocalData`, NOT on `active`: RFC 0006 runs the discard consumer
1406
+ * inside the quiesced security-preflight window, after the barrier and
1407
+ * before reactivation. Reads and the audit stay active-gated.
1408
+ */
1409
+ previousVersionDiscard(): { present: boolean; discarded: boolean } {
1410
+ this.#requireStarted();
1411
+ const present = this.#dropPreviousVersion();
1412
+ return { present, discarded: present };
1413
+ }
1414
+
1415
+ #previousVersionUnavailable(
1416
+ currentVersion: number,
1417
+ reason: PreviousVersionReason,
1418
+ ): PreviousVersionSnapshot {
1419
+ return {
1420
+ state: 'previousVersion',
1421
+ available: false,
1422
+ currentVersion,
1423
+ reason,
1424
+ rows: [],
1425
+ truncated: false,
1426
+ };
1427
+ }
1428
+
1429
+ #previousVersionExists(): boolean {
1430
+ return this.#db.siblingExists?.(PREVIOUS_VERSION_CONTAINER_NAME) === true;
1431
+ }
1432
+
1433
+ /** Read the container's own metadata; opens the file for that window only. */
1434
+ #readPreviousVersionRecord(): PreviousVersionRecord | undefined {
1435
+ return withPreviousVersionContainer(this.#db, readPreviousVersionContainer);
1436
+ }
1437
+
1438
+ /** RFC 0005 A2: presence for `statusSnapshot()`, without creating the file. */
1439
+ #previousVersionStatus(): { present: boolean; createdAtMs?: number } {
1440
+ const record = this.#readPreviousVersionRecord();
1441
+ if (record === undefined) return { present: this.#previousVersionExists() };
1442
+ return { present: true, createdAtMs: record.createdAtMs };
1443
+ }
1444
+
1445
+ #previousVersionLeaseInactive(nowMs: number): boolean {
1446
+ const lease = this.#leaseState;
1447
+ if (lease?.errorCode !== undefined) return true;
1448
+ return lease?.expiresAtMs !== undefined && lease.expiresAtMs <= nowMs;
1449
+ }
1450
+
1451
+ /**
1452
+ * RFC 0005 D7: the §7.4.5 replacement-coverage predicate — every active
1453
+ * subscription has `cursor >= 0` and no resume token. Window units own
1454
+ * subscriptions, so their pendency is included. An empty active set is not
1455
+ * completion.
1456
+ */
1457
+ #previousVersionCoverageComplete(): boolean {
1458
+ const active = loadSubscriptions(this.#db).filter(
1459
+ (sub) => sub.status === 'active',
1460
+ );
1461
+ if (active.length === 0) return false;
1462
+ return active.every(
1463
+ (sub) => sub.cursor >= 0 && sub.bootstrapState === undefined,
1464
+ );
1465
+ }
1466
+
1467
+ /**
1468
+ * Remove the container file and both metadata records. Returns whether
1469
+ * anything was present. No status notification — callers that change client
1470
+ * state wrap it with {@link #discardPreviousVersion}.
1471
+ */
1472
+ #dropPreviousVersion(): boolean {
1473
+ const present =
1474
+ this.#dropPreviousVersionContainerFile() ||
1475
+ storedPreviousVersionRefusal(this.#db) !== undefined ||
1476
+ storedPreviousVersionAudit(this.#db) !== undefined;
1477
+ this.#db.transaction(() => {
1478
+ clearPreviousVersionRefusal(this.#db);
1479
+ clearPreviousVersionAudit(this.#db);
1480
+ });
1481
+ return present;
1482
+ }
1483
+
1484
+ #discardPreviousVersion(): boolean {
1485
+ const present = this.#dropPreviousVersion();
1486
+ if (present) this.#applyBatch((batch) => batch.status());
1487
+ return present;
1488
+ }
1489
+
1490
+ /** RFC 0005 D7: lifetime triggers evaluated after a sync round. */
1491
+ #previousVersionLifetimeCheck(): void {
1492
+ if (this.#previousVersion === undefined) return;
1493
+ const nowMs = this.#now();
1494
+ if (this.#previousVersionLeaseInactive(nowMs)) {
1495
+ this.#discardPreviousVersion();
1496
+ return;
1497
+ }
1498
+ const record = this.#readPreviousVersionRecord();
1499
+ if (record === undefined) return;
1500
+ if (loadSubscriptions(this.#db).some((sub) => sub.status === 'revoked')) {
1501
+ this.#discardPreviousVersion();
1502
+ return;
1503
+ }
1504
+ if (
1505
+ nowMs - record.createdAtMs >
1506
+ previousVersionMaxAgeMs(this.#config.previousVersionContext)
1507
+ ) {
1508
+ this.#discardPreviousVersion();
1509
+ return;
1510
+ }
1511
+ if (this.#previousVersionCoverageComplete()) {
1512
+ this.#discardPreviousVersion();
1513
+ }
1514
+ }
1515
+
1516
+ /**
1517
+ * RFC 0005 D9 boot hygiene: an orphan/stale container is discarded before any
1518
+ * read. The TTL runs only for an aware binary; it makes no claim about an
1519
+ * unaware same-schema rollback, which executes none of this code.
1520
+ */
1521
+ #reconcilePreviousVersionAtBoot(): void {
1522
+ if (!this.#previousVersionExists()) return;
1523
+ const record = this.#readPreviousVersionRecord();
1524
+ let discard =
1525
+ record === undefined || record.currentVersion !== this.#schema.version;
1526
+ if (
1527
+ !discard &&
1528
+ record !== undefined &&
1529
+ this.#previousVersion !== undefined &&
1530
+ this.#now() - record.createdAtMs >
1531
+ previousVersionMaxAgeMs(this.#config.previousVersionContext)
1532
+ ) {
1533
+ discard = true;
1534
+ }
1535
+ if (discard) this.#dropPreviousVersion();
1536
+ }
1537
+
1071
1538
  // -- live-query invalidation ----------------------------------------------
1072
1539
 
1073
1540
  /**
@@ -1369,6 +1836,7 @@ export class SyncClient {
1369
1836
  leaseState: this.#leaseState,
1370
1837
  schemaFloor: this.#schemaFloor,
1371
1838
  syncNeeded: this.#needsPull,
1839
+ previousVersionContext: this.#previousVersionStatus(),
1372
1840
  };
1373
1841
  }
1374
1842
 
@@ -2153,14 +2621,49 @@ export class SyncClient {
2153
2621
  // present ones.
2154
2622
  const normalized = normalizeRecordKeys(table, mutation.values);
2155
2623
  const scopeColumns = new Set(table.scopeColumnByVariable.values());
2624
+ const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
2156
2625
  const json: Record<string, JsonRowValue> = {};
2157
2626
  for (const column of table.columns) {
2158
2627
  if (!normalized.has(column.name)) continue;
2159
- if (scopeColumns.has(column.name)) {
2160
- throw new ClientSyncError(
2161
- 'sync.invalid_request',
2162
- `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`,
2163
- );
2628
+ // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
2629
+ // server accepts a present scope column whose value equals the
2630
+ // stored row and applies it as a no-op, so a decoded envelope
2631
+ // round-trips. The local row is the only value the client can
2632
+ // prove equality against; when the row is absent locally the
2633
+ // client cannot prove equality against the server's stored row
2634
+ // and fails closed. A primary key that is also a scope column
2635
+ // needs no stored-row read: the primary key in a sparse payload is
2636
+ // by construction the row id being patched (§6.1), so its value is
2637
+ // proven equal already.
2638
+ if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
2639
+ const pkValue = normalized.get(pkColumn.name);
2640
+ const local =
2641
+ typeof pkValue === 'string' && pkValue.length > 0
2642
+ ? this.#db.query(
2643
+ `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2644
+ [pkValue],
2645
+ )[0]
2646
+ : undefined;
2647
+ if (
2648
+ local === undefined ||
2649
+ !Object.is(
2650
+ rowValueToJson(
2651
+ fromSqlValue(column, local[column.name] ?? null),
2652
+ ),
2653
+ rowValueToJson(
2654
+ coerceSqlRepresentation(
2655
+ column,
2656
+ normalized.get(column.name),
2657
+ ) as RowValue,
2658
+ ),
2659
+ )
2660
+ ) {
2661
+ throw new ClientSyncError(
2662
+ 'sync.invalid_request',
2663
+ `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`,
2664
+ );
2665
+ }
2666
+ continue;
2164
2667
  }
2165
2668
  const value = normalized.get(column.name);
2166
2669
  if (value === undefined || value === null) {
@@ -2177,7 +2680,6 @@ export class SyncClient {
2177
2680
  coerceSqlRepresentation(column, value) as RowValue,
2178
2681
  );
2179
2682
  }
2180
- const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
2181
2683
  const pkValue = json[pkColumn.name];
2182
2684
  if (typeof pkValue !== 'string' || pkValue.length === 0) {
2183
2685
  throw new ClientSyncError(
@@ -2333,6 +2835,10 @@ export class SyncClient {
2333
2835
  }
2334
2836
  return { alreadyApplied: true, purgedRows: 0, droppedCommits: 0 };
2335
2837
  }
2838
+ // RFC 0005 D7: the container file cannot be a purge target (its name is not
2839
+ // in the running schema), so purge drops it as a fixed step, before any
2840
+ // mirror row is touched and unconditionally.
2841
+ this.#dropPreviousVersion();
2336
2842
 
2337
2843
  const rejectionCount = this.#rejections.length;
2338
2844
  try {
@@ -2589,6 +3095,8 @@ export class SyncClient {
2589
3095
  setMeta(this.#db, 'leaseState', JSON.stringify(next));
2590
3096
  batch.status();
2591
3097
  });
3098
+ // RFC 0005 D7: a lease stop state ends the previous-version context.
3099
+ if (next.errorCode !== undefined) this.#discardPreviousVersion();
2592
3100
  }
2593
3101
 
2594
3102
  /** The request-level lease error codes (§7.3.4): stop-and-surface. */
@@ -3742,6 +4250,9 @@ export class SyncClient {
3742
4250
  ) {
3743
4251
  this.#setUpgrading(false);
3744
4252
  }
4253
+ // RFC 0005 D7: coverage completion, lease end, scope revocation and TTL are
4254
+ // evaluated once per round, after the apply.
4255
+ this.#previousVersionLifetimeCheck();
3745
4256
  return { ...summary, bootstrapping };
3746
4257
  }
3747
4258