@jcbuisson/express-x-plugins 5.0.1 → 5.0.2
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 +89 -0
- package/package.json +3 -2
- package/src/electric-client-plugin.mjs +19 -3
- package/src/electric-managed-sync.mjs +150 -0
- package/src/electric-offline-model.mjs +82 -33
- package/src/electric-pglite-worker.mjs +4 -0
- package/test/electric-client-plugin.test.mjs +1 -0
- package/test/electric-managed-sync.test.mjs +254 -0
package/README.md
CHANGED
|
@@ -150,6 +150,95 @@ console.log(created.id)
|
|
|
150
150
|
subscription.unsubscribe()
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
+
### Managed browser sync
|
|
154
|
+
|
|
155
|
+
With a server configured with `sync: true`, the client plugin can own the shared
|
|
156
|
+
PGlite database and multi-tab synchronization:
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
npm install @electric-sql/pglite @electric-sql/client rxjs vue
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
app.configure(electricClientPlugin, {
|
|
164
|
+
sync: true,
|
|
165
|
+
databaseName: 'selommes', // persistent IndexedDB name; unique to this app/backend
|
|
166
|
+
shapePath: '/electric/v1/shape',
|
|
167
|
+
onError: (error, { modelName }) => console.error(modelName, error),
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const ranges = app.createElectricModel('range', { primaryKey: 'uid' })
|
|
171
|
+
const subscription = ranges.getObservable().subscribe(rows => console.log(rows))
|
|
172
|
+
await ranges.create({ label: 'Works offline' })
|
|
173
|
+
await ranges.update(uid, { label: 'Edited offline' })
|
|
174
|
+
await ranges.remove(uid)
|
|
175
|
+
console.log(await ranges.getStatus()) // pending, failed, online, error
|
|
176
|
+
|
|
177
|
+
// During app teardown (for example, HMR disposal):
|
|
178
|
+
subscription.unsubscribe()
|
|
179
|
+
await app.disposeElectricSync()
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Models prepare and start automatically; operations wait for schema readiness.
|
|
183
|
+
Repeated calls for the same model reuse its instance. `findMany` and `findUnique`
|
|
184
|
+
read the local cache immediately after preparation, which can be empty before
|
|
185
|
+
Electric's first snapshot. Use `app.service('user').findMany({ deleted: false, ...filter })`
|
|
186
|
+
when an online authentication flow requires an authoritative server read.
|
|
187
|
+
|
|
188
|
+
Each plugin instance shares one persistent PGliteWorker. PGlite coordinates its
|
|
189
|
+
database worker across tabs. A separate Web Lock per database/model elects one
|
|
190
|
+
tab to run the Electric stream and mutation queue. Follower writes notify the
|
|
191
|
+
owner through BroadcastChannel. When the owner closes or stops, a waiting tab
|
|
192
|
+
takes over. Stream ownership does not depend on PGlite's leader event timing.
|
|
193
|
+
BIGINT values in cached rows are stored as lossless decimal strings. Snapshot
|
|
194
|
+
reconciliation and reads retry transient database leader changes. An indeterminate
|
|
195
|
+
foreground local mutation is reported instead of blindly repeated.
|
|
196
|
+
|
|
197
|
+
`model.stop()` stops synchronization and releases ownership without deleting local
|
|
198
|
+
rows or queued edits; `await model.start()` resumes it. `await model.dispose()`
|
|
199
|
+
also closes its channel. `await app.disposeElectricSync()` disposes all managed
|
|
200
|
+
models and closes the plugin-owned database. A supplied `localDb` remains owned
|
|
201
|
+
by the caller. Background errors appear in `getStatus()` and invoke `onError`;
|
|
202
|
+
initialization failures reject reads/writes and error observable subscriptions.
|
|
203
|
+
Worker startup times out after 30 seconds (`initTimeoutMs` can override this).
|
|
204
|
+
|
|
205
|
+
Managed sync requires HTTPS or localhost, Web Locks, BroadcastChannel, workers,
|
|
206
|
+
and IndexedDB. Configure Vite to keep the plugin and PGlite worker asset URLs
|
|
207
|
+
intact during development and emit workers as ES modules:
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
export default defineConfig({
|
|
211
|
+
optimizeDeps: {
|
|
212
|
+
exclude: ['@jcbuisson/express-x-plugins', '@electric-sql/pglite'],
|
|
213
|
+
},
|
|
214
|
+
worker: { format: 'es' },
|
|
215
|
+
})
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
For other bundlers, provide `workerFactory` if their worker discovery requires
|
|
219
|
+
an application-owned entry point:
|
|
220
|
+
|
|
221
|
+
```js
|
|
222
|
+
// electric.worker.js
|
|
223
|
+
import '@jcbuisson/express-x-plugins/electric-worker'
|
|
224
|
+
|
|
225
|
+
// Client configuration:
|
|
226
|
+
app.configure(electricClientPlugin, {
|
|
227
|
+
sync: true,
|
|
228
|
+
databaseName: 'selommes',
|
|
229
|
+
workerFactory: () => new Worker(new URL('./electric.worker.js', import.meta.url), {
|
|
230
|
+
type: 'module',
|
|
231
|
+
}),
|
|
232
|
+
})
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
PWA applications should cache the emitted PGlite `.wasm` and `.data` assets to
|
|
236
|
+
initialize after an offline reload. The WASM asset is about 10 MB; increase the
|
|
237
|
+
precache file-size limit accordingly. Apps that already provide a shared database
|
|
238
|
+
can pass plugin-level `localDb` to retain automatic ownership management.
|
|
239
|
+
|
|
240
|
+
### Explicit offline models
|
|
241
|
+
|
|
153
242
|
For offline writes, install `@electric-sql/pglite`, pass a shared PGlite database
|
|
154
243
|
as `localDb`, and use the sync
|
|
155
244
|
server mode above. `PGliteWorker` with an IndexedDB data directory is recommended
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jcbuisson/express-x-plugins",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.2",
|
|
4
4
|
"description": "Plugins for express-x",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/electric-server-plugin.mjs",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"./electric-server": "./src/electric-server-plugin.mjs",
|
|
9
9
|
"./electric-client": "./src/electric-client-plugin.mjs",
|
|
10
10
|
"./reload-server": "./src/reload-server-plugin.mjs",
|
|
11
|
-
"./reload-client": "./src/reload-client-plugin.mjs"
|
|
11
|
+
"./reload-client": "./src/reload-client-plugin.mjs",
|
|
12
|
+
"./electric-worker": "./src/electric-pglite-worker.mjs"
|
|
12
13
|
},
|
|
13
14
|
"private": false,
|
|
14
15
|
"publishConfig": {
|
|
@@ -2,6 +2,7 @@ import { Shape, ShapeStream } from '@electric-sql/client'
|
|
|
2
2
|
import { Observable, firstValueFrom, Subject, takeUntil } from 'rxjs'
|
|
3
3
|
import { getCurrentScope, onScopeDispose } from 'vue'
|
|
4
4
|
import { createOfflineElectricModel } from './electric-offline-model.mjs'
|
|
5
|
+
import { createManagedSync } from './electric-managed-sync.mjs'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Add Electric-backed reactive models to an Express-X client.
|
|
@@ -19,8 +20,21 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
19
20
|
const ShapeClass = options.Shape ?? DisposableShape
|
|
20
21
|
const ObservableClass = options.Observable ?? Observable
|
|
21
22
|
|
|
23
|
+
const managed = options.sync === true
|
|
24
|
+
? createManagedSync(options, createDirectModel)
|
|
25
|
+
: null
|
|
26
|
+
|
|
22
27
|
function createElectricModel(modelName, modelOptions = {}) {
|
|
23
28
|
quoteIdentifier(modelName)
|
|
29
|
+
quoteIdentifier(modelOptions.primaryKey ?? 'id')
|
|
30
|
+
if (managed) {
|
|
31
|
+
if (modelOptions.idGeneration === 'server') throw new TypeError('sync models require client-generated UUIDs')
|
|
32
|
+
return managed.model(modelName, modelOptions)
|
|
33
|
+
}
|
|
34
|
+
return createDirectModel(modelName, modelOptions)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createDirectModel(modelName, modelOptions) {
|
|
24
38
|
const service = app.service(modelName)
|
|
25
39
|
const url = modelOptions.url ?? modelPath(shapePath, modelName)
|
|
26
40
|
if (modelOptions.localDb) return createOfflineElectricModel({
|
|
@@ -31,6 +45,8 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
31
45
|
channel: modelOptions.channel,
|
|
32
46
|
network: modelOptions.network ?? globalThis.navigator,
|
|
33
47
|
retryMs: modelOptions.retryMs ?? 5000,
|
|
48
|
+
streamOptions: modelOptions.streamOptions,
|
|
49
|
+
onError: modelOptions.onError ?? options.onError,
|
|
34
50
|
})
|
|
35
51
|
const streamOptions = modelOptions.streamOptions ?? {}
|
|
36
52
|
const idGeneration = modelOptions.idGeneration ?? 'client'
|
|
@@ -53,7 +69,7 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
53
69
|
let previous
|
|
54
70
|
const unsubscribe = shape.subscribe(({ rows }) => {
|
|
55
71
|
const current = [...rows]
|
|
56
|
-
const serialized = JSON.stringify(current)
|
|
72
|
+
const serialized = JSON.stringify(current, (_key, value) => typeof value === 'bigint' ? String(value) : value)
|
|
57
73
|
if (serialized === previous) return
|
|
58
74
|
previous = serialized
|
|
59
75
|
subscriber.next(current)
|
|
@@ -88,7 +104,7 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
88
104
|
if (idGeneration === 'server') return service.create(mutationData)
|
|
89
105
|
const uid = globalThis.crypto?.randomUUID?.()
|
|
90
106
|
if (!uid) throw new Error('crypto.randomUUID() is required')
|
|
91
|
-
return service.create(uid,
|
|
107
|
+
return service.create(uid, mutationData)
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
async function update(id, data) {
|
|
@@ -102,7 +118,7 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
102
118
|
return { getObservable, findMany, findUnique, create, update, remove }
|
|
103
119
|
}
|
|
104
120
|
|
|
105
|
-
return Object.assign(app, { createElectricModel })
|
|
121
|
+
return Object.assign(app, { createElectricModel, ...(managed ? { disposeElectricSync: managed.dispose } : {}) })
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Observable } from 'rxjs'
|
|
2
|
+
|
|
3
|
+
/** One database and one cached model per plugin instance. Browser locks span tabs. */
|
|
4
|
+
export function createManagedSync(options, createModel) {
|
|
5
|
+
const databaseName = options.databaseName
|
|
6
|
+
if (typeof databaseName !== 'string' || !databaseName.trim()) {
|
|
7
|
+
throw new TypeError('sync: true requires a non-empty databaseName')
|
|
8
|
+
}
|
|
9
|
+
const locks = options.locks ?? globalThis.navigator?.locks
|
|
10
|
+
const Channel = options.BroadcastChannel ?? globalThis.BroadcastChannel
|
|
11
|
+
if (!locks?.request || !Channel) throw new Error('Managed sync requires Web Locks and BroadcastChannel (use HTTPS or localhost)')
|
|
12
|
+
let database
|
|
13
|
+
let preparation = Promise.resolve()
|
|
14
|
+
let closed = false
|
|
15
|
+
const models = new Map()
|
|
16
|
+
|
|
17
|
+
function getDatabase() {
|
|
18
|
+
database ??= Promise.resolve().then(async () => {
|
|
19
|
+
if (options.localDb) return options.localDb
|
|
20
|
+
if (options.createDatabase) return options.createDatabase({ dataDir: `idb://${databaseName}` })
|
|
21
|
+
const { PGliteWorker } = await import('@electric-sql/pglite/worker')
|
|
22
|
+
const worker = options.workerFactory
|
|
23
|
+
? options.workerFactory()
|
|
24
|
+
: new Worker(new URL('./electric-pglite-worker.mjs', import.meta.url), { type: 'module' })
|
|
25
|
+
let timer
|
|
26
|
+
let rejectFailure
|
|
27
|
+
const failure = new Promise((_, reject) => { rejectFailure = reject })
|
|
28
|
+
const fail = event => rejectFailure(new Error(event.message ?? 'PGlite worker failed to initialize'))
|
|
29
|
+
worker.addEventListener('error', fail)
|
|
30
|
+
worker.addEventListener('messageerror', fail)
|
|
31
|
+
timer = setTimeout(() => rejectFailure(new Error('PGlite worker initialization timed out')), options.initTimeoutMs ?? 30_000)
|
|
32
|
+
try {
|
|
33
|
+
return await Promise.race([PGliteWorker.create(worker, { dataDir: `idb://${databaseName}` }), failure])
|
|
34
|
+
} catch (error) {
|
|
35
|
+
worker.terminate()
|
|
36
|
+
throw error
|
|
37
|
+
} finally {
|
|
38
|
+
clearTimeout(timer)
|
|
39
|
+
worker.removeEventListener('error', fail)
|
|
40
|
+
worker.removeEventListener('messageerror', fail)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
return database
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function model(name, modelOptions) {
|
|
47
|
+
if (closed) throw new Error('Electric sync has been disposed')
|
|
48
|
+
const existing = models.get(name)
|
|
49
|
+
if (existing) {
|
|
50
|
+
if (existing.primaryKey !== (modelOptions.primaryKey ?? 'id')) throw new TypeError('A cached sync model cannot change primaryKey')
|
|
51
|
+
return existing.api
|
|
52
|
+
}
|
|
53
|
+
let active = true
|
|
54
|
+
let disposed = false
|
|
55
|
+
let controller
|
|
56
|
+
let release
|
|
57
|
+
let generation = 0
|
|
58
|
+
let channel
|
|
59
|
+
let offline
|
|
60
|
+
let error = null
|
|
61
|
+
function report(cause) {
|
|
62
|
+
error = cause
|
|
63
|
+
try { (modelOptions.onError ?? options.onError)?.(cause, { modelName: name }) } catch (callbackError) { console.error(callbackError) }
|
|
64
|
+
}
|
|
65
|
+
const ready = getDatabase().then(async db => {
|
|
66
|
+
channel = new Channel(`express-x:${databaseName}:${name}`)
|
|
67
|
+
offline = createModel(name, { ...modelOptions, localDb: db, channel, ownsSync: false, onError: report })
|
|
68
|
+
const prepared = preparation.then(() => offline.prepare())
|
|
69
|
+
preparation = prepared.catch(() => {})
|
|
70
|
+
await prepared
|
|
71
|
+
if (disposed) { channel.close(); return offline }
|
|
72
|
+
if (active) startOwnership()
|
|
73
|
+
return offline
|
|
74
|
+
})
|
|
75
|
+
// Report initialization failure even when the first consumer only subscribes.
|
|
76
|
+
void ready.catch(report)
|
|
77
|
+
|
|
78
|
+
function startOwnership() {
|
|
79
|
+
if (controller || disposed || !active) return
|
|
80
|
+
offline.start()
|
|
81
|
+
controller = new AbortController()
|
|
82
|
+
const ownerGeneration = ++generation
|
|
83
|
+
void locks.request(`express-x:${databaseName}:${name}`, { signal: controller.signal }, async () => {
|
|
84
|
+
if (!active || disposed || generation !== ownerGeneration) return
|
|
85
|
+
try {
|
|
86
|
+
offline.setSyncOwner(true)
|
|
87
|
+
await new Promise(resolve => { release = resolve })
|
|
88
|
+
} finally {
|
|
89
|
+
await offline.waitForIdle()
|
|
90
|
+
if (generation === ownerGeneration) { offline.setSyncOwner(false); release = undefined }
|
|
91
|
+
}
|
|
92
|
+
}).catch(cause => { if (cause.name !== 'AbortError') report(cause) })
|
|
93
|
+
}
|
|
94
|
+
function stop() {
|
|
95
|
+
active = false
|
|
96
|
+
++generation
|
|
97
|
+
controller?.abort()
|
|
98
|
+
controller = undefined
|
|
99
|
+
release?.()
|
|
100
|
+
release = undefined
|
|
101
|
+
offline?.setSyncOwner(false)
|
|
102
|
+
offline?.stop()
|
|
103
|
+
}
|
|
104
|
+
function start() {
|
|
105
|
+
if (disposed) throw new Error('Electric model has been disposed')
|
|
106
|
+
active = true
|
|
107
|
+
return ready.then(() => startOwnership())
|
|
108
|
+
}
|
|
109
|
+
function invoke(method, ...args) {
|
|
110
|
+
if (disposed) return Promise.reject(new Error('Electric model has been disposed'))
|
|
111
|
+
return ready.then(model => model[method](...args))
|
|
112
|
+
}
|
|
113
|
+
const api = {
|
|
114
|
+
prepare: () => ready.then(() => undefined), start, stop,
|
|
115
|
+
async dispose() {
|
|
116
|
+
if (disposed) return
|
|
117
|
+
disposed = true
|
|
118
|
+
stop()
|
|
119
|
+
try { await ready; await offline.waitForIdle() } finally { channel?.close(); models.delete(name) }
|
|
120
|
+
},
|
|
121
|
+
getObservable(where = {}) {
|
|
122
|
+
return new Observable(subscriber => {
|
|
123
|
+
if (disposed) { subscriber.error(new Error('Electric model has been disposed')); return }
|
|
124
|
+
let subscription
|
|
125
|
+
let cancelled = false
|
|
126
|
+
const fail = cause => subscriber.error(cause)
|
|
127
|
+
void ready.then(model => {
|
|
128
|
+
if (!cancelled) subscription = model.getObservable(where).subscribe(subscriber)
|
|
129
|
+
}).catch(fail)
|
|
130
|
+
return () => { cancelled = true; subscription?.unsubscribe() }
|
|
131
|
+
})
|
|
132
|
+
},
|
|
133
|
+
findMany: (where = {}) => invoke('findMany', where),
|
|
134
|
+
findUnique: (where = {}) => invoke('findUnique', where),
|
|
135
|
+
create: data => invoke('create', data),
|
|
136
|
+
update: (id, data) => invoke('update', id, data),
|
|
137
|
+
remove: id => invoke('remove', id),
|
|
138
|
+
flushQueue: () => invoke('flushQueue'),
|
|
139
|
+
getStatus: async () => ({ ...await invoke('getStatus'), error: error?.message ?? null }),
|
|
140
|
+
}
|
|
141
|
+
models.set(name, { api, primaryKey: modelOptions.primaryKey ?? 'id' })
|
|
142
|
+
return api
|
|
143
|
+
}
|
|
144
|
+
async function dispose() {
|
|
145
|
+
closed = true
|
|
146
|
+
await Promise.allSettled([...models.values()].map(({ api }) => api.dispose()))
|
|
147
|
+
if (database && !options.localDb) await (await database).close()
|
|
148
|
+
}
|
|
149
|
+
return { model, dispose }
|
|
150
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Observable } from 'rxjs'
|
|
2
2
|
|
|
3
3
|
/** Offline model backed by a shared PGlite database (normally PGliteWorker). */
|
|
4
|
-
export function createOfflineElectricModel({ db, service, modelName, url, primaryKey, ShapeStreamClass, ShapeClass, ownsSync, channel, network, retryMs }) {
|
|
4
|
+
export function createOfflineElectricModel({ db, service, modelName, url, primaryKey, ShapeStreamClass, ShapeClass, ownsSync, channel, network, retryMs, streamOptions = {}, onError }) {
|
|
5
5
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(primaryKey)) throw new TypeError('primaryKey must be a simple SQL identifier')
|
|
6
6
|
let snapshot = null
|
|
7
7
|
let operation = Promise.resolve()
|
|
@@ -10,10 +10,22 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
10
10
|
let connected = false
|
|
11
11
|
let timer
|
|
12
12
|
let stream
|
|
13
|
+
let streamGeneration = 0
|
|
14
|
+
let lastError = null
|
|
15
|
+
const pendingBackground = new Set()
|
|
16
|
+
function report(error) {
|
|
17
|
+
lastError = error?.message ?? String(error)
|
|
18
|
+
try { onError?.(error, { modelName }) } catch (callbackError) { console.error(callbackError) }
|
|
19
|
+
notify()
|
|
20
|
+
}
|
|
21
|
+
function background(promise) {
|
|
22
|
+
pendingBackground.add(promise)
|
|
23
|
+
void promise.catch(report).finally(() => pendingBackground.delete(promise))
|
|
24
|
+
}
|
|
13
25
|
const listeners = new Set()
|
|
14
26
|
|
|
15
27
|
async function prepare() {
|
|
16
|
-
await db.exec(`
|
|
28
|
+
await retryLeader(() => db.exec(`
|
|
17
29
|
CREATE SEQUENCE IF NOT EXISTS electric_mutation_revision_seq;
|
|
18
30
|
CREATE TABLE IF NOT EXISTS electric_sync_client (
|
|
19
31
|
singleton BOOLEAN PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
@@ -33,11 +45,11 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
33
45
|
failure_reason TEXT,
|
|
34
46
|
PRIMARY KEY (model_name, row_id)
|
|
35
47
|
);
|
|
36
|
-
`)
|
|
48
|
+
`))
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
function notify() {
|
|
40
|
-
channel?.postMessage({ modelName })
|
|
52
|
+
channel?.postMessage({ modelName, ...(ownsSync ? { online: connected && network?.onLine !== false } : {}) })
|
|
41
53
|
for (const listener of listeners) queueMicrotask(() => { try { listener() } catch (error) { console.error(error) } })
|
|
42
54
|
}
|
|
43
55
|
|
|
@@ -47,7 +59,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
async function findMany(where = {}) {
|
|
50
|
-
const { rows } = await db.query('SELECT data FROM electric_local_rows WHERE model_name = $1 ORDER BY row_id', [modelName])
|
|
62
|
+
const { rows } = await retryLeader(() => db.query('SELECT data FROM electric_local_rows WHERE model_name = $1 ORDER BY row_id', [modelName]))
|
|
51
63
|
return rows.map(row => row.data).filter(row => matchesWhere(row, where))
|
|
52
64
|
}
|
|
53
65
|
|
|
@@ -73,11 +85,11 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
73
85
|
assertId(id)
|
|
74
86
|
const row = { ...data, [primaryKey]: id }
|
|
75
87
|
await db.transaction(async tx => {
|
|
76
|
-
await tx.query('INSERT INTO electric_local_rows (model_name, row_id, data) VALUES ($1, $2, $3::jsonb)', [modelName, String(id),
|
|
88
|
+
await tx.query('INSERT INTO electric_local_rows (model_name, row_id, data) VALUES ($1, $2, $3::jsonb)', [modelName, String(id), json(row)])
|
|
77
89
|
await queueMutation(tx, id, 'create', row)
|
|
78
90
|
})
|
|
79
91
|
notify()
|
|
80
|
-
if (started)
|
|
92
|
+
if (started) background(flushQueue())
|
|
81
93
|
return row
|
|
82
94
|
}
|
|
83
95
|
|
|
@@ -90,14 +102,14 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
90
102
|
const current = await tx.query('SELECT data FROM electric_local_rows WHERE model_name = $1 AND row_id = $2', [modelName, String(id)])
|
|
91
103
|
if (!current.rows[0]) throw new Error('row not found')
|
|
92
104
|
row = { ...current.rows[0].data, ...data }
|
|
93
|
-
await tx.query('UPDATE electric_local_rows SET data = $3::jsonb WHERE model_name = $1 AND row_id = $2', [modelName, String(id),
|
|
105
|
+
await tx.query('UPDATE electric_local_rows SET data = $3::jsonb WHERE model_name = $1 AND row_id = $2', [modelName, String(id), json(row)])
|
|
94
106
|
const queued = await tx.query('SELECT action, acknowledged_version FROM electric_mutation_queue WHERE model_name = $1 AND row_id = $2', [modelName, String(id)])
|
|
95
107
|
if (queued.rows[0]?.action === 'delete') throw new Error('row has a pending delete')
|
|
96
108
|
const action = queued.rows[0]?.action === 'create' && queued.rows[0].acknowledged_version == null ? 'create' : 'update'
|
|
97
109
|
await queueMutation(tx, id, action, row)
|
|
98
110
|
})
|
|
99
111
|
notify()
|
|
100
|
-
if (started)
|
|
112
|
+
if (started) background(flushQueue())
|
|
101
113
|
return row
|
|
102
114
|
}
|
|
103
115
|
|
|
@@ -108,7 +120,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
108
120
|
await queueMutation(tx, id, 'delete', null)
|
|
109
121
|
})
|
|
110
122
|
notify()
|
|
111
|
-
if (started)
|
|
123
|
+
if (started) background(flushQueue())
|
|
112
124
|
}
|
|
113
125
|
|
|
114
126
|
async function queueMutation(tx, id, action, data) {
|
|
@@ -118,7 +130,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
118
130
|
action = EXCLUDED.action, data = EXCLUDED.data,
|
|
119
131
|
revision = nextval('electric_mutation_revision_seq'),
|
|
120
132
|
acknowledged_version = NULL, status = 'pending', failure_reason = NULL`,
|
|
121
|
-
[modelName, String(id), action, data == null ? null :
|
|
133
|
+
[modelName, String(id), action, data == null ? null : json(data)])
|
|
122
134
|
}
|
|
123
135
|
|
|
124
136
|
function ordered(task) {
|
|
@@ -145,7 +157,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
145
157
|
}
|
|
146
158
|
|
|
147
159
|
async function reconcileSnapshot(rows) {
|
|
148
|
-
await db.transaction(async tx => {
|
|
160
|
+
await retryLeader(() => db.transaction(async tx => {
|
|
149
161
|
for (const row of rows) {
|
|
150
162
|
const id = String(row[primaryKey])
|
|
151
163
|
if (row.version != null) {
|
|
@@ -158,7 +170,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
158
170
|
SELECT $1, $2, $3::jsonb WHERE NOT EXISTS
|
|
159
171
|
(SELECT 1 FROM electric_mutation_queue WHERE model_name = $1 AND row_id = $2)
|
|
160
172
|
ON CONFLICT (model_name, row_id) DO UPDATE SET data = EXCLUDED.data`,
|
|
161
|
-
[modelName, id,
|
|
173
|
+
[modelName, id, json(row)])
|
|
162
174
|
}
|
|
163
175
|
const activeIds = rows.filter(row => !row.deleted).map(row => String(row[primaryKey]))
|
|
164
176
|
await tx.query(`DELETE FROM electric_local_rows AS local
|
|
@@ -166,7 +178,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
166
178
|
AND NOT EXISTS (SELECT 1 FROM electric_mutation_queue AS pending
|
|
167
179
|
WHERE pending.model_name = local.model_name AND pending.row_id = local.row_id)`,
|
|
168
180
|
[modelName, activeIds])
|
|
169
|
-
})
|
|
181
|
+
}))
|
|
170
182
|
notify()
|
|
171
183
|
}
|
|
172
184
|
|
|
@@ -174,15 +186,17 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
174
186
|
if (!ownsSync || flushing || network?.onLine === false) return
|
|
175
187
|
flushing = true
|
|
176
188
|
try {
|
|
177
|
-
while (
|
|
178
|
-
const { rows } = await db.query(`SELECT * FROM electric_mutation_queue
|
|
189
|
+
while (ownsSync) {
|
|
190
|
+
const { rows } = await retryLeader(() => db.query(`SELECT * FROM electric_mutation_queue
|
|
179
191
|
WHERE model_name = $1 AND status = 'pending' AND acknowledged_version IS NULL
|
|
180
|
-
ORDER BY revision LIMIT 1`, [modelName])
|
|
192
|
+
ORDER BY revision LIMIT 1`, [modelName]))
|
|
181
193
|
const mutation = rows[0]
|
|
182
194
|
if (!mutation) break
|
|
183
195
|
try {
|
|
184
196
|
await sendMutation(mutation)
|
|
197
|
+
lastError = null
|
|
185
198
|
} catch (error) {
|
|
199
|
+
report(error)
|
|
186
200
|
if (!isPermanent(error)) break
|
|
187
201
|
await db.query(`UPDATE electric_mutation_queue SET status = 'failed', failure_reason = $3
|
|
188
202
|
WHERE model_name = $1 AND row_id = $2 AND revision = $4`,
|
|
@@ -196,7 +210,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
196
210
|
}
|
|
197
211
|
|
|
198
212
|
async function sendMutation(mutation) {
|
|
199
|
-
const client = await db.query('SELECT id FROM electric_sync_client WHERE singleton = true')
|
|
213
|
+
const client = await retryLeader(() => db.query('SELECT id FROM electric_sync_client WHERE singleton = true'))
|
|
200
214
|
const metadata = { clientId: client.rows[0].id, revision: String(mutation.revision) }
|
|
201
215
|
const { row_id: id, data, action } = mutation
|
|
202
216
|
const result = action === 'create'
|
|
@@ -205,7 +219,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
205
219
|
? await service.update(id, withoutKey(data), metadata)
|
|
206
220
|
: await service.delete(id, metadata)
|
|
207
221
|
if (result?.version == null) throw new Error('sync mutation did not return a version')
|
|
208
|
-
await db.transaction(async tx => {
|
|
222
|
+
await retryLeader(() => db.transaction(async tx => {
|
|
209
223
|
const current = await tx.query('SELECT action, revision, data FROM electric_mutation_queue WHERE model_name = $1 AND row_id = $2', [modelName, id])
|
|
210
224
|
const queued = current.rows[0]
|
|
211
225
|
if (!queued) return
|
|
@@ -217,7 +231,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
217
231
|
await tx.query(`UPDATE electric_mutation_queue SET action = 'update',
|
|
218
232
|
revision = nextval('electric_mutation_revision_seq') WHERE model_name = $1 AND row_id = $2`, [modelName, id])
|
|
219
233
|
}
|
|
220
|
-
})
|
|
234
|
+
}))
|
|
221
235
|
await reconcile()
|
|
222
236
|
notify()
|
|
223
237
|
}
|
|
@@ -235,45 +249,64 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
235
249
|
started = true
|
|
236
250
|
channel?.addEventListener('message', sharedChange)
|
|
237
251
|
globalThis.addEventListener?.('online', online)
|
|
238
|
-
if (!ownsSync) return
|
|
239
|
-
|
|
252
|
+
if (!ownsSync) { channel?.postMessage({ modelName, requestStatus: true }); return }
|
|
253
|
+
const generation = ++streamGeneration
|
|
254
|
+
stream = new ShapeStreamClass({ ...streamOptions, url })
|
|
240
255
|
stream.subscribe(messages => {
|
|
241
|
-
if (
|
|
256
|
+
if (generation !== streamGeneration) return
|
|
257
|
+
if (messages.some(message => message.headers?.control === 'must-refetch')) background(resetSnapshot())
|
|
242
258
|
connected = stream.isConnected?.() ?? true
|
|
243
259
|
notify()
|
|
244
|
-
},
|
|
260
|
+
}, error => { if (generation !== streamGeneration) return; connected = false; report(error) })
|
|
245
261
|
const shape = new ShapeClass(stream)
|
|
246
|
-
shape.subscribe(({ rows }) => { connected = true;
|
|
247
|
-
timer = setInterval(() => {
|
|
248
|
-
|
|
262
|
+
shape.subscribe(({ rows }) => { if (generation !== streamGeneration) return; connected = true; background(applySnapshot([...rows])) })
|
|
263
|
+
timer = setInterval(() => { background(flushQueue()) }, retryMs)
|
|
264
|
+
background(flushQueue())
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function setSyncOwner(value) {
|
|
268
|
+
if (ownsSync === value) return
|
|
269
|
+
const restart = started
|
|
270
|
+
stop()
|
|
271
|
+
ownsSync = value
|
|
272
|
+
if (restart) start()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function waitForIdle() {
|
|
276
|
+
while (pendingBackground.size) await Promise.allSettled([...pendingBackground])
|
|
277
|
+
await operation
|
|
249
278
|
}
|
|
250
279
|
|
|
251
280
|
function stop() {
|
|
252
281
|
if (!started) return
|
|
253
282
|
started = false
|
|
283
|
+
++streamGeneration
|
|
254
284
|
clearInterval(timer)
|
|
255
285
|
stream?.unsubscribeAll()
|
|
256
286
|
channel?.removeEventListener('message', sharedChange)
|
|
257
287
|
globalThis.removeEventListener?.('online', online)
|
|
258
288
|
connected = false
|
|
289
|
+
notify()
|
|
259
290
|
}
|
|
260
291
|
|
|
261
|
-
function online() {
|
|
292
|
+
function online() { background(flushQueue()) }
|
|
262
293
|
function sharedChange(event) {
|
|
263
294
|
if (event.data?.modelName !== modelName) return
|
|
295
|
+
if (!ownsSync && typeof event.data.online === 'boolean') connected = event.data.online
|
|
296
|
+
if (ownsSync && event.data.requestStatus) notify()
|
|
264
297
|
for (const listener of listeners) queueMicrotask(() => { try { listener() } catch (error) { console.error(error) } })
|
|
265
|
-
if (ownsSync)
|
|
298
|
+
if (ownsSync) background(flushQueue())
|
|
266
299
|
}
|
|
267
300
|
|
|
268
301
|
async function getStatus() {
|
|
269
|
-
const { rows } = await db.query(`SELECT
|
|
302
|
+
const { rows } = await retryLeader(() => db.query(`SELECT
|
|
270
303
|
count(*) FILTER (WHERE status = 'pending')::int AS pending,
|
|
271
304
|
count(*) FILTER (WHERE status = 'failed')::int AS failed
|
|
272
|
-
FROM electric_mutation_queue WHERE model_name = $1`, [modelName])
|
|
273
|
-
return { ...rows[0], online: network?.onLine !== false && connected }
|
|
305
|
+
FROM electric_mutation_queue WHERE model_name = $1`, [modelName]))
|
|
306
|
+
return { ...rows[0], online: network?.onLine !== false && connected, ...(lastError ? { error: lastError } : {}) }
|
|
274
307
|
}
|
|
275
308
|
|
|
276
|
-
return { prepare, start, stop, subscribe, findMany, findUnique, getObservable,
|
|
309
|
+
return { prepare, start, stop, setSyncOwner, waitForIdle, subscribe, findMany, findUnique, getObservable,
|
|
277
310
|
create, update, remove, flushQueue, getStatus }
|
|
278
311
|
}
|
|
279
312
|
|
|
@@ -309,3 +342,19 @@ function isPermanent(error) {
|
|
|
309
342
|
return error?.code === 'forbidden' || error instanceof TypeError ||
|
|
310
343
|
status >= 400 && status < 500 && ![408, 425, 429].includes(status)
|
|
311
344
|
}
|
|
345
|
+
|
|
346
|
+
function json(value) {
|
|
347
|
+
return JSON.stringify(value, (_key, item) => typeof item === 'bigint' ? String(item) : item)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Snapshots and reads are safe to repeat if PGlite's worker changes mid-request.
|
|
351
|
+
// Foreground local mutation transactions may have committed; callers see their error.
|
|
352
|
+
async function retryLeader(action) {
|
|
353
|
+
for (let attempt = 0; ; attempt++) {
|
|
354
|
+
try { return await action() } catch (error) {
|
|
355
|
+
if (!(error?.name === 'LeaderChangedError' || error?.constructor?.name === 'LeaderChangedError' ||
|
|
356
|
+
error?.message === 'Leader changed, pending operation in indeterminate state') || attempt >= 7) throw error
|
|
357
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(25 * 2 ** attempt, 250)))
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
@@ -142,6 +142,7 @@ test('model mutations retain the simple Express-X API', async () => {
|
|
|
142
142
|
await todo.update(created.uid, { completed: true })
|
|
143
143
|
await todo.remove(created.uid)
|
|
144
144
|
assert.deepEqual(calls.map(call => call[0]), ['create', 'update', 'remove'])
|
|
145
|
+
assert.deepEqual(calls[0][2], { title: 'Test' })
|
|
145
146
|
})
|
|
146
147
|
|
|
147
148
|
test('model creation supports server-generated IDs', async () => {
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { PGlite } from '@electric-sql/pglite'
|
|
4
|
+
import { electricClientPlugin } from '../src/electric-client-plugin.mjs'
|
|
5
|
+
|
|
6
|
+
class Locks {
|
|
7
|
+
queues = new Map()
|
|
8
|
+
request(name, { signal }, callback) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const queue = this.queues.get(name) ?? []
|
|
11
|
+
this.queues.set(name, queue)
|
|
12
|
+
const job = { callback, resolve, reject, active: false }
|
|
13
|
+
signal.addEventListener('abort', () => {
|
|
14
|
+
if (job.active) return
|
|
15
|
+
const index = queue.indexOf(job)
|
|
16
|
+
if (index !== -1) queue.splice(index, 1)
|
|
17
|
+
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
|
|
18
|
+
}, { once: true })
|
|
19
|
+
queue.push(job)
|
|
20
|
+
if (queue.length === 1) this.run(name)
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
run(name) {
|
|
24
|
+
const queue = this.queues.get(name)
|
|
25
|
+
const job = queue[0]
|
|
26
|
+
if (!job) return
|
|
27
|
+
job.active = true
|
|
28
|
+
Promise.resolve().then(job.callback).then(job.resolve, job.reject).finally(() => {
|
|
29
|
+
queue.shift()
|
|
30
|
+
this.run(name)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
class Channel extends EventTarget {
|
|
35
|
+
static instances = new Set()
|
|
36
|
+
constructor(name) { super(); this.name = name; Channel.instances.add(this) }
|
|
37
|
+
postMessage(data) {
|
|
38
|
+
for (const channel of Channel.instances) {
|
|
39
|
+
if (channel !== this && channel.name === this.name) channel.dispatchEvent(new MessageEvent('message', { data }))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
close() { Channel.instances.delete(this) }
|
|
43
|
+
}
|
|
44
|
+
class Stream {
|
|
45
|
+
static instances = []
|
|
46
|
+
constructor(options) { this.options = options; Stream.instances.push(this) }
|
|
47
|
+
subscribe() { return () => {} }
|
|
48
|
+
isConnected() { return true }
|
|
49
|
+
unsubscribeAll() { this.stopped = true }
|
|
50
|
+
}
|
|
51
|
+
class Shape {
|
|
52
|
+
constructor(stream) { stream.shape = this }
|
|
53
|
+
subscribe(callback) { this.emit = callback; return () => {} }
|
|
54
|
+
}
|
|
55
|
+
async function until(predicate) {
|
|
56
|
+
for (let i = 0; i < 100; i++) {
|
|
57
|
+
if (await predicate()) return
|
|
58
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
59
|
+
}
|
|
60
|
+
assert.fail('condition did not become true')
|
|
61
|
+
}
|
|
62
|
+
function configure(db, locks, service = {}, extra = {}) {
|
|
63
|
+
const app = { service: () => service }
|
|
64
|
+
electricClientPlugin(app, {
|
|
65
|
+
sync: true, databaseName: 'test-db', localDb: db, locks,
|
|
66
|
+
BroadcastChannel: Channel, ShapeStream: Stream, Shape, ...extra,
|
|
67
|
+
})
|
|
68
|
+
return app
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test('managed models initialize automatically, share one database, and cache models', async () => {
|
|
72
|
+
const db = new PGlite()
|
|
73
|
+
let created = 0
|
|
74
|
+
const locks = new Locks()
|
|
75
|
+
const app = configure(undefined, locks, {}, { createDatabase: async ({ dataDir }) => {
|
|
76
|
+
assert.equal(dataDir, 'idb://test-db'); created++; return db
|
|
77
|
+
} })
|
|
78
|
+
try {
|
|
79
|
+
const users = app.createElectricModel('users', { primaryKey: 'uid' })
|
|
80
|
+
const ranges = app.createElectricModel('ranges', { primaryKey: 'uid' })
|
|
81
|
+
assert.equal(app.createElectricModel('users', { primaryKey: 'uid' }), users)
|
|
82
|
+
assert.throws(() => app.createElectricModel('users'), /primaryKey/)
|
|
83
|
+
const row = await users.create({ name: 'Works before prepare' })
|
|
84
|
+
await ranges.prepare()
|
|
85
|
+
assert.equal(created, 1)
|
|
86
|
+
assert.equal((await users.findUnique({ uid: row.uid })).name, row.name)
|
|
87
|
+
} finally {
|
|
88
|
+
await app.disposeElectricSync()
|
|
89
|
+
}
|
|
90
|
+
assert.equal(db.closed, true)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('only one tab streams and flushes; follower edits broadcast and ownership transfers', async () => {
|
|
94
|
+
const db = new PGlite()
|
|
95
|
+
const locks = new Locks()
|
|
96
|
+
const calls = []
|
|
97
|
+
const service = { async create(id, data) { calls.push(id); return { id, ...data, version: '42' } } }
|
|
98
|
+
const first = configure(db, locks, service)
|
|
99
|
+
const second = configure(db, locks, service)
|
|
100
|
+
const before = Stream.instances.length
|
|
101
|
+
let subscription
|
|
102
|
+
try {
|
|
103
|
+
const owner = first.createElectricModel('todos')
|
|
104
|
+
const follower = second.createElectricModel('todos')
|
|
105
|
+
await owner.prepare()
|
|
106
|
+
await follower.prepare()
|
|
107
|
+
await until(() => Stream.instances.length === before + 1)
|
|
108
|
+
let observed = []
|
|
109
|
+
subscription = owner.getObservable().subscribe(rows => { observed = rows })
|
|
110
|
+
const row = await follower.create({ title: 'Written in follower' })
|
|
111
|
+
await until(() => calls.length === 1 && observed.length === 1)
|
|
112
|
+
assert.equal(observed[0].id, row.id)
|
|
113
|
+
assert.equal(Stream.instances.length, before + 1)
|
|
114
|
+
const stream = Stream.instances.at(-1)
|
|
115
|
+
stream.shape.emit({ rows: [{ ...row, version: 42n, deleted: false }] })
|
|
116
|
+
await until(async () => (await follower.getStatus()).pending === 0)
|
|
117
|
+
assert.equal((await follower.findMany())[0].version, '42')
|
|
118
|
+
await owner.dispose()
|
|
119
|
+
await until(() => Stream.instances.length === before + 2)
|
|
120
|
+
assert.equal(stream.stopped, true)
|
|
121
|
+
const next = Stream.instances.at(-1)
|
|
122
|
+
next.shape.emit({ rows: [{ ...row, version: 42n, deleted: false }] })
|
|
123
|
+
await until(async () => (await follower.findMany()).length === 1)
|
|
124
|
+
assert.equal(calls.length, 1)
|
|
125
|
+
} finally {
|
|
126
|
+
subscription?.unsubscribe()
|
|
127
|
+
await first.disposeElectricSync()
|
|
128
|
+
await second.disposeElectricSync()
|
|
129
|
+
await db.close()
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('stopping a waiting follower cancels takeover; restart can acquire ownership', async () => {
|
|
134
|
+
const db = new PGlite()
|
|
135
|
+
const locks = new Locks()
|
|
136
|
+
const first = configure(db, locks)
|
|
137
|
+
const second = configure(db, locks)
|
|
138
|
+
const before = Stream.instances.length
|
|
139
|
+
try {
|
|
140
|
+
const owner = first.createElectricModel('todos')
|
|
141
|
+
const follower = second.createElectricModel('todos')
|
|
142
|
+
await owner.prepare(); await follower.prepare()
|
|
143
|
+
await until(() => Stream.instances.length === before + 1)
|
|
144
|
+
follower.stop()
|
|
145
|
+
owner.stop()
|
|
146
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
147
|
+
assert.equal(Stream.instances.length, before + 1)
|
|
148
|
+
await follower.start()
|
|
149
|
+
await until(() => Stream.instances.length === before + 2)
|
|
150
|
+
} finally {
|
|
151
|
+
await first.disposeElectricSync(); await second.disposeElectricSync(); await db.close()
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('initialization errors reject writes and observable reads and call onError', async () => {
|
|
156
|
+
const error = new Error('Worker could not load assets')
|
|
157
|
+
const reported = []
|
|
158
|
+
const app = configure(undefined, new Locks(), {}, {
|
|
159
|
+
createDatabase: async () => { throw error },
|
|
160
|
+
onError: cause => reported.push(cause),
|
|
161
|
+
})
|
|
162
|
+
const model = app.createElectricModel('todos')
|
|
163
|
+
await assert.rejects(model.create({ title: 'No silent failure' }), /load assets/)
|
|
164
|
+
await new Promise(resolve => model.getObservable().subscribe({ error: cause => {
|
|
165
|
+
assert.equal(cause, error); resolve()
|
|
166
|
+
} }))
|
|
167
|
+
assert.deepEqual(reported, [error])
|
|
168
|
+
await assert.rejects(app.disposeElectricSync(), /load assets/)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('sync mode validates browser capabilities and persistent database identity', () => {
|
|
172
|
+
const app = { service: () => ({}) }
|
|
173
|
+
assert.throws(() => electricClientPlugin(app, { sync: true }), /databaseName/)
|
|
174
|
+
assert.throws(() => electricClientPlugin(app, { sync: true, databaseName: 'todos', locks: {} }), /Web Locks/)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('default worker errors and timeouts reject preparation instead of hanging', async () => {
|
|
178
|
+
for (const triggerError of [true, false]) {
|
|
179
|
+
let terminated = false
|
|
180
|
+
class Worker extends EventTarget {
|
|
181
|
+
constructor() {
|
|
182
|
+
super()
|
|
183
|
+
if (triggerError) setTimeout(() => {
|
|
184
|
+
const event = new Event('error')
|
|
185
|
+
event.message = 'worker module not found'
|
|
186
|
+
this.dispatchEvent(event)
|
|
187
|
+
}, 10)
|
|
188
|
+
}
|
|
189
|
+
terminate() { terminated = true }
|
|
190
|
+
}
|
|
191
|
+
const app = configure(undefined, new Locks(), {}, { workerFactory: () => new Worker(), initTimeoutMs: 30 })
|
|
192
|
+
const model = app.createElectricModel('todos')
|
|
193
|
+
await assert.rejects(model.prepare(), triggerError ? /module not found/ : /timed out/)
|
|
194
|
+
assert.equal(terminated, true)
|
|
195
|
+
await assert.rejects(app.disposeElectricSync())
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('snapshots and reads recover after a database leader changes', async () => {
|
|
200
|
+
const db = new PGlite()
|
|
201
|
+
let failSnapshot = false
|
|
202
|
+
let failRead = false
|
|
203
|
+
let snapshotRetries = 0
|
|
204
|
+
const leaderError = () => Object.assign(new Error('Leader changed, pending operation in indeterminate state'), { name: 'LeaderChangedError' })
|
|
205
|
+
const shared = {
|
|
206
|
+
exec: db.exec.bind(db),
|
|
207
|
+
async query(...args) {
|
|
208
|
+
if (failRead) { failRead = false; throw leaderError() }
|
|
209
|
+
return db.query(...args)
|
|
210
|
+
},
|
|
211
|
+
async transaction(callback) {
|
|
212
|
+
if (failSnapshot) { failSnapshot = false; snapshotRetries++; throw leaderError() }
|
|
213
|
+
return db.transaction(callback)
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
const app = configure(shared, new Locks())
|
|
217
|
+
const before = Stream.instances.length
|
|
218
|
+
try {
|
|
219
|
+
const model = app.createElectricModel('todos')
|
|
220
|
+
await model.prepare()
|
|
221
|
+
await until(() => Stream.instances.length === before + 1)
|
|
222
|
+
failSnapshot = true
|
|
223
|
+
const id = crypto.randomUUID()
|
|
224
|
+
Stream.instances.at(-1).shape.emit({ rows: [{ id, title: 'After election', version: 99n, deleted: false }] })
|
|
225
|
+
await until(async () => (await model.findMany()).length === 1)
|
|
226
|
+
assert.equal(snapshotRetries, 1)
|
|
227
|
+
failRead = true
|
|
228
|
+
assert.equal((await model.findUnique({ id })).version, '99')
|
|
229
|
+
assert.equal((await model.getStatus()).error, null)
|
|
230
|
+
} finally { await app.disposeElectricSync(); await db.close() }
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('an indeterminate foreground mutation is not replayed automatically', async () => {
|
|
234
|
+
const db = new PGlite()
|
|
235
|
+
let writes = 0
|
|
236
|
+
const app = { service: () => ({}) }
|
|
237
|
+
const localDb = {
|
|
238
|
+
exec: db.exec.bind(db), query: db.query.bind(db),
|
|
239
|
+
async transaction(callback) {
|
|
240
|
+
writes++
|
|
241
|
+
await db.transaction(callback)
|
|
242
|
+
throw Object.assign(new Error('Leader changed, pending operation in indeterminate state'), { name: 'LeaderChangedError' })
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
electricClientPlugin(app)
|
|
246
|
+
const model = app.createElectricModel('todos', { localDb, ownsSync: false })
|
|
247
|
+
try {
|
|
248
|
+
await model.prepare()
|
|
249
|
+
await assert.rejects(model.create({ title: 'Committed but reply lost' }), /Leader changed/)
|
|
250
|
+
assert.equal(writes, 1)
|
|
251
|
+
assert.equal((await model.findMany()).length, 1)
|
|
252
|
+
assert.equal((await model.getStatus()).pending, 1)
|
|
253
|
+
} finally { await db.close() }
|
|
254
|
+
})
|