@syncular/client 0.22.0 → 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
 
@@ -2367,6 +2835,10 @@ export class SyncClient {
2367
2835
  }
2368
2836
  return { alreadyApplied: true, purgedRows: 0, droppedCommits: 0 };
2369
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();
2370
2842
 
2371
2843
  const rejectionCount = this.#rejections.length;
2372
2844
  try {
@@ -2623,6 +3095,8 @@ export class SyncClient {
2623
3095
  setMeta(this.#db, 'leaseState', JSON.stringify(next));
2624
3096
  batch.status();
2625
3097
  });
3098
+ // RFC 0005 D7: a lease stop state ends the previous-version context.
3099
+ if (next.errorCode !== undefined) this.#discardPreviousVersion();
2626
3100
  }
2627
3101
 
2628
3102
  /** The request-level lease error codes (§7.3.4): stop-and-surface. */
@@ -3776,6 +4250,9 @@ export class SyncClient {
3776
4250
  ) {
3777
4251
  this.#setUpgrading(false);
3778
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();
3779
4256
  return { ...summary, bootstrapping };
3780
4257
  }
3781
4258
 
package/src/database.ts CHANGED
@@ -9,6 +9,24 @@ export type SqlValue = string | number | bigint | boolean | Uint8Array | null;
9
9
 
10
10
  export type SqlRow = Record<string, SqlValue>;
11
11
 
12
+ /**
13
+ * RFC 0005: a second database file in this database's own storage scope (the
14
+ * same on-disk directory, or the same OPFS SAH pool directory). Used to keep
15
+ * the previous-version container off the connection `query()` and unaware code
16
+ * use. The read guarantee is narrow: an older client's ordinary query
17
+ * connection does not attach this file, so no SQL it runs can reach the
18
+ * container. It is NOT protection against arbitrary same-origin access and NOT
19
+ * protection against native filesystem access.
20
+ */
21
+ export interface SiblingDatabase {
22
+ /** The open connection; the caller closes it before removal. */
23
+ readonly database: ClientDatabase;
24
+ /** Close the handle. A live handle blocks physical removal. */
25
+ close(): void;
26
+ /** Best-effort physical removal of the backing file. Close the handle first. */
27
+ removeFile(): void;
28
+ }
29
+
12
30
  export interface ClientDatabase {
13
31
  /** Execute a single statement (no result rows). */
14
32
  exec(sql: string, params?: readonly SqlValue[]): void;
@@ -32,6 +50,16 @@ export interface ClientDatabase {
32
50
  alias: string,
33
51
  fn: () => T | Promise<T>,
34
52
  ): Promise<T>;
53
+ /**
54
+ * RFC 0005: open (creating on demand) the named sibling database file.
55
+ * `undefined` means the host provides no sibling capability, which makes the
56
+ * previous-version feature unavailable there. The NAME is code-derived and is
57
+ * not persisted in the replica database (data minimisation; not a security
58
+ * control).
59
+ */
60
+ openSibling?(name: string): SiblingDatabase | undefined;
61
+ /** RFC 0005: non-creating existence probe for {@link openSibling}. */
62
+ siblingExists?(name: string): boolean;
35
63
  close(): void;
36
64
  }
37
65
 
package/src/index.ts CHANGED
@@ -28,6 +28,19 @@ export * from './multi-tab';
28
28
  export * from './naming';
29
29
  export * from './outbox';
30
30
  export * from './outcomes';
31
+ // RFC 0005: explicit public surface. Everything else in the module is
32
+ // implementation detail used by `client.ts` (capture, container file, durable
33
+ // refusal/audit codecs), not package API. The feature is opt-in and its
34
+ // consumer-facing contract is exactly the read spec and the two result types.
35
+ export {
36
+ decodePreviousVersionAuditResult,
37
+ decodePreviousVersionDiscardResult,
38
+ decodePreviousVersionSnapshot,
39
+ type PreviousVersionAudit,
40
+ type PreviousVersionReadSpec,
41
+ type PreviousVersionReason,
42
+ type PreviousVersionSnapshot,
43
+ } from './previous-version';
31
44
  export * from './query-guard';
32
45
  export * from './reactive-store';
33
46
  export * from './remote';
@@ -30,6 +30,15 @@ export interface SyncStatusSnapshot {
30
30
  readonly leaseState: LeaseState | undefined;
31
31
  readonly schemaFloor: SchemaFloor | undefined;
32
32
  readonly syncNeeded: boolean;
33
+ /**
34
+ * RFC 0005 A2: whether a previous-version cache is present, so a host's
35
+ * update/rollback path can require its discard. `createdAtMs` is the capture
36
+ * time when a readable cache exists.
37
+ */
38
+ readonly previousVersionContext: {
39
+ readonly present: boolean;
40
+ readonly createdAtMs?: number;
41
+ };
33
42
  }
34
43
 
35
44
  export interface ClientChangeBatch {