@jcbuisson/express-x-plugins 5.0.7 → 5.1.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.
@@ -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
- lastError = null
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 syncMutation(db, model, 'create', idOrData, safeData, mutation)
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 syncMutation(db, model, 'update', id, data, mutation)
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 syncMutation(db, model, 'delete', id, null, mutation)
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
- if (action === 'create' && !editedCreate) {
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
@@ -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,52 @@
1
+ import { Shape, ShapeStream } from '@electric-sql/client'
2
+
3
+ /** Install app.sql.query(name, params) and app.sql.watch(table, filter, callback). */
4
+ export function electricSqlClientPlugin(app, options = {}) {
5
+ const service = app.service(options.serviceName ?? 'sql')
6
+ const Stream = options.ShapeStream ?? ShapeStream
7
+ const LiveShape = options.Shape ?? Shape
8
+ const active = new Set()
9
+ function watch(table, filter, callback) {
10
+ if (typeof filter === 'function') { callback = filter; filter = {} }
11
+ const { where, params = [] } = filter ?? {}
12
+ if (typeof table !== 'string' || !table) throw new TypeError('table is required')
13
+ if (typeof callback !== 'function') throw new TypeError('callback is required')
14
+ if (where !== undefined && typeof where !== 'string') throw new TypeError('where must be SQL text')
15
+ if (!Array.isArray(params)) throw new TypeError('params must be an array')
16
+ const values = params.map(value => {
17
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString()
18
+ if (typeof value === 'string' || typeof value === 'boolean' || typeof value === 'bigint'
19
+ || (typeof value === 'number' && Number.isFinite(value))) return String(value)
20
+ throw new TypeError('shape params must be strings, booleans, finite numbers, bigints or Dates; use IS NULL for null')
21
+ })
22
+ const controller = new AbortController()
23
+ const stream = new Stream({
24
+ url: `${(options.shapePath ?? '/electric-sql/v1/shape').replace(/\/$/, '')}/${encodeURIComponent(table)}`,
25
+ headers: options.headers,
26
+ onError: options.onError,
27
+ signal: controller.signal,
28
+ params: { ...(where === undefined ? {} : { where }), params: values },
29
+ })
30
+ const shape = new LiveShape(stream)
31
+ const unsubscribe = shape.subscribe(({ rows }) => callback([...rows]))
32
+ let stopped = false
33
+ function stop() {
34
+ if (stopped) return
35
+ stopped = true
36
+ unsubscribe()
37
+ stream.unsubscribeAll()
38
+ controller.abort()
39
+ active.delete(stop)
40
+ }
41
+ active.add(stop)
42
+ return stop
43
+ }
44
+ app.sql = {
45
+ query: (name, params = []) => service.query(name, params),
46
+ watch,
47
+ dispose() { for (const stop of active) stop() },
48
+ }
49
+ return app
50
+ }
51
+
52
+ export default electricSqlClientPlugin
@@ -0,0 +1,69 @@
1
+ /** SQL statements execute on PostgreSQL; Electric supplies live table reads. */
2
+ export function electricSqlServerPlugin(app, db, options = {}) {
3
+ const { tables, statements = {}, authorize } = options
4
+ if (!db || typeof db.query !== 'function') throw new TypeError('db must expose query(sql, params)')
5
+ if (!Array.isArray(tables) || !tables.length || tables.some(table => typeof table !== 'string' || !table)) {
6
+ throw new TypeError('tables must be a non-empty array of table names')
7
+ }
8
+ if (typeof authorize !== 'function') throw new TypeError('authorize(context, operation) is required')
9
+ const sql = new Map(Object.entries(statements))
10
+ for (const statement of sql.values()) {
11
+ if (typeof statement !== 'string' || !statement.trim()) throw new TypeError('statements must contain SQL strings')
12
+ }
13
+ const fetchImpl = options.fetch ?? globalThis.fetch
14
+ const upstreamUrl = new URL(options.electricUrl ?? process.env.ELECTRIC_URL ?? 'http://localhost:3000/v1/shape')
15
+ const shapePath = options.shapePath ?? '/electric-sql/v1/shape/:table'
16
+ async function check(context, operation) {
17
+ if (!await authorize(context, operation)) {
18
+ const error = new Error('forbidden')
19
+ error.code = 'forbidden'
20
+ throw error
21
+ }
22
+ }
23
+ app.createService(options.serviceName ?? 'sql', {
24
+ async query(name, params = []) {
25
+ if (!sql.has(name)) throw new TypeError('unknown SQL statement')
26
+ if (!Array.isArray(params)) throw new TypeError('params must be an array')
27
+ await check(this, { action: 'query', name, params })
28
+ return (await db.query(sql.get(name), params)).rows
29
+ },
30
+ })
31
+ app.get(shapePath, async (request, response, next) => {
32
+ try {
33
+ const table = request.params.table
34
+ if (!tables.includes(table)) return response.status(404).json({ error: 'unknown table' })
35
+ const target = new URL(upstreamUrl)
36
+ // Only Electric protocol and SQL filter parameters cross the proxy.
37
+ for (const [key, value] of Object.entries(request.query)) {
38
+ if (!['offset', 'handle', 'live', 'cursor', 'where', 'columns', 'replica'].includes(key)
39
+ && !/^params\[[1-9][0-9]*\]$/.test(key)) continue
40
+ if (typeof value !== 'string') throw new TypeError('shape parameters must be strings')
41
+ target.searchParams.set(key, value)
42
+ }
43
+ target.searchParams.set('table', table)
44
+ await check({ app, request, response, transport: 'http' }, {
45
+ action: 'shape', table, params: Object.fromEntries(target.searchParams),
46
+ })
47
+ if (options.sourceId) target.searchParams.set('source_id', options.sourceId)
48
+ if (options.sourceSecret) target.searchParams.set('secret', options.sourceSecret)
49
+ const controller = new AbortController()
50
+ const abort = () => controller.abort()
51
+ response.on?.('close', abort)
52
+ try {
53
+ const upstream = await fetchImpl(target, { signal: controller.signal })
54
+ response.status(upstream.status)
55
+ upstream.headers.forEach((value, key) => {
56
+ if (!['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'set-cookie'].includes(key.toLowerCase())) {
57
+ response.setHeader(key, value)
58
+ }
59
+ })
60
+ response.send(Buffer.from(await upstream.arrayBuffer()))
61
+ } finally {
62
+ response.off?.('close', abort)
63
+ }
64
+ } catch (error) { next(error) }
65
+ })
66
+ return { shapePath }
67
+ }
68
+
69
+ export default electricSqlServerPlugin
@@ -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
+