@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
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
// Restored from express-x-drizzle commit ddf6804.
|
|
2
|
+
import { and, eq, gt, gte, lt, lte, inArray, isNull, getTableName } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
import { computeSyncResult } from './sync-algorithm.mjs'
|
|
5
|
+
|
|
6
|
+
export function truncateString(str, maxLength = 300, ellipsis = '...') {
|
|
7
|
+
// Check if the string already fits
|
|
8
|
+
if (str.length <= maxLength) return str;
|
|
9
|
+
// Calculate the cut-off point, accounting for the ellipsis length
|
|
10
|
+
const cutLength = maxLength - ellipsis.length;
|
|
11
|
+
// Ensure the string is long enough to be cut
|
|
12
|
+
if (cutLength < 0) return str.substring(0, maxLength); // Just cut it off if ellipsis doesn't fit
|
|
13
|
+
// Truncate the string and add the ellipsis
|
|
14
|
+
return str.substring(0, cutLength) + ellipsis;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
////////////////////////// UTILITIES //////////////////////////
|
|
22
|
+
|
|
23
|
+
function whereToDrizzleFilters(table, where) {
|
|
24
|
+
validateWhere(where)
|
|
25
|
+
const conditions = Object.entries(where)
|
|
26
|
+
.filter(([_, value]) => value !== undefined)
|
|
27
|
+
.map(([key, value]) => {
|
|
28
|
+
const column = table[key]
|
|
29
|
+
if (value === null) return isNull(column)
|
|
30
|
+
if (typeof value === 'object') {
|
|
31
|
+
// Collect ALL range bounds — a compound { gte:1, lte:10 } needs both
|
|
32
|
+
const bounds = []
|
|
33
|
+
if ('gte' in value) bounds.push(gte(column, coerceWhereValue(column, value.gte)))
|
|
34
|
+
if ('gt' in value) bounds.push(gt(column, coerceWhereValue(column, value.gt)))
|
|
35
|
+
if ('lte' in value) bounds.push(lte(column, coerceWhereValue(column, value.lte)))
|
|
36
|
+
if ('lt' in value) bounds.push(lt(column, coerceWhereValue(column, value.lt)))
|
|
37
|
+
if (bounds.length > 0) return and(...bounds)
|
|
38
|
+
}
|
|
39
|
+
return eq(column, coerceWhereValue(column, value))
|
|
40
|
+
})
|
|
41
|
+
return conditions.length ? and(...conditions) : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function validateWhere(where) {
|
|
45
|
+
if (!where || typeof where !== 'object' || Array.isArray(where)
|
|
46
|
+
|| Object.prototype.toString.call(where) !== '[object Object]') {
|
|
47
|
+
throw new TypeError('where must be a plain object')
|
|
48
|
+
}
|
|
49
|
+
validateJsonFilterValue(where, 'where')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateJsonFilterValue(value, path) {
|
|
53
|
+
if (value === undefined || typeof value === 'function'
|
|
54
|
+
|| typeof value === 'symbol' || typeof value === 'bigint') {
|
|
55
|
+
throw new TypeError(`${path} contains a non-serializable value`)
|
|
56
|
+
}
|
|
57
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
58
|
+
throw new TypeError(`${path} contains a non-finite number`)
|
|
59
|
+
}
|
|
60
|
+
if (value instanceof Date) {
|
|
61
|
+
if (Number.isNaN(value.getTime())) throw new TypeError(`${path} contains an invalid Date`)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (Array.isArray(value)) {
|
|
65
|
+
value.forEach((entry, index) => validateJsonFilterValue(entry, `${path}[${index}]`))
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
if (value && typeof value === 'object') {
|
|
69
|
+
if (Object.prototype.toString.call(value) !== '[object Object]') {
|
|
70
|
+
throw new TypeError(`${path} contains a non-plain object`)
|
|
71
|
+
}
|
|
72
|
+
for (const [key, entry] of Object.entries(value)) validateJsonFilterValue(entry, `${path}.${key}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function coerceWhereValue(column, value) {
|
|
77
|
+
if (column?.columnType === 'PgTimestamp' && typeof value === 'string') return new Date(value)
|
|
78
|
+
return value
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeMutationTimestamp(value, field) {
|
|
82
|
+
const timestamp = new Date(value)
|
|
83
|
+
if (Number.isNaN(timestamp.getTime())) throw new TypeError(`${field} must be a valid timestamp`)
|
|
84
|
+
const now = Date.now()
|
|
85
|
+
const maxFutureSkew = 5 * 60 * 1000
|
|
86
|
+
return timestamp.getTime() > now + maxFutureSkew ? new Date(now) : timestamp
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function metadataStorageUid(modelName, uid) {
|
|
90
|
+
return JSON.stringify([modelName, uid])
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function externalizeMetadata(row, uid) {
|
|
94
|
+
return row ? { ...row, uid } : null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function metadataTimestamp(meta, fields = ['deleted_at', 'updated_at', 'created_at']) {
|
|
98
|
+
if (!meta) return null
|
|
99
|
+
for (const field of fields) {
|
|
100
|
+
if (meta[field] != null) return new Date(meta[field])
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sanitizeMutationData(data) {
|
|
106
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
107
|
+
throw new TypeError('mutation data must be an object')
|
|
108
|
+
}
|
|
109
|
+
const { uid: _uid, __deleted__: _deleted, ...safeData } = data
|
|
110
|
+
return safeData
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isPlainObject(value) {
|
|
114
|
+
return value && typeof value === 'object' && !Array.isArray(value) && Object.prototype.toString.call(value) === '[object Object]'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function hasRangeOperator(value) {
|
|
118
|
+
return isPlainObject(value) && ['gte', 'gt', 'lte', 'lt'].some(key => key in value)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function valueWithinRange(value, range) {
|
|
122
|
+
if (value === null || value === undefined) return false
|
|
123
|
+
if ('gte' in range && compareWhereValues(value, range.gte) < 0) return false
|
|
124
|
+
if ('gt' in range && compareWhereValues(value, range.gt) <= 0) return false
|
|
125
|
+
if ('lte' in range && compareWhereValues(value, range.lte) > 0) return false
|
|
126
|
+
if ('lt' in range && compareWhereValues(value, range.lt) >= 0) return false
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function rangesOverlap(a, b) {
|
|
131
|
+
const lower = [
|
|
132
|
+
'gte' in a && { value: a.gte, inclusive: true },
|
|
133
|
+
'gt' in a && { value: a.gt, inclusive: false },
|
|
134
|
+
'gte' in b && { value: b.gte, inclusive: true },
|
|
135
|
+
'gt' in b && { value: b.gt, inclusive: false },
|
|
136
|
+
].filter(Boolean).sort((x, y) => compareRangeBounds(y, x) || Number(x.inclusive) - Number(y.inclusive))[0]
|
|
137
|
+
|
|
138
|
+
const upper = [
|
|
139
|
+
'lte' in a && { value: a.lte, inclusive: true },
|
|
140
|
+
'lt' in a && { value: a.lt, inclusive: false },
|
|
141
|
+
'lte' in b && { value: b.lte, inclusive: true },
|
|
142
|
+
'lt' in b && { value: b.lt, inclusive: false },
|
|
143
|
+
].filter(Boolean).sort((x, y) => compareRangeBounds(x, y) || Number(x.inclusive) - Number(y.inclusive))[0]
|
|
144
|
+
|
|
145
|
+
if (!lower || !upper) return true
|
|
146
|
+
const diff = compareWhereValues(lower.value, upper.value)
|
|
147
|
+
if (diff < 0) return true
|
|
148
|
+
if (diff > 0) return false
|
|
149
|
+
return lower.inclusive && upper.inclusive
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function compareRangeBounds(a, b) {
|
|
153
|
+
return compareWhereValues(a.value, b.value)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function compareWhereValues(a, b) {
|
|
157
|
+
if (a instanceof Date || b instanceof Date) {
|
|
158
|
+
const aTime = new Date(a).getTime()
|
|
159
|
+
const bTime = new Date(b).getTime()
|
|
160
|
+
if (!Number.isNaN(aTime) && !Number.isNaN(bTime)) return aTime - bTime
|
|
161
|
+
}
|
|
162
|
+
if (a === b) return 0
|
|
163
|
+
return a > b ? 1 : -1
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function constraintsOverlap(a, b) {
|
|
167
|
+
if (a === undefined || b === undefined) return true
|
|
168
|
+
if (a === null || b === null) return a === null && b === null
|
|
169
|
+
|
|
170
|
+
const aRange = hasRangeOperator(a)
|
|
171
|
+
const bRange = hasRangeOperator(b)
|
|
172
|
+
|
|
173
|
+
if (aRange && bRange) return rangesOverlap(a, b)
|
|
174
|
+
if (aRange) return valueWithinRange(b, a)
|
|
175
|
+
if (bRange) return valueWithinRange(a, b)
|
|
176
|
+
if (isPlainObject(a) || isPlainObject(b)) return true
|
|
177
|
+
return a === b
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function whereScopesOverlap(a = {}, b = {}) {
|
|
181
|
+
const sharedKeys = Object.keys(a).filter(key => Object.hasOwn(b, key))
|
|
182
|
+
for (const key of sharedKeys) {
|
|
183
|
+
if (!constraintsOverlap(a[key], b[key])) return false
|
|
184
|
+
}
|
|
185
|
+
return true
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
class OverlapLock {
|
|
189
|
+
constructor() {
|
|
190
|
+
this.active = []
|
|
191
|
+
this.queue = []
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
acquire(where) {
|
|
195
|
+
return new Promise(resolve => {
|
|
196
|
+
const entry = { where, resolve }
|
|
197
|
+
this.queue.push(entry)
|
|
198
|
+
this.pump()
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
pump() {
|
|
203
|
+
for (let i = 0; i < this.queue.length; i++) {
|
|
204
|
+
const entry = this.queue[i]
|
|
205
|
+
if (this.active.some(active => whereScopesOverlap(active.where, entry.where))) continue
|
|
206
|
+
// Preserve fairness: a later scope may bypass earlier disjoint scopes,
|
|
207
|
+
// but never an earlier queued scope that it overlaps.
|
|
208
|
+
if (this.queue.slice(0, i).some(queued => whereScopesOverlap(queued.where, entry.where))) continue
|
|
209
|
+
this.queue.splice(i, 1)
|
|
210
|
+
i -= 1
|
|
211
|
+
this.active.push(entry)
|
|
212
|
+
entry.resolve(() => {
|
|
213
|
+
this.active = this.active.filter(active => active !== entry)
|
|
214
|
+
this.pump()
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
get idle() {
|
|
220
|
+
return this.active.length === 0 && this.queue.length === 0
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
////////////////////////// DRIZZLE OFFLINE PLUGIN //////////////////////////
|
|
226
|
+
|
|
227
|
+
export function drizzleOfflinePlugin(app, db, metadata, models, options = {}) {
|
|
228
|
+
if (typeof options.authorize !== 'function') {
|
|
229
|
+
throw new TypeError('drizzleOfflinePlugin requires an authorize(context, operation) policy')
|
|
230
|
+
}
|
|
231
|
+
const computeSynchronization = options.computeSyncResult ?? computeSyncResult
|
|
232
|
+
const configuredModels = new Map(models.map(model => [getTableName(model), model]))
|
|
233
|
+
const storageUidFor = (modelName, uid) =>
|
|
234
|
+
models.length === 1 ? uid : metadataStorageUid(modelName, uid)
|
|
235
|
+
const maxSyncRecords = options.maxSyncRecords ?? 10_000
|
|
236
|
+
if (!Number.isInteger(maxSyncRecords) || maxSyncRecords < 1) {
|
|
237
|
+
throw new TypeError('maxSyncRecords must be a positive integer')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function lockMetadataRow(tx, modelName, uid) {
|
|
241
|
+
const storageUid = storageUidFor(modelName, uid)
|
|
242
|
+
await tx.insert(metadata).values({ uid: storageUid }).onConflictDoNothing({ target: metadata.uid })
|
|
243
|
+
let row = (await tx.select().from(metadata).where(eq(metadata.uid, storageUid)).for('update'))[0]
|
|
244
|
+
|
|
245
|
+
return { storageUid, meta: externalizeMetadata(row, uid) }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function lockUidOwnership(tx, uid) {
|
|
249
|
+
if (models.length === 1) return
|
|
250
|
+
const lockUid = JSON.stringify(['__uid_owner__', uid])
|
|
251
|
+
await tx.insert(metadata).values({ uid: lockUid }).onConflictDoNothing({ target: metadata.uid })
|
|
252
|
+
await tx.select({ uid: metadata.uid }).from(metadata).where(eq(metadata.uid, lockUid)).for('update')
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function authorize(context, modelName, action, args) {
|
|
256
|
+
const allowed = await options.authorize(context, { modelName, action, args })
|
|
257
|
+
if (!allowed) {
|
|
258
|
+
const error = new Error(`not authorized to ${action} '${modelName}'`)
|
|
259
|
+
error.code = 'forbidden'
|
|
260
|
+
throw error
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function assertUidUniqueToModel(tx, currentModel, uid) {
|
|
265
|
+
for (const candidate of models) {
|
|
266
|
+
if (candidate === currentModel) continue
|
|
267
|
+
const row = (await tx.select({ uid: candidate.uid }).from(candidate).where(eq(candidate.uid, uid)))[0]
|
|
268
|
+
if (row) throw new Error(`uid '${uid}' already belongs to model '${getTableName(candidate)}'`)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// add a database service for each model
|
|
273
|
+
for (const model of models) {
|
|
274
|
+
const modelName = getTableName(model)
|
|
275
|
+
|
|
276
|
+
app.createService(modelName, {
|
|
277
|
+
|
|
278
|
+
findUnique: async function(where) {
|
|
279
|
+
await authorize(this, modelName, 'findUnique', [where])
|
|
280
|
+
const rows = await db.select().from(model).where(whereToDrizzleFilters(model, where));
|
|
281
|
+
return rows[0] ?? null;
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
findMany: async function(where, queryOptions = {}) {
|
|
285
|
+
await authorize(this, modelName, 'findMany', [where, queryOptions])
|
|
286
|
+
let query = db.select().from(model).where(whereToDrizzleFilters(model, where))
|
|
287
|
+
if (queryOptions.limit != null) query = query.limit(queryOptions.limit)
|
|
288
|
+
return await query;
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
createWithMeta: async function(uid, data, created_at) {
|
|
292
|
+
await authorize(this, modelName, 'createWithMeta', [uid, data, created_at])
|
|
293
|
+
const safeData = sanitizeMutationData(data)
|
|
294
|
+
const ts = normalizeMutationTimestamp(created_at, 'created_at')
|
|
295
|
+
return await db.transaction(async (tx) => {
|
|
296
|
+
await lockUidOwnership(tx, uid)
|
|
297
|
+
const { storageUid, meta: existingMeta } = await lockMetadataRow(tx, modelName, uid)
|
|
298
|
+
await assertUidUniqueToModel(tx, model, uid)
|
|
299
|
+
const existingTime = metadataTimestamp(existingMeta)
|
|
300
|
+
if (existingTime && existingTime >= ts) {
|
|
301
|
+
const value = (await tx.select().from(model).where(eq(model.uid, uid)))[0] ?? undefined
|
|
302
|
+
if (existingMeta.deleted_at) {
|
|
303
|
+
if (value) await tx.delete(model).where(eq(model.uid, uid))
|
|
304
|
+
return [undefined, existingMeta]
|
|
305
|
+
}
|
|
306
|
+
if (!value && !existingMeta.deleted_at) {
|
|
307
|
+
const [meta] = await tx.insert(metadata)
|
|
308
|
+
.values({ uid: storageUid, deleted_at: existingTime })
|
|
309
|
+
.onConflictDoUpdate({ target: metadata.uid, set: { deleted_at: existingTime } })
|
|
310
|
+
.returning();
|
|
311
|
+
return [undefined, externalizeMetadata(meta, uid)]
|
|
312
|
+
}
|
|
313
|
+
return [value, existingMeta]
|
|
314
|
+
}
|
|
315
|
+
// Upsert: if the model row already exists (e.g. a concurrent createWithMeta
|
|
316
|
+
// from the direct create() path landed before the sync's addDatabase step),
|
|
317
|
+
// update it instead of throwing a PK conflict that would rollback the
|
|
318
|
+
// client's Dexie record.
|
|
319
|
+
const [value] = await tx.insert(model)
|
|
320
|
+
.values({ ...safeData, uid })
|
|
321
|
+
.onConflictDoUpdate({ target: model.uid, set: safeData })
|
|
322
|
+
.returning();
|
|
323
|
+
// Upsert metadata: handles re-creation after a prior deleteWithMeta.
|
|
324
|
+
const [meta] = await tx.insert(metadata)
|
|
325
|
+
.values({ uid: storageUid, created_at: ts })
|
|
326
|
+
.onConflictDoUpdate({
|
|
327
|
+
target: metadata.uid,
|
|
328
|
+
set: { created_at: ts, deleted_at: null, updated_at: null },
|
|
329
|
+
})
|
|
330
|
+
.returning();
|
|
331
|
+
return [value, externalizeMetadata(meta, uid)]
|
|
332
|
+
})
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
updateWithMeta: async function(uid, data, updated_at) {
|
|
336
|
+
await authorize(this, modelName, 'updateWithMeta', [uid, data, updated_at])
|
|
337
|
+
const safeData = sanitizeMutationData(data)
|
|
338
|
+
const ts = updated_at ? normalizeMutationTimestamp(updated_at, 'updated_at') : null
|
|
339
|
+
return await db.transaction(async (tx) => {
|
|
340
|
+
await lockUidOwnership(tx, uid)
|
|
341
|
+
const { storageUid, meta: existingMeta } = await lockMetadataRow(tx, modelName, uid)
|
|
342
|
+
await assertUidUniqueToModel(tx, model, uid)
|
|
343
|
+
if (existingMeta?.deleted_at) {
|
|
344
|
+
const deletedAt = new Date(existingMeta.deleted_at)
|
|
345
|
+
if (ts && ts > deletedAt) {
|
|
346
|
+
const [value] = await tx.insert(model)
|
|
347
|
+
.values({ ...safeData, uid })
|
|
348
|
+
.onConflictDoUpdate({ target: model.uid, set: safeData })
|
|
349
|
+
.returning();
|
|
350
|
+
const [meta] = await tx.insert(metadata)
|
|
351
|
+
.values({ uid: storageUid, updated_at: ts })
|
|
352
|
+
.onConflictDoUpdate({ target: metadata.uid, set: { updated_at: ts, deleted_at: null } })
|
|
353
|
+
.returning();
|
|
354
|
+
return [value, externalizeMetadata(meta, uid)]
|
|
355
|
+
}
|
|
356
|
+
await tx.delete(model).where(eq(model.uid, uid))
|
|
357
|
+
return [undefined, existingMeta]
|
|
358
|
+
}
|
|
359
|
+
const existingTime = metadataTimestamp(existingMeta, ['updated_at', 'created_at'])
|
|
360
|
+
if (existingTime && (!ts || existingTime >= ts)) {
|
|
361
|
+
const value = (await tx.select().from(model).where(eq(model.uid, uid)))[0] ?? undefined
|
|
362
|
+
if (!value) {
|
|
363
|
+
const [meta] = await tx.insert(metadata)
|
|
364
|
+
.values({ uid: storageUid, deleted_at: existingTime })
|
|
365
|
+
.onConflictDoUpdate({ target: metadata.uid, set: { deleted_at: existingTime } })
|
|
366
|
+
.returning();
|
|
367
|
+
return [undefined, externalizeMetadata(meta, uid)]
|
|
368
|
+
}
|
|
369
|
+
return [value, existingMeta]
|
|
370
|
+
}
|
|
371
|
+
const [value] = await tx.insert(model)
|
|
372
|
+
.values({ ...safeData, uid })
|
|
373
|
+
.onConflictDoUpdate({ target: model.uid, set: safeData })
|
|
374
|
+
.returning();
|
|
375
|
+
// Upsert metadata: if metadata is missing (data-integrity gap where the
|
|
376
|
+
// model row exists but no metadata row), create it so the loop stops.
|
|
377
|
+
const [meta] = await tx.insert(metadata)
|
|
378
|
+
.values({ uid: storageUid, updated_at: ts })
|
|
379
|
+
.onConflictDoUpdate({ target: metadata.uid, set: { updated_at: ts, deleted_at: null } })
|
|
380
|
+
.returning();
|
|
381
|
+
return [value, externalizeMetadata(meta, uid)]
|
|
382
|
+
})
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
deleteWithMeta: async function(uid, deleted_at) {
|
|
386
|
+
await authorize(this, modelName, 'deleteWithMeta', [uid, deleted_at])
|
|
387
|
+
return await db.transaction(async (tx) => {
|
|
388
|
+
await lockUidOwnership(tx, uid)
|
|
389
|
+
const { storageUid, meta: existingMeta } = await lockMetadataRow(tx, modelName, uid)
|
|
390
|
+
await assertUidUniqueToModel(tx, model, uid)
|
|
391
|
+
const ts = normalizeMutationTimestamp(deleted_at, 'deleted_at')
|
|
392
|
+
const existingTime = metadataTimestamp(existingMeta)
|
|
393
|
+
if (existingTime && existingTime > ts) {
|
|
394
|
+
const value = (await tx.select().from(model).where(eq(model.uid, uid)))[0] ?? undefined
|
|
395
|
+
if (existingMeta.deleted_at) {
|
|
396
|
+
if (value) await tx.delete(model).where(eq(model.uid, uid))
|
|
397
|
+
return [undefined, existingMeta]
|
|
398
|
+
}
|
|
399
|
+
if (!value && !existingMeta.deleted_at) {
|
|
400
|
+
const [meta] = await tx.insert(metadata)
|
|
401
|
+
.values({ uid: storageUid, deleted_at: existingTime })
|
|
402
|
+
.onConflictDoUpdate({ target: metadata.uid, set: { deleted_at: existingTime } })
|
|
403
|
+
.returning();
|
|
404
|
+
return [undefined, externalizeMetadata(meta, uid)]
|
|
405
|
+
}
|
|
406
|
+
return [value, existingMeta]
|
|
407
|
+
}
|
|
408
|
+
const [value] = await tx.delete(model).where(eq(model.uid, uid)).returning();
|
|
409
|
+
const [meta] = await tx.insert(metadata)
|
|
410
|
+
.values({ uid: storageUid, deleted_at: ts })
|
|
411
|
+
.onConflictDoUpdate({ target: metadata.uid, set: { deleted_at: ts } })
|
|
412
|
+
.returning();
|
|
413
|
+
return [value, externalizeMetadata(meta, uid)]
|
|
414
|
+
})
|
|
415
|
+
},
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const syncLocks = new Map()
|
|
420
|
+
|
|
421
|
+
// add a synchronization service
|
|
422
|
+
app.createService('sync', {
|
|
423
|
+
|
|
424
|
+
go: async function(modelName, where, clientMetadataDict) {
|
|
425
|
+
if (!configuredModels.has(modelName)) throw new Error(`model '${modelName}' is not configured for synchronization`)
|
|
426
|
+
await authorize(this, modelName, 'sync', [where, clientMetadataDict])
|
|
427
|
+
if (!clientMetadataDict || typeof clientMetadataDict !== 'object' || Array.isArray(clientMetadataDict)) {
|
|
428
|
+
throw new TypeError('client metadata must be an object')
|
|
429
|
+
}
|
|
430
|
+
const clientUids = Object.keys(clientMetadataDict)
|
|
431
|
+
if (clientUids.length > maxSyncRecords) {
|
|
432
|
+
throw new Error(`sync metadata exceeds maxSyncRecords (${maxSyncRecords})`)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// overlap-aware lock so independent scopes can still run in parallel, but overlapping where predicates do not
|
|
436
|
+
if (!syncLocks.has(modelName)) syncLocks.set(modelName, new OverlapLock())
|
|
437
|
+
const syncLock = syncLocks.get(modelName)
|
|
438
|
+
const releaseSyncLock = await syncLock.acquire(where)
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
console.log('>>>>> SYNC', modelName, where)
|
|
442
|
+
const databaseService = app.service(modelName)
|
|
443
|
+
|
|
444
|
+
// STEP1: get existing database `where` values and build a dictionary
|
|
445
|
+
const databaseValues = await databaseService.callWithContext(
|
|
446
|
+
this, 'findMany', where, { limit: maxSyncRecords + 1 },
|
|
447
|
+
)
|
|
448
|
+
if (databaseValues.length > maxSyncRecords) {
|
|
449
|
+
throw new Error(`sync result exceeds maxSyncRecords (${maxSyncRecords})`)
|
|
450
|
+
}
|
|
451
|
+
const databaseValuesDict = databaseValues.reduce((accu, value) => {
|
|
452
|
+
accu[value.uid] = value
|
|
453
|
+
return accu
|
|
454
|
+
}, Object.create(null))
|
|
455
|
+
|
|
456
|
+
// STEP 2: fetch metadata for each database record and each client-sent uid.
|
|
457
|
+
// Client uids matter when the server row was deleted: only the tombstone remains.
|
|
458
|
+
const effectiveClientMetadata = Object.assign(Object.create(null), clientMetadataDict)
|
|
459
|
+
|
|
460
|
+
// A client-supplied uid whose real row exists outside `where` must not
|
|
461
|
+
// participate in this scoped sync. Treating it as a missing row can
|
|
462
|
+
// otherwise schedule an out-of-scope deletion.
|
|
463
|
+
const candidateOutsideUids = clientUids.filter(uid => !Object.hasOwn(databaseValuesDict, uid))
|
|
464
|
+
if (candidateOutsideUids.length > 0) {
|
|
465
|
+
const outsideRows = await db.select({ uid: configuredModels.get(modelName).uid })
|
|
466
|
+
.from(configuredModels.get(modelName))
|
|
467
|
+
.where(inArray(configuredModels.get(modelName).uid, candidateOutsideUids))
|
|
468
|
+
for (const row of outsideRows) delete effectiveClientMetadata[row.uid]
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const databaseMetadataDict = Object.create(null)
|
|
472
|
+
const metadataUids = new Set([
|
|
473
|
+
...Object.keys(databaseValuesDict),
|
|
474
|
+
...Object.keys(effectiveClientMetadata),
|
|
475
|
+
])
|
|
476
|
+
const metadataUidList = [...metadataUids]
|
|
477
|
+
if (metadataUidList.length > 0) {
|
|
478
|
+
const storageToUid = new Map(metadataUidList.map(uid => [storageUidFor(modelName, uid), uid]))
|
|
479
|
+
const rows = await db.select().from(metadata)
|
|
480
|
+
.where(inArray(metadata.uid, [...storageToUid.keys()]))
|
|
481
|
+
for (const row of rows) {
|
|
482
|
+
const uid = storageToUid.get(row.uid)
|
|
483
|
+
databaseMetadataDict[uid] = externalizeMetadata(row, uid)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
}
|
|
487
|
+
const metadataRepairTimestamp = new Date()
|
|
488
|
+
for (const uid of Object.keys(databaseValuesDict)) {
|
|
489
|
+
if (databaseMetadataDict[uid]) continue
|
|
490
|
+
if (Object.hasOwn(effectiveClientMetadata, uid)
|
|
491
|
+
&& !effectiveClientMetadata[uid]?.__repaired__) continue
|
|
492
|
+
|
|
493
|
+
const [meta] = await db.insert(metadata)
|
|
494
|
+
.values({ uid: storageUidFor(modelName, uid), created_at: metadataRepairTimestamp })
|
|
495
|
+
.onConflictDoUpdate({
|
|
496
|
+
target: metadata.uid,
|
|
497
|
+
set: { created_at: metadataRepairTimestamp, updated_at: null, deleted_at: null },
|
|
498
|
+
})
|
|
499
|
+
.returning()
|
|
500
|
+
databaseMetadataDict[uid] = externalizeMetadata(meta, uid)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// STEP 3: compute sync result
|
|
504
|
+
const result = computeSynchronization(databaseValuesDict, effectiveClientMetadata, databaseMetadataDict)
|
|
505
|
+
|
|
506
|
+
// STEP 4: execute server-side deletions
|
|
507
|
+
for (const uid of result.deleteDatabase) {
|
|
508
|
+
const deletedAt = effectiveClientMetadata[uid]?.deleted_at
|
|
509
|
+
?? databaseMetadataDict[uid]?.deleted_at
|
|
510
|
+
?? databaseMetadataDict[uid]?.updated_at
|
|
511
|
+
?? databaseMetadataDict[uid]?.created_at
|
|
512
|
+
await databaseService.callWithContext(this, 'deleteWithMeta', uid, deletedAt)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
console.log('addDatabase', truncateString(JSON.stringify(result.addDatabase)))
|
|
516
|
+
console.log('updateDatabase', truncateString(JSON.stringify(result.updateDatabase)))
|
|
517
|
+
console.log('addClient', truncateString(JSON.stringify(result.addClient)))
|
|
518
|
+
console.log('deleteClient', truncateString(JSON.stringify(result.deleteClient)))
|
|
519
|
+
console.log('updateClient', truncateString(JSON.stringify(result.updateClient)))
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
addClient: result.addClient,
|
|
523
|
+
updateClient: result.updateClient,
|
|
524
|
+
deleteClient: result.deleteClient,
|
|
525
|
+
addDatabase: result.addDatabase,
|
|
526
|
+
updateDatabase: result.updateDatabase,
|
|
527
|
+
}
|
|
528
|
+
} catch(err) {
|
|
529
|
+
console.log('*** err sync', err)
|
|
530
|
+
throw err
|
|
531
|
+
} finally {
|
|
532
|
+
releaseSyncLock()
|
|
533
|
+
if (syncLock.idle) syncLocks.delete(modelName)
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export { computeSyncResult, drizzleOfflinePlugin as syncServerPlugin }
|
|
541
|
+
export default drizzleOfflinePlugin
|
|
@@ -76,13 +76,26 @@ test('managed models initialize automatically, share one database, and cache mod
|
|
|
76
76
|
assert.equal(dataDir, 'idb://test-db'); created++; return db
|
|
77
77
|
} })
|
|
78
78
|
try {
|
|
79
|
-
const users = app.createElectricModel('users'
|
|
80
|
-
const
|
|
79
|
+
const users = app.createElectricModel('users')
|
|
80
|
+
const customVisibility = { getCurrentState: () => 'hidden', subscribe: () => () => {} }
|
|
81
|
+
const ranges = app.createElectricModel('ranges', { streamOptions: { runtimeVisibility: customVisibility, params: { test: 'preserved' } } })
|
|
81
82
|
assert.equal(app.createElectricModel('users', { primaryKey: 'uid' }), users)
|
|
82
|
-
assert.
|
|
83
|
+
assert.equal(app.createElectricModel('users'), users)
|
|
84
|
+
assert.throws(() => app.createElectricModel('users', { primaryKey: 'id' }), /primaryKey/)
|
|
83
85
|
const row = await users.create({ name: 'Works before prepare' })
|
|
84
86
|
await ranges.prepare()
|
|
85
87
|
assert.equal(created, 1)
|
|
88
|
+
await until(() => Stream.instances.some(stream => stream.options.url.endsWith('/users')))
|
|
89
|
+
const visibility = Stream.instances.findLast(stream => stream.options.url.endsWith('/users')).options.runtimeVisibility
|
|
90
|
+
assert.equal(visibility.getCurrentState(), 'visible')
|
|
91
|
+
let notified = false
|
|
92
|
+
const cleanup = visibility.subscribe(() => { notified = true })
|
|
93
|
+
cleanup()
|
|
94
|
+
assert.equal(notified, false)
|
|
95
|
+
await until(() => Stream.instances.some(stream => stream.options.url.endsWith('/ranges')))
|
|
96
|
+
const rangeOptions = Stream.instances.findLast(stream => stream.options.url.endsWith('/ranges')).options
|
|
97
|
+
assert.equal(rangeOptions.runtimeVisibility, customVisibility)
|
|
98
|
+
assert.deepEqual(rangeOptions.params, { test: 'preserved' })
|
|
86
99
|
assert.equal((await users.findUnique({ uid: row.uid })).name, row.name)
|
|
87
100
|
} finally {
|
|
88
101
|
await app.disposeElectricSync()
|
|
@@ -100,8 +113,8 @@ test('only one tab streams and flushes; follower edits broadcast and ownership t
|
|
|
100
113
|
const before = Stream.instances.length
|
|
101
114
|
let subscription
|
|
102
115
|
try {
|
|
103
|
-
const owner = first.createElectricModel('todos')
|
|
104
|
-
const follower = second.createElectricModel('todos')
|
|
116
|
+
const owner = first.createElectricModel('todos', { primaryKey: 'id' })
|
|
117
|
+
const follower = second.createElectricModel('todos', { primaryKey: 'id' })
|
|
105
118
|
await owner.prepare()
|
|
106
119
|
await follower.prepare()
|
|
107
120
|
await until(() => Stream.instances.length === before + 1)
|
|
@@ -137,8 +150,8 @@ test('stopping a waiting follower cancels takeover; restart can acquire ownershi
|
|
|
137
150
|
const second = configure(db, locks)
|
|
138
151
|
const before = Stream.instances.length
|
|
139
152
|
try {
|
|
140
|
-
const owner = first.createElectricModel('todos')
|
|
141
|
-
const follower = second.createElectricModel('todos')
|
|
153
|
+
const owner = first.createElectricModel('todos', { primaryKey: 'id' })
|
|
154
|
+
const follower = second.createElectricModel('todos', { primaryKey: 'id' })
|
|
142
155
|
await owner.prepare(); await follower.prepare()
|
|
143
156
|
await until(() => Stream.instances.length === before + 1)
|
|
144
157
|
follower.stop()
|
|
@@ -169,8 +182,8 @@ test('a returning sync owner preserves shared rows until its new snapshot arrive
|
|
|
169
182
|
const changed = { ...original, title: 'updated by next owner', version: '2' }
|
|
170
183
|
const added = { id: crypto.randomUUID(), title: 'added by next owner', version: '2', deleted: false }
|
|
171
184
|
try {
|
|
172
|
-
const owner = first.createElectricModel('todos')
|
|
173
|
-
const follower = second.createElectricModel('todos')
|
|
185
|
+
const owner = first.createElectricModel('todos', { primaryKey: 'id' })
|
|
186
|
+
const follower = second.createElectricModel('todos', { primaryKey: 'id' })
|
|
174
187
|
await owner.prepare(); await follower.prepare()
|
|
175
188
|
await until(() => Stream.instances.length === before + 1)
|
|
176
189
|
Stream.instances.at(-1).shape.emit({ rows: [original] })
|
|
@@ -209,7 +222,7 @@ test('initialization errors reject writes and observable reads and call onError'
|
|
|
209
222
|
createDatabase: async () => { throw error },
|
|
210
223
|
onError: cause => reported.push(cause),
|
|
211
224
|
})
|
|
212
|
-
const model = app.createElectricModel('todos')
|
|
225
|
+
const model = app.createElectricModel('todos', { primaryKey: 'id' })
|
|
213
226
|
await assert.rejects(model.create({ title: 'No silent failure' }), /load assets/)
|
|
214
227
|
await new Promise(resolve => model.getObservable().subscribe({ error: cause => {
|
|
215
228
|
assert.equal(cause, error); resolve()
|
|
@@ -239,7 +252,7 @@ test('default worker errors and timeouts reject preparation instead of hanging',
|
|
|
239
252
|
terminate() { terminated = true }
|
|
240
253
|
}
|
|
241
254
|
const app = configure(undefined, new Locks(), {}, { workerFactory: () => new Worker(), initTimeoutMs: 30 })
|
|
242
|
-
const model = app.createElectricModel('todos')
|
|
255
|
+
const model = app.createElectricModel('todos', { primaryKey: 'id' })
|
|
243
256
|
await assert.rejects(model.prepare(), triggerError ? /module not found/ : /timed out/)
|
|
244
257
|
assert.equal(terminated, true)
|
|
245
258
|
await assert.rejects(app.disposeElectricSync())
|
|
@@ -266,7 +279,7 @@ test('snapshots and reads recover after a database leader changes', async () =>
|
|
|
266
279
|
const app = configure(shared, new Locks())
|
|
267
280
|
const before = Stream.instances.length
|
|
268
281
|
try {
|
|
269
|
-
const model = app.createElectricModel('todos')
|
|
282
|
+
const model = app.createElectricModel('todos', { primaryKey: 'id' })
|
|
270
283
|
await model.prepare()
|
|
271
284
|
await until(() => Stream.instances.length === before + 1)
|
|
272
285
|
failSnapshot = true
|
|
@@ -315,8 +328,8 @@ test('network lifecycle preserves offline edits, transfers ownership and respect
|
|
|
315
328
|
const before = Stream.instances.length
|
|
316
329
|
let subscription
|
|
317
330
|
try {
|
|
318
|
-
const owner = first.createElectricModel('todos')
|
|
319
|
-
const follower = second.createElectricModel('todos')
|
|
331
|
+
const owner = first.createElectricModel('todos', { primaryKey: 'id' })
|
|
332
|
+
const follower = second.createElectricModel('todos', { primaryKey: 'id' })
|
|
320
333
|
await owner.prepare(); await follower.prepare()
|
|
321
334
|
let rows = []
|
|
322
335
|
subscription = follower.getObservable().subscribe(value => { rows = value })
|