@jcbuisson/express-x-plugins 5.0.6 → 5.0.7
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 +12 -2
- package/package.json +1 -1
- package/src/electric-client-plugin.mjs +4 -4
- package/src/electric-managed-sync.mjs +11 -3
- package/src/electric-server-plugin.mjs +1 -1
- package/test/electric-managed-sync.test.mjs +27 -14
- package/test/electric-offline-model.test.mjs +5 -5
- package/test/electric-server-plugin.test.mjs +9 -2
package/README.md
CHANGED
|
@@ -150,11 +150,12 @@ app.configure(electricClientPlugin, {
|
|
|
150
150
|
shapePath: '/electric/v1/shape',
|
|
151
151
|
})
|
|
152
152
|
|
|
153
|
-
// Client-generated UUID stored in the default `
|
|
153
|
+
// Client-generated UUID stored in the default `uid` primary key:
|
|
154
154
|
const todo = app.createElectricModel('todos')
|
|
155
155
|
|
|
156
156
|
// Or, for a database-generated primary key such as SERIAL/IDENTITY:
|
|
157
157
|
const numberedTodo = app.createElectricModel('numberedTodos', {
|
|
158
|
+
primaryKey: 'id',
|
|
158
159
|
idGeneration: 'server',
|
|
159
160
|
})
|
|
160
161
|
|
|
@@ -192,7 +193,7 @@ app.configure(electricClientPlugin, {
|
|
|
192
193
|
onError: (error, { modelName }) => console.error(modelName, error),
|
|
193
194
|
})
|
|
194
195
|
|
|
195
|
-
const ranges = app.createElectricModel('range'
|
|
196
|
+
const ranges = app.createElectricModel('range')
|
|
196
197
|
const subscription = ranges.getObservable().subscribe(rows => console.log(rows))
|
|
197
198
|
await ranges.create({ label: 'Works offline' })
|
|
198
199
|
await ranges.update(uid, { label: 'Edited offline' })
|
|
@@ -365,3 +366,12 @@ Disposing a model removes its network listeners. Browser connectivity is a hint;
|
|
|
365
366
|
requests can still fail while reported online and retain the usual retry behavior.
|
|
366
367
|
For custom runtimes/tests, plugin options `network` and `networkEvents` can supply
|
|
367
368
|
the connectivity state and an EventTarget respectively.
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
Models default to `primaryKey: 'uid'` on both client and server. Set
|
|
372
|
+
`primaryKey: 'id'` explicitly for tables that use `id`.
|
|
373
|
+
|
|
374
|
+
With `sync: true`, the sync owner's Electric stream stays active when its tab is
|
|
375
|
+
hidden because it serves other tabs too. To customize this behavior, pass
|
|
376
|
+
`streamOptions.runtimeVisibility` to `createElectricModel`. Other stream options
|
|
377
|
+
are preserved. Independent streams retain Electric's normal visibility behavior.
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@ import { createManagedSync } from './electric-managed-sync.mjs'
|
|
|
9
9
|
*
|
|
10
10
|
* Usage:
|
|
11
11
|
* electricClientPlugin(app)
|
|
12
|
-
* const todoModel = app.createElectricModel('todos') //
|
|
12
|
+
* const todoModel = app.createElectricModel('todos') // client-generated UUID in the default uid primary key
|
|
13
13
|
* const todoMilk = await todoModel.create({ label: "Buy milk" })
|
|
14
14
|
* todoMilk.update({ completed: true })
|
|
15
15
|
* todoModel.getObservable({ completed: false }).subscribe(...) // subscribe to an observable for todos with completed===false
|
|
@@ -26,7 +26,7 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
26
26
|
|
|
27
27
|
function createElectricModel(modelName, modelOptions = {}) {
|
|
28
28
|
quoteIdentifier(modelName)
|
|
29
|
-
quoteIdentifier(modelOptions.primaryKey ?? '
|
|
29
|
+
quoteIdentifier(modelOptions.primaryKey ?? 'uid')
|
|
30
30
|
if (managed) {
|
|
31
31
|
if (modelOptions.idGeneration === 'server') throw new TypeError('sync models require client-generated UUIDs')
|
|
32
32
|
return managed.model(modelName, modelOptions)
|
|
@@ -39,7 +39,7 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
39
39
|
const url = modelOptions.url ?? modelPath(shapePath, modelName)
|
|
40
40
|
if (modelOptions.localDb) return createOfflineElectricModel({
|
|
41
41
|
db: modelOptions.localDb, service, modelName, url,
|
|
42
|
-
primaryKey: modelOptions.primaryKey ?? '
|
|
42
|
+
primaryKey: modelOptions.primaryKey ?? 'uid',
|
|
43
43
|
ShapeStreamClass, ShapeClass,
|
|
44
44
|
ownsSync: modelOptions.ownsSync ?? true,
|
|
45
45
|
channel: modelOptions.channel,
|
|
@@ -50,7 +50,7 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
50
50
|
})
|
|
51
51
|
const streamOptions = modelOptions.streamOptions ?? {}
|
|
52
52
|
const idGeneration = modelOptions.idGeneration ?? 'client'
|
|
53
|
-
const primaryKey = modelOptions.primaryKey ?? '
|
|
53
|
+
const primaryKey = modelOptions.primaryKey ?? 'uid'
|
|
54
54
|
quoteIdentifier(primaryKey)
|
|
55
55
|
if (!['client', 'server'].includes(idGeneration)) {
|
|
56
56
|
throw new TypeError("idGeneration must be 'client' or 'server'")
|
|
@@ -47,7 +47,7 @@ export function createManagedSync(options, createModel) {
|
|
|
47
47
|
if (closed) throw new Error('Electric sync has been disposed')
|
|
48
48
|
const existing = models.get(name)
|
|
49
49
|
if (existing) {
|
|
50
|
-
if (existing.primaryKey !== (modelOptions.primaryKey ?? '
|
|
50
|
+
if (existing.primaryKey !== (modelOptions.primaryKey ?? 'uid')) throw new TypeError('A cached sync model cannot change primaryKey')
|
|
51
51
|
return existing.api
|
|
52
52
|
}
|
|
53
53
|
let active = true
|
|
@@ -66,7 +66,15 @@ export function createManagedSync(options, createModel) {
|
|
|
66
66
|
}
|
|
67
67
|
const ready = getDatabase().then(async db => {
|
|
68
68
|
channel = new Channel(`express-x:${databaseName}:${name}`)
|
|
69
|
-
|
|
69
|
+
// The sync owner serves every tab, even while its own tab is hidden.
|
|
70
|
+
const streamOptions = {
|
|
71
|
+
...modelOptions.streamOptions,
|
|
72
|
+
runtimeVisibility: modelOptions.streamOptions?.runtimeVisibility ?? {
|
|
73
|
+
getCurrentState: () => 'visible',
|
|
74
|
+
subscribe: () => () => {},
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
offline = createModel(name, { ...modelOptions, streamOptions, network, localDb: db, channel, ownsSync: false, onError: report })
|
|
70
78
|
const prepared = preparation.then(() => offline.prepare())
|
|
71
79
|
preparation = prepared.catch(() => {})
|
|
72
80
|
await prepared
|
|
@@ -153,7 +161,7 @@ export function createManagedSync(options, createModel) {
|
|
|
153
161
|
flushQueue: () => invoke('flushQueue'),
|
|
154
162
|
getStatus: async () => ({ ...await invoke('getStatus'), error: error?.message ?? null }),
|
|
155
163
|
}
|
|
156
|
-
models.set(name, { api, primaryKey: modelOptions.primaryKey ?? '
|
|
164
|
+
models.set(name, { api, primaryKey: modelOptions.primaryKey ?? 'uid' })
|
|
157
165
|
return api
|
|
158
166
|
}
|
|
159
167
|
async function dispose() {
|
|
@@ -253,7 +253,7 @@ function normalizeModels(models) {
|
|
|
253
253
|
}
|
|
254
254
|
const name = config.name
|
|
255
255
|
const table = config.table ?? name
|
|
256
|
-
const primaryKey = config.primaryKey ?? '
|
|
256
|
+
const primaryKey = config.primaryKey ?? 'uid'
|
|
257
257
|
quoteIdentifier(name, 'model name')
|
|
258
258
|
const tombstoneData = config.tombstoneData ?? {}
|
|
259
259
|
if (typeof tombstoneData !== 'function') validateTombstoneData(tombstoneData, primaryKey)
|
|
@@ -76,13 +76,26 @@ test('managed models initialize automatically, share one database, and cache mod
|
|
|
76
76
|
assert.equal(dataDir, 'idb://test-db'); created++; return db
|
|
77
77
|
} })
|
|
78
78
|
try {
|
|
79
|
-
const users = app.createElectricModel('users'
|
|
80
|
-
const
|
|
79
|
+
const users = app.createElectricModel('users')
|
|
80
|
+
const customVisibility = { getCurrentState: () => 'hidden', subscribe: () => () => {} }
|
|
81
|
+
const ranges = app.createElectricModel('ranges', { streamOptions: { runtimeVisibility: customVisibility, params: { test: 'preserved' } } })
|
|
81
82
|
assert.equal(app.createElectricModel('users', { primaryKey: 'uid' }), users)
|
|
82
|
-
assert.
|
|
83
|
+
assert.equal(app.createElectricModel('users'), users)
|
|
84
|
+
assert.throws(() => app.createElectricModel('users', { primaryKey: 'id' }), /primaryKey/)
|
|
83
85
|
const row = await users.create({ name: 'Works before prepare' })
|
|
84
86
|
await ranges.prepare()
|
|
85
87
|
assert.equal(created, 1)
|
|
88
|
+
await until(() => Stream.instances.some(stream => stream.options.url.endsWith('/users')))
|
|
89
|
+
const visibility = Stream.instances.findLast(stream => stream.options.url.endsWith('/users')).options.runtimeVisibility
|
|
90
|
+
assert.equal(visibility.getCurrentState(), 'visible')
|
|
91
|
+
let notified = false
|
|
92
|
+
const cleanup = visibility.subscribe(() => { notified = true })
|
|
93
|
+
cleanup()
|
|
94
|
+
assert.equal(notified, false)
|
|
95
|
+
await until(() => Stream.instances.some(stream => stream.options.url.endsWith('/ranges')))
|
|
96
|
+
const rangeOptions = Stream.instances.findLast(stream => stream.options.url.endsWith('/ranges')).options
|
|
97
|
+
assert.equal(rangeOptions.runtimeVisibility, customVisibility)
|
|
98
|
+
assert.deepEqual(rangeOptions.params, { test: 'preserved' })
|
|
86
99
|
assert.equal((await users.findUnique({ uid: row.uid })).name, row.name)
|
|
87
100
|
} finally {
|
|
88
101
|
await app.disposeElectricSync()
|
|
@@ -100,8 +113,8 @@ test('only one tab streams and flushes; follower edits broadcast and ownership t
|
|
|
100
113
|
const before = Stream.instances.length
|
|
101
114
|
let subscription
|
|
102
115
|
try {
|
|
103
|
-
const owner = first.createElectricModel('todos')
|
|
104
|
-
const follower = second.createElectricModel('todos')
|
|
116
|
+
const owner = first.createElectricModel('todos', { primaryKey: 'id' })
|
|
117
|
+
const follower = second.createElectricModel('todos', { primaryKey: 'id' })
|
|
105
118
|
await owner.prepare()
|
|
106
119
|
await follower.prepare()
|
|
107
120
|
await until(() => Stream.instances.length === before + 1)
|
|
@@ -137,8 +150,8 @@ test('stopping a waiting follower cancels takeover; restart can acquire ownershi
|
|
|
137
150
|
const second = configure(db, locks)
|
|
138
151
|
const before = Stream.instances.length
|
|
139
152
|
try {
|
|
140
|
-
const owner = first.createElectricModel('todos')
|
|
141
|
-
const follower = second.createElectricModel('todos')
|
|
153
|
+
const owner = first.createElectricModel('todos', { primaryKey: 'id' })
|
|
154
|
+
const follower = second.createElectricModel('todos', { primaryKey: 'id' })
|
|
142
155
|
await owner.prepare(); await follower.prepare()
|
|
143
156
|
await until(() => Stream.instances.length === before + 1)
|
|
144
157
|
follower.stop()
|
|
@@ -169,8 +182,8 @@ test('a returning sync owner preserves shared rows until its new snapshot arrive
|
|
|
169
182
|
const changed = { ...original, title: 'updated by next owner', version: '2' }
|
|
170
183
|
const added = { id: crypto.randomUUID(), title: 'added by next owner', version: '2', deleted: false }
|
|
171
184
|
try {
|
|
172
|
-
const owner = first.createElectricModel('todos')
|
|
173
|
-
const follower = second.createElectricModel('todos')
|
|
185
|
+
const owner = first.createElectricModel('todos', { primaryKey: 'id' })
|
|
186
|
+
const follower = second.createElectricModel('todos', { primaryKey: 'id' })
|
|
174
187
|
await owner.prepare(); await follower.prepare()
|
|
175
188
|
await until(() => Stream.instances.length === before + 1)
|
|
176
189
|
Stream.instances.at(-1).shape.emit({ rows: [original] })
|
|
@@ -209,7 +222,7 @@ test('initialization errors reject writes and observable reads and call onError'
|
|
|
209
222
|
createDatabase: async () => { throw error },
|
|
210
223
|
onError: cause => reported.push(cause),
|
|
211
224
|
})
|
|
212
|
-
const model = app.createElectricModel('todos')
|
|
225
|
+
const model = app.createElectricModel('todos', { primaryKey: 'id' })
|
|
213
226
|
await assert.rejects(model.create({ title: 'No silent failure' }), /load assets/)
|
|
214
227
|
await new Promise(resolve => model.getObservable().subscribe({ error: cause => {
|
|
215
228
|
assert.equal(cause, error); resolve()
|
|
@@ -239,7 +252,7 @@ test('default worker errors and timeouts reject preparation instead of hanging',
|
|
|
239
252
|
terminate() { terminated = true }
|
|
240
253
|
}
|
|
241
254
|
const app = configure(undefined, new Locks(), {}, { workerFactory: () => new Worker(), initTimeoutMs: 30 })
|
|
242
|
-
const model = app.createElectricModel('todos')
|
|
255
|
+
const model = app.createElectricModel('todos', { primaryKey: 'id' })
|
|
243
256
|
await assert.rejects(model.prepare(), triggerError ? /module not found/ : /timed out/)
|
|
244
257
|
assert.equal(terminated, true)
|
|
245
258
|
await assert.rejects(app.disposeElectricSync())
|
|
@@ -266,7 +279,7 @@ test('snapshots and reads recover after a database leader changes', async () =>
|
|
|
266
279
|
const app = configure(shared, new Locks())
|
|
267
280
|
const before = Stream.instances.length
|
|
268
281
|
try {
|
|
269
|
-
const model = app.createElectricModel('todos')
|
|
282
|
+
const model = app.createElectricModel('todos', { primaryKey: 'id' })
|
|
270
283
|
await model.prepare()
|
|
271
284
|
await until(() => Stream.instances.length === before + 1)
|
|
272
285
|
failSnapshot = true
|
|
@@ -315,8 +328,8 @@ test('network lifecycle preserves offline edits, transfers ownership and respect
|
|
|
315
328
|
const before = Stream.instances.length
|
|
316
329
|
let subscription
|
|
317
330
|
try {
|
|
318
|
-
const owner = first.createElectricModel('todos')
|
|
319
|
-
const follower = second.createElectricModel('todos')
|
|
331
|
+
const owner = first.createElectricModel('todos', { primaryKey: 'id' })
|
|
332
|
+
const follower = second.createElectricModel('todos', { primaryKey: 'id' })
|
|
320
333
|
await owner.prepare(); await follower.prepare()
|
|
321
334
|
let rows = []
|
|
322
335
|
subscription = follower.getObservable().subscribe(value => { rows = value })
|
|
@@ -22,14 +22,14 @@ test('edits survive a committed create whose response was lost', async () => {
|
|
|
22
22
|
let model
|
|
23
23
|
try {
|
|
24
24
|
await server.exec('CREATE TABLE todos (id UUID PRIMARY KEY, title TEXT NOT NULL)')
|
|
25
|
-
await prepareElectricSyncSchema(server, [{ name: 'todos', tombstoneData: { title: '' } }])
|
|
25
|
+
await prepareElectricSyncSchema(server, [{ name: 'todos', primaryKey: 'id', tombstoneData: { title: '' } }])
|
|
26
26
|
let service
|
|
27
27
|
const pool = {
|
|
28
28
|
query: server.query.bind(server),
|
|
29
29
|
async connect() { return { query: server.query.bind(server), release() {} } },
|
|
30
30
|
}
|
|
31
31
|
electricServerPlugin({ createService(_name, methods) { service = methods }, get() {} }, pool,
|
|
32
|
-
[{ name: 'todos', tombstoneData: { title: '' } }], { sync: true })
|
|
32
|
+
[{ name: 'todos', primaryKey: 'id', tombstoneData: { title: '' } }], { sync: true })
|
|
33
33
|
let loseResponse = true
|
|
34
34
|
const requests = []
|
|
35
35
|
const proxy = {
|
|
@@ -42,7 +42,7 @@ test('edits survive a committed create whose response was lost', async () => {
|
|
|
42
42
|
}
|
|
43
43
|
const app = { service: () => proxy }
|
|
44
44
|
electricClientPlugin(app, { ShapeStream: Stream, Shape })
|
|
45
|
-
model = app.createElectricModel('todos', { localDb: db, retryMs: 60_000 })
|
|
45
|
+
model = app.createElectricModel('todos', { primaryKey: 'id', localDb: db, retryMs: 60_000 })
|
|
46
46
|
await model.prepare()
|
|
47
47
|
const row = await model.create({ title: 'original' })
|
|
48
48
|
await model.flushQueue()
|
|
@@ -95,7 +95,7 @@ test('offline writes stay local until the acknowledged Electric version arrives'
|
|
|
95
95
|
}
|
|
96
96
|
const app = { service: () => service }
|
|
97
97
|
electricClientPlugin(app, { ShapeStream: Stream, Shape })
|
|
98
|
-
const model = app.createElectricModel('todos', { localDb: db, network: { onLine: false }, retryMs: 60_000 })
|
|
98
|
+
const model = app.createElectricModel('todos', { primaryKey: 'id', localDb: db, network: { onLine: false }, retryMs: 60_000 })
|
|
99
99
|
try {
|
|
100
100
|
await model.prepare()
|
|
101
101
|
const local = await model.create({ title: 'offline' })
|
|
@@ -105,7 +105,7 @@ test('offline writes stay local until the acknowledged Electric version arrives'
|
|
|
105
105
|
assert.equal(calls.length, 0)
|
|
106
106
|
|
|
107
107
|
// Simulate reconnect, then an old Shape snapshot followed by confirmation.
|
|
108
|
-
const onlineModel = app.createElectricModel('todos', { localDb: db, network: { onLine: true }, retryMs: 60_000 })
|
|
108
|
+
const onlineModel = app.createElectricModel('todos', { primaryKey: 'id', localDb: db, network: { onLine: true }, retryMs: 60_000 })
|
|
109
109
|
await onlineModel.flushQueue()
|
|
110
110
|
assert.equal(calls.length, 1)
|
|
111
111
|
assert.equal(calls[0].metadata.revision, '1')
|
|
@@ -17,7 +17,7 @@ function fixture({ authorize = async () => true, fetch } = {}) {
|
|
|
17
17
|
createService(name, methods) { services.set(name, methods) },
|
|
18
18
|
get(path, handler) { routes.set(path, handler) },
|
|
19
19
|
}
|
|
20
|
-
const registration = electricServerPlugin(app, db, ['todos'], { authorize, fetch: fetch ?? globalThis.fetch })
|
|
20
|
+
const registration = electricServerPlugin(app, db, [{ name: 'todos', primaryKey: 'id' }], { authorize, fetch: fetch ?? globalThis.fetch })
|
|
21
21
|
return { app, db, services, routes, queries, registration }
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -192,7 +192,7 @@ test('sync mode commits a versioned write and rejects replayed or stale revision
|
|
|
192
192
|
|
|
193
193
|
test('sync schema prepares version and tombstone columns', async () => {
|
|
194
194
|
const queries = []
|
|
195
|
-
await prepareElectricSyncSchema({ query: async sql => { queries.push(sql) } }, ['todos'])
|
|
195
|
+
await prepareElectricSyncSchema({ query: async sql => { queries.push(sql) } }, [{ name: 'todos', primaryKey: 'id' }])
|
|
196
196
|
assert.equal(queries.length, 4)
|
|
197
197
|
assert.match(queries[2], /ADD COLUMN IF NOT EXISTS version BIGINT/)
|
|
198
198
|
assert.match(queries[3], /ADD COLUMN IF NOT EXISTS deleted BOOLEAN/)
|
|
@@ -301,3 +301,10 @@ test('invalid tombstone factory results roll back before writing a tombstone', a
|
|
|
301
301
|
assert.ok(!queries.some(sql => sql.includes('INSERT INTO "users"')))
|
|
302
302
|
}
|
|
303
303
|
})
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
test('server models default to the uid primary key', () => {
|
|
307
|
+
const app = { createService() {}, get() {} }
|
|
308
|
+
const result = electricServerPlugin(app, { query() {} }, ['users'], { authorize: async () => true })
|
|
309
|
+
assert.equal(result.models[0].primaryKey, 'uid')
|
|
310
|
+
})
|