@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.
@@ -4,7 +4,7 @@
4
4
  * browser entry never touches `bun:sqlite`.
5
5
  */
6
6
  import { Database } from 'bun:sqlite';
7
- import { type ClientDatabase, type SqlRow, type SqlValue } from './database.js';
7
+ import { type ClientDatabase, type SiblingDatabase, type SqlRow, type SqlValue } from './database.js';
8
8
  declare module 'bun:sqlite' {
9
9
  interface Database {
10
10
  clearQueryCache(): void;
@@ -16,6 +16,9 @@ export declare class BunClientDatabase implements ClientDatabase {
16
16
  constructor(path?: string);
17
17
  exec(sql: string, params?: readonly SqlValue[]): void;
18
18
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
19
+ /** RFC 0005: a sibling file beside the replica, e.g. `<path>.prev-context`. */
20
+ openSibling(name: string): SiblingDatabase;
21
+ siblingExists(name: string): boolean;
19
22
  transaction<T>(fn: () => T): T;
20
23
  /**
21
24
  * §5.3 image import: bun:sqlite attaches files, not buffers, so the
@@ -4,7 +4,7 @@
4
4
  * browser entry never touches `bun:sqlite`.
5
5
  */
6
6
  import { Database } from 'bun:sqlite';
7
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { assertImageAlias, runTransaction, } from './database.js';
@@ -17,8 +17,10 @@ function coerceParams(params) {
17
17
  }
18
18
  export class BunClientDatabase {
19
19
  db;
20
+ #path;
20
21
  #tx = { depth: 0 };
21
22
  constructor(path = ':memory:') {
23
+ this.#path = path;
22
24
  this.db = new Database(path);
23
25
  // Match native Rust persistence: append durable commits to the WAL
24
26
  // instead of creating and syncing a rollback journal per transaction.
@@ -43,6 +45,27 @@ export class BunClientDatabase {
43
45
  query(sql, params = []) {
44
46
  return this.db.query(sql).all(...coerceParams(params));
45
47
  }
48
+ /** RFC 0005: a sibling file beside the replica, e.g. `<path>.prev-context`. */
49
+ openSibling(name) {
50
+ const siblingPath = this.#path === ':memory:' ? ':memory:' : `${this.#path}.${name}`;
51
+ const database = new BunClientDatabase(siblingPath);
52
+ return {
53
+ database,
54
+ close: () => database.close(),
55
+ removeFile: () => {
56
+ if (siblingPath === ':memory:')
57
+ return;
58
+ rmSync(siblingPath, { force: true });
59
+ rmSync(`${siblingPath}-wal`, { force: true });
60
+ rmSync(`${siblingPath}-shm`, { force: true });
61
+ },
62
+ };
63
+ }
64
+ siblingExists(name) {
65
+ if (this.#path === ':memory:')
66
+ return false;
67
+ return existsSync(`${this.#path}.${name}`);
68
+ }
46
69
  transaction(fn) {
47
70
  return runTransaction(this.#tx, (sql) => this.db.run(sql), fn);
48
71
  }
package/dist/client.d.ts CHANGED
@@ -19,6 +19,7 @@ import { type LocalDataPurgeInput, type LocalDataPurgeResult } from './local-pur
19
19
  import { type LocalDataRebootstrapInput, type LocalDataRebootstrapResult } from './local-rebootstrap.js';
20
20
  import { type OutboxCommit } from './outbox.js';
21
21
  import { type CommitOutcome, type CommitOutcomeQuery, type ConflictRecord, type RejectionRecord, type ResolveCommitOutcomeInput } from './outcomes.js';
22
+ import { type PreviousVersionAudit, type PreviousVersionReadSpec, type PreviousVersionSnapshot } from './previous-version.js';
22
23
  import { type ClientSchema } from './schema.js';
23
24
  import { type SubscriptionRecord } from './state.js';
24
25
  import type { RealtimeConnector, SegmentDownloader, SyncTransport } from './transport.js';
@@ -161,6 +162,31 @@ export interface SyncClientConfig {
161
162
  * hosts must not materialize key bytes before their preflight has passed.
162
163
  */
163
164
  readonly securityPreflight?: boolean;
165
+ /**
166
+ * RFC 0005 previous-version context. A feature flag, default off: when
167
+ * absent or `enabled: false`, a schema bump behaves exactly as 0.22.0 did
168
+ * apart from the schema-descriptor write and the unconditional orphan
169
+ * sweep. It is not a security control.
170
+ *
171
+ * When enabled, the §7.4.3 reset captures a bounded typed copy of the
172
+ * pre-reset rows and `previousVersionSnapshot()` can read it until
173
+ * coverage completes, the lease ends, a scope is revoked, the TTL passes,
174
+ * the data is purged, or the host discards it.
175
+ */
176
+ readonly previousVersionContext?: {
177
+ readonly enabled: boolean;
178
+ /** Total captured bytes; default 8 MiB. */
179
+ readonly maxBytes?: number;
180
+ /** Total captured rows; default 20,000. */
181
+ readonly maxRows?: number;
182
+ /** Captured table count; default 32. */
183
+ readonly maxTables?: number;
184
+ /** Largest single captured row in bytes; default 1 MiB. */
185
+ readonly maxRowBytes?: number;
186
+ /** Aware-binary hygiene TTL; default 24h. It does not bound an
187
+ * unaware same-schema rollback, which runs none of this code. */
188
+ readonly maxAgeMs?: number;
189
+ };
164
190
  }
165
191
  /** The fail-closed local-replica security lifecycle shared by every host. */
166
192
  export type SecurityLifecycle = 'preflight' | 'active';
@@ -282,6 +308,27 @@ export declare class SyncClient {
282
308
  * `windowState()` across separate worker/IPC calls.
283
309
  */
284
310
  querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row>;
311
+ /**
312
+ * RFC 0005 D7: read captured rows from the previous local schema. `state` is
313
+ * always `'previousVersion'`; the container never makes `querySnapshot()`
314
+ * coverage complete.
315
+ */
316
+ previousVersionSnapshot(spec: PreviousVersionReadSpec): PreviousVersionSnapshot;
317
+ /** RFC 0005 D6: the pre-reset compatibility audit, advisory only. */
318
+ previousVersionAudit(): PreviousVersionAudit | undefined;
319
+ /**
320
+ * RFC 0005 A2/D9: the executable downgrade step. Drop the container file and
321
+ * both metadata records, and report whether anything was present.
322
+ *
323
+ * Authorized cleanup, so it is gated only on `start()` like
324
+ * `purgeLocalData`, NOT on `active`: RFC 0006 runs the discard consumer
325
+ * inside the quiesced security-preflight window, after the barrier and
326
+ * before reactivation. Reads and the audit stay active-gated.
327
+ */
328
+ previousVersionDiscard(): {
329
+ present: boolean;
330
+ discarded: boolean;
331
+ };
285
332
  /**
286
333
  * Subscribe to fine-grained invalidation. The callback fires ONCE per
287
334
  * apply batch (never per row, I1) with the `{tables, scopeKeys}` touched
package/dist/client.js CHANGED
@@ -23,6 +23,7 @@ import { decodeLocalDataRebootstrapReceipt, encodeLocalDataRebootstrapReceipt, }
23
23
  import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, iterateOutbox, countOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
24
24
  import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
25
25
  import { assertReadOnlyQuery } from './query-guard.js';
26
+ import { buildPreviousVersionAudit, capturePreviousVersion, clearPreviousVersionAudit, clearPreviousVersionRefusal, dropPreviousVersionContainer, loadLocalSchemaDescriptor, PREVIOUS_VERSION_CONTAINER_NAME, PREVIOUS_VERSION_MAX_LIMIT, readPreviousVersionContainer, readPreviousVersionRows, setLocalSchemaDescriptor, storedPreviousVersionAudit, storedPreviousVersionRefusal, withPreviousVersionContainer, writePreviousVersionAudit, writePreviousVersionRefusal, } from './previous-version.js';
26
27
  import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
27
28
  import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
28
29
  import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
@@ -74,6 +75,42 @@ function emptySummary(pushed) {
74
75
  };
75
76
  }
76
77
  const LOG_EPOCH_META_KEY = 'logEpoch';
78
+ /** RFC 0005 defaults (D2/A4/D7). */
79
+ const PREVIOUS_VERSION_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
80
+ const PREVIOUS_VERSION_DEFAULT_MAX_ROWS = 20_000;
81
+ const PREVIOUS_VERSION_DEFAULT_MAX_TABLES = 32;
82
+ const PREVIOUS_VERSION_DEFAULT_MAX_ROW_BYTES = 1024 * 1024;
83
+ const PREVIOUS_VERSION_DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
84
+ function boundedBudget(value, fallback, name) {
85
+ if (value === undefined)
86
+ return fallback;
87
+ if (!Number.isSafeInteger(value) || value < 1) {
88
+ throw new ClientSyncError('sync.invalid_request', `previousVersionContext.${name} must be a positive safe integer`);
89
+ }
90
+ return value;
91
+ }
92
+ function resolvePreviousVersionCaptureConfig(config) {
93
+ if (config === undefined || config.enabled !== true)
94
+ return undefined;
95
+ return {
96
+ maxBytes: boundedBudget(config.maxBytes, PREVIOUS_VERSION_DEFAULT_MAX_BYTES, 'maxBytes'),
97
+ maxRows: boundedBudget(config.maxRows, PREVIOUS_VERSION_DEFAULT_MAX_ROWS, 'maxRows'),
98
+ maxTables: boundedBudget(config.maxTables, PREVIOUS_VERSION_DEFAULT_MAX_TABLES, 'maxTables'),
99
+ maxRowBytes: boundedBudget(config.maxRowBytes, PREVIOUS_VERSION_DEFAULT_MAX_ROW_BYTES, 'maxRowBytes'),
100
+ };
101
+ }
102
+ function previousVersionMaxAgeMs(config) {
103
+ return boundedBudget(config?.maxAgeMs, PREVIOUS_VERSION_DEFAULT_MAX_AGE_MS, 'maxAgeMs');
104
+ }
105
+ /** RFC 0005 D7: `limit` defaults to 50 and is capped at 200. */
106
+ function resolvePreviousVersionLimit(limit) {
107
+ if (limit === undefined)
108
+ return 50;
109
+ if (!Number.isFinite(limit) || limit < 1) {
110
+ throw new ClientSyncError('sync.invalid_request', 'previousVersionSnapshot limit must be a positive number');
111
+ }
112
+ return Math.min(PREVIOUS_VERSION_MAX_LIMIT, Math.trunc(limit));
113
+ }
77
114
  function isFinalPushResult(frame) {
78
115
  return (frame.status !== 'rejected' ||
79
116
  !frame.results.some((result) => result.status === 'error' &&
@@ -86,6 +123,8 @@ export class SyncClient {
86
123
  #schema;
87
124
  /** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
88
125
  #encryption;
126
+ /** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
127
+ #previousVersion;
89
128
  #securityLifecycle;
90
129
  #now;
91
130
  #outcomeRetentionMaxEntries;
@@ -182,6 +221,13 @@ export class SyncClient {
182
221
  throw new ClientSyncError('sync.invalid_request', 'outcomeRetentionMaxEntries must be a positive safe integer');
183
222
  }
184
223
  this.#outcomeRetentionMaxEntries = outcomeRetentionMaxEntries;
224
+ // RFC 0005: the container needs a sibling database file. A host without
225
+ // that capability (native bridges today) has no storage for it, so the
226
+ // feature resolves as not configured rather than pretending to capture.
227
+ this.#previousVersion =
228
+ this.#db.openSibling === undefined
229
+ ? undefined
230
+ : resolvePreviousVersionCaptureConfig(config.previousVersionContext);
185
231
  this.#hasBlobs = schemaHasBlobs(this.#schema);
186
232
  }
187
233
  // -- lifecycle ------------------------------------------------------------
@@ -225,6 +271,10 @@ export class SyncClient {
225
271
  // before the first sync round. A fresh install (no marker) is treated as
226
272
  // already at the generated version.
227
273
  this.#detectAndResetSchema();
274
+ // RFC 0005 D9: a container with missing/stale metadata, or past its TTL, is
275
+ // discarded at boot before anything can read it. The orphan/stale sweep is
276
+ // unconditional; the TTL is aware-binary hygiene only.
277
+ this.#reconcilePreviousVersionAtBoot();
228
278
  // A registration remains app intent only while its table exists in the
229
279
  // running schema. Removed-table registrations would poison every pull.
230
280
  const subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), new Set(this.#schema.tables.keys()));
@@ -269,14 +319,22 @@ export class SyncClient {
269
319
  const markerJson = getMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY);
270
320
  if (markerJson === undefined) {
271
321
  // Fresh install: the tables just created match the running code.
272
- ensureLocalSyncedSchema(this.#db, this.#schema);
273
- setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
322
+ this.#db.transaction(() => {
323
+ ensureLocalSyncedSchema(this.#db, this.#schema);
324
+ setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
325
+ setLocalSchemaDescriptor(this.#db, this.#schema);
326
+ });
274
327
  return;
275
328
  }
276
329
  const marker = Number(markerJson);
277
330
  if (marker === this.#schema.version) {
278
- // Same-version opens remain self-healing for absent tables/indexes.
279
- ensureLocalSyncedSchema(this.#db, this.#schema);
331
+ // Same-version opens remain self-healing for absent tables/indexes, and
332
+ // RFC 0005 D1 backfills the descriptor for a database first opened by
333
+ // 0.22.0 or earlier WITHOUT a schema bump.
334
+ this.#db.transaction(() => {
335
+ ensureLocalSyncedSchema(this.#db, this.#schema);
336
+ setLocalSchemaDescriptor(this.#db, this.#schema);
337
+ });
280
338
  return;
281
339
  }
282
340
  this.#runSchemaReset();
@@ -289,12 +347,24 @@ export class SyncClient {
289
347
  * The bump is idempotent by the marker (rewritten last).
290
348
  */
291
349
  #runSchemaReset() {
350
+ const previousVersion = Number(getMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY) ?? this.#schema.version);
292
351
  this.#setUpgrading(true);
352
+ // D5 step 1: the container file is swept FIRST, then captured into its own
353
+ // transaction, then the reset transaction runs with the marker LAST. A
354
+ // crash before the reset commit leaves the OLD marker, so the next boot
355
+ // re-runs the reset and the sweep removes the container: idempotent by the
356
+ // existing marker, with no cross-file transaction.
357
+ this.#sweepPreviousVersionContainer();
358
+ this.#capturePreviousVersion(previousVersion);
293
359
  this.#applyBatch((batch) => {
294
360
  this.#db.transaction(() => {
361
+ if (this.#previousVersion !== undefined) {
362
+ writePreviousVersionAudit(this.#db, buildPreviousVersionAudit(this.#schema, listOutbox(this.#db), previousVersion, this.#schema.version, this.#now()));
363
+ }
295
364
  dropAndRecreateSyncedTables(this.#db, this.#schema);
296
365
  resetSubscriptionsForBump(this.#db);
297
366
  setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
367
+ setLocalSchemaDescriptor(this.#db, this.#schema);
298
368
  });
299
369
  // Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
300
370
  for (const table of this.#schema.tables.values())
@@ -306,11 +376,69 @@ export class SyncClient {
306
376
  this.#setSchemaFloor(undefined);
307
377
  this.#replayOutbox();
308
378
  }
379
+ /**
380
+ * RFC 0005 D5 steps 2-5: capture the previous-version context into its own
381
+ * database file, in its own transaction, before the wipe. A refused capture
382
+ * stores nothing and records its reason for the read surface.
383
+ */
384
+ #capturePreviousVersion(previousVersion) {
385
+ const config = this.#previousVersion;
386
+ if (config === undefined)
387
+ return;
388
+ const descriptor = loadLocalSchemaDescriptor(this.#db);
389
+ if (descriptor === undefined || descriptor.version !== previousVersion) {
390
+ writePreviousVersionRefusal(this.#db, 'no-previous-descriptor', {
391
+ tables: 0,
392
+ rows: 0,
393
+ bytes: 0,
394
+ });
395
+ return;
396
+ }
397
+ const handle = this.#db.openSibling?.(PREVIOUS_VERSION_CONTAINER_NAME);
398
+ if (handle === undefined)
399
+ return;
400
+ let captured = false;
401
+ try {
402
+ const outcome = capturePreviousVersion(this.#db, handle.database, descriptor, this.#schema, config, this.#now());
403
+ if (outcome.ok) {
404
+ captured = true;
405
+ clearPreviousVersionRefusal(this.#db);
406
+ }
407
+ else {
408
+ writePreviousVersionRefusal(this.#db, outcome.reason, outcome.measurement);
409
+ }
410
+ }
411
+ finally {
412
+ handle.close();
413
+ // A refused capture must leave no container file at all.
414
+ if (!captured)
415
+ handle.removeFile();
416
+ }
417
+ }
418
+ /** D5 step 1: unconditional orphan sweep on the container file. */
419
+ #sweepPreviousVersionContainer() {
420
+ if (this.#dropPreviousVersionContainerFile()) {
421
+ clearPreviousVersionRefusal(this.#db);
422
+ }
423
+ }
424
+ /**
425
+ * Drop the container file when present and report whether it was. Shared by
426
+ * the orphan sweep and the discard path; the file is gone either way.
427
+ */
428
+ #dropPreviousVersionContainerFile() {
429
+ return (withPreviousVersionContainer(this.#db, (container) => {
430
+ dropPreviousVersionContainer(container);
431
+ return true;
432
+ }, true) === true);
433
+ }
309
434
  /** §2.1 reset after the server reports a different log continuity. */
310
435
  #runLogEpochReset(logEpoch) {
311
436
  const subscriptions = loadSubscriptions(this.#db);
312
437
  const pending = listOutbox(this.#db);
313
438
  this.#setUpgrading(true);
439
+ // RFC 0005 D5: the log-epoch reset is not a schema bump. It performs the
440
+ // orphan sweep but captures no new shadow.
441
+ this.#sweepPreviousVersionContainer();
314
442
  this.#applyBatch((batch) => {
315
443
  this.#db.transaction(() => {
316
444
  dropAndRecreateSyncedTables(this.#db, this.#schema);
@@ -474,6 +602,8 @@ export class SyncClient {
474
602
  query(sql, params) {
475
603
  this.#requireActive();
476
604
  assertReadOnlyQuery(sql);
605
+ // RFC 0005's container lives in a separate database file, so this
606
+ // connection cannot see it and there is no table-access guard to run.
477
607
  return stripSyncColumns(this.#db.query(sql, params));
478
608
  }
479
609
  /** Current durable local observer revision (SPEC §7.5). */
@@ -527,6 +657,187 @@ export class SyncClient {
527
657
  };
528
658
  });
529
659
  }
660
+ // -- previous-version context (RFC 0005) ----------------------------------
661
+ /**
662
+ * RFC 0005 D7: read captured rows from the previous local schema. `state` is
663
+ * always `'previousVersion'`; the container never makes `querySnapshot()`
664
+ * coverage complete.
665
+ */
666
+ previousVersionSnapshot(spec) {
667
+ this.#requireActive();
668
+ const currentVersion = this.#schema.version;
669
+ if (this.#previousVersion === undefined) {
670
+ return this.#previousVersionUnavailable(currentVersion, 'not-configured');
671
+ }
672
+ const record = this.#readPreviousVersionRecord();
673
+ const nowMs = this.#now();
674
+ if (this.#previousVersionLeaseInactive(nowMs)) {
675
+ this.#discardPreviousVersion();
676
+ return this.#previousVersionUnavailable(currentVersion, 'lease-inactive');
677
+ }
678
+ if (loadSubscriptions(this.#db).some((sub) => sub.status === 'revoked')) {
679
+ this.#discardPreviousVersion();
680
+ return this.#previousVersionUnavailable(currentVersion, 'scope-revoked');
681
+ }
682
+ // Coverage completion is a live predicate: the container is normally
683
+ // dropped by the sync path first, so a read can be the first observer.
684
+ if (this.#previousVersionCoverageComplete()) {
685
+ this.#discardPreviousVersion();
686
+ return this.#previousVersionUnavailable(currentVersion, 'coverage-complete');
687
+ }
688
+ if (record === undefined) {
689
+ const refusal = storedPreviousVersionRefusal(this.#db);
690
+ return this.#previousVersionUnavailable(currentVersion, refusal?.reason ?? 'no-previous-descriptor');
691
+ }
692
+ if (nowMs - record.createdAtMs >
693
+ previousVersionMaxAgeMs(this.#config.previousVersionContext)) {
694
+ this.#discardPreviousVersion();
695
+ return this.#previousVersionUnavailable(currentVersion, 'expired');
696
+ }
697
+ const table = record.tables.find((entry) => entry.name === spec.table);
698
+ if (table === undefined) {
699
+ throw new ClientSyncError('sync.invalid_request', `previousVersionSnapshot names unknown previous table ${JSON.stringify(spec.table)}`);
700
+ }
701
+ const read = withPreviousVersionContainer(this.#db, (container) => readPreviousVersionRows(container, table, spec.rowIds?.map(String) ?? [], resolvePreviousVersionLimit(spec.limit)));
702
+ if (read === undefined) {
703
+ return this.#previousVersionUnavailable(currentVersion, 'no-previous-descriptor');
704
+ }
705
+ return {
706
+ state: 'previousVersion',
707
+ available: true,
708
+ previousVersion: record.previousVersion,
709
+ currentVersion,
710
+ rows: read.rows,
711
+ truncated: read.truncated,
712
+ };
713
+ }
714
+ /** RFC 0005 D6: the pre-reset compatibility audit, advisory only. */
715
+ previousVersionAudit() {
716
+ this.#requireActive();
717
+ return storedPreviousVersionAudit(this.#db);
718
+ }
719
+ /**
720
+ * RFC 0005 A2/D9: the executable downgrade step. Drop the container file and
721
+ * both metadata records, and report whether anything was present.
722
+ *
723
+ * Authorized cleanup, so it is gated only on `start()` like
724
+ * `purgeLocalData`, NOT on `active`: RFC 0006 runs the discard consumer
725
+ * inside the quiesced security-preflight window, after the barrier and
726
+ * before reactivation. Reads and the audit stay active-gated.
727
+ */
728
+ previousVersionDiscard() {
729
+ this.#requireStarted();
730
+ const present = this.#dropPreviousVersion();
731
+ return { present, discarded: present };
732
+ }
733
+ #previousVersionUnavailable(currentVersion, reason) {
734
+ return {
735
+ state: 'previousVersion',
736
+ available: false,
737
+ currentVersion,
738
+ reason,
739
+ rows: [],
740
+ truncated: false,
741
+ };
742
+ }
743
+ #previousVersionExists() {
744
+ return this.#db.siblingExists?.(PREVIOUS_VERSION_CONTAINER_NAME) === true;
745
+ }
746
+ /** Read the container's own metadata; opens the file for that window only. */
747
+ #readPreviousVersionRecord() {
748
+ return withPreviousVersionContainer(this.#db, readPreviousVersionContainer);
749
+ }
750
+ /** RFC 0005 A2: presence for `statusSnapshot()`, without creating the file. */
751
+ #previousVersionStatus() {
752
+ const record = this.#readPreviousVersionRecord();
753
+ if (record === undefined)
754
+ return { present: this.#previousVersionExists() };
755
+ return { present: true, createdAtMs: record.createdAtMs };
756
+ }
757
+ #previousVersionLeaseInactive(nowMs) {
758
+ const lease = this.#leaseState;
759
+ if (lease?.errorCode !== undefined)
760
+ return true;
761
+ return lease?.expiresAtMs !== undefined && lease.expiresAtMs <= nowMs;
762
+ }
763
+ /**
764
+ * RFC 0005 D7: the §7.4.5 replacement-coverage predicate — every active
765
+ * subscription has `cursor >= 0` and no resume token. Window units own
766
+ * subscriptions, so their pendency is included. An empty active set is not
767
+ * completion.
768
+ */
769
+ #previousVersionCoverageComplete() {
770
+ const active = loadSubscriptions(this.#db).filter((sub) => sub.status === 'active');
771
+ if (active.length === 0)
772
+ return false;
773
+ return active.every((sub) => sub.cursor >= 0 && sub.bootstrapState === undefined);
774
+ }
775
+ /**
776
+ * Remove the container file and both metadata records. Returns whether
777
+ * anything was present. No status notification — callers that change client
778
+ * state wrap it with {@link #discardPreviousVersion}.
779
+ */
780
+ #dropPreviousVersion() {
781
+ const present = this.#dropPreviousVersionContainerFile() ||
782
+ storedPreviousVersionRefusal(this.#db) !== undefined ||
783
+ storedPreviousVersionAudit(this.#db) !== undefined;
784
+ this.#db.transaction(() => {
785
+ clearPreviousVersionRefusal(this.#db);
786
+ clearPreviousVersionAudit(this.#db);
787
+ });
788
+ return present;
789
+ }
790
+ #discardPreviousVersion() {
791
+ const present = this.#dropPreviousVersion();
792
+ if (present)
793
+ this.#applyBatch((batch) => batch.status());
794
+ return present;
795
+ }
796
+ /** RFC 0005 D7: lifetime triggers evaluated after a sync round. */
797
+ #previousVersionLifetimeCheck() {
798
+ if (this.#previousVersion === undefined)
799
+ return;
800
+ const nowMs = this.#now();
801
+ if (this.#previousVersionLeaseInactive(nowMs)) {
802
+ this.#discardPreviousVersion();
803
+ return;
804
+ }
805
+ const record = this.#readPreviousVersionRecord();
806
+ if (record === undefined)
807
+ return;
808
+ if (loadSubscriptions(this.#db).some((sub) => sub.status === 'revoked')) {
809
+ this.#discardPreviousVersion();
810
+ return;
811
+ }
812
+ if (nowMs - record.createdAtMs >
813
+ previousVersionMaxAgeMs(this.#config.previousVersionContext)) {
814
+ this.#discardPreviousVersion();
815
+ return;
816
+ }
817
+ if (this.#previousVersionCoverageComplete()) {
818
+ this.#discardPreviousVersion();
819
+ }
820
+ }
821
+ /**
822
+ * RFC 0005 D9 boot hygiene: an orphan/stale container is discarded before any
823
+ * read. The TTL runs only for an aware binary; it makes no claim about an
824
+ * unaware same-schema rollback, which executes none of this code.
825
+ */
826
+ #reconcilePreviousVersionAtBoot() {
827
+ if (!this.#previousVersionExists())
828
+ return;
829
+ const record = this.#readPreviousVersionRecord();
830
+ let discard = record === undefined || record.currentVersion !== this.#schema.version;
831
+ if (!discard &&
832
+ record !== undefined &&
833
+ this.#previousVersion !== undefined &&
834
+ this.#now() - record.createdAtMs >
835
+ previousVersionMaxAgeMs(this.#config.previousVersionContext)) {
836
+ discard = true;
837
+ }
838
+ if (discard)
839
+ this.#dropPreviousVersion();
840
+ }
530
841
  // -- live-query invalidation ----------------------------------------------
531
842
  /**
532
843
  * Subscribe to fine-grained invalidation. The callback fires ONCE per
@@ -774,6 +1085,7 @@ export class SyncClient {
774
1085
  leaseState: this.#leaseState,
775
1086
  schemaFloor: this.#schemaFloor,
776
1087
  syncNeeded: this.#needsPull,
1088
+ previousVersionContext: this.#previousVersionStatus(),
777
1089
  };
778
1090
  }
779
1091
  /**
@@ -1577,6 +1889,10 @@ export class SyncClient {
1577
1889
  }
1578
1890
  return { alreadyApplied: true, purgedRows: 0, droppedCommits: 0 };
1579
1891
  }
1892
+ // RFC 0005 D7: the container file cannot be a purge target (its name is not
1893
+ // in the running schema), so purge drops it as a fixed step, before any
1894
+ // mirror row is touched and unconditionally.
1895
+ this.#dropPreviousVersion();
1580
1896
  const rejectionCount = this.#rejections.length;
1581
1897
  try {
1582
1898
  return this.#applyBatch((batch) => {
@@ -1788,6 +2104,9 @@ export class SyncClient {
1788
2104
  setMeta(this.#db, 'leaseState', JSON.stringify(next));
1789
2105
  batch.status();
1790
2106
  });
2107
+ // RFC 0005 D7: a lease stop state ends the previous-version context.
2108
+ if (next.errorCode !== undefined)
2109
+ this.#discardPreviousVersion();
1791
2110
  }
1792
2111
  /** The request-level lease error codes (§7.3.4): stop-and-surface. */
1793
2112
  #isLeaseErrorCode(code) {
@@ -2701,6 +3020,9 @@ export class SyncClient {
2701
3020
  this.#localResetEpoch === resetEpoch) {
2702
3021
  this.#setUpgrading(false);
2703
3022
  }
3023
+ // RFC 0005 D7: coverage completion, lease end, scope revocation and TTL are
3024
+ // evaluated once per round, after the apply.
3025
+ this.#previousVersionLifetimeCheck();
2704
3026
  return { ...summary, bootstrapping };
2705
3027
  }
2706
3028
  #handlePushResult(frame, commitsById, summary, batch, rejectionDetails) {
@@ -7,6 +7,23 @@
7
7
  */
8
8
  export type SqlValue = string | number | bigint | boolean | Uint8Array | null;
9
9
  export type SqlRow = Record<string, SqlValue>;
10
+ /**
11
+ * RFC 0005: a second database file in this database's own storage scope (the
12
+ * same on-disk directory, or the same OPFS SAH pool directory). Used to keep
13
+ * the previous-version container off the connection `query()` and unaware code
14
+ * use. The read guarantee is narrow: an older client's ordinary query
15
+ * connection does not attach this file, so no SQL it runs can reach the
16
+ * container. It is NOT protection against arbitrary same-origin access and NOT
17
+ * protection against native filesystem access.
18
+ */
19
+ export interface SiblingDatabase {
20
+ /** The open connection; the caller closes it before removal. */
21
+ readonly database: ClientDatabase;
22
+ /** Close the handle. A live handle blocks physical removal. */
23
+ close(): void;
24
+ /** Best-effort physical removal of the backing file. Close the handle first. */
25
+ removeFile(): void;
26
+ }
10
27
  export interface ClientDatabase {
11
28
  /** Execute a single statement (no result rows). */
12
29
  exec(sql: string, params?: readonly SqlValue[]): void;
@@ -26,6 +43,16 @@ export interface ClientDatabase {
26
43
  * transaction (SQLite cannot ATTACH inside one); `fn` may open its own.
27
44
  */
28
45
  withSqliteImage?<T>(bytes: Uint8Array, alias: string, fn: () => T | Promise<T>): Promise<T>;
46
+ /**
47
+ * RFC 0005: open (creating on demand) the named sibling database file.
48
+ * `undefined` means the host provides no sibling capability, which makes the
49
+ * previous-version feature unavailable there. The NAME is code-derived and is
50
+ * not persisted in the replica database (data minimisation; not a security
51
+ * control).
52
+ */
53
+ openSibling?(name: string): SiblingDatabase | undefined;
54
+ /** RFC 0005: non-creating existence probe for {@link openSibling}. */
55
+ siblingExists?(name: string): boolean;
29
56
  close(): void;
30
57
  }
31
58
  /** Attach-schema aliases are code-chosen, never user input — enforce it. */
package/dist/index.d.ts CHANGED
@@ -28,6 +28,7 @@ export * from './multi-tab.js';
28
28
  export * from './naming.js';
29
29
  export * from './outbox.js';
30
30
  export * from './outcomes.js';
31
+ export { decodePreviousVersionAuditResult, decodePreviousVersionDiscardResult, decodePreviousVersionSnapshot, type PreviousVersionAudit, type PreviousVersionReadSpec, type PreviousVersionReason, type PreviousVersionSnapshot, } from './previous-version.js';
31
32
  export * from './query-guard.js';
32
33
  export * from './reactive-store.js';
33
34
  export * from './remote.js';
package/dist/index.js CHANGED
@@ -28,6 +28,11 @@ export * from './multi-tab.js';
28
28
  export * from './naming.js';
29
29
  export * from './outbox.js';
30
30
  export * from './outcomes.js';
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 { decodePreviousVersionAuditResult, decodePreviousVersionDiscardResult, decodePreviousVersionSnapshot, } from './previous-version.js';
31
36
  export * from './query-guard.js';
32
37
  export * from './reactive-store.js';
33
38
  export * from './remote.js';
@@ -26,6 +26,15 @@ export interface SyncStatusSnapshot {
26
26
  readonly leaseState: LeaseState | undefined;
27
27
  readonly schemaFloor: SchemaFloor | undefined;
28
28
  readonly syncNeeded: boolean;
29
+ /**
30
+ * RFC 0005 A2: whether a previous-version cache is present, so a host's
31
+ * update/rollback path can require its discard. `createdAtMs` is the capture
32
+ * time when a readable cache exists.
33
+ */
34
+ readonly previousVersionContext: {
35
+ readonly present: boolean;
36
+ readonly createdAtMs?: number;
37
+ };
29
38
  }
30
39
  export interface ClientChangeBatch {
31
40
  readonly revision: LocalRevision;
@@ -3,13 +3,16 @@
3
3
  * adapter: synchronous queries, nested transactions, and SQLite image attach.
4
4
  */
5
5
  import { DatabaseSync } from 'node:sqlite';
6
- import { type ClientDatabase, type SqlRow, type SqlValue } from './database.js';
6
+ import { type ClientDatabase, type SiblingDatabase, type SqlRow, type SqlValue } from './database.js';
7
7
  export declare class NodeClientDatabase implements ClientDatabase {
8
8
  #private;
9
9
  readonly db: DatabaseSync;
10
10
  constructor(path?: string);
11
11
  exec(sql: string, params?: readonly SqlValue[]): void;
12
12
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
13
+ /** RFC 0005: a sibling file beside the replica, e.g. `<path>.prev-context`. */
14
+ openSibling(name: string): SiblingDatabase;
15
+ siblingExists(name: string): boolean;
13
16
  transaction<T>(fn: () => T): T;
14
17
  /** §5.3 image import through a private file attached for one callback. */
15
18
  withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T | Promise<T>): Promise<T>;