@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
|
@@ -196,8 +196,9 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
196
196
|
const mutation = rows[0]
|
|
197
197
|
if (!mutation) break
|
|
198
198
|
try {
|
|
199
|
-
await sendMutation(mutation)
|
|
200
|
-
|
|
199
|
+
const rejection = await sendMutation(mutation)
|
|
200
|
+
if (rejection) report(rejection)
|
|
201
|
+
else lastError = null
|
|
201
202
|
} catch (error) {
|
|
202
203
|
report(error)
|
|
203
204
|
if (!isPermanent(error)) break
|
|
@@ -222,6 +223,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
222
223
|
? await service.update(id, withoutKey(data), metadata)
|
|
223
224
|
: await service.delete(id, metadata)
|
|
224
225
|
if (result?.version == null) throw new Error('sync mutation did not return a version')
|
|
226
|
+
let discarded = false
|
|
225
227
|
await retryLeader(() => db.transaction(async tx => {
|
|
226
228
|
const current = await tx.query('SELECT action, revision, data FROM electric_mutation_queue WHERE model_name = $1 AND row_id = $2', [modelName, id])
|
|
227
229
|
const queued = current.rows[0]
|
|
@@ -229,6 +231,10 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
229
231
|
if (String(queued.revision) === String(mutation.revision) && queued.action === action) {
|
|
230
232
|
await tx.query(`UPDATE electric_mutation_queue SET acknowledged_version = $3::numeric
|
|
231
233
|
WHERE model_name = $1 AND row_id = $2`, [modelName, id, String(result.version)])
|
|
234
|
+
if (result.deleted && action !== 'delete') {
|
|
235
|
+
await tx.query('DELETE FROM electric_local_rows WHERE model_name = $1 AND row_id = $2', [modelName, id])
|
|
236
|
+
discarded = true
|
|
237
|
+
}
|
|
232
238
|
} else if (action === 'create' && queued.action === 'create') {
|
|
233
239
|
// The create was sent, then edited locally. Its next request is an update.
|
|
234
240
|
await tx.query(`UPDATE electric_mutation_queue SET action = 'update',
|
|
@@ -237,6 +243,12 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
237
243
|
}))
|
|
238
244
|
await reconcile()
|
|
239
245
|
notify()
|
|
246
|
+
if (discarded) {
|
|
247
|
+
const error = new Error(result.syncRejection?.message ?? `Change to '${modelName}' discarded because the row was deleted`)
|
|
248
|
+
error.code = result.syncRejection?.code ?? 'sync-row-deleted'
|
|
249
|
+
error.rowId = id
|
|
250
|
+
return error
|
|
251
|
+
}
|
|
240
252
|
}
|
|
241
253
|
|
|
242
254
|
function withoutKey(data) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { discoverSyncRelations } from './electric-sync-relations.mjs'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Register Express-X mutation services and an Electric Shape proxy.
|
|
3
5
|
*
|
|
@@ -14,6 +16,12 @@ export function electricServerPlugin(app, db, models, options = {}) {
|
|
|
14
16
|
const configuredModels = normalizeModels(models)
|
|
15
17
|
const sync = options.sync === true
|
|
16
18
|
if (sync && typeof db.connect !== 'function') throw new TypeError('sync requires a pg Pool with connect()')
|
|
19
|
+
let relationsPromise
|
|
20
|
+
const getRelations = () => relationsPromise ??= discoverSyncRelations(db, configuredModels).catch(error => {
|
|
21
|
+
relationsPromise = undefined
|
|
22
|
+
throw error
|
|
23
|
+
})
|
|
24
|
+
const mutate = async (...args) => syncMutation(db, ...args, await getRelations())
|
|
17
25
|
const electricUrl = new URL(options.electricUrl ?? process.env.ELECTRIC_URL ?? 'http://localhost:3000/v1/shape')
|
|
18
26
|
const shapePath = options.shapePath ?? '/electric/v1/shape/:model'
|
|
19
27
|
const fetchImpl = options.fetch ?? globalThis.fetch
|
|
@@ -70,7 +78,7 @@ export function electricServerPlugin(app, db, models, options = {}) {
|
|
|
70
78
|
: { ...mutationData }
|
|
71
79
|
if (sync) {
|
|
72
80
|
if (!hasClientId) throw new TypeError('sync mutations require a client-generated id')
|
|
73
|
-
return
|
|
81
|
+
return mutate(model, 'create', idOrData, safeData, mutation)
|
|
74
82
|
}
|
|
75
83
|
const entries = Object.entries(safeData).filter(([, value]) => value !== undefined)
|
|
76
84
|
const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
|
|
@@ -101,7 +109,7 @@ export function electricServerPlugin(app, db, models, options = {}) {
|
|
|
101
109
|
|
|
102
110
|
update: async function(id, data, mutation) {
|
|
103
111
|
await authorize(this, model.name, 'update', sync ? [id, data, mutation] : [id, data])
|
|
104
|
-
if (sync) return
|
|
112
|
+
if (sync) return mutate(model, 'update', id, data, mutation)
|
|
105
113
|
const set = buildSet(data, model.primaryKey)
|
|
106
114
|
return withTransaction(db, async client => {
|
|
107
115
|
const result = await client.query(
|
|
@@ -114,7 +122,7 @@ export function electricServerPlugin(app, db, models, options = {}) {
|
|
|
114
122
|
|
|
115
123
|
delete: async function(id, mutation) {
|
|
116
124
|
await authorize(this, model.name, 'delete', sync ? [id, mutation] : [id])
|
|
117
|
-
if (sync) return
|
|
125
|
+
if (sync) return mutate(model, 'delete', id, null, mutation)
|
|
118
126
|
return withTransaction(db, async client => {
|
|
119
127
|
const result = await client.query(
|
|
120
128
|
`DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [id],
|
|
@@ -166,9 +174,11 @@ export async function prepareElectricSyncSchema(db, models) {
|
|
|
166
174
|
await db.query(`ALTER TABLE ${model.quotedTable} ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT nextval('electric_sync_version_seq')`)
|
|
167
175
|
await db.query(`ALTER TABLE ${model.quotedTable} ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT false`)
|
|
168
176
|
}
|
|
177
|
+
const relations = await discoverSyncRelations(db, configuredModels)
|
|
178
|
+
await withTransaction(db, tx => relations.repair(tx, writeTombstone))
|
|
169
179
|
}
|
|
170
180
|
|
|
171
|
-
async function syncMutation(db, model, action, id, data, mutation) {
|
|
181
|
+
async function syncMutation(db, model, action, id, data, mutation, relations) {
|
|
172
182
|
if (!mutation || typeof mutation !== 'object') throw new TypeError('sync mutation metadata is required')
|
|
173
183
|
const { clientId, revision } = mutation
|
|
174
184
|
if (typeof clientId !== 'string' || !UUID.test(clientId)) throw new TypeError('clientId must be a UUID')
|
|
@@ -179,6 +189,7 @@ async function syncMutation(db, model, action, id, data, mutation) {
|
|
|
179
189
|
if (action !== 'delete') assertPlainObject(data, 'mutation data')
|
|
180
190
|
if (data && ('version' in data || 'deleted' in data)) throw new TypeError('version and deleted are managed by sync')
|
|
181
191
|
return withTransaction(db, async tx => {
|
|
192
|
+
await relations.lock(tx, model)
|
|
182
193
|
const key = [model.table, clientId, String(id)]
|
|
183
194
|
await tx.query(`INSERT INTO electric_mutation_cursor (table_name, client_id, row_id)
|
|
184
195
|
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, key)
|
|
@@ -191,7 +202,10 @@ async function syncMutation(db, model, action, id, data, mutation) {
|
|
|
191
202
|
const editedCreate = action === 'create' && BigInt(cursor.revision) > 0n
|
|
192
203
|
&& Object.entries(data).some(([column, value]) => column !== model.primaryKey && value !== undefined)
|
|
193
204
|
let result
|
|
194
|
-
|
|
205
|
+
const rejection = await relations.deletedParent(tx, model, action, id, data, editedCreate)
|
|
206
|
+
if (rejection) {
|
|
207
|
+
result = { ...await writeTombstone(tx, model, id), syncRejection: rejection }
|
|
208
|
+
} else if (action === 'create' && !editedCreate) {
|
|
195
209
|
const entries = Object.entries({ ...data, [model.primaryKey]: id }).filter(([, value]) => value !== undefined)
|
|
196
210
|
const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
|
|
197
211
|
const values = entries.map(([, value]) => value)
|
|
@@ -208,6 +222,7 @@ async function syncMutation(db, model, action, id, data, mutation) {
|
|
|
208
222
|
} else {
|
|
209
223
|
result = await writeTombstone(tx, model, id)
|
|
210
224
|
}
|
|
225
|
+
if (result?.deleted) await relations.cascade(tx, model, id, writeTombstone)
|
|
211
226
|
await tx.query(`UPDATE electric_mutation_cursor SET revision = $4, result = $5::jsonb
|
|
212
227
|
WHERE table_name = $1 AND client_id = $2 AND row_id = $3`, [...key, String(revision), JSON.stringify(result ?? null)])
|
|
213
228
|
return result
|
|
@@ -253,7 +268,7 @@ function normalizeModels(models) {
|
|
|
253
268
|
}
|
|
254
269
|
const name = config.name
|
|
255
270
|
const table = config.table ?? name
|
|
256
|
-
const primaryKey = config.primaryKey ?? '
|
|
271
|
+
const primaryKey = config.primaryKey ?? 'uid'
|
|
257
272
|
quoteIdentifier(name, 'model name')
|
|
258
273
|
const tombstoneData = config.tombstoneData ?? {}
|
|
259
274
|
if (typeof tombstoneData !== 'function') validateTombstoneData(tombstoneData, primaryKey)
|
|
@@ -321,6 +336,7 @@ function buildSet(data, primaryKey, startIndex = 1) {
|
|
|
321
336
|
}
|
|
322
337
|
|
|
323
338
|
async function withTransaction(db, operation) {
|
|
339
|
+
if (typeof db?.connect !== 'function' && typeof db?.transaction === 'function') return db.transaction(operation)
|
|
324
340
|
if (typeof db?.connect !== 'function') return operation(db)
|
|
325
341
|
const client = await db.connect()
|
|
326
342
|
try {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// PostgreSQL foreign keys remain satisfied by soft-deleted rows. Mirror CASCADE
|
|
2
|
+
// relationships at the sync boundary, where deletions must produce tombstones.
|
|
3
|
+
export async function discoverSyncRelations(db, models) {
|
|
4
|
+
const { rows } = await db.query(`SELECT child.table_name AS child_table, parent.table_name AS parent_table,
|
|
5
|
+
ARRAY(SELECT attname::text FROM unnest(fk.conkey) WITH ORDINALITY AS key(attnum, position)
|
|
6
|
+
JOIN pg_attribute ON attrelid = fk.conrelid AND pg_attribute.attnum = key.attnum ORDER BY position) AS child_columns,
|
|
7
|
+
ARRAY(SELECT attname::text FROM unnest(fk.confkey) WITH ORDINALITY AS key(attnum, position)
|
|
8
|
+
JOIN pg_attribute ON attrelid = fk.confrelid AND pg_attribute.attnum = key.attnum ORDER BY position) AS parent_columns
|
|
9
|
+
FROM pg_constraint AS fk
|
|
10
|
+
JOIN unnest($1::text[]) AS child(table_name) ON fk.conrelid = to_regclass(child.table_name)
|
|
11
|
+
JOIN unnest($1::text[]) AS parent(table_name) ON fk.confrelid = to_regclass(parent.table_name)
|
|
12
|
+
WHERE fk.contype = 'f' AND fk.confdeltype = 'c'`, [models.map(model => model.quotedTable)])
|
|
13
|
+
const byTable = new Map(models.map(model => [model.quotedTable, model]))
|
|
14
|
+
const relations = rows.map(row => {
|
|
15
|
+
const child = byTable.get(row.child_table)
|
|
16
|
+
const parent = byTable.get(row.parent_table)
|
|
17
|
+
if (row.child_columns.length !== 1 || row.parent_columns.length !== 1 || row.parent_columns[0] !== parent.primaryKey) {
|
|
18
|
+
throw new TypeError('sync CASCADE foreign keys must reference a single configured primary key')
|
|
19
|
+
}
|
|
20
|
+
// Names come from PostgreSQL, not request data; still quote embedded quotes.
|
|
21
|
+
const column = row.child_columns[0]
|
|
22
|
+
return { child, parent, column, quotedColumn: `"${column.replaceAll('"', '""')}"` }
|
|
23
|
+
})
|
|
24
|
+
const members = new Set(relations.flatMap(({ child, parent }) => [child, parent]))
|
|
25
|
+
|
|
26
|
+
async function lock(tx, model) {
|
|
27
|
+
if (!members.size || model && !members.has(model)) return
|
|
28
|
+
// One transaction-scoped lock, acquired before any cursor/row locks. This
|
|
29
|
+
// serializes relational mutations across processes and handles cyclic
|
|
30
|
+
// graphs without lock-order inversions. Unrelated models remain parallel.
|
|
31
|
+
await tx.query('SELECT pg_advisory_xact_lock(178693771, 1)')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function deletedParent(tx, model, action, id, data, editedCreate) {
|
|
35
|
+
const parents = relations.filter(relation => relation.child === model)
|
|
36
|
+
if (action === 'delete' || !parents.length) return null
|
|
37
|
+
const { rows } = await tx.query(`SELECT * FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 FOR UPDATE`, [id])
|
|
38
|
+
const current = rows[0]
|
|
39
|
+
// A colliding create cannot overwrite or delete another client's row.
|
|
40
|
+
if (action === 'create' && !editedCreate && current) return null
|
|
41
|
+
if (action === 'update' || editedCreate) {
|
|
42
|
+
if (!current || current.deleted) return null
|
|
43
|
+
}
|
|
44
|
+
const changes = Object.fromEntries(Object.entries(data).filter(([column, value]) => column !== model.primaryKey && value !== undefined))
|
|
45
|
+
const proposed = { ...current, ...changes, [model.primaryKey]: id }
|
|
46
|
+
for (const relation of parents) {
|
|
47
|
+
const parentId = proposed[relation.column]
|
|
48
|
+
if (parentId == null) continue
|
|
49
|
+
const { parent } = relation
|
|
50
|
+
// PostgreSQL permits a newly inserted row to reference itself.
|
|
51
|
+
if (parent.table === model.table && String(parentId) === String(id)) continue
|
|
52
|
+
const result = await tx.query(`SELECT deleted FROM ${parent.quotedTable} WHERE ${parent.quotedPrimaryKey} = $1 FOR UPDATE`, [parentId])
|
|
53
|
+
if (!result.rows[0]) {
|
|
54
|
+
// The parent may still be queued on another model. Retry rather than
|
|
55
|
+
// discarding a legitimate offline parent+child creation.
|
|
56
|
+
const error = new Error(`Waiting for '${parent.name}' parent '${parentId}' to synchronize`)
|
|
57
|
+
error.code = 'sync-parent-missing'
|
|
58
|
+
throw error
|
|
59
|
+
}
|
|
60
|
+
if (result.rows[0].deleted) return {
|
|
61
|
+
code: 'sync-parent-deleted',
|
|
62
|
+
message: `Change to '${model.name}' discarded because '${parent.name}' parent '${parentId}' was deleted`,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function cascade(tx, model, id, writeTombstone, visited = new Set()) {
|
|
69
|
+
const key = JSON.stringify([model.table, id])
|
|
70
|
+
if (visited.has(key)) return
|
|
71
|
+
visited.add(key)
|
|
72
|
+
for (const relation of relations.filter(relation => relation.parent === model)) {
|
|
73
|
+
const { child } = relation
|
|
74
|
+
const { rows } = await tx.query(`SELECT ${child.quotedPrimaryKey} FROM ${child.quotedTable}
|
|
75
|
+
WHERE ${relation.quotedColumn} = $1 AND NOT deleted ORDER BY ${child.quotedPrimaryKey} FOR UPDATE`, [id])
|
|
76
|
+
for (const row of rows) {
|
|
77
|
+
const childId = row[child.primaryKey]
|
|
78
|
+
await writeTombstone(tx, child, childId)
|
|
79
|
+
await cascade(tx, child, childId, writeTombstone, visited)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function repair(tx, writeTombstone) {
|
|
85
|
+
await lock(tx)
|
|
86
|
+
for (const { child, parent, quotedColumn } of relations) {
|
|
87
|
+
const { rows } = await tx.query(`SELECT child.${child.quotedPrimaryKey} FROM ${child.quotedTable} AS child
|
|
88
|
+
JOIN ${parent.quotedTable} AS parent ON child.${quotedColumn} = parent.${parent.quotedPrimaryKey}
|
|
89
|
+
WHERE NOT child.deleted AND parent.deleted ORDER BY child.${child.quotedPrimaryKey} FOR UPDATE OF child`)
|
|
90
|
+
for (const row of rows) {
|
|
91
|
+
const id = row[child.primaryKey]
|
|
92
|
+
await writeTombstone(tx, child, id)
|
|
93
|
+
await cascade(tx, child, id, writeTombstone)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { lock, deletedParent, cascade, repair }
|
|
99
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Restored from express-x commit edb1db3 (last complete custom sync algorithm).
|
|
2
|
+
|
|
3
|
+
// This sync algorithm uses the metadata table as its only source of truth for existence and recency. Manual SQL changes
|
|
4
|
+
// create a split reality — the data table and the metadata table diverge — and the algorithm's set-intersection logic
|
|
5
|
+
// misidentifies the divergence as intentional client-side changes that must be propagated.
|
|
6
|
+
// If you need to seed or manipulate data outside the framework, you must also write the corresponding rows into the
|
|
7
|
+
// metadata table with valid created_at/updated_at/deleted_at values.
|
|
8
|
+
|
|
9
|
+
// The metadata table is a tombstone log that grows monotonically. Every record ever created leaves a permanent entry. For
|
|
10
|
+
// long-lived applications with high churn this is worth planning for: you can only safely purge a tombstone once you're certain no
|
|
11
|
+
// client still holds a copy of that record (i.e., every client has synced at least once after the deletion). There's currently no
|
|
12
|
+
// pruning mechanism in the framework.
|
|
13
|
+
|
|
14
|
+
export function computeSyncResult(databaseValuesDict, clientMetadataDict, databaseMetadataDict) {
|
|
15
|
+
assertValidMetadataTimestamps(clientMetadataDict, 'client')
|
|
16
|
+
assertValidMetadataTimestamps(databaseMetadataDict, 'database')
|
|
17
|
+
const onlyDatabaseIds = new Set()
|
|
18
|
+
const onlyClientIds = new Set()
|
|
19
|
+
const databaseAndClientIds = new Set()
|
|
20
|
+
|
|
21
|
+
for (const uid of Object.keys(databaseValuesDict)) {
|
|
22
|
+
if (Object.hasOwn(clientMetadataDict, uid)) databaseAndClientIds.add(uid)
|
|
23
|
+
else onlyDatabaseIds.add(uid)
|
|
24
|
+
}
|
|
25
|
+
for (const uid of Object.keys(clientMetadataDict)) {
|
|
26
|
+
if (Object.hasOwn(databaseValuesDict, uid)) databaseAndClientIds.add(uid)
|
|
27
|
+
else onlyClientIds.add(uid)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const addDatabase = [], updateDatabase = [], deleteDatabase = []
|
|
31
|
+
const addClient = [], updateClient = [], deleteClient = []
|
|
32
|
+
|
|
33
|
+
for (const uid of onlyDatabaseIds) {
|
|
34
|
+
const databaseMetaData = databaseMetadataDict[uid] || { uid, created_at: null }
|
|
35
|
+
if (databaseMetaData.deleted_at) {
|
|
36
|
+
deleteDatabase.push(uid)
|
|
37
|
+
} else {
|
|
38
|
+
addClient.push([databaseValuesDict[uid], databaseMetaData])
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const uid of onlyClientIds) {
|
|
43
|
+
const clientMetaData = clientMetadataDict[uid]
|
|
44
|
+
const databaseMetaData = databaseMetadataDict[uid]
|
|
45
|
+
if (databaseMetaData?.deleted_at) {
|
|
46
|
+
const clientUpdatedAt = new Date(clientMetaData.deleted_at || clientMetaData.updated_at || clientMetaData.created_at)
|
|
47
|
+
const databaseDeletedAt = new Date(databaseMetaData.deleted_at)
|
|
48
|
+
if (databaseDeletedAt >= clientUpdatedAt) {
|
|
49
|
+
deleteClient.push([uid, databaseMetaData.deleted_at])
|
|
50
|
+
} else if (clientMetaData.deleted_at) {
|
|
51
|
+
deleteDatabase.push(uid)
|
|
52
|
+
deleteClient.push([uid, clientMetaData.deleted_at])
|
|
53
|
+
} else {
|
|
54
|
+
addDatabase.push(clientMetaData)
|
|
55
|
+
}
|
|
56
|
+
} else if (databaseMetaData) {
|
|
57
|
+
const clientUpdatedAt = new Date(clientMetaData.deleted_at || clientMetaData.updated_at || clientMetaData.created_at)
|
|
58
|
+
const databaseUpdatedAt = new Date(databaseMetaData.updated_at || databaseMetaData.created_at)
|
|
59
|
+
if (databaseUpdatedAt >= clientUpdatedAt) {
|
|
60
|
+
deleteDatabase.push(uid)
|
|
61
|
+
deleteClient.push([uid, databaseUpdatedAt])
|
|
62
|
+
} else if (clientMetaData.deleted_at) {
|
|
63
|
+
deleteDatabase.push(uid)
|
|
64
|
+
deleteClient.push([uid, clientMetaData.deleted_at])
|
|
65
|
+
} else {
|
|
66
|
+
addDatabase.push(clientMetaData)
|
|
67
|
+
}
|
|
68
|
+
} else if (clientMetaData.deleted_at) {
|
|
69
|
+
deleteClient.push([uid, clientMetaData.deleted_at])
|
|
70
|
+
} else {
|
|
71
|
+
addDatabase.push(clientMetaData)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const uid of databaseAndClientIds) {
|
|
76
|
+
const clientMetaData = clientMetadataDict[uid]
|
|
77
|
+
const databaseMetaData = databaseMetadataDict[uid] || { uid, created_at: null }
|
|
78
|
+
if (databaseMetaData.deleted_at) {
|
|
79
|
+
const clientUpdatedAt = new Date(clientMetaData.deleted_at || clientMetaData.updated_at || clientMetaData.created_at)
|
|
80
|
+
const databaseDeletedAt = new Date(databaseMetaData.deleted_at)
|
|
81
|
+
if (databaseDeletedAt >= clientUpdatedAt) {
|
|
82
|
+
deleteDatabase.push(uid)
|
|
83
|
+
deleteClient.push([uid, databaseMetaData.deleted_at])
|
|
84
|
+
} else if (clientMetaData.deleted_at) {
|
|
85
|
+
deleteDatabase.push(uid)
|
|
86
|
+
deleteClient.push([uid, clientMetaData.deleted_at])
|
|
87
|
+
} else {
|
|
88
|
+
updateDatabase.push(clientMetaData)
|
|
89
|
+
}
|
|
90
|
+
} else if (clientMetaData.deleted_at) {
|
|
91
|
+
const clientDeletedAt = new Date(clientMetaData.deleted_at)
|
|
92
|
+
const databaseUpdatedAt = new Date(databaseMetaData.updated_at || databaseMetaData.created_at)
|
|
93
|
+
const diff = clientDeletedAt - databaseUpdatedAt
|
|
94
|
+
if (diff >= 0) {
|
|
95
|
+
deleteDatabase.push(uid)
|
|
96
|
+
deleteClient.push([uid, clientMetaData.deleted_at])
|
|
97
|
+
} else {
|
|
98
|
+
updateClient.push([databaseValuesDict[uid], databaseMetaData])
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
const clientUpdatedAt = new Date(clientMetaData.updated_at || clientMetaData.created_at)
|
|
102
|
+
const databaseUpdatedAt = new Date(databaseMetaData.updated_at || databaseMetaData.created_at)
|
|
103
|
+
const diff = clientUpdatedAt - databaseUpdatedAt
|
|
104
|
+
if (diff > 0) updateDatabase.push(clientMetaData)
|
|
105
|
+
else if (diff < 0) updateClient.push([databaseValuesDict[uid], databaseMetaData])
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
addClient,
|
|
111
|
+
updateClient,
|
|
112
|
+
deleteClient,
|
|
113
|
+
addDatabase,
|
|
114
|
+
updateDatabase,
|
|
115
|
+
deleteDatabase,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function assertValidMetadataTimestamps(metadataDict, source) {
|
|
120
|
+
if (!metadataDict || typeof metadataDict !== 'object' || Array.isArray(metadataDict)) {
|
|
121
|
+
throw new TypeError(`${source} metadata must be an object`)
|
|
122
|
+
}
|
|
123
|
+
for (const [uid, metadata] of Object.entries(metadataDict)) {
|
|
124
|
+
if (!metadata || typeof metadata !== 'object') {
|
|
125
|
+
throw new TypeError(`${source} metadata for '${uid}' must be an object`)
|
|
126
|
+
}
|
|
127
|
+
if (metadata.uid !== uid) {
|
|
128
|
+
throw new TypeError(`${source} metadata key '${uid}' does not match uid '${metadata.uid}'`)
|
|
129
|
+
}
|
|
130
|
+
const timestampFields = ['created_at', 'updated_at', 'deleted_at']
|
|
131
|
+
// Timestamp-free database metadata can exist transiently while the
|
|
132
|
+
// Drizzle plugin locks/repairs a row. Client metadata must always carry
|
|
133
|
+
// a usable version.
|
|
134
|
+
if (source !== 'database' && !timestampFields.some(field => metadata[field] != null)) {
|
|
135
|
+
throw new TypeError(`${source} metadata '${uid}' must contain a timestamp`)
|
|
136
|
+
}
|
|
137
|
+
for (const field of timestampFields) {
|
|
138
|
+
const value = metadata[field]
|
|
139
|
+
if (value == null) continue
|
|
140
|
+
if (Number.isNaN(new Date(value).getTime())) {
|
|
141
|
+
throw new TypeError(`${source} metadata '${uid}.${field}' must be a valid timestamp`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|