@syncular/client 0.15.4 → 0.15.6
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 +29 -9
- package/dist/client.js +10 -3
- package/dist/errors.d.ts +7 -2
- package/dist/errors.js +7 -2
- package/dist/schema.d.ts +12 -2
- package/dist/schema.js +19 -3
- package/dist/wasm-database.js +20 -4
- package/package.json +3 -3
- package/src/client.ts +12 -3
- package/src/errors.ts +9 -2
- package/src/schema.ts +24 -3
- package/src/wasm-database.ts +35 -9
package/README.md
CHANGED
|
@@ -42,10 +42,8 @@ const handle = await createSyncClientHandle({
|
|
|
42
42
|
},
|
|
43
43
|
});
|
|
44
44
|
if (handle.role === 'follower') {
|
|
45
|
-
// Another tab owns the core for this origin.
|
|
46
|
-
//
|
|
47
|
-
// every call rejects with `client.not_leader` (a clear state, not a
|
|
48
|
-
// broken client).
|
|
45
|
+
// Another tab owns the core for this origin. The default multi-tab mode
|
|
46
|
+
// transparently proxies this handle to that leader.
|
|
49
47
|
}
|
|
50
48
|
await handle.subscribe({ id: 'todos', table: 'todos', scopes: { list_id: ['l1'] } });
|
|
51
49
|
await handle.syncUntilIdle();
|
|
@@ -66,14 +64,13 @@ persists, on purpose, and that is the only main-thread mode.
|
|
|
66
64
|
|
|
67
65
|
## Multi-tab followers (TODO 3.2, REVISE B3)
|
|
68
66
|
|
|
69
|
-
|
|
67
|
+
By default, every tab of the same origin shares ONE core:
|
|
70
68
|
one sync loop, one WebSocket, one OPFS database, N tabs.
|
|
71
69
|
|
|
72
70
|
```ts
|
|
73
71
|
const handle = await createSyncClientHandle({
|
|
74
72
|
worker: () => new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }),
|
|
75
73
|
schema, database: { mode: 'persistent', name: 'app' }, endpoints,
|
|
76
|
-
multiTab: true,
|
|
77
74
|
onRoleChange: (role) => console.log('now', role), // 'follower' → 'leader'
|
|
78
75
|
});
|
|
79
76
|
// handle.role is 'leader' or 'follower'; the API is identical either way.
|
|
@@ -115,9 +112,8 @@ forwards to the leader's single publisher; there is no per-tab presence
|
|
|
115
112
|
peer. This is the honest model — the wire only ever sees one connection per
|
|
116
113
|
device.
|
|
117
114
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
`client.not_leader`.
|
|
115
|
+
Set `multiTab: false` to opt out. A losing tab then becomes an
|
|
116
|
+
`isLeader === false` handle whose calls reject with `client.not_leader`.
|
|
121
117
|
|
|
122
118
|
## Durable commit outcomes
|
|
123
119
|
|
|
@@ -169,6 +165,30 @@ successful history may be dismissed. See SPEC §7.2.1.
|
|
|
169
165
|
- `openPersistentWasmDatabase` refuses to run on the main thread — not a
|
|
170
166
|
sahpool limitation, an enforcement of whole-core-in-a-worker.
|
|
171
167
|
|
|
168
|
+
### OPFS ownership and startup recovery
|
|
169
|
+
|
|
170
|
+
An OPFS SAH pool has exactly one live owner per storage directory. Syncular's
|
|
171
|
+
default multi-tab mode prevents ordinary same-origin tabs from opening a second
|
|
172
|
+
pool: followers proxy the one leader over `BroadcastChannel`. A collision can
|
|
173
|
+
still happen during rapid hot-module replacement, or in an embedded/test host
|
|
174
|
+
that shares OPFS data without sharing the same Web Locks and BroadcastChannel
|
|
175
|
+
coordination domain.
|
|
176
|
+
|
|
177
|
+
Pool acquisition failures surface as `ClientSyncError` with code
|
|
178
|
+
`client.storage_busy` and `retryable === true`. Treat that as a startup state:
|
|
179
|
+
close the competing instance or let it finish shutting down, then create the
|
|
180
|
+
handle again. **Do not delete, rename, or silently replace the database with an
|
|
181
|
+
in-memory one**; the local database and pending outbox may be perfectly healthy.
|
|
182
|
+
Missing or obsolete OPFS APIs instead use the non-retryable
|
|
183
|
+
`client.storage_unavailable` code.
|
|
184
|
+
|
|
185
|
+
When using `@syncular/react`, `createSyncClientResource()` exposes `retry()` and
|
|
186
|
+
passes the same action as the second argument to `SyncProvider.renderError`.
|
|
187
|
+
Applications may use a small bounded backoff for errors whose `retryable` flag
|
|
188
|
+
is true, followed by a visible manual retry. Preserve the resource across HMR
|
|
189
|
+
or dispose the previous resource before replacing it so development reloads do
|
|
190
|
+
not manufacture a second owner.
|
|
191
|
+
|
|
172
192
|
## Blob attachments (§5.9) — the client storage model
|
|
173
193
|
|
|
174
194
|
File attachments (`blob_ref` columns) ride the `uploadBlob` / `fetchBlob` API
|
package/dist/client.js
CHANGED
|
@@ -17,7 +17,7 @@ import { singleOwnerLock, } from './leader-lock.js';
|
|
|
17
17
|
import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
|
|
18
18
|
import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
|
|
19
19
|
import { assertReadOnlyQuery } from './query-guard.js';
|
|
20
|
-
import { compileClientSchema, dropAndRecreateSyncedTables,
|
|
20
|
+
import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
|
|
21
21
|
import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
|
|
22
22
|
import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
|
|
23
23
|
/**
|
|
@@ -144,7 +144,10 @@ export class SyncClient {
|
|
|
144
144
|
return;
|
|
145
145
|
const lock = this.#config.leaderLock ?? singleOwnerLock();
|
|
146
146
|
this.#lease = await lock.acquire(this.#config.lockName ?? 'syncular-leader');
|
|
147
|
-
|
|
147
|
+
// Bookkeeping must exist before we inspect the persisted schema marker.
|
|
148
|
+
// Do not materialize new app indexes/FTS projections yet: on a version
|
|
149
|
+
// bump they may reference columns that only exist after the reset.
|
|
150
|
+
ensureLocalBookkeepingSchema(this.#db);
|
|
148
151
|
if (this.#hasBlobs)
|
|
149
152
|
ensureBlobSchema(this.#db);
|
|
150
153
|
this.#db.transaction(() => {
|
|
@@ -214,12 +217,16 @@ export class SyncClient {
|
|
|
214
217
|
const markerJson = getMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY);
|
|
215
218
|
if (markerJson === undefined) {
|
|
216
219
|
// Fresh install: the tables just created match the running code.
|
|
220
|
+
ensureLocalSyncedSchema(this.#db, this.#schema);
|
|
217
221
|
setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
|
|
218
222
|
return;
|
|
219
223
|
}
|
|
220
224
|
const marker = Number(markerJson);
|
|
221
|
-
if (marker === this.#schema.version)
|
|
225
|
+
if (marker === this.#schema.version) {
|
|
226
|
+
// Same-version opens remain self-healing for absent tables/indexes.
|
|
227
|
+
ensureLocalSyncedSchema(this.#db, this.#schema);
|
|
222
228
|
return;
|
|
229
|
+
}
|
|
223
230
|
this.#runSchemaReset();
|
|
224
231
|
}
|
|
225
232
|
/**
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
/** A persistent local store is temporarily owned by another live engine. */
|
|
2
|
+
export declare const STORAGE_BUSY_CODE = "client.storage_busy";
|
|
3
|
+
/** The browser cannot provide the persistent storage APIs Syncular requires. */
|
|
4
|
+
export declare const STORAGE_UNAVAILABLE_CODE = "client.storage_unavailable";
|
|
1
5
|
/**
|
|
2
|
-
* Client-side errors. Protocol codes
|
|
3
|
-
*
|
|
6
|
+
* Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
|
|
7
|
+
* unchanged. Host/runtime-only conditions may use the separate `client.*`
|
|
8
|
+
* namespace and never travel on the wire.
|
|
4
9
|
*/
|
|
5
10
|
export declare class ClientSyncError extends Error {
|
|
6
11
|
readonly name = "ClientSyncError";
|
package/dist/errors.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
/** A persistent local store is temporarily owned by another live engine. */
|
|
2
|
+
export const STORAGE_BUSY_CODE = 'client.storage_busy';
|
|
3
|
+
/** The browser cannot provide the persistent storage APIs Syncular requires. */
|
|
4
|
+
export const STORAGE_UNAVAILABLE_CODE = 'client.storage_unavailable';
|
|
1
5
|
/**
|
|
2
|
-
* Client-side errors. Protocol codes
|
|
3
|
-
*
|
|
6
|
+
* Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
|
|
7
|
+
* unchanged. Host/runtime-only conditions may use the separate `client.*`
|
|
8
|
+
* namespace and never travel on the wire.
|
|
4
9
|
*/
|
|
5
10
|
export class ClientSyncError extends Error {
|
|
6
11
|
name = 'ClientSyncError';
|
package/dist/schema.d.ts
CHANGED
|
@@ -103,9 +103,19 @@ export declare function localColumnType(column: RowColumn): RowColumn['type'];
|
|
|
103
103
|
/** §7.4.1 persisted local schema-version marker (`_syncular_meta` key). */
|
|
104
104
|
export declare const LOCAL_SCHEMA_VERSION_KEY = "localSchemaVersion";
|
|
105
105
|
/**
|
|
106
|
-
* Create the synced tables
|
|
107
|
-
*
|
|
106
|
+
* Create only the application-owned synced tables, indexes, and FTS
|
|
107
|
+
* projections. Callers opening an existing database must compare the
|
|
108
|
+
* persisted schema version before invoking this: a new index may reference a
|
|
109
|
+
* column that does not exist until the schema-bump reset recreates the table.
|
|
108
110
|
*/
|
|
111
|
+
export declare function ensureLocalSyncedSchema(db: ClientDatabase, schema: CompiledClientSchema): void;
|
|
112
|
+
/**
|
|
113
|
+
* Create the protected Syncular bookkeeping tables without touching the
|
|
114
|
+
* application schema. This makes the persisted schema-version marker
|
|
115
|
+
* readable before any new application index or FTS projection is applied.
|
|
116
|
+
*/
|
|
117
|
+
export declare function ensureLocalBookkeepingSchema(db: ClientDatabase): void;
|
|
118
|
+
/** Create both application and protected bookkeeping tables. */
|
|
109
119
|
export declare function ensureLocalSchema(db: ClientDatabase, schema: CompiledClientSchema): void;
|
|
110
120
|
/**
|
|
111
121
|
* §7.4.3 reset: drop every synced local table (whatever the *previous*
|
package/dist/schema.js
CHANGED
|
@@ -241,10 +241,12 @@ function createFtsProjection(db, table, index) {
|
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
243
|
/**
|
|
244
|
-
* Create the synced tables
|
|
245
|
-
*
|
|
244
|
+
* Create only the application-owned synced tables, indexes, and FTS
|
|
245
|
+
* projections. Callers opening an existing database must compare the
|
|
246
|
+
* persisted schema version before invoking this: a new index may reference a
|
|
247
|
+
* column that does not exist until the schema-bump reset recreates the table.
|
|
246
248
|
*/
|
|
247
|
-
export function
|
|
249
|
+
export function ensureLocalSyncedSchema(db, schema) {
|
|
248
250
|
db.transaction(() => {
|
|
249
251
|
for (const table of schema.tables.values()) {
|
|
250
252
|
createSyncedTable(db, table);
|
|
@@ -254,6 +256,15 @@ export function ensureLocalSchema(db, schema) {
|
|
|
254
256
|
createFtsProjection(db, table, index);
|
|
255
257
|
}
|
|
256
258
|
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Create the protected Syncular bookkeeping tables without touching the
|
|
263
|
+
* application schema. This makes the persisted schema-version marker
|
|
264
|
+
* readable before any new application index or FTS projection is applied.
|
|
265
|
+
*/
|
|
266
|
+
export function ensureLocalBookkeepingSchema(db) {
|
|
267
|
+
db.transaction(() => {
|
|
257
268
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_meta(
|
|
258
269
|
key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
259
270
|
db.exec(`INSERT OR IGNORE INTO _syncular_meta(key, value) VALUES ('localRevision', '0')`);
|
|
@@ -316,6 +327,11 @@ export function ensureLocalSchema(db, schema) {
|
|
|
316
327
|
effective_scopes TEXT NOT NULL)`);
|
|
317
328
|
});
|
|
318
329
|
}
|
|
330
|
+
/** Create both application and protected bookkeeping tables. */
|
|
331
|
+
export function ensureLocalSchema(db, schema) {
|
|
332
|
+
ensureLocalBookkeepingSchema(db);
|
|
333
|
+
ensureLocalSyncedSchema(db, schema);
|
|
334
|
+
}
|
|
319
335
|
/** Bookkeeping tables the schema-bump reset (§7.4.3) MUST NOT drop. */
|
|
320
336
|
const RESERVED_TABLE_PREFIX = '_syncular_';
|
|
321
337
|
/**
|
package/dist/wasm-database.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
|
23
23
|
import { assertImageAlias, runTransaction, } from './database.js';
|
|
24
|
-
import { ClientSyncError } from './errors.js';
|
|
24
|
+
import { ClientSyncError, STORAGE_BUSY_CODE, STORAGE_UNAVAILABLE_CODE, } from './errors.js';
|
|
25
25
|
function coerceParams(params) {
|
|
26
26
|
return params.map((value) => {
|
|
27
27
|
if (typeof value === 'boolean')
|
|
@@ -119,6 +119,19 @@ function inWorkerContext() {
|
|
|
119
119
|
}
|
|
120
120
|
/** One registered VFS per pool directory, reused across opens. */
|
|
121
121
|
const sahPools = new Map();
|
|
122
|
+
function opfsSahPoolError(error, directory) {
|
|
123
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
124
|
+
const normalized = detail.toLowerCase();
|
|
125
|
+
if (normalized.includes('missing required opfs apis') ||
|
|
126
|
+
normalized.includes('opfs api is too old')) {
|
|
127
|
+
return new ClientSyncError(STORAGE_UNAVAILABLE_CODE, `Persistent OPFS storage is unavailable: ${detail}`);
|
|
128
|
+
}
|
|
129
|
+
return new ClientSyncError(STORAGE_BUSY_CODE, 'Could not acquire the persistent OPFS storage directory ' +
|
|
130
|
+
`${JSON.stringify(directory)}. Another live engine may still own its ` +
|
|
131
|
+
'SAH pool, or the browser may still be releasing it after a reload. ' +
|
|
132
|
+
'Close the other instance or retry after a short delay; do not delete ' +
|
|
133
|
+
`or rename the directory. Underlying error: ${detail}`, true);
|
|
134
|
+
}
|
|
122
135
|
/**
|
|
123
136
|
* THE persistent browser mode: a named database on OPFS via the
|
|
124
137
|
* `opfs-sahpool` VFS. Worker-context only — not because SAHPool requires
|
|
@@ -141,7 +154,7 @@ export async function openPersistentWasmDatabase(name, options) {
|
|
|
141
154
|
}
|
|
142
155
|
const storage = globalThis.navigator?.storage;
|
|
143
156
|
if (typeof storage?.getDirectory !== 'function') {
|
|
144
|
-
throw new ClientSyncError(
|
|
157
|
+
throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'OPFS is unavailable in this browser — the syncular support floor ' +
|
|
145
158
|
'requires OPFS (~2023+ browsers). There is no IndexedDB or ' +
|
|
146
159
|
'in-memory fallback for persistent mode.');
|
|
147
160
|
}
|
|
@@ -154,14 +167,17 @@ export async function openPersistentWasmDatabase(name, options) {
|
|
|
154
167
|
// VFS registration names must be unique per directory.
|
|
155
168
|
name: `syncular-sahpool-${directory.replace(/[^A-Za-z0-9]+/g, '-')}`,
|
|
156
169
|
directory,
|
|
170
|
+
// sqlite-wasm caches a rejected initialization promise by VFS name.
|
|
171
|
+
// Syncular removes its own rejected entry below, so allow a later
|
|
172
|
+
// open in the same worker to make a real attempt as well.
|
|
173
|
+
forceReinitIfPreviouslyFailed: true,
|
|
157
174
|
...(options?.initialCapacity !== undefined
|
|
158
175
|
? { initialCapacity: options.initialCapacity }
|
|
159
176
|
: {}),
|
|
160
177
|
})
|
|
161
178
|
.catch((error) => {
|
|
162
179
|
sahPools.delete(directory);
|
|
163
|
-
throw
|
|
164
|
-
`is another live instance using directory ${JSON.stringify(directory)}?): ${String(error)}`);
|
|
180
|
+
throw opfsSahPoolError(error, directory);
|
|
165
181
|
});
|
|
166
182
|
sahPools.set(directory, pool);
|
|
167
183
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.6",
|
|
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",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
84
|
-
"@syncular/core": "0.15.
|
|
84
|
+
"@syncular/core": "0.15.6"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"better-sqlite3": ">=11"
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.15.
|
|
95
|
+
"@syncular/server": "0.15.6",
|
|
96
96
|
"@types/better-sqlite3": "^7.6.13",
|
|
97
97
|
"better-sqlite3": "^12.11.1"
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -114,7 +114,8 @@ import {
|
|
|
114
114
|
type CompiledClientTable,
|
|
115
115
|
compileClientSchema,
|
|
116
116
|
dropAndRecreateSyncedTables,
|
|
117
|
-
|
|
117
|
+
ensureLocalBookkeepingSchema,
|
|
118
|
+
ensureLocalSyncedSchema,
|
|
118
119
|
fromSqlValue,
|
|
119
120
|
jsonToRowValue,
|
|
120
121
|
LOCAL_SCHEMA_VERSION_KEY,
|
|
@@ -544,7 +545,10 @@ export class SyncClient {
|
|
|
544
545
|
this.#lease = await lock.acquire(
|
|
545
546
|
this.#config.lockName ?? 'syncular-leader',
|
|
546
547
|
);
|
|
547
|
-
|
|
548
|
+
// Bookkeeping must exist before we inspect the persisted schema marker.
|
|
549
|
+
// Do not materialize new app indexes/FTS projections yet: on a version
|
|
550
|
+
// bump they may reference columns that only exist after the reset.
|
|
551
|
+
ensureLocalBookkeepingSchema(this.#db);
|
|
548
552
|
if (this.#hasBlobs) ensureBlobSchema(this.#db);
|
|
549
553
|
this.#db.transaction(() => {
|
|
550
554
|
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
@@ -622,11 +626,16 @@ export class SyncClient {
|
|
|
622
626
|
const markerJson = getMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY);
|
|
623
627
|
if (markerJson === undefined) {
|
|
624
628
|
// Fresh install: the tables just created match the running code.
|
|
629
|
+
ensureLocalSyncedSchema(this.#db, this.#schema);
|
|
625
630
|
setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
|
|
626
631
|
return;
|
|
627
632
|
}
|
|
628
633
|
const marker = Number(markerJson);
|
|
629
|
-
if (marker === this.#schema.version)
|
|
634
|
+
if (marker === this.#schema.version) {
|
|
635
|
+
// Same-version opens remain self-healing for absent tables/indexes.
|
|
636
|
+
ensureLocalSyncedSchema(this.#db, this.#schema);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
630
639
|
this.#runSchemaReset();
|
|
631
640
|
}
|
|
632
641
|
|
package/src/errors.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
/** A persistent local store is temporarily owned by another live engine. */
|
|
2
|
+
export const STORAGE_BUSY_CODE = 'client.storage_busy';
|
|
3
|
+
|
|
4
|
+
/** The browser cannot provide the persistent storage APIs Syncular requires. */
|
|
5
|
+
export const STORAGE_UNAVAILABLE_CODE = 'client.storage_unavailable';
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
|
-
* Client-side errors. Protocol codes
|
|
3
|
-
*
|
|
8
|
+
* Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
|
|
9
|
+
* unchanged. Host/runtime-only conditions may use the separate `client.*`
|
|
10
|
+
* namespace and never travel on the wire.
|
|
4
11
|
*/
|
|
5
12
|
export class ClientSyncError extends Error {
|
|
6
13
|
override readonly name = 'ClientSyncError';
|
package/src/schema.ts
CHANGED
|
@@ -395,10 +395,12 @@ function createFtsProjection(
|
|
|
395
395
|
}
|
|
396
396
|
|
|
397
397
|
/**
|
|
398
|
-
* Create the synced tables
|
|
399
|
-
*
|
|
398
|
+
* Create only the application-owned synced tables, indexes, and FTS
|
|
399
|
+
* projections. Callers opening an existing database must compare the
|
|
400
|
+
* persisted schema version before invoking this: a new index may reference a
|
|
401
|
+
* column that does not exist until the schema-bump reset recreates the table.
|
|
400
402
|
*/
|
|
401
|
-
export function
|
|
403
|
+
export function ensureLocalSyncedSchema(
|
|
402
404
|
db: ClientDatabase,
|
|
403
405
|
schema: CompiledClientSchema,
|
|
404
406
|
): void {
|
|
@@ -411,6 +413,16 @@ export function ensureLocalSchema(
|
|
|
411
413
|
createFtsProjection(db, table, index);
|
|
412
414
|
}
|
|
413
415
|
}
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Create the protected Syncular bookkeeping tables without touching the
|
|
421
|
+
* application schema. This makes the persisted schema-version marker
|
|
422
|
+
* readable before any new application index or FTS projection is applied.
|
|
423
|
+
*/
|
|
424
|
+
export function ensureLocalBookkeepingSchema(db: ClientDatabase): void {
|
|
425
|
+
db.transaction(() => {
|
|
414
426
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_meta(
|
|
415
427
|
key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
416
428
|
db.exec(
|
|
@@ -477,6 +489,15 @@ export function ensureLocalSchema(
|
|
|
477
489
|
});
|
|
478
490
|
}
|
|
479
491
|
|
|
492
|
+
/** Create both application and protected bookkeeping tables. */
|
|
493
|
+
export function ensureLocalSchema(
|
|
494
|
+
db: ClientDatabase,
|
|
495
|
+
schema: CompiledClientSchema,
|
|
496
|
+
): void {
|
|
497
|
+
ensureLocalBookkeepingSchema(db);
|
|
498
|
+
ensureLocalSyncedSchema(db, schema);
|
|
499
|
+
}
|
|
500
|
+
|
|
480
501
|
/** Bookkeeping tables the schema-bump reset (§7.4.3) MUST NOT drop. */
|
|
481
502
|
const RESERVED_TABLE_PREFIX = '_syncular_';
|
|
482
503
|
|
package/src/wasm-database.ts
CHANGED
|
@@ -27,7 +27,11 @@ import {
|
|
|
27
27
|
type SqlRow,
|
|
28
28
|
type SqlValue,
|
|
29
29
|
} from './database';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
ClientSyncError,
|
|
32
|
+
STORAGE_BUSY_CODE,
|
|
33
|
+
STORAGE_UNAVAILABLE_CODE,
|
|
34
|
+
} from './errors';
|
|
31
35
|
|
|
32
36
|
/** Structural view of the sqlite3 oo1 surface this binding uses. */
|
|
33
37
|
interface Oo1Database {
|
|
@@ -69,6 +73,7 @@ interface Sqlite3Static {
|
|
|
69
73
|
name?: string;
|
|
70
74
|
directory?: string;
|
|
71
75
|
initialCapacity?: number;
|
|
76
|
+
forceReinitIfPreviouslyFailed?: boolean;
|
|
72
77
|
}): Promise<SahPoolUtil>;
|
|
73
78
|
}
|
|
74
79
|
|
|
@@ -209,6 +214,29 @@ function inWorkerContext(): boolean {
|
|
|
209
214
|
/** One registered VFS per pool directory, reused across opens. */
|
|
210
215
|
const sahPools = new Map<string, Promise<SahPoolUtil>>();
|
|
211
216
|
|
|
217
|
+
function opfsSahPoolError(error: unknown, directory: string): ClientSyncError {
|
|
218
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
219
|
+
const normalized = detail.toLowerCase();
|
|
220
|
+
if (
|
|
221
|
+
normalized.includes('missing required opfs apis') ||
|
|
222
|
+
normalized.includes('opfs api is too old')
|
|
223
|
+
) {
|
|
224
|
+
return new ClientSyncError(
|
|
225
|
+
STORAGE_UNAVAILABLE_CODE,
|
|
226
|
+
`Persistent OPFS storage is unavailable: ${detail}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return new ClientSyncError(
|
|
230
|
+
STORAGE_BUSY_CODE,
|
|
231
|
+
'Could not acquire the persistent OPFS storage directory ' +
|
|
232
|
+
`${JSON.stringify(directory)}. Another live engine may still own its ` +
|
|
233
|
+
'SAH pool, or the browser may still be releasing it after a reload. ' +
|
|
234
|
+
'Close the other instance or retry after a short delay; do not delete ' +
|
|
235
|
+
`or rename the directory. Underlying error: ${detail}`,
|
|
236
|
+
true,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
212
240
|
/**
|
|
213
241
|
* THE persistent browser mode: a named database on OPFS via the
|
|
214
242
|
* `opfs-sahpool` VFS. Worker-context only — not because SAHPool requires
|
|
@@ -243,7 +271,7 @@ export async function openPersistentWasmDatabase(
|
|
|
243
271
|
).navigator?.storage;
|
|
244
272
|
if (typeof storage?.getDirectory !== 'function') {
|
|
245
273
|
throw new ClientSyncError(
|
|
246
|
-
|
|
274
|
+
STORAGE_UNAVAILABLE_CODE,
|
|
247
275
|
'OPFS is unavailable in this browser — the syncular support floor ' +
|
|
248
276
|
'requires OPFS (~2023+ browsers). There is no IndexedDB or ' +
|
|
249
277
|
'in-memory fallback for persistent mode.',
|
|
@@ -258,19 +286,17 @@ export async function openPersistentWasmDatabase(
|
|
|
258
286
|
// VFS registration names must be unique per directory.
|
|
259
287
|
name: `syncular-sahpool-${directory.replace(/[^A-Za-z0-9]+/g, '-')}`,
|
|
260
288
|
directory,
|
|
289
|
+
// sqlite-wasm caches a rejected initialization promise by VFS name.
|
|
290
|
+
// Syncular removes its own rejected entry below, so allow a later
|
|
291
|
+
// open in the same worker to make a real attempt as well.
|
|
292
|
+
forceReinitIfPreviouslyFailed: true,
|
|
261
293
|
...(options?.initialCapacity !== undefined
|
|
262
294
|
? { initialCapacity: options.initialCapacity }
|
|
263
295
|
: {}),
|
|
264
296
|
})
|
|
265
297
|
.catch((error: unknown) => {
|
|
266
298
|
sahPools.delete(directory);
|
|
267
|
-
throw
|
|
268
|
-
'sync.invalid_request',
|
|
269
|
-
'opfs-sahpool VFS failed to initialize (is OPFS available, and ' +
|
|
270
|
-
`is another live instance using directory ${JSON.stringify(
|
|
271
|
-
directory,
|
|
272
|
-
)}?): ${String(error)}`,
|
|
273
|
-
);
|
|
299
|
+
throw opfsSahPoolError(error, directory);
|
|
274
300
|
});
|
|
275
301
|
sahPools.set(directory, pool);
|
|
276
302
|
}
|