@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 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 `id` primary key:
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', { primaryKey: 'uid' })
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-plugins",
3
- "version": "5.0.6",
3
+ "version": "5.0.7",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -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') // primary key is automatically provided by server (auto-increment)
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 ?? 'id')
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 ?? 'id',
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 ?? 'id'
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 ?? 'id')) throw new TypeError('A cached sync model cannot change 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
- offline = createModel(name, { ...modelOptions, network, localDb: db, channel, ownsSync: false, onError: report })
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 ?? 'id' })
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 ?? 'id'
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', { primaryKey: 'uid' })
80
- const ranges = app.createElectricModel('ranges', { primaryKey: 'uid' })
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.throws(() => app.createElectricModel('users'), /primaryKey/)
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
+ })