@syncular/client 0.21.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +347 -7
- 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/reactive-store.js +34 -9
- package/dist/schema.d.ts +4 -2
- package/dist/schema.js +15 -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 +521 -10
- 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/reactive-store.ts +36 -16
- package/src/schema.ts +19 -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/src/query-guard.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
|
|
|
22
27
|
/** Verbs a read-only query may begin with (lowercased). */
|
package/src/reactive-store.ts
CHANGED
|
@@ -196,15 +196,21 @@ function batchMatches<Row>(
|
|
|
196
196
|
spec: ReactiveQuerySpec<Row>,
|
|
197
197
|
): boolean {
|
|
198
198
|
for (const change of batch.tables) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
199
|
+
// A composed spec carries one dependency per query branch. Every
|
|
200
|
+
// dependency on the changed table is consulted: a scoped dependency
|
|
201
|
+
// that does not cover the change must not mask a sibling dependency
|
|
202
|
+
// on the same table that does.
|
|
203
|
+
for (const dependency of spec.dependencies) {
|
|
204
|
+
if (dependency.table !== change.table) continue;
|
|
205
|
+
if (
|
|
206
|
+
dependency.scopeKeys === undefined ||
|
|
207
|
+
change.scopeKeys === undefined
|
|
208
|
+
) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
for (const key of dependency.scopeKeys) {
|
|
212
|
+
if (change.scopeKeys.has(key)) return true;
|
|
213
|
+
}
|
|
208
214
|
}
|
|
209
215
|
}
|
|
210
216
|
for (const change of batch.windows) {
|
|
@@ -389,14 +395,28 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
389
395
|
this.#onAvailabilityChange();
|
|
390
396
|
if (this.spec.claimCoverage !== false) {
|
|
391
397
|
const claims: Promise<void>[] = [];
|
|
398
|
+
// A composed spec carries one coverage entry per query branch, and
|
|
399
|
+
// several branches can target the same base. One claim per distinct
|
|
400
|
+
// base covers the union of their units; a later entry for the same
|
|
401
|
+
// base does not replace the earlier one.
|
|
402
|
+
const unitsByBase = new Map<
|
|
403
|
+
string,
|
|
404
|
+
{ base: WindowBase; units: Set<string> }
|
|
405
|
+
>();
|
|
392
406
|
for (const coverage of this.spec.coverage ?? []) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
coverage.
|
|
398
|
-
|
|
399
|
-
|
|
407
|
+
const baseKey = windowBaseKey(coverage.base);
|
|
408
|
+
const existing = unitsByBase.get(baseKey);
|
|
409
|
+
if (existing === undefined) {
|
|
410
|
+
unitsByBase.set(baseKey, {
|
|
411
|
+
base: coverage.base,
|
|
412
|
+
units: new Set(coverage.units),
|
|
413
|
+
});
|
|
414
|
+
} else {
|
|
415
|
+
for (const unit of coverage.units) existing.units.add(unit);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
for (const { base, units } of unitsByBase.values()) {
|
|
419
|
+
claims.push(this.store.setWindowClaim(this.#owner, base, [...units]));
|
|
400
420
|
}
|
|
401
421
|
const generation = this.#generation;
|
|
402
422
|
this.#claimPending = claims.length > 0;
|
package/src/schema.ts
CHANGED
|
@@ -121,6 +121,21 @@ export function compileClientSchema(
|
|
|
121
121
|
`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} is not a column`,
|
|
122
122
|
);
|
|
123
123
|
}
|
|
124
|
+
// §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
|
|
125
|
+
// string every implementation produces identically and local storage
|
|
126
|
+
// resolves without deferring to a SQLite build.
|
|
127
|
+
const primaryKeyColumn = table.columns[primaryKeyIndex];
|
|
128
|
+
if (
|
|
129
|
+
primaryKeyColumn !== undefined &&
|
|
130
|
+
primaryKeyColumn.type !== 'string' &&
|
|
131
|
+
primaryKeyColumn.type !== 'integer' &&
|
|
132
|
+
primaryKeyColumn.type !== 'boolean' &&
|
|
133
|
+
primaryKeyColumn.type !== 'json'
|
|
134
|
+
) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
124
139
|
if (table.scopes.length === 0) {
|
|
125
140
|
throw new Error(
|
|
126
141
|
`table ${table.name}: every synced table declares at least one scope pattern (§3.1)`,
|
|
@@ -605,8 +620,10 @@ const RESERVED_TABLE_PREFIX = '_syncular_';
|
|
|
605
620
|
* generated schema created — discovered from `sqlite_master`, since a
|
|
606
621
|
* bump may add/remove tables) and recreate the synced tables from the
|
|
607
622
|
* NEW schema. Bookkeeping tables (`_syncular_*`: outbox, meta,
|
|
608
|
-
* subscriptions, blob cache) are preserved.
|
|
609
|
-
*
|
|
623
|
+
* subscriptions, blob cache) are preserved. RFC 0005's container is not
|
|
624
|
+
* reachable from here: it lives in its own database file, so this reset cannot
|
|
625
|
+
* see or destroy it. Caller owns the surrounding transaction and the
|
|
626
|
+
* subscription-state reset (state.ts).
|
|
610
627
|
*/
|
|
611
628
|
export function dropAndRecreateSyncedTables(
|
|
612
629
|
db: ClientDatabase,
|
package/src/wasm-database.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
assertImageAlias,
|
|
28
28
|
type ClientDatabase,
|
|
29
29
|
runTransaction,
|
|
30
|
+
type SiblingDatabase,
|
|
30
31
|
type SqlRow,
|
|
31
32
|
type SqlValue,
|
|
32
33
|
} from './database';
|
|
@@ -51,6 +52,9 @@ interface Oo1Database {
|
|
|
51
52
|
|
|
52
53
|
interface SahPoolUtil {
|
|
53
54
|
OpfsSAHPoolDb: new (filename: string) => Oo1Database;
|
|
55
|
+
/** RFC 0005: the runtime exposes naming and removal on the pool util. */
|
|
56
|
+
getFileNames(): string[];
|
|
57
|
+
unlink(filename: string): boolean;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
interface SqliteIoMethods {
|
|
@@ -116,11 +120,18 @@ function coerceParams(params: readonly SqlValue[]): unknown[] {
|
|
|
116
120
|
class WasmClientDatabase implements ClientDatabase {
|
|
117
121
|
readonly #db: Oo1Database;
|
|
118
122
|
readonly #sqlite3: Sqlite3Static;
|
|
123
|
+
/** RFC 0005: present only for a persistent OPFS database. */
|
|
124
|
+
readonly #sah: { readonly util: SahPoolUtil } | undefined;
|
|
119
125
|
#tx = { depth: 0 };
|
|
120
126
|
|
|
121
|
-
constructor(
|
|
127
|
+
constructor(
|
|
128
|
+
db: Oo1Database,
|
|
129
|
+
sqlite3: Sqlite3Static,
|
|
130
|
+
sah?: { readonly util: SahPoolUtil },
|
|
131
|
+
) {
|
|
122
132
|
this.#db = db;
|
|
123
133
|
this.#sqlite3 = sqlite3;
|
|
134
|
+
this.#sah = sah;
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
exec(sql: string, params: readonly SqlValue[] = []): void {
|
|
@@ -147,6 +158,39 @@ class WasmClientDatabase implements ClientDatabase {
|
|
|
147
158
|
return runTransaction(this.#tx, (sql) => this.exec(sql), fn);
|
|
148
159
|
}
|
|
149
160
|
|
|
161
|
+
/**
|
|
162
|
+
* RFC 0005: a second named file on the same SAH pool directory. The pool
|
|
163
|
+
* forbids two live instances per directory and charges two slots per
|
|
164
|
+
* writable database (`.db` plus its transient `-journal`), which is why the
|
|
165
|
+
* pool's initial capacity must cover three. Removal is the pool's `unlink`,
|
|
166
|
+
* never OPFS `removeEntry` (the pool stores an opaque entry).
|
|
167
|
+
*/
|
|
168
|
+
openSibling(name: string): SiblingDatabase | undefined {
|
|
169
|
+
const sah = this.#sah;
|
|
170
|
+
if (sah === undefined) return undefined;
|
|
171
|
+
const fileName = `/${name}.db`;
|
|
172
|
+
const db = new sah.util.OpfsSAHPoolDb(fileName);
|
|
173
|
+
try {
|
|
174
|
+
configureSahCrashRecovery(db, this.#sqlite3);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
db.close();
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
database: new WasmClientDatabase(db, this.#sqlite3),
|
|
181
|
+
close: () => db.close(),
|
|
182
|
+
removeFile: () => {
|
|
183
|
+
sah.util.unlink(fileName);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
siblingExists(name: string): boolean {
|
|
189
|
+
const sah = this.#sah;
|
|
190
|
+
if (sah === undefined) return false;
|
|
191
|
+
return sah.util.getFileNames().includes(`/${name}.db`);
|
|
192
|
+
}
|
|
193
|
+
|
|
150
194
|
/**
|
|
151
195
|
* §5.3 image import at near-file-copy speed: attach an empty in-memory
|
|
152
196
|
* schema, then `sqlite3_deserialize` the image bytes into it (the
|
|
@@ -274,6 +318,69 @@ function opfsSahPoolError(error: unknown, directory: string): ClientSyncError {
|
|
|
274
318
|
);
|
|
275
319
|
}
|
|
276
320
|
|
|
321
|
+
/** sqlite-wasm's default SAH pool capacity. */
|
|
322
|
+
const SAH_POOL_DEFAULT_CAPACITY = 6;
|
|
323
|
+
/**
|
|
324
|
+
* RFC 0005: each writable database consumes two slots (`.db` plus its
|
|
325
|
+
* transient `-journal`), so a replica plus one container file needs three.
|
|
326
|
+
* Exhaustion is a loud `SQLITE_CANTOPEN` ("SAH pool is full") that names the
|
|
327
|
+
* missing journal, not corruption — raise the floor rather than discover it.
|
|
328
|
+
*/
|
|
329
|
+
const SAH_POOL_MIN_CAPACITY = 3;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Do this before the first SQL statement, when SQLite checks for a hot
|
|
333
|
+
* journal. The SAH-pool callback in sqlite-wasm 3.53 reports a reserved lock
|
|
334
|
+
* unconditionally, which suppresses crash rollback. The pool already excludes
|
|
335
|
+
* other owners; report no competing writer, as the other OPFS VFS does. Keep
|
|
336
|
+
* DELETE/FULL and the existing database/journal files. Shared by the replica
|
|
337
|
+
* and every RFC 0005 sibling file.
|
|
338
|
+
*/
|
|
339
|
+
function configureSahCrashRecovery(
|
|
340
|
+
db: Oo1Database,
|
|
341
|
+
sqlite3: Sqlite3Static,
|
|
342
|
+
): void {
|
|
343
|
+
const { capi, wasm } = sqlite3;
|
|
344
|
+
const stack = wasm.pstack.pointer;
|
|
345
|
+
try {
|
|
346
|
+
const output = wasm.pstack.allocPtr();
|
|
347
|
+
if (
|
|
348
|
+
db.pointer === undefined ||
|
|
349
|
+
capi.sqlite3_file_control(
|
|
350
|
+
db.pointer,
|
|
351
|
+
'main',
|
|
352
|
+
capi.SQLITE_FCNTL_FILE_POINTER,
|
|
353
|
+
output,
|
|
354
|
+
) !== 0 ||
|
|
355
|
+
!wasm.peekPtr(output)
|
|
356
|
+
) {
|
|
357
|
+
throw new ClientSyncError(
|
|
358
|
+
STORAGE_UNAVAILABLE_CODE,
|
|
359
|
+
'Could not configure OPFS crash recovery',
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
const file = new capi.sqlite3_file(wasm.peekPtr(output));
|
|
363
|
+
const methodsPointer = file.$pMethods;
|
|
364
|
+
file.dispose(); // Borrowed struct view; SQLite still owns the file.
|
|
365
|
+
if (!methodsPointer) {
|
|
366
|
+
throw new ClientSyncError(
|
|
367
|
+
STORAGE_UNAVAILABLE_CODE,
|
|
368
|
+
'Could not configure OPFS crash recovery',
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
if (!sahIoMethods.has(methodsPointer)) {
|
|
372
|
+
const methods = new capi.sqlite3_io_methods(methodsPointer);
|
|
373
|
+
methods.installMethod('xCheckReservedLock', (_file, reserved) => {
|
|
374
|
+
wasm.poke32(reserved, 0);
|
|
375
|
+
return 0;
|
|
376
|
+
});
|
|
377
|
+
sahIoMethods.set(methodsPointer, methods);
|
|
378
|
+
}
|
|
379
|
+
} finally {
|
|
380
|
+
wasm.pstack.restore(stack);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
277
384
|
/**
|
|
278
385
|
* THE reload-persistent browser mode: a named database on OPFS via the
|
|
279
386
|
* `opfs-sahpool` VFS. Worker-context only — not because SAHPool requires
|
|
@@ -329,9 +436,10 @@ export async function openPersistentWasmDatabase(
|
|
|
329
436
|
// Syncular removes its own rejected entry below, so allow a later
|
|
330
437
|
// open in the same worker to make a real attempt as well.
|
|
331
438
|
forceReinitIfPreviouslyFailed: true,
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
439
|
+
initialCapacity: Math.max(
|
|
440
|
+
options?.initialCapacity ?? SAH_POOL_DEFAULT_CAPACITY,
|
|
441
|
+
SAH_POOL_MIN_CAPACITY,
|
|
442
|
+
),
|
|
335
443
|
})
|
|
336
444
|
.catch((error: unknown) => {
|
|
337
445
|
sahPools.delete(directory);
|
|
@@ -341,52 +449,11 @@ export async function openPersistentWasmDatabase(
|
|
|
341
449
|
}
|
|
342
450
|
const util = await pool;
|
|
343
451
|
const db = new util.OpfsSAHPoolDb(`/${name}.db`);
|
|
344
|
-
const { capi, wasm } = sqlite3;
|
|
345
|
-
const stack = wasm.pstack.pointer;
|
|
346
452
|
try {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// lock unconditionally, which suppresses crash rollback. The pool already
|
|
350
|
-
// excludes other owners; report no competing writer, as the other OPFS
|
|
351
|
-
// VFS does. Keep DELETE/FULL and the existing database/journal files.
|
|
352
|
-
const output = wasm.pstack.allocPtr();
|
|
353
|
-
if (
|
|
354
|
-
db.pointer === undefined ||
|
|
355
|
-
capi.sqlite3_file_control(
|
|
356
|
-
db.pointer,
|
|
357
|
-
'main',
|
|
358
|
-
capi.SQLITE_FCNTL_FILE_POINTER,
|
|
359
|
-
output,
|
|
360
|
-
) !== 0 ||
|
|
361
|
-
!wasm.peekPtr(output)
|
|
362
|
-
) {
|
|
363
|
-
throw new ClientSyncError(
|
|
364
|
-
STORAGE_UNAVAILABLE_CODE,
|
|
365
|
-
'Could not configure OPFS crash recovery',
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
const file = new capi.sqlite3_file(wasm.peekPtr(output));
|
|
369
|
-
const methodsPointer = file.$pMethods;
|
|
370
|
-
file.dispose(); // Borrowed struct view; SQLite still owns the file.
|
|
371
|
-
if (!methodsPointer) {
|
|
372
|
-
throw new ClientSyncError(
|
|
373
|
-
STORAGE_UNAVAILABLE_CODE,
|
|
374
|
-
'Could not configure OPFS crash recovery',
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
if (!sahIoMethods.has(methodsPointer)) {
|
|
378
|
-
const methods = new capi.sqlite3_io_methods(methodsPointer);
|
|
379
|
-
methods.installMethod('xCheckReservedLock', (_file, reserved) => {
|
|
380
|
-
wasm.poke32(reserved, 0);
|
|
381
|
-
return 0;
|
|
382
|
-
});
|
|
383
|
-
sahIoMethods.set(methodsPointer, methods);
|
|
384
|
-
}
|
|
385
|
-
return new WasmClientDatabase(db, sqlite3);
|
|
453
|
+
configureSahCrashRecovery(db, sqlite3);
|
|
454
|
+
return new WasmClientDatabase(db, sqlite3, { util });
|
|
386
455
|
} catch (error) {
|
|
387
456
|
db.close();
|
|
388
457
|
throw error;
|
|
389
|
-
} finally {
|
|
390
|
-
wasm.pstack.restore(stack);
|
|
391
458
|
}
|
|
392
459
|
}
|
package/src/worker-entry.ts
CHANGED
|
@@ -347,6 +347,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
347
347
|
...(config.securityPreflight !== undefined
|
|
348
348
|
? { securityPreflight: config.securityPreflight }
|
|
349
349
|
: {}),
|
|
350
|
+
...(config.previousVersionContext !== undefined
|
|
351
|
+
? { previousVersionContext: config.previousVersionContext }
|
|
352
|
+
: {}),
|
|
350
353
|
onSyncNeeded: (reason) => {
|
|
351
354
|
post({ t: 'event', event: { kind: 'sync-needed', reason } });
|
|
352
355
|
consumeSyncIntent({ kind: 'interactive' });
|
|
@@ -446,6 +449,10 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
446
449
|
purgeLocalData: (input) => requireClient().purgeLocalData(input),
|
|
447
450
|
rebootstrapLocalData: (input) =>
|
|
448
451
|
requireClient().rebootstrapLocalData(input),
|
|
452
|
+
previousVersionSnapshot: (spec) =>
|
|
453
|
+
requireClient().previousVersionSnapshot(spec),
|
|
454
|
+
previousVersionAudit: () => requireClient().previousVersionAudit(),
|
|
455
|
+
previousVersionDiscard: () => requireClient().previousVersionDiscard(),
|
|
449
456
|
sync: () => {
|
|
450
457
|
const running = requireClient();
|
|
451
458
|
return serializedSync(() => running.sync());
|
|
@@ -512,13 +519,18 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
512
519
|
},
|
|
513
520
|
};
|
|
514
521
|
|
|
515
|
-
// Local purge/rebootstrap rewrite the same durable state an
|
|
516
|
-
// sync round captured at send time
|
|
517
|
-
//
|
|
518
|
-
//
|
|
522
|
+
// Local purge/rebootstrap/discard rewrite the same durable state an
|
|
523
|
+
// in-flight sync round captured at send time, and the RFC 0005 reads observe
|
|
524
|
+
// (and can themselves trigger the discard of) that same previous-version
|
|
525
|
+
// container. Running all of them on the sync chain orders them against RPC-
|
|
526
|
+
// and auto-driven rounds (the client core's reset fence covers hosts that
|
|
527
|
+
// call the core directly).
|
|
519
528
|
const syncChainMethods: ReadonlySet<WorkerMethod> = new Set<WorkerMethod>([
|
|
520
529
|
'purgeLocalData',
|
|
521
530
|
'rebootstrapLocalData',
|
|
531
|
+
'previousVersionSnapshot',
|
|
532
|
+
'previousVersionAudit',
|
|
533
|
+
'previousVersionDiscard',
|
|
522
534
|
]);
|
|
523
535
|
|
|
524
536
|
async function dispatch(message: WorkerCallMessage): Promise<unknown> {
|
package/src/worker-host.ts
CHANGED
|
@@ -38,6 +38,7 @@ import type {
|
|
|
38
38
|
RejectionRecord,
|
|
39
39
|
SecurityLifecycle,
|
|
40
40
|
SubscribeInput,
|
|
41
|
+
SyncClientConfig,
|
|
41
42
|
SyncClientLimits,
|
|
42
43
|
SyncSummary,
|
|
43
44
|
WindowState,
|
|
@@ -89,6 +90,14 @@ import type {
|
|
|
89
90
|
CommitOutcomeQuery,
|
|
90
91
|
ResolveCommitOutcomeInput,
|
|
91
92
|
} from './outcomes';
|
|
93
|
+
import {
|
|
94
|
+
decodePreviousVersionAuditResult,
|
|
95
|
+
decodePreviousVersionDiscardResult,
|
|
96
|
+
decodePreviousVersionSnapshot,
|
|
97
|
+
type PreviousVersionAudit,
|
|
98
|
+
type PreviousVersionReadSpec,
|
|
99
|
+
type PreviousVersionSnapshot,
|
|
100
|
+
} from './previous-version';
|
|
92
101
|
import type { ClientSchema } from './schema';
|
|
93
102
|
import type { SubscriptionRecord } from './state';
|
|
94
103
|
import type { WindowBase } from './window';
|
|
@@ -177,6 +186,11 @@ export interface SyncClientHandleConfig {
|
|
|
177
186
|
readonly securityPreflight?: boolean;
|
|
178
187
|
readonly clientId?: string;
|
|
179
188
|
readonly limits?: SyncClientLimits;
|
|
189
|
+
/**
|
|
190
|
+
* RFC 0005 previous-version context, forwarded into the worker's
|
|
191
|
+
* `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
|
|
192
|
+
*/
|
|
193
|
+
readonly previousVersionContext?: SyncClientConfig['previousVersionContext'];
|
|
180
194
|
/** Worker-side host loop (§8.4); default true. */
|
|
181
195
|
readonly autoSync?: boolean;
|
|
182
196
|
/** Default: Web Locks when available, else single-owner. */
|
|
@@ -546,6 +560,29 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
546
560
|
);
|
|
547
561
|
}
|
|
548
562
|
|
|
563
|
+
async previousVersionSnapshot(
|
|
564
|
+
spec: PreviousVersionReadSpec,
|
|
565
|
+
): Promise<PreviousVersionSnapshot> {
|
|
566
|
+
return decodePreviousVersionSnapshot(
|
|
567
|
+
await this.#call('previousVersionSnapshot', [spec]),
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async previousVersionAudit(): Promise<PreviousVersionAudit | undefined> {
|
|
572
|
+
return decodePreviousVersionAuditResult(
|
|
573
|
+
await this.#call('previousVersionAudit', []),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async previousVersionDiscard(): Promise<{
|
|
578
|
+
present: boolean;
|
|
579
|
+
discarded: boolean;
|
|
580
|
+
}> {
|
|
581
|
+
return decodePreviousVersionDiscardResult(
|
|
582
|
+
await this.#call('previousVersionDiscard', []),
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
549
586
|
sync(): Promise<SyncSummary> {
|
|
550
587
|
return this.#call('sync', []);
|
|
551
588
|
}
|
|
@@ -815,6 +852,9 @@ function buildInitConfig(config: SyncClientHandleConfig): WorkerInitConfig {
|
|
|
815
852
|
: {}),
|
|
816
853
|
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
817
854
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
855
|
+
...(config.previousVersionContext !== undefined
|
|
856
|
+
? { previousVersionContext: config.previousVersionContext }
|
|
857
|
+
: {}),
|
|
818
858
|
...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
|
|
819
859
|
};
|
|
820
860
|
}
|
package/src/worker-protocol.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
QuerySnapshot,
|
|
29
29
|
SecurityLifecycle,
|
|
30
30
|
SubscribeInput,
|
|
31
|
+
SyncClientConfig,
|
|
31
32
|
SyncClientLimits,
|
|
32
33
|
SyncSummary,
|
|
33
34
|
WindowState,
|
|
@@ -42,6 +43,11 @@ import type {
|
|
|
42
43
|
LocalDataRebootstrapResult,
|
|
43
44
|
} from './local-rebootstrap';
|
|
44
45
|
import type { OutboxCommit } from './outbox';
|
|
46
|
+
import type {
|
|
47
|
+
PreviousVersionAudit,
|
|
48
|
+
PreviousVersionReadSpec,
|
|
49
|
+
PreviousVersionSnapshot,
|
|
50
|
+
} from './previous-version';
|
|
45
51
|
import type { ClientSchema } from './schema';
|
|
46
52
|
import type { SubscriptionRecord } from './state';
|
|
47
53
|
import type { WindowBase } from './window';
|
|
@@ -102,6 +108,11 @@ export interface WorkerInitConfig {
|
|
|
102
108
|
readonly securityPreflight?: boolean;
|
|
103
109
|
readonly clientId?: string;
|
|
104
110
|
readonly limits?: SyncClientLimits;
|
|
111
|
+
/**
|
|
112
|
+
* RFC 0005 previous-version context, forwarded verbatim into the worker's
|
|
113
|
+
* `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
|
|
114
|
+
*/
|
|
115
|
+
readonly previousVersionContext?: SyncClientConfig['previousVersionContext'];
|
|
105
116
|
/**
|
|
106
117
|
* Worker-side host loop (§8.4): coalesce interactive work immediately and
|
|
107
118
|
* honor explicit background retry deadlines
|
|
@@ -151,6 +162,14 @@ export interface WorkerApi extends Omit<
|
|
|
151
162
|
rebootstrapLocalData(
|
|
152
163
|
input: LocalDataRebootstrapInput,
|
|
153
164
|
): LocalDataRebootstrapResult;
|
|
165
|
+
/** RFC 0005 D7: read the captured previous-schema rows for one table. */
|
|
166
|
+
previousVersionSnapshot(
|
|
167
|
+
spec: PreviousVersionReadSpec,
|
|
168
|
+
): PreviousVersionSnapshot;
|
|
169
|
+
/** RFC 0005 D6: the advisory pre-reset compatibility audit, if recorded. */
|
|
170
|
+
previousVersionAudit(): PreviousVersionAudit | undefined;
|
|
171
|
+
/** RFC 0005 D9: the executable rollback step — drop the container. */
|
|
172
|
+
previousVersionDiscard(): { present: boolean; discarded: boolean };
|
|
154
173
|
sync(): Promise<SyncSummary>;
|
|
155
174
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
156
175
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|