@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/dist/bun-database.d.ts +4 -1
- package/dist/bun-database.js +24 -1
- package/dist/client.d.ts +47 -0
- package/dist/client.js +326 -4
- package/dist/database.d.ts +27 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/invalidation.d.ts +9 -0
- package/dist/node-database.d.ts +4 -1
- package/dist/node-database.js +24 -1
- package/dist/previous-version.d.ts +207 -0
- package/dist/previous-version.js +732 -0
- package/dist/query-guard.d.ts +5 -0
- package/dist/query-guard.js +5 -0
- package/dist/schema.d.ts +4 -2
- package/dist/schema.js +4 -2
- package/dist/wasm-database.js +87 -35
- package/dist/worker-entry.js +15 -4
- package/dist/worker-host.d.ts +13 -1
- package/dist/worker-host.js +13 -0
- package/dist/worker-protocol.d.ts +16 -1
- package/package.json +3 -3
- package/src/bun-database.ts +26 -1
- package/src/client.ts +481 -4
- package/src/database.ts +28 -0
- package/src/index.ts +13 -0
- package/src/invalidation.ts +9 -0
- package/src/node-database.ts +26 -1
- package/src/previous-version.ts +1070 -0
- package/src/query-guard.ts +5 -0
- package/src/schema.ts +4 -2
- package/src/wasm-database.ts +114 -47
- package/src/worker-entry.ts +16 -4
- package/src/worker-host.ts +40 -0
- package/src/worker-protocol.ts +19 -0
package/dist/bun-database.d.ts
CHANGED
|
@@ -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
|
package/dist/bun-database.js
CHANGED
|
@@ -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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/database.d.ts
CHANGED
|
@@ -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';
|
package/dist/invalidation.d.ts
CHANGED
|
@@ -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;
|
package/dist/node-database.d.ts
CHANGED
|
@@ -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>;
|