@syncular/client 0.30.10 → 0.30.12
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 +15 -0
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +58 -58
- package/dist/blob.js +13 -39
- package/dist/client.js +178 -161
- package/dist/failed-overlay.d.ts +10 -5
- package/dist/failed-overlay.js +118 -36
- package/dist/outbox.d.ts +2 -1
- package/dist/outbox.js +20 -40
- package/dist/outcomes.d.ts +8 -0
- package/dist/outcomes.js +22 -30
- package/dist/previous-version.js +14 -28
- package/dist/schema.d.ts +3 -0
- package/dist/schema.js +89 -133
- 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 +95 -72
- package/src/blob.ts +17 -39
- package/src/client.ts +312 -214
- package/src/failed-overlay.ts +197 -48
- package/src/outbox.ts +25 -44
- package/src/outcomes.ts +40 -35
- package/src/previous-version.ts +14 -28
- package/src/schema.ts +154 -166
- package/src/state.ts +90 -33
- package/src/wasm-database.ts +56 -29
- package/src/worker-host.ts +34 -12
package/src/state.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* exactly for that purpose.
|
|
6
6
|
*/
|
|
7
7
|
import type { ScopeMap } from '@syncular/core';
|
|
8
|
+
import type { CompiledClientSchema } from './schema';
|
|
8
9
|
import type { ClientDatabase } from './database';
|
|
9
10
|
import type { LocalRevision } from './invalidation';
|
|
10
11
|
|
|
@@ -65,7 +66,7 @@ export function getSubscription(
|
|
|
65
66
|
db: ClientDatabase,
|
|
66
67
|
id: string,
|
|
67
68
|
): SubscriptionRecord | undefined {
|
|
68
|
-
const row = db.query('SELECT * FROM _syncular_subscriptions WHERE id
|
|
69
|
+
const row = db.query('SELECT * FROM _syncular_subscriptions WHERE id=?', [
|
|
69
70
|
id,
|
|
70
71
|
])[0];
|
|
71
72
|
return row === undefined ? undefined : rowToRecord(row);
|
|
@@ -76,10 +77,7 @@ export function saveSubscription(
|
|
|
76
77
|
record: SubscriptionRecord,
|
|
77
78
|
): void {
|
|
78
79
|
db.exec(
|
|
79
|
-
|
|
80
|
-
id, tbl, requested_scopes, params, cursor, bootstrap_state,
|
|
81
|
-
effective_scopes, status, reason_code)
|
|
82
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
80
|
+
'INSERT OR REPLACE INTO _syncular_subscriptions(id,tbl,requested_scopes,params,cursor,bootstrap_state,effective_scopes,status,reason_code)VALUES(?,?,?,?,?,?,?,?,?)',
|
|
83
81
|
[
|
|
84
82
|
record.id,
|
|
85
83
|
record.table,
|
|
@@ -97,11 +95,11 @@ export function saveSubscription(
|
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
export function deleteSubscription(db: ClientDatabase, id: string): void {
|
|
100
|
-
db.exec('DELETE FROM _syncular_subscriptions WHERE id
|
|
98
|
+
db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
|
|
101
99
|
}
|
|
102
100
|
|
|
103
101
|
/**
|
|
104
|
-
* §7.4.3 reset: keep
|
|
102
|
+
* §7.4.3 reset: keep compatible subscription REGISTRATIONS (id, table,
|
|
105
103
|
* requested scopes, params — the app's declared intent) but discard all
|
|
106
104
|
* synced state (cursor → -1, no resume token, no effective-scope map,
|
|
107
105
|
* status → active), so the next round fresh-bootstraps exactly the
|
|
@@ -109,56 +107,115 @@ export function deleteSubscription(db: ClientDatabase, id: string): void {
|
|
|
109
107
|
*/
|
|
110
108
|
export function resetSubscriptionsForBump(db: ClientDatabase): void {
|
|
111
109
|
db.exec(
|
|
112
|
-
|
|
113
|
-
SET cursor = -1, bootstrap_state = NULL, effective_scopes = NULL,
|
|
114
|
-
status = 'active', reason_code = NULL`,
|
|
110
|
+
"UPDATE _syncular_subscriptions SET cursor=-1,bootstrap_state=NULL,effective_scopes=NULL,status='active',reason_code=NULL",
|
|
115
111
|
);
|
|
116
112
|
}
|
|
117
113
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
114
|
+
type ScopeDeclarations = Record<
|
|
115
|
+
string,
|
|
116
|
+
Record<string, readonly [string, string]>
|
|
117
|
+
>;
|
|
118
|
+
|
|
119
|
+
/** Persist declarations separately from the previous-version row descriptor. */
|
|
120
|
+
export function saveSubscriptionScopeSchema(
|
|
121
|
+
db: ClientDatabase,
|
|
122
|
+
schema: CompiledClientSchema,
|
|
123
|
+
): void {
|
|
124
|
+
const declarations: ScopeDeclarations = {};
|
|
125
|
+
for (const table of schema.tables.values()) {
|
|
126
|
+
declarations[table.name] = Object.fromEntries(
|
|
127
|
+
[...table.scopeColumnByVariable].map(([variable, column]) => [
|
|
128
|
+
variable,
|
|
129
|
+
[table.scopePrefixByVariable.get(variable)!, column],
|
|
130
|
+
]),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
setMeta(db, 'subscriptionScopeSchema', JSON.stringify(declarations));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Drop incompatible registrations and their windows; never translate scopes. */
|
|
125
137
|
export function pruneUnknownSubscriptions(
|
|
126
138
|
db: ClientDatabase,
|
|
127
139
|
subscriptions: readonly SubscriptionRecord[],
|
|
128
|
-
|
|
140
|
+
schema: CompiledClientSchema,
|
|
141
|
+
bump = false,
|
|
129
142
|
): SubscriptionRecord[] {
|
|
130
|
-
const
|
|
131
|
-
|
|
143
|
+
const stored = bump ? getMeta(db, 'subscriptionScopeSchema') : undefined;
|
|
144
|
+
const previous =
|
|
145
|
+
stored === undefined
|
|
146
|
+
? undefined
|
|
147
|
+
: (JSON.parse(stored) as ScopeDeclarations);
|
|
148
|
+
const compatible = (
|
|
149
|
+
record: Pick<SubscriptionRecord, 'table' | 'scopes'>,
|
|
150
|
+
): boolean => {
|
|
151
|
+
const table = schema.tables.get(record.table);
|
|
152
|
+
if (table === undefined) return false;
|
|
153
|
+
const variables = Object.keys(record.scopes);
|
|
154
|
+
if (
|
|
155
|
+
!variables.every((variable) => table.scopeColumnByVariable.has(variable))
|
|
156
|
+
)
|
|
157
|
+
return false;
|
|
158
|
+
if (!bump) return true;
|
|
159
|
+
// An old binary stored no declaration evidence. Re-registration is required
|
|
160
|
+
// at a bump: matching names cannot prove unchanged column/prefix meaning.
|
|
161
|
+
const old = previous?.[record.table];
|
|
162
|
+
if (old === undefined) return false;
|
|
163
|
+
const compared =
|
|
164
|
+
variables.length === 0
|
|
165
|
+
? [
|
|
166
|
+
...new Set([
|
|
167
|
+
...Object.keys(old),
|
|
168
|
+
...table.scopeColumnByVariable.keys(),
|
|
169
|
+
]),
|
|
170
|
+
]
|
|
171
|
+
: variables;
|
|
172
|
+
return compared.every(
|
|
173
|
+
(variable) =>
|
|
174
|
+
old[variable]?.[0] === table.scopePrefixByVariable.get(variable) &&
|
|
175
|
+
old[variable]?.[1] === table.scopeColumnByVariable.get(variable),
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
const retained = subscriptions.filter(compatible);
|
|
179
|
+
const staleIds = new Set(
|
|
180
|
+
subscriptions
|
|
181
|
+
.filter((record) => !compatible(record))
|
|
182
|
+
.map((record) => record.id),
|
|
132
183
|
);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
184
|
+
// Shrinking a pinned window already removed its registration. Its deferred
|
|
185
|
+
// eviction still carries the old scope declaration and needs the same fence.
|
|
186
|
+
for (const row of db.query(
|
|
187
|
+
'SELECT sub_id,tbl,effective_scopes FROM _syncular_window_pending_evict',
|
|
188
|
+
)) {
|
|
189
|
+
if (
|
|
190
|
+
!compatible({
|
|
191
|
+
table: String(row.tbl),
|
|
192
|
+
scopes: JSON.parse(String(row.effective_scopes)) as ScopeMap,
|
|
193
|
+
})
|
|
194
|
+
)
|
|
195
|
+
staleIds.add(String(row.sub_id));
|
|
196
|
+
}
|
|
197
|
+
if (staleIds.size === 0) return retained;
|
|
141
198
|
db.transaction(() => {
|
|
142
199
|
for (const id of staleIds) {
|
|
143
|
-
db.exec('DELETE FROM _syncular_windows WHERE sub_id
|
|
144
|
-
db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id
|
|
200
|
+
db.exec('DELETE FROM _syncular_windows WHERE sub_id=?', [id]);
|
|
201
|
+
db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id=?', [
|
|
145
202
|
id,
|
|
146
203
|
]);
|
|
147
|
-
db.exec('DELETE FROM _syncular_subscriptions WHERE id
|
|
204
|
+
db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
|
|
148
205
|
}
|
|
149
206
|
});
|
|
150
207
|
return retained;
|
|
151
208
|
}
|
|
152
209
|
|
|
153
210
|
export function getMeta(db: ClientDatabase, key: string): string | undefined {
|
|
154
|
-
const row = db.query('SELECT value FROM _syncular_meta WHERE key
|
|
211
|
+
const row = db.query('SELECT value FROM _syncular_meta WHERE key=?', [
|
|
155
212
|
key,
|
|
156
213
|
])[0];
|
|
157
214
|
return row === undefined ? undefined : (row.value as string);
|
|
158
215
|
}
|
|
159
216
|
|
|
160
217
|
export function setMeta(db: ClientDatabase, key: string, value: string): void {
|
|
161
|
-
db.exec('INSERT OR REPLACE INTO _syncular_meta(key,
|
|
218
|
+
db.exec('INSERT OR REPLACE INTO _syncular_meta(key,value)VALUES(?,?)', [
|
|
162
219
|
key,
|
|
163
220
|
value,
|
|
164
221
|
]);
|
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
|
}
|