@jcbuisson/express-x-plugins 4.1.2 → 5.0.0
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
|
@@ -44,8 +44,8 @@ import { reloadPlugin } from '@jcbuisson/express-x-plugins/reload-server'
|
|
|
44
44
|
|
|
45
45
|
## Local-first Postgres plugin
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
Express-X handles authorized PostgreSQL mutations; Electric streams committed rows to clients.
|
|
48
|
+
For durable offline writes, use sync mode with a shared PGlite database.
|
|
49
49
|
|
|
50
50
|
### Server
|
|
51
51
|
|
|
@@ -56,15 +56,15 @@ npm install pg
|
|
|
56
56
|
```js
|
|
57
57
|
import { Pool } from 'pg'
|
|
58
58
|
import { expressX } from '@jcbuisson/express-x/server'
|
|
59
|
-
import { electricOfflinePlugin } from '@jcbuisson/express-x-plugins/electric-server'
|
|
59
|
+
import { electricOfflinePlugin, prepareElectricSyncSchema } from '@jcbuisson/express-x-plugins/electric-server'
|
|
60
60
|
|
|
61
61
|
const app = expressX()
|
|
62
62
|
const db = new Pool({ connectionString: process.env.DATABASE_URL })
|
|
63
63
|
|
|
64
64
|
app.configure(electricOfflinePlugin, db, [
|
|
65
|
-
'todos',
|
|
66
|
-
{ name: 'projects', table: 'projects', primaryKey: 'uid' },
|
|
65
|
+
{ name: 'todos', tombstoneData: { title: '' } },
|
|
67
66
|
], {
|
|
67
|
+
sync: true,
|
|
68
68
|
electricUrl: process.env.ELECTRIC_URL, // ElectricSQL sync service, e.g. http://localhost:3000/v1/shape
|
|
69
69
|
sourceId: process.env.ELECTRIC_SOURCE_ID,
|
|
70
70
|
sourceSecret: process.env.ELECTRIC_SOURCE_SECRET,
|
|
@@ -73,9 +73,12 @@ app.configure(electricOfflinePlugin, db, [
|
|
|
73
73
|
return Boolean(context.request?.user || context.socket?.data?.user)
|
|
74
74
|
},
|
|
75
75
|
})
|
|
76
|
+
|
|
77
|
+
// Run this during startup, before serving sync mutations:
|
|
78
|
+
await prepareElectricSyncSchema(db, [{ name: 'todos' }])
|
|
76
79
|
```
|
|
77
80
|
|
|
78
|
-
This registers one Express-X service per model
|
|
81
|
+
This registers one Express-X service per model. In direct mode, it has the familiar API:
|
|
79
82
|
|
|
80
83
|
- `findUnique(where)`
|
|
81
84
|
- `findMany(where, queryOptions)`
|
|
@@ -88,7 +91,22 @@ The client model provides synchronized reads through `findMany(where)` and
|
|
|
88
91
|
`getObservable(where)`. The service also exposes direct one-shot server reads
|
|
89
92
|
when synchronization is not required.
|
|
90
93
|
|
|
91
|
-
|
|
94
|
+
In sync mode, create requires a client-generated UUID and every mutation requires
|
|
95
|
+
`{ clientId, revision }` as its last argument. The returned row includes `version`.
|
|
96
|
+
The server records the latest revision per client and row in the same transaction
|
|
97
|
+
as the write. Retries replay the saved result; older requests cannot overwrite a
|
|
98
|
+
newer edit from that client. Deletes write versioned tombstones instead of removing
|
|
99
|
+
rows. Configure `tombstoneData` for required columns without defaults, such as a
|
|
100
|
+
required title. Keep the cursor table and tombstones to protect delayed retries and
|
|
101
|
+
offline clients. This does not resolve concurrent edits by different clients.
|
|
102
|
+
|
|
103
|
+
Import `prepareElectricSyncSchema` alongside `electricOfflinePlugin`. It adds
|
|
104
|
+
`version` and `deleted` columns and creates the shared version sequence and cursor
|
|
105
|
+
table. The primary key must be UUID in sync mode. All writes to synced tables must
|
|
106
|
+
advance `version`; hard deletes bypass tombstone confirmation.
|
|
107
|
+
|
|
108
|
+
Without `sync: true`, the existing direct service methods remain available and
|
|
109
|
+
return created, updated, or deleted rows directly.
|
|
92
110
|
|
|
93
111
|
### Client
|
|
94
112
|
|
|
@@ -98,6 +116,8 @@ Install the optional client dependencies in the browser application:
|
|
|
98
116
|
npm install @electric-sql/client rxjs
|
|
99
117
|
```
|
|
100
118
|
|
|
119
|
+
For a server configured without `sync: true`, the direct client API is:
|
|
120
|
+
|
|
101
121
|
```js
|
|
102
122
|
import { electricClientPlugin } from '@jcbuisson/express-x-plugins/electric-client'
|
|
103
123
|
|
|
@@ -119,7 +139,7 @@ const subscription = todo.getObservable({ completed: false }).subscribe(rows =>
|
|
|
119
139
|
console.log(rows)
|
|
120
140
|
})
|
|
121
141
|
|
|
122
|
-
//
|
|
142
|
+
// Direct mutations use the matching Express-X service and are reflected by Electric.
|
|
123
143
|
await todo.create({ title: 'Learn Shapes', completed: false })
|
|
124
144
|
await todo.update(id, { completed: true })
|
|
125
145
|
await todo.remove(id)
|
|
@@ -130,6 +150,32 @@ console.log(created.id)
|
|
|
130
150
|
subscription.unsubscribe()
|
|
131
151
|
```
|
|
132
152
|
|
|
153
|
+
For offline writes, install `@electric-sql/pglite`, pass a shared PGlite database
|
|
154
|
+
as `localDb`, and use the sync
|
|
155
|
+
server mode above. `PGliteWorker` with an IndexedDB data directory is recommended
|
|
156
|
+
for multiple tabs. Only one worker should use `ownsSync: true`; use a shared
|
|
157
|
+
`BroadcastChannel` so other tabs receive change notifications.
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
const todo = app.createElectricModel('todos', {
|
|
161
|
+
localDb: db, // PGliteWorker with a persistent idb:// data directory
|
|
162
|
+
channel: new BroadcastChannel('todos-sync'),
|
|
163
|
+
})
|
|
164
|
+
await todo.prepare() // creates local rows, mutation queue, client ID, revision sequence
|
|
165
|
+
todo.start() // starts Electric and mutation retries
|
|
166
|
+
await todo.create({ title: 'Works offline' })
|
|
167
|
+
await todo.update(id, { title: 'Still offline' })
|
|
168
|
+
await todo.remove(id)
|
|
169
|
+
const rows = await todo.findMany()
|
|
170
|
+
const status = await todo.getStatus() // pending, failed, online
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Local edits and queue entries commit together. Successful service responses leave
|
|
174
|
+
queue entries in place until Electric sends the acknowledged `version` or newer.
|
|
175
|
+
Rows with `deleted = true` confirm deletes but stay hidden locally. Network errors
|
|
176
|
+
remain queued for retry; permanent HTTP 4xx errors are marked failed. The local
|
|
177
|
+
database, including its identity and revision sequence, must remain persistent.
|
|
178
|
+
|
|
133
179
|
`findMany(where)` resolves with all matching rows from the first synchronized Shape emission.
|
|
134
180
|
|
|
135
181
|
It unsubscribes after the first emission; if called within a Vue scope, it also
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jcbuisson/express-x-plugins",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "Plugins for express-x",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/electric-server-plugin.mjs",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/client": "^1.5.24",
|
|
38
|
+
"@electric-sql/pglite": "^0.5.8",
|
|
38
39
|
"rxjs": "^7.8.2",
|
|
39
40
|
"vue": "^3.5.0"
|
|
40
41
|
},
|
|
@@ -42,6 +43,9 @@
|
|
|
42
43
|
"@electric-sql/client": {
|
|
43
44
|
"optional": true
|
|
44
45
|
},
|
|
46
|
+
"@electric-sql/pglite": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
45
49
|
"rxjs": {
|
|
46
50
|
"optional": true
|
|
47
51
|
},
|
|
@@ -51,6 +55,7 @@
|
|
|
51
55
|
},
|
|
52
56
|
"devDependencies": {
|
|
53
57
|
"@electric-sql/client": "^1.5.24",
|
|
58
|
+
"@electric-sql/pglite": "^0.5.8",
|
|
54
59
|
"rxjs": "^7.8.2",
|
|
55
60
|
"vue": "^3.5.0"
|
|
56
61
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Shape, ShapeStream } from '@electric-sql/client'
|
|
2
|
-
import { Observable } from 'rxjs'
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { Observable, firstValueFrom, Subject, takeUntil } from 'rxjs'
|
|
3
|
+
import { getCurrentScope, onScopeDispose } from 'vue'
|
|
4
|
+
import { createOfflineElectricModel } from './electric-offline-model.mjs'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Add Electric-backed reactive models to an Express-X client.
|
|
@@ -23,11 +23,22 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
23
23
|
quoteIdentifier(modelName)
|
|
24
24
|
const service = app.service(modelName)
|
|
25
25
|
const url = modelOptions.url ?? modelPath(shapePath, modelName)
|
|
26
|
+
if (modelOptions.localDb) return createOfflineElectricModel({
|
|
27
|
+
db: modelOptions.localDb, service, modelName, url,
|
|
28
|
+
primaryKey: modelOptions.primaryKey ?? 'id',
|
|
29
|
+
ShapeStreamClass, ShapeClass,
|
|
30
|
+
ownsSync: modelOptions.ownsSync ?? true,
|
|
31
|
+
channel: modelOptions.channel,
|
|
32
|
+
network: modelOptions.network ?? globalThis.navigator,
|
|
33
|
+
retryMs: modelOptions.retryMs ?? 5000,
|
|
34
|
+
})
|
|
26
35
|
const streamOptions = modelOptions.streamOptions ?? {}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
const idGeneration = modelOptions.idGeneration ?? 'client'
|
|
37
|
+
const primaryKey = modelOptions.primaryKey ?? 'id'
|
|
38
|
+
quoteIdentifier(primaryKey)
|
|
39
|
+
if (!['client', 'server'].includes(idGeneration)) {
|
|
40
|
+
throw new TypeError("idGeneration must be 'client' or 'server'")
|
|
41
|
+
}
|
|
31
42
|
|
|
32
43
|
function getObservable(where = {}) {
|
|
33
44
|
// Validate eagerly
|
|
@@ -53,35 +64,31 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
53
64
|
})
|
|
54
65
|
}
|
|
55
66
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// const uid = globalThis.crypto?.randomUUID?.()
|
|
72
|
-
// if (!uid) throw new Error('crypto.randomUUID() is required')
|
|
73
|
-
// return service.create(uid, data)
|
|
74
|
-
// }
|
|
67
|
+
function findMany(where = {}) {
|
|
68
|
+
const observable = getObservable(where)
|
|
69
|
+
if (!getCurrentScope()) return firstValueFrom(observable)
|
|
70
|
+
const scopeDisposed = new Subject()
|
|
71
|
+
onScopeDispose(() => {
|
|
72
|
+
scopeDisposed.next()
|
|
73
|
+
scopeDisposed.complete()
|
|
74
|
+
})
|
|
75
|
+
return firstValueFrom(observable.pipe(takeUntil(scopeDisposed)))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function findUnique(where) {
|
|
79
|
+
const rows = await findMany(where)
|
|
80
|
+
return rows[0] ?? null
|
|
81
|
+
}
|
|
75
82
|
|
|
76
83
|
async function create(uidOrData, data) {
|
|
77
84
|
const hasUid = data !== undefined
|
|
78
85
|
const mutationData = hasUid ? data : uidOrData
|
|
79
86
|
assertPlainObject(mutationData, 'mutation data')
|
|
80
|
-
if (hasUid)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
if (hasUid) return service.create(uidOrData, data)
|
|
88
|
+
if (idGeneration === 'server') return service.create(mutationData)
|
|
89
|
+
const uid = globalThis.crypto?.randomUUID?.()
|
|
90
|
+
if (!uid) throw new Error('crypto.randomUUID() is required')
|
|
91
|
+
return service.create(uid, data)
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
async function update(id, data) {
|
|
@@ -92,7 +99,7 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
92
99
|
return service.delete(id)
|
|
93
100
|
}
|
|
94
101
|
|
|
95
|
-
return { getObservable,
|
|
102
|
+
return { getObservable, findMany, findUnique, create, update, remove }
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
return Object.assign(app, { createElectricModel })
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { Observable } from 'rxjs'
|
|
2
|
+
|
|
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 }) {
|
|
5
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(primaryKey)) throw new TypeError('primaryKey must be a simple SQL identifier')
|
|
6
|
+
let snapshot = null
|
|
7
|
+
let operation = Promise.resolve()
|
|
8
|
+
let flushing = false
|
|
9
|
+
let started = false
|
|
10
|
+
let connected = false
|
|
11
|
+
let timer
|
|
12
|
+
let stream
|
|
13
|
+
const listeners = new Set()
|
|
14
|
+
|
|
15
|
+
async function prepare() {
|
|
16
|
+
await db.exec(`
|
|
17
|
+
CREATE SEQUENCE IF NOT EXISTS electric_mutation_revision_seq;
|
|
18
|
+
CREATE TABLE IF NOT EXISTS electric_sync_client (
|
|
19
|
+
singleton BOOLEAN PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
20
|
+
id UUID NOT NULL DEFAULT gen_random_uuid()
|
|
21
|
+
);
|
|
22
|
+
INSERT INTO electric_sync_client (singleton) VALUES (true) ON CONFLICT DO NOTHING;
|
|
23
|
+
CREATE TABLE IF NOT EXISTS electric_local_rows (
|
|
24
|
+
model_name TEXT NOT NULL, row_id TEXT NOT NULL, data JSONB NOT NULL,
|
|
25
|
+
PRIMARY KEY (model_name, row_id)
|
|
26
|
+
);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS electric_mutation_queue (
|
|
28
|
+
model_name TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
29
|
+
action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')),
|
|
30
|
+
data JSONB, revision BIGINT NOT NULL DEFAULT nextval('electric_mutation_revision_seq'),
|
|
31
|
+
acknowledged_version NUMERIC,
|
|
32
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'failed')),
|
|
33
|
+
failure_reason TEXT,
|
|
34
|
+
PRIMARY KEY (model_name, row_id)
|
|
35
|
+
);
|
|
36
|
+
`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function notify() {
|
|
40
|
+
channel?.postMessage({ modelName })
|
|
41
|
+
for (const listener of listeners) queueMicrotask(() => { try { listener() } catch (error) { console.error(error) } })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function subscribe(listener) {
|
|
45
|
+
listeners.add(listener)
|
|
46
|
+
return () => listeners.delete(listener)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
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])
|
|
51
|
+
return rows.map(row => row.data).filter(row => matchesWhere(row, where))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function findUnique(where) {
|
|
55
|
+
return (await findMany(where))[0] ?? null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getObservable(where = {}) {
|
|
59
|
+
return new Observable(subscriber => {
|
|
60
|
+
let active = true
|
|
61
|
+
const emit = () => { void findMany(where).then(rows => { if (active) subscriber.next(rows) }, error => subscriber.error(error)) }
|
|
62
|
+
const unsubscribe = subscribe(emit)
|
|
63
|
+
emit()
|
|
64
|
+
return () => { active = false; unsubscribe() }
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function create(data) {
|
|
69
|
+
assertData(data)
|
|
70
|
+
if ('version' in data || 'deleted' in data) throw new TypeError('version and deleted are managed by sync')
|
|
71
|
+
const id = data[primaryKey] ?? globalThis.crypto?.randomUUID?.()
|
|
72
|
+
if (!id) throw new Error('crypto.randomUUID() is required')
|
|
73
|
+
assertId(id)
|
|
74
|
+
const row = { ...data, [primaryKey]: id }
|
|
75
|
+
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), JSON.stringify(row)])
|
|
77
|
+
await queueMutation(tx, id, 'create', row)
|
|
78
|
+
})
|
|
79
|
+
notify()
|
|
80
|
+
if (started) void flushQueue()
|
|
81
|
+
return row
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function update(id, data) {
|
|
85
|
+
assertId(id)
|
|
86
|
+
assertData(data)
|
|
87
|
+
if (primaryKey in data || 'version' in data || 'deleted' in data) throw new TypeError('sync-managed columns cannot be updated')
|
|
88
|
+
let row
|
|
89
|
+
await db.transaction(async tx => {
|
|
90
|
+
const current = await tx.query('SELECT data FROM electric_local_rows WHERE model_name = $1 AND row_id = $2', [modelName, String(id)])
|
|
91
|
+
if (!current.rows[0]) throw new Error('row not found')
|
|
92
|
+
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), JSON.stringify(row)])
|
|
94
|
+
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
|
+
if (queued.rows[0]?.action === 'delete') throw new Error('row has a pending delete')
|
|
96
|
+
const action = queued.rows[0]?.action === 'create' && queued.rows[0].acknowledged_version == null ? 'create' : 'update'
|
|
97
|
+
await queueMutation(tx, id, action, row)
|
|
98
|
+
})
|
|
99
|
+
notify()
|
|
100
|
+
if (started) void flushQueue()
|
|
101
|
+
return row
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function remove(id) {
|
|
105
|
+
assertId(id)
|
|
106
|
+
await db.transaction(async tx => {
|
|
107
|
+
await tx.query('DELETE FROM electric_local_rows WHERE model_name = $1 AND row_id = $2', [modelName, String(id)])
|
|
108
|
+
await queueMutation(tx, id, 'delete', null)
|
|
109
|
+
})
|
|
110
|
+
notify()
|
|
111
|
+
if (started) void flushQueue()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function queueMutation(tx, id, action, data) {
|
|
115
|
+
await tx.query(`INSERT INTO electric_mutation_queue (model_name, row_id, action, data)
|
|
116
|
+
VALUES ($1, $2, $3, $4::jsonb)
|
|
117
|
+
ON CONFLICT (model_name, row_id) DO UPDATE SET
|
|
118
|
+
action = EXCLUDED.action, data = EXCLUDED.data,
|
|
119
|
+
revision = nextval('electric_mutation_revision_seq'),
|
|
120
|
+
acknowledged_version = NULL, status = 'pending', failure_reason = NULL`,
|
|
121
|
+
[modelName, String(id), action, data == null ? null : JSON.stringify(data)])
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ordered(task) {
|
|
125
|
+
const current = operation.then(task)
|
|
126
|
+
operation = current.catch(() => {})
|
|
127
|
+
return current
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function applySnapshot(rows) {
|
|
131
|
+
return ordered(async () => {
|
|
132
|
+
snapshot = rows
|
|
133
|
+
await reconcileSnapshot(rows)
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function reconcile() {
|
|
138
|
+
return ordered(async () => {
|
|
139
|
+
if (snapshot !== null) await reconcileSnapshot(snapshot)
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resetSnapshot() {
|
|
144
|
+
return ordered(() => { snapshot = null })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function reconcileSnapshot(rows) {
|
|
148
|
+
await db.transaction(async tx => {
|
|
149
|
+
for (const row of rows) {
|
|
150
|
+
const id = String(row[primaryKey])
|
|
151
|
+
if (row.version != null) {
|
|
152
|
+
await tx.query(`DELETE FROM electric_mutation_queue
|
|
153
|
+
WHERE model_name = $1 AND row_id = $2 AND acknowledged_version <= $3::numeric`,
|
|
154
|
+
[modelName, id, String(row.version)])
|
|
155
|
+
}
|
|
156
|
+
if (row.deleted) continue
|
|
157
|
+
await tx.query(`INSERT INTO electric_local_rows (model_name, row_id, data)
|
|
158
|
+
SELECT $1, $2, $3::jsonb WHERE NOT EXISTS
|
|
159
|
+
(SELECT 1 FROM electric_mutation_queue WHERE model_name = $1 AND row_id = $2)
|
|
160
|
+
ON CONFLICT (model_name, row_id) DO UPDATE SET data = EXCLUDED.data`,
|
|
161
|
+
[modelName, id, JSON.stringify(row)])
|
|
162
|
+
}
|
|
163
|
+
const activeIds = rows.filter(row => !row.deleted).map(row => String(row[primaryKey]))
|
|
164
|
+
await tx.query(`DELETE FROM electric_local_rows AS local
|
|
165
|
+
WHERE local.model_name = $1 AND NOT (local.row_id = ANY($2::text[]))
|
|
166
|
+
AND NOT EXISTS (SELECT 1 FROM electric_mutation_queue AS pending
|
|
167
|
+
WHERE pending.model_name = local.model_name AND pending.row_id = local.row_id)`,
|
|
168
|
+
[modelName, activeIds])
|
|
169
|
+
})
|
|
170
|
+
notify()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function flushQueue() {
|
|
174
|
+
if (!ownsSync || flushing || network?.onLine === false) return
|
|
175
|
+
flushing = true
|
|
176
|
+
try {
|
|
177
|
+
while (true) {
|
|
178
|
+
const { rows } = await db.query(`SELECT * FROM electric_mutation_queue
|
|
179
|
+
WHERE model_name = $1 AND status = 'pending' AND acknowledged_version IS NULL
|
|
180
|
+
ORDER BY revision LIMIT 1`, [modelName])
|
|
181
|
+
const mutation = rows[0]
|
|
182
|
+
if (!mutation) break
|
|
183
|
+
try {
|
|
184
|
+
await sendMutation(mutation)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (!isPermanent(error)) break
|
|
187
|
+
await db.query(`UPDATE electric_mutation_queue SET status = 'failed', failure_reason = $3
|
|
188
|
+
WHERE model_name = $1 AND row_id = $2 AND revision = $4`,
|
|
189
|
+
[modelName, mutation.row_id, error.message, mutation.revision])
|
|
190
|
+
notify()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} finally {
|
|
194
|
+
flushing = false
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function sendMutation(mutation) {
|
|
199
|
+
const client = await db.query('SELECT id FROM electric_sync_client WHERE singleton = true')
|
|
200
|
+
const metadata = { clientId: client.rows[0].id, revision: String(mutation.revision) }
|
|
201
|
+
const { row_id: id, data, action } = mutation
|
|
202
|
+
const result = action === 'create'
|
|
203
|
+
? await service.create(id, withoutKey(data), metadata)
|
|
204
|
+
: action === 'update'
|
|
205
|
+
? await service.update(id, withoutKey(data), metadata)
|
|
206
|
+
: await service.delete(id, metadata)
|
|
207
|
+
if (result?.version == null) throw new Error('sync mutation did not return a version')
|
|
208
|
+
await db.transaction(async tx => {
|
|
209
|
+
const current = await tx.query('SELECT action, revision, data FROM electric_mutation_queue WHERE model_name = $1 AND row_id = $2', [modelName, id])
|
|
210
|
+
const queued = current.rows[0]
|
|
211
|
+
if (!queued) return
|
|
212
|
+
if (String(queued.revision) === String(mutation.revision) && queued.action === action) {
|
|
213
|
+
await tx.query(`UPDATE electric_mutation_queue SET acknowledged_version = $3::numeric
|
|
214
|
+
WHERE model_name = $1 AND row_id = $2`, [modelName, id, String(result.version)])
|
|
215
|
+
} else if (action === 'create' && queued.action === 'create') {
|
|
216
|
+
// The create was sent, then edited locally. Its next request is an update.
|
|
217
|
+
await tx.query(`UPDATE electric_mutation_queue SET action = 'update',
|
|
218
|
+
revision = nextval('electric_mutation_revision_seq') WHERE model_name = $1 AND row_id = $2`, [modelName, id])
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
await reconcile()
|
|
222
|
+
notify()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function withoutKey(data) {
|
|
226
|
+
const copy = { ...data }
|
|
227
|
+
delete copy[primaryKey]
|
|
228
|
+
delete copy.version
|
|
229
|
+
delete copy.deleted
|
|
230
|
+
return copy
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function start() {
|
|
234
|
+
if (started) return
|
|
235
|
+
started = true
|
|
236
|
+
channel?.addEventListener('message', sharedChange)
|
|
237
|
+
globalThis.addEventListener?.('online', online)
|
|
238
|
+
if (!ownsSync) return
|
|
239
|
+
stream = new ShapeStreamClass({ url })
|
|
240
|
+
stream.subscribe(messages => {
|
|
241
|
+
if (messages.some(message => message.headers?.control === 'must-refetch')) void resetSnapshot()
|
|
242
|
+
connected = stream.isConnected?.() ?? true
|
|
243
|
+
notify()
|
|
244
|
+
}, () => { connected = false; notify() })
|
|
245
|
+
const shape = new ShapeClass(stream)
|
|
246
|
+
shape.subscribe(({ rows }) => { connected = true; void applySnapshot([...rows]) })
|
|
247
|
+
timer = setInterval(() => { void flushQueue() }, retryMs)
|
|
248
|
+
void flushQueue()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function stop() {
|
|
252
|
+
if (!started) return
|
|
253
|
+
started = false
|
|
254
|
+
clearInterval(timer)
|
|
255
|
+
stream?.unsubscribeAll()
|
|
256
|
+
channel?.removeEventListener('message', sharedChange)
|
|
257
|
+
globalThis.removeEventListener?.('online', online)
|
|
258
|
+
connected = false
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function online() { void flushQueue() }
|
|
262
|
+
function sharedChange(event) {
|
|
263
|
+
if (event.data?.modelName !== modelName) return
|
|
264
|
+
for (const listener of listeners) queueMicrotask(() => { try { listener() } catch (error) { console.error(error) } })
|
|
265
|
+
if (ownsSync) void flushQueue()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function getStatus() {
|
|
269
|
+
const { rows } = await db.query(`SELECT
|
|
270
|
+
count(*) FILTER (WHERE status = 'pending')::int AS pending,
|
|
271
|
+
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 }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { prepare, start, stop, subscribe, findMany, findUnique, getObservable,
|
|
277
|
+
create, update, remove, flushQueue, getStatus }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function assertData(data) {
|
|
281
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new TypeError('mutation data must be a plain object')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function assertId(id) {
|
|
285
|
+
if (typeof id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) {
|
|
286
|
+
throw new TypeError('sync id must be a UUID')
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function matchesWhere(row, where) {
|
|
291
|
+
return Object.entries(where).every(([key, constraint]) => {
|
|
292
|
+
if (constraint === undefined) return true
|
|
293
|
+
const value = row[key]
|
|
294
|
+
if (constraint && typeof constraint === 'object' && !(constraint instanceof Date)) {
|
|
295
|
+
return Object.entries(constraint).every(([operator, limit]) => {
|
|
296
|
+
if (operator === 'gt') return value > limit
|
|
297
|
+
if (operator === 'gte') return value >= limit
|
|
298
|
+
if (operator === 'lt') return value < limit
|
|
299
|
+
if (operator === 'lte') return value <= limit
|
|
300
|
+
throw new TypeError(`unsupported where constraint for '${key}'`)
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
return value === constraint
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function isPermanent(error) {
|
|
308
|
+
const status = error?.status ?? error?.response?.status
|
|
309
|
+
return error?.code === 'forbidden' || error instanceof TypeError ||
|
|
310
|
+
status >= 400 && status < 500 && ![408, 425, 429].includes(status)
|
|
311
|
+
}
|
|
@@ -12,6 +12,8 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
|
|
|
12
12
|
// throw new TypeError('electricOfflinePlugin requires an authorize(context, operation) policy')
|
|
13
13
|
// }
|
|
14
14
|
const configuredModels = normalizeModels(models)
|
|
15
|
+
const sync = options.sync === true
|
|
16
|
+
if (sync && typeof db.connect !== 'function') throw new TypeError('sync requires a pg Pool with connect()')
|
|
15
17
|
const electricUrl = new URL(options.electricUrl ?? process.env.ELECTRIC_URL ?? 'http://localhost:3000/v1/shape')
|
|
16
18
|
const shapePath = options.shapePath ?? '/electric/v1/shape/:model'
|
|
17
19
|
const fetchImpl = options.fetch ?? globalThis.fetch
|
|
@@ -56,14 +58,20 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
|
|
|
56
58
|
|
|
57
59
|
// create(data): the primary key is server-generated
|
|
58
60
|
// create(id, data): id (the primary key) is provided by the client
|
|
59
|
-
create: async function(idOrData, data) {
|
|
61
|
+
create: async function(idOrData, data, mutation) {
|
|
60
62
|
const hasClientId = data !== undefined
|
|
61
63
|
const mutationData = hasClientId ? data : idOrData
|
|
62
|
-
await authorize(this, model.name, 'create', hasClientId
|
|
64
|
+
await authorize(this, model.name, 'create', hasClientId
|
|
65
|
+
? (sync ? [idOrData, mutationData, mutation] : [idOrData, mutationData])
|
|
66
|
+
: [mutationData])
|
|
63
67
|
assertPlainObject(mutationData, 'mutation data')
|
|
64
68
|
const safeData = hasClientId
|
|
65
69
|
? { ...mutationData, [model.primaryKey]: idOrData }
|
|
66
70
|
: { ...mutationData }
|
|
71
|
+
if (sync) {
|
|
72
|
+
if (!hasClientId) throw new TypeError('sync mutations require a client-generated id')
|
|
73
|
+
return syncMutation(db, model, 'create', idOrData, safeData, mutation)
|
|
74
|
+
}
|
|
67
75
|
const entries = Object.entries(safeData).filter(([, value]) => value !== undefined)
|
|
68
76
|
const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
|
|
69
77
|
const values = entries.map(([, value]) => value)
|
|
@@ -91,8 +99,9 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
|
|
|
91
99
|
})
|
|
92
100
|
},
|
|
93
101
|
|
|
94
|
-
update: async function(id, data) {
|
|
95
|
-
await authorize(this, model.name, 'update', [id, data])
|
|
102
|
+
update: async function(id, data, mutation) {
|
|
103
|
+
await authorize(this, model.name, 'update', sync ? [id, data, mutation] : [id, data])
|
|
104
|
+
if (sync) return syncMutation(db, model, 'update', id, data, mutation)
|
|
96
105
|
const set = buildSet(data, model.primaryKey)
|
|
97
106
|
return withTransaction(db, async client => {
|
|
98
107
|
const result = await client.query(
|
|
@@ -103,8 +112,9 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
|
|
|
103
112
|
})
|
|
104
113
|
},
|
|
105
114
|
|
|
106
|
-
delete: async function(id) {
|
|
107
|
-
await authorize(this, model.name, 'delete', [id])
|
|
115
|
+
delete: async function(id, mutation) {
|
|
116
|
+
await authorize(this, model.name, 'delete', sync ? [id, mutation] : [id])
|
|
117
|
+
if (sync) return syncMutation(db, model, 'delete', id, null, mutation)
|
|
108
118
|
return withTransaction(db, async client => {
|
|
109
119
|
const result = await client.query(
|
|
110
120
|
`DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [id],
|
|
@@ -139,11 +149,81 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
|
|
|
139
149
|
}
|
|
140
150
|
})
|
|
141
151
|
|
|
142
|
-
return { shapePath, models: configuredModels.map(({ name, table, primaryKey }) => ({ name, table, primaryKey })) }
|
|
152
|
+
return { shapePath, sync, models: configuredModels.map(({ name, table, primaryKey }) => ({ name, table, primaryKey })) }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Run once before accepting sync mutations. Existing rows receive a version. */
|
|
156
|
+
export async function prepareElectricSyncSchema(db, models) {
|
|
157
|
+
const configuredModels = normalizeModels(models)
|
|
158
|
+
await db.query('CREATE SEQUENCE IF NOT EXISTS electric_sync_version_seq')
|
|
159
|
+
await db.query(`CREATE TABLE IF NOT EXISTS electric_mutation_cursor (
|
|
160
|
+
table_name TEXT NOT NULL, client_id UUID NOT NULL, row_id TEXT NOT NULL,
|
|
161
|
+
revision BIGINT NOT NULL DEFAULT 0, result JSONB,
|
|
162
|
+
PRIMARY KEY (table_name, client_id, row_id)
|
|
163
|
+
)`)
|
|
164
|
+
for (const model of configuredModels) {
|
|
165
|
+
await db.query(`ALTER TABLE ${model.quotedTable} ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT nextval('electric_sync_version_seq')`)
|
|
166
|
+
await db.query(`ALTER TABLE ${model.quotedTable} ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT false`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function syncMutation(db, model, action, id, data, mutation) {
|
|
171
|
+
if (!mutation || typeof mutation !== 'object') throw new TypeError('sync mutation metadata is required')
|
|
172
|
+
const { clientId, revision } = mutation
|
|
173
|
+
if (typeof clientId !== 'string' || !UUID.test(clientId)) throw new TypeError('clientId must be a UUID')
|
|
174
|
+
if (!/^[1-9][0-9]*$/.test(String(revision)) || BigInt(revision) > 9223372036854775807n) {
|
|
175
|
+
throw new TypeError('revision must be a positive bigint')
|
|
176
|
+
}
|
|
177
|
+
if (typeof id !== 'string' || !UUID.test(id)) throw new TypeError('sync id must be a UUID')
|
|
178
|
+
if (action !== 'delete') assertPlainObject(data, 'mutation data')
|
|
179
|
+
if (data && ('version' in data || 'deleted' in data)) throw new TypeError('version and deleted are managed by sync')
|
|
180
|
+
return withTransaction(db, async tx => {
|
|
181
|
+
const key = [model.table, clientId, String(id)]
|
|
182
|
+
await tx.query(`INSERT INTO electric_mutation_cursor (table_name, client_id, row_id)
|
|
183
|
+
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, key)
|
|
184
|
+
const { rows } = await tx.query(`SELECT revision, result FROM electric_mutation_cursor
|
|
185
|
+
WHERE table_name = $1 AND client_id = $2 AND row_id = $3 FOR UPDATE`, key)
|
|
186
|
+
const cursor = rows[0]
|
|
187
|
+
if (BigInt(revision) <= BigInt(cursor.revision)) return cursor.result
|
|
188
|
+
let result
|
|
189
|
+
if (action === 'create') {
|
|
190
|
+
const entries = Object.entries({ ...data, [model.primaryKey]: id }).filter(([, value]) => value !== undefined)
|
|
191
|
+
const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
|
|
192
|
+
const values = entries.map(([, value]) => value)
|
|
193
|
+
const parameters = values.map((_, index) => `$${index + 1}`)
|
|
194
|
+
const inserted = await tx.query(`INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')})
|
|
195
|
+
ON CONFLICT (${model.quotedPrimaryKey}) DO UPDATE SET ${model.quotedPrimaryKey} = EXCLUDED.${model.quotedPrimaryKey}
|
|
196
|
+
RETURNING *`, values)
|
|
197
|
+
result = inserted.rows[0]
|
|
198
|
+
} else if (action === 'update') {
|
|
199
|
+
const set = buildSet(data, model.primaryKey)
|
|
200
|
+
const updated = await tx.query(`UPDATE ${model.quotedTable} SET ${set.sql}, version = nextval('electric_sync_version_seq')
|
|
201
|
+
WHERE ${model.quotedPrimaryKey} = $${set.values.length + 1} AND NOT deleted RETURNING *`, [...set.values, id])
|
|
202
|
+
result = updated.rows[0] ?? await writeTombstone(tx, model, id)
|
|
203
|
+
} else {
|
|
204
|
+
result = await writeTombstone(tx, model, id)
|
|
205
|
+
}
|
|
206
|
+
await tx.query(`UPDATE electric_mutation_cursor SET revision = $4, result = $5::jsonb
|
|
207
|
+
WHERE table_name = $1 AND client_id = $2 AND row_id = $3`, [...key, String(revision), JSON.stringify(result ?? null)])
|
|
208
|
+
return result
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function writeTombstone(tx, model, id) {
|
|
213
|
+
const tombstone = Object.entries(model.tombstoneData)
|
|
214
|
+
const columns = [model.quotedPrimaryKey, ...tombstone.map(([column]) => quoteIdentifier(column, 'tombstone column')), 'deleted']
|
|
215
|
+
const values = [id, ...tombstone.map(([, value]) => value)]
|
|
216
|
+
const parameters = values.map((_, index) => `$${index + 1}`)
|
|
217
|
+
const clearFields = tombstone.map(([column]) => `${quoteIdentifier(column, 'tombstone column')} = EXCLUDED.${quoteIdentifier(column, 'tombstone column')}`)
|
|
218
|
+
const { rows } = await tx.query(`INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')}, true)
|
|
219
|
+
ON CONFLICT (${model.quotedPrimaryKey}) DO UPDATE SET ${clearFields.length ? clearFields.join(', ') + ', ' : ''}deleted = true, version = nextval('electric_sync_version_seq')
|
|
220
|
+
RETURNING *`, values)
|
|
221
|
+
return rows[0]
|
|
143
222
|
}
|
|
144
223
|
|
|
145
224
|
|
|
146
225
|
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
226
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
147
227
|
const RANGE_OPERATORS = { gt: '>', gte: '>=', lt: '<', lte: '<=' }
|
|
148
228
|
|
|
149
229
|
function quoteIdentifier(value, label) {
|
|
@@ -166,10 +246,17 @@ function normalizeModels(models) {
|
|
|
166
246
|
const table = config.table ?? name
|
|
167
247
|
const primaryKey = config.primaryKey ?? 'id'
|
|
168
248
|
quoteIdentifier(name, 'model name')
|
|
249
|
+
const tombstoneData = config.tombstoneData ?? {}
|
|
250
|
+
assertPlainObject(tombstoneData, 'tombstoneData')
|
|
251
|
+
for (const column of Object.keys(tombstoneData)) {
|
|
252
|
+
quoteIdentifier(column, 'tombstone column')
|
|
253
|
+
if ([primaryKey, 'version', 'deleted'].includes(column)) throw new TypeError('tombstoneData contains a sync-managed column')
|
|
254
|
+
}
|
|
169
255
|
return {
|
|
170
256
|
name,
|
|
171
257
|
table,
|
|
172
258
|
primaryKey,
|
|
259
|
+
tombstoneData,
|
|
173
260
|
quotedTable: quoteIdentifier(table, `table for '${name}'`),
|
|
174
261
|
quotedPrimaryKey: quoteIdentifier(primaryKey, `primary key for '${name}'`),
|
|
175
262
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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 Stream {
|
|
7
|
+
subscribe() { return () => {} }
|
|
8
|
+
isConnected() { return true }
|
|
9
|
+
unsubscribeAll() {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class Shape {
|
|
13
|
+
static current
|
|
14
|
+
constructor() { Shape.current = this }
|
|
15
|
+
subscribe(callback) { this.emit = callback; return () => {} }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('offline writes stay local until the acknowledged Electric version arrives', async () => {
|
|
19
|
+
const db = new PGlite()
|
|
20
|
+
const calls = []
|
|
21
|
+
const service = {
|
|
22
|
+
async create(id, data, metadata) {
|
|
23
|
+
calls.push({ id, data, metadata })
|
|
24
|
+
return { id, ...data, version: '42' }
|
|
25
|
+
},
|
|
26
|
+
async delete(id, metadata) {
|
|
27
|
+
calls.push({ id, metadata, action: 'delete' })
|
|
28
|
+
return { id, deleted: true, version: '43' }
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
const app = { service: () => service }
|
|
32
|
+
electricClientPlugin(app, { ShapeStream: Stream, Shape })
|
|
33
|
+
const model = app.createElectricModel('todos', { localDb: db, network: { onLine: false }, retryMs: 60_000 })
|
|
34
|
+
try {
|
|
35
|
+
await model.prepare()
|
|
36
|
+
const local = await model.create({ title: 'offline' })
|
|
37
|
+
assert.match(local.id, /^[0-9a-f-]{36}$/)
|
|
38
|
+
assert.equal((await model.findMany())[0].title, 'offline')
|
|
39
|
+
assert.equal((await model.getStatus()).pending, 1)
|
|
40
|
+
assert.equal(calls.length, 0)
|
|
41
|
+
|
|
42
|
+
// Simulate reconnect, then an old Shape snapshot followed by confirmation.
|
|
43
|
+
const onlineModel = app.createElectricModel('todos', { localDb: db, network: { onLine: true }, retryMs: 60_000 })
|
|
44
|
+
await onlineModel.flushQueue()
|
|
45
|
+
assert.equal(calls.length, 1)
|
|
46
|
+
assert.equal(calls[0].metadata.revision, '1')
|
|
47
|
+
assert.equal((await onlineModel.getStatus()).pending, 1)
|
|
48
|
+
onlineModel.start()
|
|
49
|
+
Shape.current.emit({ rows: [{ id: local.id, title: 'older', version: '41', deleted: false }] })
|
|
50
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
51
|
+
assert.equal((await onlineModel.findMany())[0].title, 'offline')
|
|
52
|
+
Shape.current.emit({ rows: [{ id: local.id, title: 'offline', version: '42', deleted: false }] })
|
|
53
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
54
|
+
assert.equal((await onlineModel.getStatus()).pending, 0)
|
|
55
|
+
await onlineModel.remove(local.id)
|
|
56
|
+
await onlineModel.flushQueue()
|
|
57
|
+
assert.equal((await onlineModel.findMany()).length, 0)
|
|
58
|
+
Shape.current.emit({ rows: [{ id: local.id, title: 'offline', version: '42', deleted: false }] })
|
|
59
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
60
|
+
assert.equal((await onlineModel.findMany()).length, 0)
|
|
61
|
+
Shape.current.emit({ rows: [{ id: local.id, title: '', version: '43', deleted: true }] })
|
|
62
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
63
|
+
assert.equal((await onlineModel.getStatus()).pending, 0)
|
|
64
|
+
onlineModel.stop()
|
|
65
|
+
} finally {
|
|
66
|
+
await db.close()
|
|
67
|
+
}
|
|
68
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import test from 'node:test'
|
|
3
3
|
|
|
4
|
-
import { electricOfflinePlugin } from '../src/electric-server-plugin.mjs'
|
|
4
|
+
import { electricOfflinePlugin, prepareElectricSyncSchema } from '../src/electric-server-plugin.mjs'
|
|
5
5
|
|
|
6
6
|
function fixture({ authorize = async () => true, fetch } = {}) {
|
|
7
7
|
const services = new Map()
|
|
@@ -154,3 +154,72 @@ test('rejects unsafe SQL identifiers', () => {
|
|
|
154
154
|
/simple SQL identifier/,
|
|
155
155
|
)
|
|
156
156
|
})
|
|
157
|
+
|
|
158
|
+
test('sync mode commits a versioned write and rejects replayed or stale revisions', async () => {
|
|
159
|
+
const queries = []
|
|
160
|
+
let cursor = { revision: '0', result: null }
|
|
161
|
+
let inserts = 0
|
|
162
|
+
const client = {
|
|
163
|
+
async query(sql, values = []) {
|
|
164
|
+
queries.push({ sql, values })
|
|
165
|
+
if (sql.includes('SELECT revision, result FROM electric_mutation_cursor')) return { rows: [cursor] }
|
|
166
|
+
if (sql.includes('INSERT INTO "todos"')) {
|
|
167
|
+
inserts++
|
|
168
|
+
return { rows: [{ id: values.at(-1), version: '42', deleted: false }] }
|
|
169
|
+
}
|
|
170
|
+
if (sql.includes('UPDATE electric_mutation_cursor')) cursor = { revision: values[3], result: JSON.parse(values[4]) }
|
|
171
|
+
return { rows: [] }
|
|
172
|
+
},
|
|
173
|
+
release() {},
|
|
174
|
+
}
|
|
175
|
+
const db = { query: client.query.bind(client), async connect() { return client } }
|
|
176
|
+
const services = new Map()
|
|
177
|
+
electricOfflinePlugin({ createService(name, methods) { services.set(name, methods) }, get() {} }, db,
|
|
178
|
+
[{ name: 'todos', tombstoneData: { title: '' } }], { sync: true })
|
|
179
|
+
const service = services.get('todos')
|
|
180
|
+
const id = '64d76168-775f-481e-8974-18d31d835d9e'
|
|
181
|
+
const clientId = '5e199e1c-23a4-473e-9989-14d4a1fb857e'
|
|
182
|
+
const metadata = { clientId, revision: '2' }
|
|
183
|
+
|
|
184
|
+
const result = await service.create.call({}, id, { title: 'first' }, metadata)
|
|
185
|
+
assert.equal(result.version, '42')
|
|
186
|
+
assert.equal((await service.create.call({}, id, { title: 'first' }, metadata)).version, '42')
|
|
187
|
+
assert.equal((await service.create.call({}, id, { title: 'stale' }, { clientId, revision: '1' })).version, '42')
|
|
188
|
+
assert.equal(inserts, 1)
|
|
189
|
+
assert.equal(queries.filter(({ sql }) => sql === 'COMMIT').length, 3)
|
|
190
|
+
await assert.rejects(service.create.call({}, id, { title: 'bad' }), /metadata is required/)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('sync schema prepares version and tombstone columns', async () => {
|
|
194
|
+
const queries = []
|
|
195
|
+
await prepareElectricSyncSchema({ query: async sql => { queries.push(sql) } }, ['todos'])
|
|
196
|
+
assert.equal(queries.length, 4)
|
|
197
|
+
assert.match(queries[2], /ADD COLUMN IF NOT EXISTS version BIGINT/)
|
|
198
|
+
assert.match(queries[3], /ADD COLUMN IF NOT EXISTS deleted BOOLEAN/)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('sync delete writes a versioned tombstone and clears configured fields', async () => {
|
|
202
|
+
const queries = []
|
|
203
|
+
const id = '64d76168-775f-481e-8974-18d31d835d9e'
|
|
204
|
+
const client = {
|
|
205
|
+
async query(sql, values = []) {
|
|
206
|
+
queries.push({ sql, values })
|
|
207
|
+
if (sql.includes('SELECT revision, result FROM electric_mutation_cursor')) return { rows: [{ revision: '0', result: null }] }
|
|
208
|
+
if (sql.includes('INSERT INTO "todos"')) return { rows: [{ id, title: '', deleted: true, version: '7' }] }
|
|
209
|
+
return { rows: [] }
|
|
210
|
+
},
|
|
211
|
+
release() {},
|
|
212
|
+
}
|
|
213
|
+
const db = { query: client.query.bind(client), async connect() { return client } }
|
|
214
|
+
const services = new Map()
|
|
215
|
+
electricOfflinePlugin({ createService(name, methods) { services.set(name, methods) }, get() {} }, db,
|
|
216
|
+
[{ name: 'todos', tombstoneData: { title: '' } }], { sync: true })
|
|
217
|
+
const result = await services.get('todos').delete.call({}, id, {
|
|
218
|
+
clientId: '5e199e1c-23a4-473e-9989-14d4a1fb857e', revision: '1',
|
|
219
|
+
})
|
|
220
|
+
assert.equal(result.deleted, true)
|
|
221
|
+
const tombstone = queries.find(({ sql }) => sql.includes('INSERT INTO "todos"'))
|
|
222
|
+
assert.match(tombstone.sql, /"title" = EXCLUDED\."title"/)
|
|
223
|
+
assert.match(tombstone.sql, /version = nextval/)
|
|
224
|
+
assert.deepEqual(tombstone.values, [id, ''])
|
|
225
|
+
})
|