@syncular/client 0.30.10 → 0.30.11
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/README.md +8 -0
- package/dist/apply.js +17 -25
- package/dist/blob.js +13 -39
- package/dist/client.js +118 -117
- package/dist/failed-overlay.d.ts +6 -2
- package/dist/failed-overlay.js +82 -15
- package/dist/outbox.js +11 -21
- package/dist/outcomes.d.ts +8 -0
- package/dist/outcomes.js +18 -27
- package/dist/previous-version.js +14 -28
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +50 -85
- package/dist/state.d.ts +6 -9
- package/dist/state.js +66 -32
- package/dist/wasm-database.js +40 -24
- package/dist/worker-host.js +37 -11
- package/package.json +3 -3
- package/src/apply.ts +17 -25
- package/src/blob.ts +17 -39
- package/src/client.ts +222 -163
- package/src/failed-overlay.ts +155 -17
- package/src/outbox.ts +11 -21
- package/src/outcomes.ts +35 -31
- package/src/previous-version.ts +14 -28
- package/src/schema.ts +91 -96
- package/src/state.ts +90 -33
- package/src/wasm-database.ts +56 -29
- package/src/worker-host.ts +34 -12
package/src/wasm-database.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* `ClientDatabase`. Browser-only — exercised by the demo, never imported
|
|
23
23
|
* by bun tests (subpath export `@syncular/client/wasm`).
|
|
24
24
|
*/
|
|
25
|
+
import { webLocksLeaderLock, type LeaderLease } from './leader-lock';
|
|
25
26
|
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
|
26
27
|
import {
|
|
27
28
|
assertImageAlias,
|
|
@@ -56,6 +57,8 @@ interface SahPoolUtil {
|
|
|
56
57
|
/** RFC 0005: the runtime exposes naming and removal on the pool util. */
|
|
57
58
|
getFileNames(): string[];
|
|
58
59
|
unlink(filename: string): boolean;
|
|
60
|
+
pauseVfs(): unknown;
|
|
61
|
+
unpauseVfs(): Promise<unknown>;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
interface SqliteIoMethods {
|
|
@@ -122,13 +125,16 @@ class WasmClientDatabase implements ClientDatabase {
|
|
|
122
125
|
readonly #db: Oo1Database;
|
|
123
126
|
readonly #sqlite3: Sqlite3Static;
|
|
124
127
|
/** RFC 0005: present only for a persistent OPFS database. */
|
|
125
|
-
readonly #sah:
|
|
128
|
+
readonly #sah:
|
|
129
|
+
| { readonly util: SahPoolUtil; readonly lease: LeaderLease }
|
|
130
|
+
| undefined;
|
|
131
|
+
#closed = false;
|
|
126
132
|
#tx = { depth: 0 };
|
|
127
133
|
|
|
128
134
|
constructor(
|
|
129
135
|
db: Oo1Database,
|
|
130
136
|
sqlite3: Sqlite3Static,
|
|
131
|
-
sah?: { readonly util: SahPoolUtil },
|
|
137
|
+
sah?: { readonly util: SahPoolUtil; readonly lease: LeaderLease },
|
|
132
138
|
) {
|
|
133
139
|
this.#db = db;
|
|
134
140
|
this.#sqlite3 = sqlite3;
|
|
@@ -236,7 +242,13 @@ class WasmClientDatabase implements ClientDatabase {
|
|
|
236
242
|
}
|
|
237
243
|
|
|
238
244
|
close(): void {
|
|
245
|
+
if (this.#closed) return;
|
|
239
246
|
this.#db.close();
|
|
247
|
+
// DB.close() leaves the SAH pool live. Close siblings first, then pause the
|
|
248
|
+
// VFS without deleting files, before releasing this worker's physical lock.
|
|
249
|
+
this.#sah?.util.pauseVfs();
|
|
250
|
+
this.#closed = true;
|
|
251
|
+
void this.#sah?.lease.release();
|
|
240
252
|
}
|
|
241
253
|
}
|
|
242
254
|
|
|
@@ -420,37 +432,52 @@ export async function openPersistentWasmDatabase(
|
|
|
420
432
|
'in-memory fallback for persistent mode.',
|
|
421
433
|
);
|
|
422
434
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
.installOpfsSAHPoolVfs({
|
|
429
|
-
// VFS registration names must be unique per directory.
|
|
430
|
-
name: `syncular-sahpool-${directory.replace(/[^A-Za-z0-9]+/g, '-')}`,
|
|
431
|
-
directory,
|
|
432
|
-
// sqlite-wasm caches a rejected initialization promise by VFS name.
|
|
433
|
-
// Syncular removes its own rejected entry below, so allow a later
|
|
434
|
-
// open in the same worker to make a real attempt as well.
|
|
435
|
-
forceReinitIfPreviouslyFailed: true,
|
|
436
|
-
initialCapacity: Math.max(
|
|
437
|
-
options?.initialCapacity ?? SAH_POOL_DEFAULT_CAPACITY,
|
|
438
|
-
SAH_POOL_MIN_CAPACITY,
|
|
439
|
-
),
|
|
440
|
-
})
|
|
441
|
-
.catch((error: unknown) => {
|
|
442
|
-
sahPools.delete(directory);
|
|
443
|
-
throw opfsSahPoolError(error, directory);
|
|
444
|
-
});
|
|
445
|
-
sahPools.set(directory, pool);
|
|
435
|
+
if (!navigator.locks) {
|
|
436
|
+
throw new ClientSyncError(
|
|
437
|
+
STORAGE_UNAVAILABLE_CODE,
|
|
438
|
+
'Persistent OPFS storage requires Web Locks',
|
|
439
|
+
);
|
|
446
440
|
}
|
|
447
|
-
const
|
|
448
|
-
|
|
441
|
+
const directory = (options?.directory ?? `.syncular/${name}`).replace(
|
|
442
|
+
/^\/+|\/+$/g,
|
|
443
|
+
'',
|
|
444
|
+
);
|
|
445
|
+
// The document's leader lease can end before its worker's SAHs close. This
|
|
446
|
+
// worker-owned lock has exactly the same lifetime as the physical pool.
|
|
447
|
+
const lease = await webLocksLeaderLock().acquire(
|
|
448
|
+
`syncular-opfs/${directory}`,
|
|
449
|
+
);
|
|
450
|
+
let util: SahPoolUtil | undefined;
|
|
451
|
+
let db: Oo1Database | undefined;
|
|
449
452
|
try {
|
|
453
|
+
const sqlite3 = await initSqlite3();
|
|
454
|
+
let pool = sahPools.get(directory);
|
|
455
|
+
if (pool === undefined) {
|
|
456
|
+
pool = sqlite3
|
|
457
|
+
.installOpfsSAHPoolVfs({
|
|
458
|
+
name: `syncular-sahpool-${directory.replace(/[^A-Za-z0-9]+/g, '-')}`,
|
|
459
|
+
directory,
|
|
460
|
+
forceReinitIfPreviouslyFailed: true,
|
|
461
|
+
initialCapacity: Math.max(
|
|
462
|
+
options?.initialCapacity ?? SAH_POOL_DEFAULT_CAPACITY,
|
|
463
|
+
SAH_POOL_MIN_CAPACITY,
|
|
464
|
+
),
|
|
465
|
+
})
|
|
466
|
+
.catch((error: unknown) => {
|
|
467
|
+
sahPools.delete(directory);
|
|
468
|
+
throw opfsSahPoolError(error, directory);
|
|
469
|
+
});
|
|
470
|
+
sahPools.set(directory, pool);
|
|
471
|
+
}
|
|
472
|
+
util = await pool;
|
|
473
|
+
await util.unpauseVfs();
|
|
474
|
+
db = new util.OpfsSAHPoolDb(`/${name}.db`);
|
|
450
475
|
configureSahCrashRecovery(db, sqlite3);
|
|
451
|
-
return new WasmClientDatabase(db, sqlite3, { util });
|
|
476
|
+
return new WasmClientDatabase(db, sqlite3, { util, lease });
|
|
452
477
|
} catch (error) {
|
|
453
|
-
db
|
|
478
|
+
db?.close();
|
|
479
|
+
util?.pauseVfs();
|
|
480
|
+
await lease.release();
|
|
454
481
|
throw error;
|
|
455
482
|
}
|
|
456
483
|
}
|
package/src/worker-host.ts
CHANGED
|
@@ -804,11 +804,33 @@ async function startWorkerCore(options: {
|
|
|
804
804
|
const worker = config.worker();
|
|
805
805
|
const pending = new Map<number, Pending>();
|
|
806
806
|
const nextId = { value: 1 };
|
|
807
|
+
let terminated = false;
|
|
808
|
+
let rejectReady: ((error: Error) => void) | undefined;
|
|
809
|
+
const closedError = new ClientSyncError(
|
|
810
|
+
WORKER_FAILED_CODE,
|
|
811
|
+
'the handle was closed',
|
|
812
|
+
);
|
|
813
|
+
const terminate = () => {
|
|
814
|
+
if (terminated) return;
|
|
815
|
+
terminated = true;
|
|
816
|
+
globalThis.removeEventListener?.('pagehide', terminate);
|
|
817
|
+
bridge?.close();
|
|
818
|
+
worker.terminate();
|
|
819
|
+
rejectReady?.(closedError);
|
|
820
|
+
for (const entry of pending.values()) entry.reject(closedError);
|
|
821
|
+
pending.clear();
|
|
822
|
+
void lease.release();
|
|
823
|
+
};
|
|
824
|
+
// Installed before bootstrap starts: pagehide must also terminate a worker
|
|
825
|
+
// whose first open/import is still pending. Termination closes its SAHs and
|
|
826
|
+
// releases its physical Web Lock; the next document waits for that lock.
|
|
827
|
+
globalThis.addEventListener?.('pagehide', terminate);
|
|
807
828
|
|
|
808
829
|
const invoke = (
|
|
809
830
|
method: string,
|
|
810
831
|
args: readonly unknown[],
|
|
811
832
|
): Promise<unknown> => {
|
|
833
|
+
if (terminated) return Promise.reject(closedError);
|
|
812
834
|
const id = nextId.value++;
|
|
813
835
|
return new Promise<unknown>((resolve, reject) => {
|
|
814
836
|
pending.set(id, { resolve, reject });
|
|
@@ -824,6 +846,7 @@ async function startWorkerCore(options: {
|
|
|
824
846
|
let bridge: LeaderBridge | undefined;
|
|
825
847
|
|
|
826
848
|
const ready = new Promise<void>((resolve, reject) => {
|
|
849
|
+
rejectReady = reject;
|
|
827
850
|
const onMessage = (event: MessageEvent) => {
|
|
828
851
|
const message = event.data as WorkerToMainMessage;
|
|
829
852
|
switch (message.t) {
|
|
@@ -889,26 +912,25 @@ async function startWorkerCore(options: {
|
|
|
889
912
|
invoke,
|
|
890
913
|
bridge,
|
|
891
914
|
lease,
|
|
892
|
-
close: async (
|
|
915
|
+
close: async (terminateWorker) => {
|
|
916
|
+
if (terminated) return;
|
|
893
917
|
bridge?.close();
|
|
894
918
|
try {
|
|
895
919
|
await invoke('close', []);
|
|
896
920
|
} catch {
|
|
897
|
-
//
|
|
921
|
+
// A terminated or failed worker has no further close RPC to service.
|
|
922
|
+
}
|
|
923
|
+
if (terminateWorker) terminate();
|
|
924
|
+
else {
|
|
925
|
+
globalThis.removeEventListener?.('pagehide', terminate);
|
|
926
|
+
for (const entry of pending.values()) entry.reject(closedError);
|
|
927
|
+
pending.clear();
|
|
928
|
+
await lease.release();
|
|
898
929
|
}
|
|
899
|
-
if (terminate) worker.terminate();
|
|
900
|
-
const closedError = new ClientSyncError(
|
|
901
|
-
WORKER_FAILED_CODE,
|
|
902
|
-
'the handle was closed',
|
|
903
|
-
);
|
|
904
|
-
for (const entry of pending.values()) entry.reject(closedError);
|
|
905
|
-
pending.clear();
|
|
906
|
-
await lease.release();
|
|
907
930
|
},
|
|
908
931
|
};
|
|
909
932
|
} catch (error) {
|
|
910
|
-
|
|
911
|
-
await lease.release();
|
|
933
|
+
terminate();
|
|
912
934
|
throw error;
|
|
913
935
|
}
|
|
914
936
|
}
|