@jcbuisson/express-x-plugins 5.0.6 → 5.0.8
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 +127 -3
- package/docs/electric-fsm.md +244 -0
- package/docs/sync-fsm.md +117 -0
- package/package.json +32 -2
- package/src/electric-client-plugin.mjs +4 -4
- package/src/electric-managed-sync.mjs +11 -3
- package/src/electric-offline-model.mjs +14 -2
- package/src/electric-server-plugin.mjs +22 -6
- package/src/electric-sync-relations.mjs +99 -0
- package/src/sync-algorithm.mjs +146 -0
- package/src/sync-client-plugin.mjs +1023 -0
- package/src/sync-server-plugin.mjs +541 -0
- package/test/electric-managed-sync.test.mjs +27 -14
- package/test/electric-offline-model.test.mjs +5 -5
- package/test/electric-relations-postgres.test.mjs +113 -0
- package/test/electric-relations.test.mjs +220 -0
- package/test/electric-server-plugin.test.mjs +11 -3
- package/test/sync-algorithm.test.mjs +229 -0
- package/test/sync-offline.test.mjs +4643 -0
|
@@ -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')
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
4
|
+
import { electricServerPlugin, prepareElectricSyncSchema } from '../src/electric-server-plugin.mjs'
|
|
5
|
+
|
|
6
|
+
const connectionString = process.env.ELECTRIC_TEST_DATABASE_URL
|
|
7
|
+
|
|
8
|
+
function register(db, models) {
|
|
9
|
+
const services = new Map()
|
|
10
|
+
electricServerPlugin({ createService(name, methods) { services.set(name, methods) }, get() {} }, db, models, { sync: true })
|
|
11
|
+
return services
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function commitGate(pool) {
|
|
15
|
+
let reached
|
|
16
|
+
let release
|
|
17
|
+
const waiting = new Promise(resolve => { reached = resolve })
|
|
18
|
+
const released = new Promise(resolve => { release = resolve })
|
|
19
|
+
return {
|
|
20
|
+
waiting, release,
|
|
21
|
+
db: {
|
|
22
|
+
query: (...args) => pool.query(...args),
|
|
23
|
+
async connect() {
|
|
24
|
+
const client = await pool.connect()
|
|
25
|
+
return {
|
|
26
|
+
async query(sql, values) {
|
|
27
|
+
if (sql === 'COMMIT') { reached(); await released }
|
|
28
|
+
return client.query(sql, values)
|
|
29
|
+
},
|
|
30
|
+
release: () => client.release(),
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('PostgreSQL serializes concurrent membership writes and soft deletes across plugin instances', {
|
|
38
|
+
skip: !connectionString,
|
|
39
|
+
timeout: 30_000,
|
|
40
|
+
}, async t => {
|
|
41
|
+
const { Pool } = await import('pg')
|
|
42
|
+
const schema = `electric_sync_test_${crypto.randomUUID().replaceAll('-', '')}`
|
|
43
|
+
const admin = new Pool({ connectionString })
|
|
44
|
+
let pool
|
|
45
|
+
t.after(async () => {
|
|
46
|
+
try {
|
|
47
|
+
await pool?.end()
|
|
48
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`)
|
|
49
|
+
} finally { await admin.end() }
|
|
50
|
+
})
|
|
51
|
+
await admin.query(`CREATE SCHEMA "${schema}"`)
|
|
52
|
+
pool = new Pool({ connectionString, options: `-c search_path=${schema}`, statement_timeout: 10_000 })
|
|
53
|
+
await pool.query(`
|
|
54
|
+
CREATE TABLE people (uid UUID PRIMARY KEY);
|
|
55
|
+
CREATE TABLE groups (uid UUID PRIMARY KEY);
|
|
56
|
+
CREATE TABLE membership (
|
|
57
|
+
uid UUID PRIMARY KEY,
|
|
58
|
+
user_uid UUID REFERENCES people(uid) ON DELETE CASCADE,
|
|
59
|
+
group_uid UUID REFERENCES groups(uid) ON DELETE CASCADE
|
|
60
|
+
);
|
|
61
|
+
`)
|
|
62
|
+
const models = [
|
|
63
|
+
{ name: 'user', table: 'people' },
|
|
64
|
+
{ name: 'group', table: 'groups' },
|
|
65
|
+
{ name: 'membership', tombstoneData: { user_uid: null, group_uid: null } },
|
|
66
|
+
]
|
|
67
|
+
await prepareElectricSyncSchema(pool, models)
|
|
68
|
+
const services = register(pool, models)
|
|
69
|
+
const metadata = () => ({ clientId: crypto.randomUUID(), revision: '1' })
|
|
70
|
+
|
|
71
|
+
for (const first of ['delete', 'create']) {
|
|
72
|
+
await t.test(`${first} reaches the server first`, async () => {
|
|
73
|
+
const user = crypto.randomUUID()
|
|
74
|
+
const group = crypto.randomUUID()
|
|
75
|
+
const membership = crypto.randomUUID()
|
|
76
|
+
await services.get('user').create(user, {}, metadata())
|
|
77
|
+
await services.get('group').create(group, {}, metadata())
|
|
78
|
+
const gate = commitGate(pool)
|
|
79
|
+
const gated = register(gate.db, models)
|
|
80
|
+
const create = service => service.get('membership').create(membership, { user_uid: user, group_uid: group }, metadata())
|
|
81
|
+
const remove = service => service.get('group').delete(group, metadata())
|
|
82
|
+
const firstWrite = first === 'delete' ? remove(gated) : create(gated)
|
|
83
|
+
let secondWrite
|
|
84
|
+
try {
|
|
85
|
+
// Keep the first transaction open after its mutation. The second
|
|
86
|
+
// must wait for the same advisory lock on another connection.
|
|
87
|
+
await Promise.race([gate.waiting, firstWrite.then(() => { throw new Error('commit gate was not reached') })])
|
|
88
|
+
secondWrite = first === 'delete' ? create(services) : remove(services)
|
|
89
|
+
let blocked = false
|
|
90
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
91
|
+
const { rows } = await pool.query(`SELECT EXISTS (
|
|
92
|
+
SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND NOT granted
|
|
93
|
+
AND classid = 178693771 AND objid = 1
|
|
94
|
+
AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
|
|
95
|
+
) AS blocked`)
|
|
96
|
+
if (rows[0].blocked) { blocked = true; break }
|
|
97
|
+
await delay(10)
|
|
98
|
+
}
|
|
99
|
+
assert.equal(blocked, true, 'the second mutation must block until the first commits')
|
|
100
|
+
} finally {
|
|
101
|
+
gate.release()
|
|
102
|
+
await Promise.allSettled([firstWrite, secondWrite])
|
|
103
|
+
}
|
|
104
|
+
const results = await Promise.all([firstWrite, secondWrite])
|
|
105
|
+
if (first === 'delete') assert.equal(results[1].syncRejection.code, 'sync-parent-deleted')
|
|
106
|
+
const row = (await pool.query('SELECT * FROM membership WHERE uid = $1', [membership])).rows[0]
|
|
107
|
+
assert.equal(row.deleted, true)
|
|
108
|
+
assert.equal(row.user_uid, null)
|
|
109
|
+
assert.equal(row.group_uid, null)
|
|
110
|
+
assert.equal((await pool.query('SELECT deleted FROM groups WHERE uid = $1', [group])).rows[0].deleted, true)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
})
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { PGlite } from '@electric-sql/pglite'
|
|
4
|
+
import { electricServerPlugin, prepareElectricSyncSchema } from '../src/electric-server-plugin.mjs'
|
|
5
|
+
import { electricClientPlugin } from '../src/electric-client-plugin.mjs'
|
|
6
|
+
|
|
7
|
+
const models = [
|
|
8
|
+
{ name: 'user', table: 'people', tombstoneData: { email: null } },
|
|
9
|
+
{ name: 'group', table: 'groups', tombstoneData: { name: null } },
|
|
10
|
+
{ name: 'membership', tombstoneData: { user_uid: null, group_uid: null } },
|
|
11
|
+
{ name: 'note', primaryKey: 'id', tombstoneData: { membership_uid: null } },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
// Pool semantics on a single PGlite connection: queue complete transactions,
|
|
15
|
+
// never interleave BEGIN/COMMIT from concurrently flushing model queues.
|
|
16
|
+
function poolFor(db) {
|
|
17
|
+
let tail = Promise.resolve()
|
|
18
|
+
return {
|
|
19
|
+
query: (...args) => db.query(...args),
|
|
20
|
+
async connect() {
|
|
21
|
+
let release
|
|
22
|
+
const next = new Promise(resolve => { release = resolve })
|
|
23
|
+
const previous = tail
|
|
24
|
+
tail = next
|
|
25
|
+
await previous
|
|
26
|
+
return { query: (...args) => db.query(...args), release }
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function fixture(t) {
|
|
32
|
+
const db = new PGlite()
|
|
33
|
+
t.after(() => db.close())
|
|
34
|
+
await db.exec(`
|
|
35
|
+
CREATE TABLE people (uid UUID PRIMARY KEY, email TEXT UNIQUE);
|
|
36
|
+
CREATE TABLE groups (uid UUID PRIMARY KEY, name TEXT UNIQUE);
|
|
37
|
+
CREATE TABLE membership (
|
|
38
|
+
uid UUID PRIMARY KEY,
|
|
39
|
+
user_uid UUID REFERENCES people(uid) ON DELETE CASCADE,
|
|
40
|
+
group_uid UUID REFERENCES groups(uid) ON DELETE CASCADE,
|
|
41
|
+
label TEXT,
|
|
42
|
+
UNIQUE (user_uid, group_uid)
|
|
43
|
+
);
|
|
44
|
+
CREATE TABLE note (id UUID PRIMARY KEY, membership_uid UUID REFERENCES membership(uid) ON DELETE CASCADE);
|
|
45
|
+
`)
|
|
46
|
+
await prepareElectricSyncSchema(db, models)
|
|
47
|
+
const services = new Map()
|
|
48
|
+
const app = { createService(name, methods) { services.set(name, methods) }, get() {} }
|
|
49
|
+
electricServerPlugin(app, poolFor(db), models, { sync: true })
|
|
50
|
+
const metadata = () => ({ clientId: crypto.randomUUID(), revision: '1' })
|
|
51
|
+
const user = crypto.randomUUID()
|
|
52
|
+
const group = crypto.randomUUID()
|
|
53
|
+
await services.get('user').create(user, { email: 'member@example.com' }, metadata())
|
|
54
|
+
await services.get('group').create(group, { name: 'Team' }, metadata())
|
|
55
|
+
return { db, services, metadata, user, group }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('membership committed before an offline group delete is cascaded, including descendants', async t => {
|
|
59
|
+
const { db, services, metadata, user, group } = await fixture(t)
|
|
60
|
+
const membership = crypto.randomUUID()
|
|
61
|
+
const note = crypto.randomUUID()
|
|
62
|
+
const created = await services.get('membership').create(membership, { user_uid: user, group_uid: group }, metadata())
|
|
63
|
+
await services.get('note').create(note, { membership_uid: membership }, metadata())
|
|
64
|
+
const deleted = await services.get('group').delete(group, metadata())
|
|
65
|
+
assert.equal(deleted.deleted, true)
|
|
66
|
+
const row = (await db.query('SELECT * FROM membership')).rows[0]
|
|
67
|
+
assert.equal(row.deleted, true)
|
|
68
|
+
assert.equal(row.user_uid, null)
|
|
69
|
+
assert.equal(row.group_uid, null)
|
|
70
|
+
assert.ok(BigInt(row.version) > BigInt(created.version))
|
|
71
|
+
assert.equal((await db.query('SELECT * FROM note')).rows[0].deleted, true)
|
|
72
|
+
assert.equal((await db.query('SELECT * FROM people')).rows[0].deleted, false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('membership sent after parent deletion becomes a replayable rejection tombstone', async t => {
|
|
76
|
+
const { db, services, metadata, user, group } = await fixture(t)
|
|
77
|
+
await services.get('group').delete(group, metadata())
|
|
78
|
+
const id = crypto.randomUUID()
|
|
79
|
+
const request = [id, { user_uid: user, group_uid: group }, metadata()]
|
|
80
|
+
const rejected = await services.get('membership').create(...request)
|
|
81
|
+
assert.equal(rejected.deleted, true)
|
|
82
|
+
assert.equal(rejected.syncRejection.code, 'sync-parent-deleted')
|
|
83
|
+
assert.deepEqual(await services.get('membership').create(...request), rejected)
|
|
84
|
+
assert.equal((await db.query('SELECT count(*)::int AS count FROM membership WHERE NOT deleted')).rows[0].count, 0)
|
|
85
|
+
assert.equal((await db.query('SELECT * FROM membership')).rows[0].group_uid, null)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('user deletion also cascades; delayed updates cannot resurrect the membership', async t => {
|
|
89
|
+
const { db, services, metadata, user, group } = await fixture(t)
|
|
90
|
+
const id = crypto.randomUUID()
|
|
91
|
+
await services.get('membership').create(id, { user_uid: user, group_uid: group }, metadata())
|
|
92
|
+
await services.get('user').delete(user, metadata())
|
|
93
|
+
const delayed = await services.get('membership').update(id, { label: 'late edit' }, metadata())
|
|
94
|
+
assert.equal(delayed.deleted, true)
|
|
95
|
+
assert.equal((await db.query('SELECT * FROM membership')).rows[0].deleted, true)
|
|
96
|
+
assert.equal((await db.query('SELECT * FROM groups')).rows[0].deleted, false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('membership reassignment checks deleted parents and colliding creates cannot delete valid rows', async t => {
|
|
100
|
+
const { db, services, metadata, user, group } = await fixture(t)
|
|
101
|
+
const id = crypto.randomUUID()
|
|
102
|
+
const other = crypto.randomUUID()
|
|
103
|
+
await services.get('group').create(other, { name: 'Other' }, metadata())
|
|
104
|
+
await services.get('group').delete(other, metadata())
|
|
105
|
+
const original = await services.get('membership').create(id, { user_uid: user, group_uid: group }, metadata())
|
|
106
|
+
const collision = await services.get('membership').create(id, { user_uid: user, group_uid: other }, metadata())
|
|
107
|
+
assert.deepEqual(collision, original)
|
|
108
|
+
const reassigned = await services.get('membership').update(id, { group_uid: other }, metadata())
|
|
109
|
+
assert.equal(reassigned.deleted, true)
|
|
110
|
+
assert.equal(reassigned.syncRejection.code, 'sync-parent-deleted')
|
|
111
|
+
assert.equal((await db.query('SELECT * FROM membership')).rows[0].deleted, true)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('a missing parent is retriable so parent and child can be created offline together', async t => {
|
|
115
|
+
const { services, metadata, user } = await fixture(t)
|
|
116
|
+
const group = crypto.randomUUID()
|
|
117
|
+
const request = [crypto.randomUUID(), { user_uid: user, group_uid: group }, metadata()]
|
|
118
|
+
await assert.rejects(services.get('membership').create(...request), { code: 'sync-parent-missing' })
|
|
119
|
+
await services.get('group').create(group, { name: 'Queued parent' }, metadata())
|
|
120
|
+
assert.equal((await services.get('membership').create(...request)).deleted, false)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('partial updates validate retained foreign keys, including fields passed as undefined', async t => {
|
|
124
|
+
const { db, services, metadata, user, group } = await fixture(t)
|
|
125
|
+
const id = crypto.randomUUID()
|
|
126
|
+
await services.get('membership').create(id, { user_uid: user, group_uid: group }, metadata())
|
|
127
|
+
// Simulate a legacy orphan left by an older server instance.
|
|
128
|
+
await db.query('UPDATE groups SET deleted = true WHERE uid = $1', [group])
|
|
129
|
+
const result = await services.get('membership').update(id, { group_uid: undefined, label: 'edit' }, metadata())
|
|
130
|
+
assert.equal(result.deleted, true)
|
|
131
|
+
assert.equal(result.syncRejection.code, 'sync-parent-deleted')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('schema preparation repairs existing soft-delete orphans and is idempotent', async t => {
|
|
135
|
+
const { db, services, metadata, user, group } = await fixture(t)
|
|
136
|
+
const id = crypto.randomUUID()
|
|
137
|
+
await services.get('membership').create(id, { user_uid: user, group_uid: group }, metadata())
|
|
138
|
+
await services.get('note').create(crypto.randomUUID(), { membership_uid: id }, metadata())
|
|
139
|
+
await db.query('UPDATE groups SET deleted = true WHERE uid = $1', [group])
|
|
140
|
+
await prepareElectricSyncSchema(db, models)
|
|
141
|
+
const repaired = (await db.query('SELECT * FROM membership')).rows[0]
|
|
142
|
+
assert.equal(repaired.deleted, true)
|
|
143
|
+
assert.equal((await db.query('SELECT * FROM note')).rows[0].deleted, true)
|
|
144
|
+
await prepareElectricSyncSchema(db, models)
|
|
145
|
+
assert.deepEqual((await db.query('SELECT * FROM membership')).rows[0], repaired)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('cascade failure rolls back the parent, children, and mutation cursor together', async t => {
|
|
149
|
+
const { db, services, metadata, user, group } = await fixture(t)
|
|
150
|
+
await services.get('membership').create(crypto.randomUUID(), { user_uid: user, group_uid: group }, metadata())
|
|
151
|
+
const broken = models.map(model => model.name === 'membership' ? { ...model, tombstoneData: () => { throw new Error('factory failed') } } : model)
|
|
152
|
+
const brokenServices = new Map()
|
|
153
|
+
electricServerPlugin({ createService(name, methods) { brokenServices.set(name, methods) }, get() {} }, poolFor(db), broken, { sync: true })
|
|
154
|
+
const mutation = metadata()
|
|
155
|
+
await assert.rejects(brokenServices.get('group').delete(group, mutation), /factory failed/)
|
|
156
|
+
assert.equal((await db.query('SELECT * FROM groups')).rows[0].deleted, false)
|
|
157
|
+
assert.equal((await db.query('SELECT * FROM membership')).rows[0].deleted, false)
|
|
158
|
+
assert.equal((await db.query('SELECT count(*)::int AS count FROM electric_mutation_cursor WHERE client_id = $1', [mutation.clientId])).rows[0].count, 0)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('cyclic CASCADE relationships terminate and tombstone every descendant', async t => {
|
|
162
|
+
const db = new PGlite()
|
|
163
|
+
t.after(() => db.close())
|
|
164
|
+
await db.exec('CREATE TABLE nodes (uid UUID PRIMARY KEY, parent_uid UUID REFERENCES nodes(uid) ON DELETE CASCADE)')
|
|
165
|
+
const models = [{ name: 'nodes', tombstoneData: { parent_uid: null } }]
|
|
166
|
+
await prepareElectricSyncSchema(db, models)
|
|
167
|
+
let service
|
|
168
|
+
electricServerPlugin({ createService(_name, methods) { service = methods }, get() {} }, poolFor(db), models, { sync: true })
|
|
169
|
+
const first = crypto.randomUUID()
|
|
170
|
+
const second = crypto.randomUUID()
|
|
171
|
+
await service.create(first, { parent_uid: first }, { clientId: crypto.randomUUID(), revision: '1' })
|
|
172
|
+
await service.create(second, { parent_uid: first }, { clientId: crypto.randomUUID(), revision: '1' })
|
|
173
|
+
await service.update(first, { parent_uid: second }, { clientId: crypto.randomUUID(), revision: '1' })
|
|
174
|
+
await service.delete(first, { clientId: crypto.randomUUID(), revision: '1' })
|
|
175
|
+
assert.ok((await db.query('SELECT * FROM nodes')).rows.every(row => row.deleted))
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
class Stream {
|
|
179
|
+
subscribe() {}
|
|
180
|
+
isConnected() { return true }
|
|
181
|
+
unsubscribeAll() {}
|
|
182
|
+
}
|
|
183
|
+
class Shape {
|
|
184
|
+
static current
|
|
185
|
+
constructor() { Shape.current = this }
|
|
186
|
+
subscribe(callback) { this.emit = callback }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
test('offline rejected memberships disappear immediately, report a conflict, and stop retrying', async t => {
|
|
190
|
+
const { db: server, services, metadata, user, group } = await fixture(t)
|
|
191
|
+
const db = new PGlite()
|
|
192
|
+
t.after(() => db.close())
|
|
193
|
+
const errors = []
|
|
194
|
+
let requests = 0
|
|
195
|
+
const app = { service: () => ({ async create(...args) { requests++; return services.get('membership').create(...args) } }) }
|
|
196
|
+
electricClientPlugin(app, { ShapeStream: Stream, Shape })
|
|
197
|
+
const network = { onLine: false }
|
|
198
|
+
const model = app.createElectricModel('membership', { localDb: db, network, retryMs: 60_000, onError: error => errors.push(error) })
|
|
199
|
+
await model.prepare()
|
|
200
|
+
const local = await model.create({ user_uid: user, group_uid: group })
|
|
201
|
+
assert.equal((await model.findMany()).length, 1)
|
|
202
|
+
await services.get('group').delete(group, metadata())
|
|
203
|
+
network.onLine = true
|
|
204
|
+
await model.flushQueue()
|
|
205
|
+
assert.equal((await model.findMany()).length, 0)
|
|
206
|
+
assert.equal(errors[0].code, 'sync-parent-deleted')
|
|
207
|
+
assert.equal(errors[0].rowId, local.uid)
|
|
208
|
+
await model.flushQueue()
|
|
209
|
+
assert.equal(requests, 1)
|
|
210
|
+
model.start()
|
|
211
|
+
t.after(async () => { model.stop(); await model.waitForIdle() })
|
|
212
|
+
// An older snapshot cannot restore the rejected optimistic row.
|
|
213
|
+
Shape.current.emit({ rows: [{ ...local, version: '0', deleted: false }] })
|
|
214
|
+
await model.waitForIdle()
|
|
215
|
+
assert.equal((await model.findMany()).length, 0)
|
|
216
|
+
Shape.current.emit({ rows: (await server.query('SELECT * FROM membership')).rows })
|
|
217
|
+
await model.waitForIdle()
|
|
218
|
+
assert.equal((await model.getStatus()).pending, 0)
|
|
219
|
+
assert.equal((await model.findMany()).length, 0)
|
|
220
|
+
})
|
|
@@ -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,10 +192,11 @@ 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'])
|
|
196
|
-
assert.equal(queries.length,
|
|
195
|
+
await prepareElectricSyncSchema({ query: async sql => { queries.push(sql); return { rows: [] } } }, [{ name: 'todos', primaryKey: 'id' }])
|
|
196
|
+
assert.equal(queries.length, 5)
|
|
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/)
|
|
199
|
+
assert.match(queries[4], /FROM pg_constraint/)
|
|
199
200
|
})
|
|
200
201
|
|
|
201
202
|
test('sync delete writes a versioned tombstone and clears configured fields', async () => {
|
|
@@ -301,3 +302,10 @@ test('invalid tombstone factory results roll back before writing a tombstone', a
|
|
|
301
302
|
assert.ok(!queries.some(sql => sql.includes('INSERT INTO "users"')))
|
|
302
303
|
}
|
|
303
304
|
})
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
test('server models default to the uid primary key', () => {
|
|
308
|
+
const app = { createService() {}, get() {} }
|
|
309
|
+
const result = electricServerPlugin(app, { query() {} }, ['users'], { authorize: async () => true })
|
|
310
|
+
assert.equal(result.models[0].primaryKey, 'uid')
|
|
311
|
+
})
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { test, describe, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { computeSyncResult } from '../src/sync-algorithm.mjs'
|
|
5
|
+
|
|
6
|
+
const T0 = new Date('2026-01-01T00:00:00Z')
|
|
7
|
+
const T1 = new Date('2026-01-02T00:00:00Z')
|
|
8
|
+
const T2 = new Date('2026-01-03T00:00:00Z')
|
|
9
|
+
|
|
10
|
+
// ─── Dialog helpers ────────────────────────────────────────────────────────────
|
|
11
|
+
// Lightweight simulations of server (drizzle-plugins.mjs) and client (client.js)
|
|
12
|
+
// that run the sync protocol using plain objects instead of Dexie / PostgreSQL.
|
|
13
|
+
|
|
14
|
+
function matchesWhere(value, where) {
|
|
15
|
+
return Object.entries(where).every(([k, v]) => value[k] === v)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('computeSyncResult', () => {
|
|
19
|
+
|
|
20
|
+
test('both empty → all arrays empty', () => {
|
|
21
|
+
const result = computeSyncResult({}, {}, {})
|
|
22
|
+
assert.deepEqual(result.addClient, [])
|
|
23
|
+
assert.deepEqual(result.updateClient, [])
|
|
24
|
+
assert.deepEqual(result.deleteClient, [])
|
|
25
|
+
assert.deepEqual(result.addDatabase, [])
|
|
26
|
+
assert.deepEqual(result.updateDatabase, [])
|
|
27
|
+
assert.deepEqual(result.deleteDatabase, [])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('record only in DB → sent to client (addClient)', () => {
|
|
31
|
+
const value = { uid: 'a', label: 'Vacances' }
|
|
32
|
+
const meta = { uid: 'a', created_at: T0 }
|
|
33
|
+
const result = computeSyncResult({ a: value }, {}, { a: meta })
|
|
34
|
+
assert.equal(result.addClient.length, 1)
|
|
35
|
+
assert.deepEqual(result.addClient[0], [value, meta])
|
|
36
|
+
assert.deepEqual(result.addDatabase, [])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('record only on client, not deleted → sent to database (addDatabase)', () => {
|
|
40
|
+
const clientMeta = { uid: 'b', created_at: T0 }
|
|
41
|
+
const result = computeSyncResult({}, { b: clientMeta }, {})
|
|
42
|
+
assert.deepEqual(result.addDatabase, [clientMeta])
|
|
43
|
+
assert.deepEqual(result.addClient, [])
|
|
44
|
+
assert.deepEqual(result.deleteClient, [])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('record only on client, deleted → ignored on both sides (deleteClient)', () => {
|
|
48
|
+
const clientMeta = { uid: 'c', created_at: T0, deleted_at: T1 }
|
|
49
|
+
const result = computeSyncResult({}, { c: clientMeta }, {})
|
|
50
|
+
assert.deepEqual(result.deleteClient, [['c', T1]])
|
|
51
|
+
assert.deepEqual(result.addDatabase, [])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('server tombstone newer than client copy → delete client, do not recreate DB', () => {
|
|
55
|
+
const clientMeta = { uid: 'x', created_at: T0 }
|
|
56
|
+
const dbTombstone = { uid: 'x', created_at: T0, deleted_at: T1 }
|
|
57
|
+
const result = computeSyncResult({}, { x: clientMeta }, { x: dbTombstone })
|
|
58
|
+
assert.deepEqual(result.deleteClient, [['x', T1]])
|
|
59
|
+
assert.deepEqual(result.addDatabase, [])
|
|
60
|
+
assert.deepEqual(result.updateDatabase, [])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('client tombstone newer than server tombstone → upgrade server tombstone', () => {
|
|
64
|
+
const clientMeta = { uid: 'x2', created_at: T0, deleted_at: T2 }
|
|
65
|
+
const dbTombstone = { uid: 'x2', created_at: T0, deleted_at: T1 }
|
|
66
|
+
const result = computeSyncResult({}, { x2: clientMeta }, { x2: dbTombstone })
|
|
67
|
+
assert.deepEqual(result.deleteDatabase, ['x2'])
|
|
68
|
+
assert.deepEqual(result.deleteClient, [['x2', T2]])
|
|
69
|
+
assert.deepEqual(result.addDatabase, [])
|
|
70
|
+
assert.deepEqual(result.updateDatabase, [])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('record only in DB with tombstone metadata → delete stale DB row, do not add client', () => {
|
|
74
|
+
const value = { uid: 'd', label: 'stale-server-row' }
|
|
75
|
+
const dbTombstone = { uid: 'd', created_at: T0, deleted_at: T1 }
|
|
76
|
+
const result = computeSyncResult({ d: value }, {}, { d: dbTombstone })
|
|
77
|
+
assert.deepEqual(result.deleteDatabase, ['d'])
|
|
78
|
+
assert.deepEqual(result.addClient, [])
|
|
79
|
+
assert.deepEqual(result.updateClient, [])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('client update newer than server tombstone → recreate database', () => {
|
|
83
|
+
const clientMeta = { uid: 'y', created_at: T0, updated_at: T2 }
|
|
84
|
+
const dbTombstone = { uid: 'y', created_at: T0, deleted_at: T1 }
|
|
85
|
+
const result = computeSyncResult({}, { y: clientMeta }, { y: dbTombstone })
|
|
86
|
+
assert.deepEqual(result.addDatabase, [clientMeta])
|
|
87
|
+
assert.deepEqual(result.deleteClient, [])
|
|
88
|
+
assert.deepEqual(result.updateDatabase, [])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('server live metadata newer than client but row missing → tombstone server metadata and delete client', () => {
|
|
92
|
+
const clientMeta = { uid: 'm', created_at: T0, updated_at: T1 }
|
|
93
|
+
const dbMeta = { uid: 'm', created_at: T0, updated_at: T2 }
|
|
94
|
+
const result = computeSyncResult({}, { m: clientMeta }, { m: dbMeta })
|
|
95
|
+
assert.deepEqual(result.deleteDatabase, ['m'])
|
|
96
|
+
assert.equal(result.deleteClient.length, 1)
|
|
97
|
+
assert.equal(result.deleteClient[0][0], 'm')
|
|
98
|
+
assert.equal(new Date(result.deleteClient[0][1]).getTime(), T2.getTime())
|
|
99
|
+
assert.deepEqual(result.addDatabase, [])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('client tombstone newer than server live metadata but row missing → tombstone server metadata too', () => {
|
|
103
|
+
const clientMeta = { uid: 'n', created_at: T0, deleted_at: T2 }
|
|
104
|
+
const dbMeta = { uid: 'n', created_at: T0, updated_at: T1 }
|
|
105
|
+
const result = computeSyncResult({}, { n: clientMeta }, { n: dbMeta })
|
|
106
|
+
assert.deepEqual(result.deleteDatabase, ['n'])
|
|
107
|
+
assert.deepEqual(result.deleteClient, [['n', T2]])
|
|
108
|
+
assert.deepEqual(result.addDatabase, [])
|
|
109
|
+
assert.deepEqual(result.updateDatabase, [])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('record in both, server tombstone newer than client copy → delete stale DB row and client', () => {
|
|
113
|
+
const value = { uid: 'z', label: 'stale-server-row' }
|
|
114
|
+
const clientMeta = { uid: 'z', created_at: T0 }
|
|
115
|
+
const dbTombstone = { uid: 'z', created_at: T0, deleted_at: T1 }
|
|
116
|
+
const result = computeSyncResult({ z: value }, { z: clientMeta }, { z: dbTombstone })
|
|
117
|
+
assert.deepEqual(result.deleteDatabase, ['z'])
|
|
118
|
+
assert.deepEqual(result.deleteClient, [['z', T1]])
|
|
119
|
+
assert.deepEqual(result.updateClient, [])
|
|
120
|
+
assert.deepEqual(result.updateDatabase, [])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('record in both, client newer → update database (updateDatabase)', () => {
|
|
124
|
+
const value = { uid: 'd', label: 'old' }
|
|
125
|
+
const dbMeta = { uid: 'd', created_at: T0, updated_at: T1 }
|
|
126
|
+
const clientMeta = { uid: 'd', created_at: T0, updated_at: T2 }
|
|
127
|
+
const result = computeSyncResult({ d: value }, { d: clientMeta }, { d: dbMeta })
|
|
128
|
+
assert.deepEqual(result.updateDatabase, [clientMeta])
|
|
129
|
+
assert.deepEqual(result.updateClient, [])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('record in both, DB newer → update client (updateClient)', () => {
|
|
133
|
+
const value = { uid: 'e', label: 'new' }
|
|
134
|
+
const dbMeta = { uid: 'e', created_at: T0, updated_at: T2 }
|
|
135
|
+
const clientMeta = { uid: 'e', created_at: T0, updated_at: T1 }
|
|
136
|
+
const result = computeSyncResult({ e: value }, { e: clientMeta }, { e: dbMeta })
|
|
137
|
+
assert.deepEqual(result.updateClient, [[value, dbMeta]])
|
|
138
|
+
assert.deepEqual(result.updateDatabase, [])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('sub-day timestamp matches its day-truncated DB counterpart → no action', () => {
|
|
142
|
+
// The DB stores TIMESTAMP. Before this fix it stored DATE (day precision), which
|
|
143
|
+
// caused new Date('2026-01-02T06:00:00Z') - new Date('2026-01-02') > 0, triggering
|
|
144
|
+
// a spurious updateDatabase on every sync after the initial addDatabase push.
|
|
145
|
+
const value = { uid: 'p', label: 'precision' }
|
|
146
|
+
const dbMeta = { uid: 'p', created_at: '2026-01-02T06:00:00.000Z' } // TIMESTAMP from server
|
|
147
|
+
const clientMeta = { uid: 'p', created_at: new Date('2026-01-02T06:00:00.000Z') } // full timestamp on client
|
|
148
|
+
const result = computeSyncResult({ p: value }, { p: clientMeta }, { p: dbMeta })
|
|
149
|
+
assert.deepEqual(result.updateDatabase, [], 'identical timestamps must not trigger spurious updateDatabase')
|
|
150
|
+
assert.deepEqual(result.updateClient, [])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('record in both, same timestamp → no action', () => {
|
|
154
|
+
const value = { uid: 'f', label: 'same' }
|
|
155
|
+
const dbMeta = { uid: 'f', created_at: T0, updated_at: T1 }
|
|
156
|
+
const clientMeta = { uid: 'f', created_at: T0, updated_at: T1 }
|
|
157
|
+
const result = computeSyncResult({ f: value }, { f: clientMeta }, { f: dbMeta })
|
|
158
|
+
assert.deepEqual(result.updateClient, [])
|
|
159
|
+
assert.deepEqual(result.updateDatabase, [])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('record in both, client deleted → delete on DB and notify client', () => {
|
|
163
|
+
const value = { uid: 'g', label: 'bye' }
|
|
164
|
+
const dbMeta = { uid: 'g', created_at: T0 }
|
|
165
|
+
const clientMeta = { uid: 'g', created_at: T0, deleted_at: T1 }
|
|
166
|
+
const result = computeSyncResult({ g: value }, { g: clientMeta }, { g: dbMeta })
|
|
167
|
+
assert.deepEqual(result.deleteDatabase, ['g'])
|
|
168
|
+
assert.deepEqual(result.deleteClient, [['g', T1]])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('record in both, client delete newer than DB update → delete on DB and notify client', () => {
|
|
172
|
+
const value = { uid: 'h', label: 'server-old' }
|
|
173
|
+
const dbMeta = { uid: 'h', created_at: T0, updated_at: T1 }
|
|
174
|
+
const clientMeta = { uid: 'h', created_at: T0, deleted_at: T2 }
|
|
175
|
+
const result = computeSyncResult({ h: value }, { h: clientMeta }, { h: dbMeta })
|
|
176
|
+
assert.deepEqual(result.deleteDatabase, ['h'])
|
|
177
|
+
assert.deepEqual(result.deleteClient, [['h', T2]])
|
|
178
|
+
assert.deepEqual(result.updateClient, [])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('record in both, DB update newer than client delete → restore client from DB', () => {
|
|
182
|
+
const value = { uid: 'i', label: 'server-new' }
|
|
183
|
+
const dbMeta = { uid: 'i', created_at: T0, updated_at: T2 }
|
|
184
|
+
const clientMeta = { uid: 'i', created_at: T0, deleted_at: T1 }
|
|
185
|
+
const result = computeSyncResult({ i: value }, { i: clientMeta }, { i: dbMeta })
|
|
186
|
+
assert.deepEqual(result.updateClient, [[value, dbMeta]])
|
|
187
|
+
assert.deepEqual(result.deleteDatabase, [])
|
|
188
|
+
assert.deepEqual(result.deleteClient, [])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('mixed scenario: one of each case', () => {
|
|
192
|
+
const dbOnly = { uid: 'db', label: 'db-only' }
|
|
193
|
+
const dbOnlyMeta = { uid: 'db', created_at: T0 }
|
|
194
|
+
|
|
195
|
+
const clientNewMeta = { uid: 'cn', created_at: T1 }
|
|
196
|
+
|
|
197
|
+
const sharedValue = { uid: 'sh', label: 'shared' }
|
|
198
|
+
const sharedDbMeta = { uid: 'sh', created_at: T0, updated_at: T1 }
|
|
199
|
+
const sharedClientMeta = { uid: 'sh', created_at: T0, updated_at: T2 }
|
|
200
|
+
|
|
201
|
+
const result = computeSyncResult(
|
|
202
|
+
{ db: dbOnly, sh: sharedValue },
|
|
203
|
+
{ cn: clientNewMeta, sh: sharedClientMeta },
|
|
204
|
+
{ db: dbOnlyMeta, sh: sharedDbMeta },
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
assert.equal(result.addClient.length, 1)
|
|
208
|
+
assert.deepEqual(result.addClient[0], [dbOnly, dbOnlyMeta])
|
|
209
|
+
assert.deepEqual(result.addDatabase, [clientNewMeta])
|
|
210
|
+
assert.deepEqual(result.updateDatabase, [sharedClientMeta])
|
|
211
|
+
assert.deepEqual(result.updateClient, [])
|
|
212
|
+
assert.deepEqual(result.deleteClient, [])
|
|
213
|
+
assert.deepEqual(result.deleteDatabase, [])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('special uid keys do not corrupt synchronization dictionaries', () => {
|
|
217
|
+
const databaseValues = Object.create(null)
|
|
218
|
+
const databaseMetadata = Object.create(null)
|
|
219
|
+
const clientMetadata = Object.create(null)
|
|
220
|
+
databaseValues.__proto__ = { uid: '__proto__', label: 'safe' }
|
|
221
|
+
databaseMetadata.__proto__ = { uid: '__proto__', created_at: T0 }
|
|
222
|
+
|
|
223
|
+
const result = computeSyncResult(databaseValues, clientMetadata, databaseMetadata)
|
|
224
|
+
|
|
225
|
+
assert.equal(result.addClient.length, 1)
|
|
226
|
+
assert.equal(result.addClient[0][0].uid, '__proto__')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
})
|