@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/query-guard.d.ts
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* The guard only fronts the PUBLIC `client.query()` — engine-internal reads
|
|
18
18
|
* call the `ClientDatabase` directly and are trusted, so they are never
|
|
19
19
|
* routed through here.
|
|
20
|
+
*
|
|
21
|
+
* RFC 0005 previously added a third rule here, denying reads of its container
|
|
22
|
+
* table by root page. That table now lives in its own database file and is not
|
|
23
|
+
* on the connection `query()` uses, so there is nothing left to guard and the
|
|
24
|
+
* rule was removed with its EXPLAIN plumbing.
|
|
20
25
|
*/
|
|
21
26
|
/** Raised when `client.query()` is handed SQL it will not run. */
|
|
22
27
|
export declare class RawSqlError extends Error {
|
package/dist/query-guard.js
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* The guard only fronts the PUBLIC `client.query()` — engine-internal reads
|
|
18
18
|
* call the `ClientDatabase` directly and are trusted, so they are never
|
|
19
19
|
* routed through here.
|
|
20
|
+
*
|
|
21
|
+
* RFC 0005 previously added a third rule here, denying reads of its container
|
|
22
|
+
* table by root page. That table now lives in its own database file and is not
|
|
23
|
+
* on the connection `query()` uses, so there is nothing left to guard and the
|
|
24
|
+
* rule was removed with its EXPLAIN plumbing.
|
|
20
25
|
*/
|
|
21
26
|
/** Verbs a read-only query may begin with (lowercased). */
|
|
22
27
|
const READ_ONLY_VERBS = new Set([
|
package/dist/schema.d.ts
CHANGED
|
@@ -122,8 +122,10 @@ export declare function ensureLocalSchema(db: ClientDatabase, schema: CompiledCl
|
|
|
122
122
|
* generated schema created — discovered from `sqlite_master`, since a
|
|
123
123
|
* bump may add/remove tables) and recreate the synced tables from the
|
|
124
124
|
* NEW schema. Bookkeeping tables (`_syncular_*`: outbox, meta,
|
|
125
|
-
* subscriptions, blob cache) are preserved.
|
|
126
|
-
*
|
|
125
|
+
* subscriptions, blob cache) are preserved. RFC 0005's container is not
|
|
126
|
+
* reachable from here: it lives in its own database file, so this reset cannot
|
|
127
|
+
* see or destroy it. Caller owns the surrounding transaction and the
|
|
128
|
+
* subscription-state reset (state.ts).
|
|
127
129
|
*/
|
|
128
130
|
export declare function dropAndRecreateSyncedTables(db: ClientDatabase, schema: CompiledClientSchema): void;
|
|
129
131
|
/** RowValue → SQL bind value for the local mirror tables. */
|
package/dist/schema.js
CHANGED
|
@@ -438,8 +438,10 @@ const RESERVED_TABLE_PREFIX = '_syncular_';
|
|
|
438
438
|
* generated schema created — discovered from `sqlite_master`, since a
|
|
439
439
|
* bump may add/remove tables) and recreate the synced tables from the
|
|
440
440
|
* NEW schema. Bookkeeping tables (`_syncular_*`: outbox, meta,
|
|
441
|
-
* subscriptions, blob cache) are preserved.
|
|
442
|
-
*
|
|
441
|
+
* subscriptions, blob cache) are preserved. RFC 0005's container is not
|
|
442
|
+
* reachable from here: it lives in its own database file, so this reset cannot
|
|
443
|
+
* see or destroy it. Caller owns the surrounding transaction and the
|
|
444
|
+
* subscription-state reset (state.ts).
|
|
443
445
|
*/
|
|
444
446
|
export function dropAndRecreateSyncedTables(db, schema) {
|
|
445
447
|
// Drop virtual tables first. SQLite then removes their shadow tables as one
|
package/dist/wasm-database.js
CHANGED
|
@@ -35,10 +35,13 @@ function coerceParams(params) {
|
|
|
35
35
|
class WasmClientDatabase {
|
|
36
36
|
#db;
|
|
37
37
|
#sqlite3;
|
|
38
|
+
/** RFC 0005: present only for a persistent OPFS database. */
|
|
39
|
+
#sah;
|
|
38
40
|
#tx = { depth: 0 };
|
|
39
|
-
constructor(db, sqlite3) {
|
|
41
|
+
constructor(db, sqlite3, sah) {
|
|
40
42
|
this.#db = db;
|
|
41
43
|
this.#sqlite3 = sqlite3;
|
|
44
|
+
this.#sah = sah;
|
|
42
45
|
}
|
|
43
46
|
exec(sql, params = []) {
|
|
44
47
|
this.#db.exec({
|
|
@@ -61,6 +64,40 @@ class WasmClientDatabase {
|
|
|
61
64
|
transaction(fn) {
|
|
62
65
|
return runTransaction(this.#tx, (sql) => this.exec(sql), fn);
|
|
63
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* RFC 0005: a second named file on the same SAH pool directory. The pool
|
|
69
|
+
* forbids two live instances per directory and charges two slots per
|
|
70
|
+
* writable database (`.db` plus its transient `-journal`), which is why the
|
|
71
|
+
* pool's initial capacity must cover three. Removal is the pool's `unlink`,
|
|
72
|
+
* never OPFS `removeEntry` (the pool stores an opaque entry).
|
|
73
|
+
*/
|
|
74
|
+
openSibling(name) {
|
|
75
|
+
const sah = this.#sah;
|
|
76
|
+
if (sah === undefined)
|
|
77
|
+
return undefined;
|
|
78
|
+
const fileName = `/${name}.db`;
|
|
79
|
+
const db = new sah.util.OpfsSAHPoolDb(fileName);
|
|
80
|
+
try {
|
|
81
|
+
configureSahCrashRecovery(db, this.#sqlite3);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
db.close();
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
database: new WasmClientDatabase(db, this.#sqlite3),
|
|
89
|
+
close: () => db.close(),
|
|
90
|
+
removeFile: () => {
|
|
91
|
+
sah.util.unlink(fileName);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
siblingExists(name) {
|
|
96
|
+
const sah = this.#sah;
|
|
97
|
+
if (sah === undefined)
|
|
98
|
+
return false;
|
|
99
|
+
return sah.util.getFileNames().includes(`/${name}.db`);
|
|
100
|
+
}
|
|
64
101
|
/**
|
|
65
102
|
* §5.3 image import at near-file-copy speed: attach an empty in-memory
|
|
66
103
|
* schema, then `sqlite3_deserialize` the image bytes into it (the
|
|
@@ -138,6 +175,52 @@ function opfsSahPoolError(error, directory) {
|
|
|
138
175
|
'Close the other instance or retry after a short delay; do not delete ' +
|
|
139
176
|
`or rename the directory. Underlying error: ${detail}`, true);
|
|
140
177
|
}
|
|
178
|
+
/** sqlite-wasm's default SAH pool capacity. */
|
|
179
|
+
const SAH_POOL_DEFAULT_CAPACITY = 6;
|
|
180
|
+
/**
|
|
181
|
+
* RFC 0005: each writable database consumes two slots (`.db` plus its
|
|
182
|
+
* transient `-journal`), so a replica plus one container file needs three.
|
|
183
|
+
* Exhaustion is a loud `SQLITE_CANTOPEN` ("SAH pool is full") that names the
|
|
184
|
+
* missing journal, not corruption — raise the floor rather than discover it.
|
|
185
|
+
*/
|
|
186
|
+
const SAH_POOL_MIN_CAPACITY = 3;
|
|
187
|
+
/**
|
|
188
|
+
* Do this before the first SQL statement, when SQLite checks for a hot
|
|
189
|
+
* journal. The SAH-pool callback in sqlite-wasm 3.53 reports a reserved lock
|
|
190
|
+
* unconditionally, which suppresses crash rollback. The pool already excludes
|
|
191
|
+
* other owners; report no competing writer, as the other OPFS VFS does. Keep
|
|
192
|
+
* DELETE/FULL and the existing database/journal files. Shared by the replica
|
|
193
|
+
* and every RFC 0005 sibling file.
|
|
194
|
+
*/
|
|
195
|
+
function configureSahCrashRecovery(db, sqlite3) {
|
|
196
|
+
const { capi, wasm } = sqlite3;
|
|
197
|
+
const stack = wasm.pstack.pointer;
|
|
198
|
+
try {
|
|
199
|
+
const output = wasm.pstack.allocPtr();
|
|
200
|
+
if (db.pointer === undefined ||
|
|
201
|
+
capi.sqlite3_file_control(db.pointer, 'main', capi.SQLITE_FCNTL_FILE_POINTER, output) !== 0 ||
|
|
202
|
+
!wasm.peekPtr(output)) {
|
|
203
|
+
throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Could not configure OPFS crash recovery');
|
|
204
|
+
}
|
|
205
|
+
const file = new capi.sqlite3_file(wasm.peekPtr(output));
|
|
206
|
+
const methodsPointer = file.$pMethods;
|
|
207
|
+
file.dispose(); // Borrowed struct view; SQLite still owns the file.
|
|
208
|
+
if (!methodsPointer) {
|
|
209
|
+
throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Could not configure OPFS crash recovery');
|
|
210
|
+
}
|
|
211
|
+
if (!sahIoMethods.has(methodsPointer)) {
|
|
212
|
+
const methods = new capi.sqlite3_io_methods(methodsPointer);
|
|
213
|
+
methods.installMethod('xCheckReservedLock', (_file, reserved) => {
|
|
214
|
+
wasm.poke32(reserved, 0);
|
|
215
|
+
return 0;
|
|
216
|
+
});
|
|
217
|
+
sahIoMethods.set(methodsPointer, methods);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
wasm.pstack.restore(stack);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
141
224
|
/**
|
|
142
225
|
* THE reload-persistent browser mode: a named database on OPFS via the
|
|
143
226
|
* `opfs-sahpool` VFS. Worker-context only — not because SAHPool requires
|
|
@@ -179,9 +262,7 @@ export async function openPersistentWasmDatabase(name, options) {
|
|
|
179
262
|
// Syncular removes its own rejected entry below, so allow a later
|
|
180
263
|
// open in the same worker to make a real attempt as well.
|
|
181
264
|
forceReinitIfPreviouslyFailed: true,
|
|
182
|
-
|
|
183
|
-
? { initialCapacity: options.initialCapacity }
|
|
184
|
-
: {}),
|
|
265
|
+
initialCapacity: Math.max(options?.initialCapacity ?? SAH_POOL_DEFAULT_CAPACITY, SAH_POOL_MIN_CAPACITY),
|
|
185
266
|
})
|
|
186
267
|
.catch((error) => {
|
|
187
268
|
sahPools.delete(directory);
|
|
@@ -191,41 +272,12 @@ export async function openPersistentWasmDatabase(name, options) {
|
|
|
191
272
|
}
|
|
192
273
|
const util = await pool;
|
|
193
274
|
const db = new util.OpfsSAHPoolDb(`/${name}.db`);
|
|
194
|
-
const { capi, wasm } = sqlite3;
|
|
195
|
-
const stack = wasm.pstack.pointer;
|
|
196
275
|
try {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
// lock unconditionally, which suppresses crash rollback. The pool already
|
|
200
|
-
// excludes other owners; report no competing writer, as the other OPFS
|
|
201
|
-
// VFS does. Keep DELETE/FULL and the existing database/journal files.
|
|
202
|
-
const output = wasm.pstack.allocPtr();
|
|
203
|
-
if (db.pointer === undefined ||
|
|
204
|
-
capi.sqlite3_file_control(db.pointer, 'main', capi.SQLITE_FCNTL_FILE_POINTER, output) !== 0 ||
|
|
205
|
-
!wasm.peekPtr(output)) {
|
|
206
|
-
throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Could not configure OPFS crash recovery');
|
|
207
|
-
}
|
|
208
|
-
const file = new capi.sqlite3_file(wasm.peekPtr(output));
|
|
209
|
-
const methodsPointer = file.$pMethods;
|
|
210
|
-
file.dispose(); // Borrowed struct view; SQLite still owns the file.
|
|
211
|
-
if (!methodsPointer) {
|
|
212
|
-
throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Could not configure OPFS crash recovery');
|
|
213
|
-
}
|
|
214
|
-
if (!sahIoMethods.has(methodsPointer)) {
|
|
215
|
-
const methods = new capi.sqlite3_io_methods(methodsPointer);
|
|
216
|
-
methods.installMethod('xCheckReservedLock', (_file, reserved) => {
|
|
217
|
-
wasm.poke32(reserved, 0);
|
|
218
|
-
return 0;
|
|
219
|
-
});
|
|
220
|
-
sahIoMethods.set(methodsPointer, methods);
|
|
221
|
-
}
|
|
222
|
-
return new WasmClientDatabase(db, sqlite3);
|
|
276
|
+
configureSahCrashRecovery(db, sqlite3);
|
|
277
|
+
return new WasmClientDatabase(db, sqlite3, { util });
|
|
223
278
|
}
|
|
224
279
|
catch (error) {
|
|
225
280
|
db.close();
|
|
226
281
|
throw error;
|
|
227
282
|
}
|
|
228
|
-
finally {
|
|
229
|
-
wasm.pstack.restore(stack);
|
|
230
|
-
}
|
|
231
283
|
}
|
package/dist/worker-entry.js
CHANGED
|
@@ -251,6 +251,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
251
251
|
...(config.securityPreflight !== undefined
|
|
252
252
|
? { securityPreflight: config.securityPreflight }
|
|
253
253
|
: {}),
|
|
254
|
+
...(config.previousVersionContext !== undefined
|
|
255
|
+
? { previousVersionContext: config.previousVersionContext }
|
|
256
|
+
: {}),
|
|
254
257
|
onSyncNeeded: (reason) => {
|
|
255
258
|
post({ t: 'event', event: { kind: 'sync-needed', reason } });
|
|
256
259
|
consumeSyncIntent({ kind: 'interactive' });
|
|
@@ -342,6 +345,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
342
345
|
},
|
|
343
346
|
purgeLocalData: (input) => requireClient().purgeLocalData(input),
|
|
344
347
|
rebootstrapLocalData: (input) => requireClient().rebootstrapLocalData(input),
|
|
348
|
+
previousVersionSnapshot: (spec) => requireClient().previousVersionSnapshot(spec),
|
|
349
|
+
previousVersionAudit: () => requireClient().previousVersionAudit(),
|
|
350
|
+
previousVersionDiscard: () => requireClient().previousVersionDiscard(),
|
|
345
351
|
sync: () => {
|
|
346
352
|
const running = requireClient();
|
|
347
353
|
return serializedSync(() => running.sync());
|
|
@@ -407,13 +413,18 @@ export function startSyncWorker(overrides = {}) {
|
|
|
407
413
|
database = undefined;
|
|
408
414
|
},
|
|
409
415
|
};
|
|
410
|
-
// Local purge/rebootstrap rewrite the same durable state an
|
|
411
|
-
// sync round captured at send time
|
|
412
|
-
//
|
|
413
|
-
//
|
|
416
|
+
// Local purge/rebootstrap/discard rewrite the same durable state an
|
|
417
|
+
// in-flight sync round captured at send time, and the RFC 0005 reads observe
|
|
418
|
+
// (and can themselves trigger the discard of) that same previous-version
|
|
419
|
+
// container. Running all of them on the sync chain orders them against RPC-
|
|
420
|
+
// and auto-driven rounds (the client core's reset fence covers hosts that
|
|
421
|
+
// call the core directly).
|
|
414
422
|
const syncChainMethods = new Set([
|
|
415
423
|
'purgeLocalData',
|
|
416
424
|
'rebootstrapLocalData',
|
|
425
|
+
'previousVersionSnapshot',
|
|
426
|
+
'previousVersionAudit',
|
|
427
|
+
'previousVersionDiscard',
|
|
417
428
|
]);
|
|
418
429
|
async function dispatch(message) {
|
|
419
430
|
const method = api[message.method];
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type { PromiseMethods } from './client.js';
|
|
|
25
25
|
*/
|
|
26
26
|
import type { WakeReason } from '@syncular/core';
|
|
27
27
|
import type { BlobRef, CachedBlob } from './blob.js';
|
|
28
|
-
import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
28
|
+
import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
29
29
|
import type { SqlRow, SqlValue } from './database.js';
|
|
30
30
|
import { ClientDiagnosticsEmitter, type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot } from './diagnostics.js';
|
|
31
31
|
import type { EncryptionKeyringConfig } from './encryption.js';
|
|
@@ -37,6 +37,7 @@ import { type LocalDataRebootstrapInput, type LocalDataRebootstrapResult } from
|
|
|
37
37
|
import { type CrossTabChannel, FollowerLink, LeaderBridge, type LeadershipState } from './multi-tab.js';
|
|
38
38
|
import type { OutboxCommit } from './outbox.js';
|
|
39
39
|
import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
|
|
40
|
+
import { type PreviousVersionAudit, type PreviousVersionReadSpec, type PreviousVersionSnapshot } from './previous-version.js';
|
|
40
41
|
import type { ClientSchema } from './schema.js';
|
|
41
42
|
import type { SubscriptionRecord } from './state.js';
|
|
42
43
|
import type { WindowBase } from './window.js';
|
|
@@ -80,6 +81,11 @@ export interface SyncClientHandleConfig {
|
|
|
80
81
|
readonly securityPreflight?: boolean;
|
|
81
82
|
readonly clientId?: string;
|
|
82
83
|
readonly limits?: SyncClientLimits;
|
|
84
|
+
/**
|
|
85
|
+
* RFC 0005 previous-version context, forwarded into the worker's
|
|
86
|
+
* `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
|
|
87
|
+
*/
|
|
88
|
+
readonly previousVersionContext?: SyncClientConfig['previousVersionContext'];
|
|
83
89
|
/** Worker-side host loop (§8.4); default true. */
|
|
84
90
|
readonly autoSync?: boolean;
|
|
85
91
|
/** Default: Web Locks when available, else single-owner. */
|
|
@@ -206,6 +212,12 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
206
212
|
}): Promise<string>;
|
|
207
213
|
purgeLocalData(input: LocalDataPurgeInput): Promise<LocalDataPurgeResult>;
|
|
208
214
|
rebootstrapLocalData(input: LocalDataRebootstrapInput): Promise<LocalDataRebootstrapResult>;
|
|
215
|
+
previousVersionSnapshot(spec: PreviousVersionReadSpec): Promise<PreviousVersionSnapshot>;
|
|
216
|
+
previousVersionAudit(): Promise<PreviousVersionAudit | undefined>;
|
|
217
|
+
previousVersionDiscard(): Promise<{
|
|
218
|
+
present: boolean;
|
|
219
|
+
discarded: boolean;
|
|
220
|
+
}>;
|
|
209
221
|
sync(): Promise<SyncSummary>;
|
|
210
222
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
211
223
|
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
|
package/dist/worker-host.js
CHANGED
|
@@ -6,6 +6,7 @@ import { ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './i
|
|
|
6
6
|
import { singleOwnerLock, webLocksLeaderLock, } from './leader-lock.js';
|
|
7
7
|
import { decodeLocalDataRebootstrapResult, } from './local-rebootstrap.js';
|
|
8
8
|
import { broadcastChannelFactory, FollowerLink, LeaderBridge, multiTabChannelName, newTabId, } from './multi-tab.js';
|
|
9
|
+
import { decodePreviousVersionAuditResult, decodePreviousVersionDiscardResult, decodePreviousVersionSnapshot, } from './previous-version.js';
|
|
9
10
|
import { NOT_LEADER_CODE, WORKER_FAILED_CODE, WORKER_RESTART_REQUIRED_CODE, } from './worker-protocol.js';
|
|
10
11
|
const WORKER_BUNDLE_LOAD_FAILURE = /(?:failed to fetch dynamically imported module|error loading dynamically imported module|importing a module script failed|failed to load module script)/iu;
|
|
11
12
|
/** Classify startup without echoing a chunk URL or bundler text to UI/logs. */
|
|
@@ -280,6 +281,15 @@ export class SyncClientHandle {
|
|
|
280
281
|
async rebootstrapLocalData(input) {
|
|
281
282
|
return decodeLocalDataRebootstrapResult(await this.#call('rebootstrapLocalData', [input]));
|
|
282
283
|
}
|
|
284
|
+
async previousVersionSnapshot(spec) {
|
|
285
|
+
return decodePreviousVersionSnapshot(await this.#call('previousVersionSnapshot', [spec]));
|
|
286
|
+
}
|
|
287
|
+
async previousVersionAudit() {
|
|
288
|
+
return decodePreviousVersionAuditResult(await this.#call('previousVersionAudit', []));
|
|
289
|
+
}
|
|
290
|
+
async previousVersionDiscard() {
|
|
291
|
+
return decodePreviousVersionDiscardResult(await this.#call('previousVersionDiscard', []));
|
|
292
|
+
}
|
|
283
293
|
sync() {
|
|
284
294
|
return this.#call('sync', []);
|
|
285
295
|
}
|
|
@@ -490,6 +500,9 @@ function buildInitConfig(config) {
|
|
|
490
500
|
: {}),
|
|
491
501
|
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
492
502
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
503
|
+
...(config.previousVersionContext !== undefined
|
|
504
|
+
? { previousVersionContext: config.previousVersionContext }
|
|
505
|
+
: {}),
|
|
493
506
|
...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
|
|
494
507
|
};
|
|
495
508
|
}
|
|
@@ -19,7 +19,7 @@ import type { SyncProgress } from './progress.js';
|
|
|
19
19
|
*/
|
|
20
20
|
import type { WakeReason } from '@syncular/core';
|
|
21
21
|
import type { BlobRef, CachedBlob } from './blob.js';
|
|
22
|
-
import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
22
|
+
import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
23
23
|
import type { SqlRow, SqlValue } from './database.js';
|
|
24
24
|
import type { ClientDiagnosticsSnapshot } from './diagnostics.js';
|
|
25
25
|
import type { EncryptionKeyringConfig } from './encryption.js';
|
|
@@ -27,6 +27,7 @@ import type { ClientChangeBatch, LocalRevision } from './invalidation.js';
|
|
|
27
27
|
import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
|
|
28
28
|
import type { LocalDataRebootstrapInput, LocalDataRebootstrapResult } from './local-rebootstrap.js';
|
|
29
29
|
import type { OutboxCommit } from './outbox.js';
|
|
30
|
+
import type { PreviousVersionAudit, PreviousVersionReadSpec, PreviousVersionSnapshot } from './previous-version.js';
|
|
30
31
|
import type { ClientSchema } from './schema.js';
|
|
31
32
|
import type { SubscriptionRecord } from './state.js';
|
|
32
33
|
import type { WindowBase } from './window.js';
|
|
@@ -73,6 +74,11 @@ export interface WorkerInitConfig {
|
|
|
73
74
|
readonly securityPreflight?: boolean;
|
|
74
75
|
readonly clientId?: string;
|
|
75
76
|
readonly limits?: SyncClientLimits;
|
|
77
|
+
/**
|
|
78
|
+
* RFC 0005 previous-version context, forwarded verbatim into the worker's
|
|
79
|
+
* `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
|
|
80
|
+
*/
|
|
81
|
+
readonly previousVersionContext?: SyncClientConfig['previousVersionContext'];
|
|
76
82
|
/**
|
|
77
83
|
* Worker-side host loop (§8.4): coalesce interactive work immediately and
|
|
78
84
|
* honor explicit background retry deadlines
|
|
@@ -107,6 +113,15 @@ export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'>
|
|
|
107
113
|
purgeLocalData(input: LocalDataPurgeInput): LocalDataPurgeResult;
|
|
108
114
|
/** Application-authorized, outbox-preserving projection recovery. */
|
|
109
115
|
rebootstrapLocalData(input: LocalDataRebootstrapInput): LocalDataRebootstrapResult;
|
|
116
|
+
/** RFC 0005 D7: read the captured previous-schema rows for one table. */
|
|
117
|
+
previousVersionSnapshot(spec: PreviousVersionReadSpec): PreviousVersionSnapshot;
|
|
118
|
+
/** RFC 0005 D6: the advisory pre-reset compatibility audit, if recorded. */
|
|
119
|
+
previousVersionAudit(): PreviousVersionAudit | undefined;
|
|
120
|
+
/** RFC 0005 D9: the executable rollback step — drop the container. */
|
|
121
|
+
previousVersionDiscard(): {
|
|
122
|
+
present: boolean;
|
|
123
|
+
discarded: boolean;
|
|
124
|
+
};
|
|
110
125
|
sync(): Promise<SyncSummary>;
|
|
111
126
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
112
127
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.
|
|
92
|
+
"@syncular/core": "0.23.0"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.23.0",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/bun-database.ts
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
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 {
|
|
11
11
|
assertImageAlias,
|
|
12
12
|
type ClientDatabase,
|
|
13
13
|
runTransaction,
|
|
14
|
+
type SiblingDatabase,
|
|
14
15
|
type SqlRow,
|
|
15
16
|
type SqlValue,
|
|
16
17
|
} from './database';
|
|
@@ -32,9 +33,11 @@ function coerceParams(params: readonly SqlValue[]): BunParam[] {
|
|
|
32
33
|
|
|
33
34
|
export class BunClientDatabase implements ClientDatabase {
|
|
34
35
|
readonly db: Database;
|
|
36
|
+
readonly #path: string;
|
|
35
37
|
#tx = { depth: 0 };
|
|
36
38
|
|
|
37
39
|
constructor(path = ':memory:') {
|
|
40
|
+
this.#path = path;
|
|
38
41
|
this.db = new Database(path);
|
|
39
42
|
// Match native Rust persistence: append durable commits to the WAL
|
|
40
43
|
// instead of creating and syncing a rollback journal per transaction.
|
|
@@ -61,6 +64,28 @@ export class BunClientDatabase implements ClientDatabase {
|
|
|
61
64
|
return this.db.query(sql).all(...coerceParams(params)) as SqlRow[];
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
/** RFC 0005: a sibling file beside the replica, e.g. `<path>.prev-context`. */
|
|
68
|
+
openSibling(name: string): SiblingDatabase {
|
|
69
|
+
const siblingPath =
|
|
70
|
+
this.#path === ':memory:' ? ':memory:' : `${this.#path}.${name}`;
|
|
71
|
+
const database = new BunClientDatabase(siblingPath);
|
|
72
|
+
return {
|
|
73
|
+
database,
|
|
74
|
+
close: () => database.close(),
|
|
75
|
+
removeFile: () => {
|
|
76
|
+
if (siblingPath === ':memory:') return;
|
|
77
|
+
rmSync(siblingPath, { force: true });
|
|
78
|
+
rmSync(`${siblingPath}-wal`, { force: true });
|
|
79
|
+
rmSync(`${siblingPath}-shm`, { force: true });
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
siblingExists(name: string): boolean {
|
|
85
|
+
if (this.#path === ':memory:') return false;
|
|
86
|
+
return existsSync(`${this.#path}.${name}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
64
89
|
transaction<T>(fn: () => T): T {
|
|
65
90
|
return runTransaction(this.#tx, (sql) => this.db.run(sql), fn);
|
|
66
91
|
}
|