velocious 1.0.475 → 1.0.477
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 +3 -0
- package/build/configuration-types.js +4 -0
- package/build/configuration.js +29 -0
- package/build/database/drivers/base.js +16 -0
- package/build/database/drivers/mssql/index.js +13 -0
- package/build/database/drivers/mssql/sql/remove-index.js +6 -0
- package/build/database/drivers/mysql/index.js +13 -0
- package/build/database/drivers/mysql/sql/remove-index.js +6 -0
- package/build/database/drivers/pgsql/index.js +13 -0
- package/build/database/drivers/pgsql/sql/remove-index.js +6 -0
- package/build/database/drivers/sqlite/base.js +13 -0
- package/build/database/drivers/sqlite/sql/remove-index.js +6 -0
- package/build/database/migration/index.js +44 -0
- package/build/database/pool/base-methods-forward.js +1 -0
- package/build/database/query/remove-index-base.js +39 -0
- package/build/database/record/index.js +25 -9
- package/build/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
- package/build/frontend-model-controller.js +632 -1
- package/build/frontend-models/base.js +5 -1
- package/build/frontend-models/resource-definition.js +20 -3
- package/build/http-server/client/websocket-session.js +93 -0
- package/build/routes/hooks/frontend-model-command-route-hook.js +24 -0
- package/build/src/configuration-types.d.ts +20 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +5 -1
- package/build/src/configuration.d.ts +13 -0
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +28 -1
- package/build/src/database/drivers/base.d.ts +20 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +16 -1
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +12 -1
- package/build/src/database/drivers/mssql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/mssql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/mssql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +12 -1
- package/build/src/database/drivers/mysql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/mysql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/mysql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/index.js +12 -1
- package/build/src/database/drivers/pgsql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/pgsql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/pgsql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/base.js +12 -1
- package/build/src/database/drivers/sqlite/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/sqlite/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/sqlite/sql/remove-index.js +5 -0
- package/build/src/database/migration/index.d.ts +25 -0
- package/build/src/database/migration/index.d.ts.map +1 -1
- package/build/src/database/migration/index.js +39 -1
- package/build/src/database/pool/base-methods-forward.d.ts.map +1 -1
- package/build/src/database/pool/base-methods-forward.js +2 -1
- package/build/src/database/query/remove-index-base.d.ts +35 -0
- package/build/src/database/query/remove-index-base.d.ts.map +1 -0
- package/build/src/database/query/remove-index-base.js +34 -0
- package/build/src/database/record/index.d.ts +11 -0
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +26 -11
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts +15 -0
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts.map +1 -1
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +33 -1
- package/build/src/frontend-model-controller.d.ts +156 -0
- package/build/src/frontend-model-controller.d.ts.map +1 -1
- package/build/src/frontend-model-controller.js +555 -2
- package/build/src/frontend-models/base.d.ts +5 -0
- package/build/src/frontend-models/base.d.ts.map +1 -1
- package/build/src/frontend-models/base.js +6 -2
- package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
- package/build/src/frontend-models/resource-definition.js +20 -4
- package/build/src/http-server/client/websocket-session.d.ts +39 -0
- package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
- package/build/src/http-server/client/websocket-session.js +85 -1
- package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
- package/build/src/routes/hooks/frontend-model-command-route-hook.js +22 -1
- package/build/src/sync/conflict-strategy.d.ts +83 -0
- package/build/src/sync/conflict-strategy.d.ts.map +1 -0
- package/build/src/sync/conflict-strategy.js +215 -0
- package/build/src/sync/peer-mutation-bundle.d.ts +97 -0
- package/build/src/sync/peer-mutation-bundle.d.ts.map +1 -0
- package/build/src/sync/peer-mutation-bundle.js +215 -0
- package/build/src/sync/server-change-feed.d.ts +265 -0
- package/build/src/sync/server-change-feed.d.ts.map +1 -0
- package/build/src/sync/server-change-feed.js +475 -0
- package/build/sync/conflict-strategy.js +225 -0
- package/build/sync/peer-mutation-bundle.js +232 -0
- package/build/sync/server-change-feed.js +524 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/configuration-types.js +4 -0
- package/src/configuration.js +29 -0
- package/src/database/drivers/base.js +16 -0
- package/src/database/drivers/mssql/index.js +13 -0
- package/src/database/drivers/mssql/sql/remove-index.js +6 -0
- package/src/database/drivers/mysql/index.js +13 -0
- package/src/database/drivers/mysql/sql/remove-index.js +6 -0
- package/src/database/drivers/pgsql/index.js +13 -0
- package/src/database/drivers/pgsql/sql/remove-index.js +6 -0
- package/src/database/drivers/sqlite/base.js +13 -0
- package/src/database/drivers/sqlite/sql/remove-index.js +6 -0
- package/src/database/migration/index.js +44 -0
- package/src/database/pool/base-methods-forward.js +1 -0
- package/src/database/query/remove-index-base.js +39 -0
- package/src/database/record/index.js +25 -9
- package/src/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
- package/src/frontend-model-controller.js +632 -1
- package/src/frontend-models/base.js +5 -1
- package/src/frontend-models/resource-definition.js +20 -3
- package/src/http-server/client/websocket-session.js +93 -0
- package/src/routes/hooks/frontend-model-command-route-hook.js +24 -0
- package/src/sync/conflict-strategy.js +225 -0
- package/src/sync/peer-mutation-bundle.js +232 -0
- package/src/sync/server-change-feed.js +524 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {randomUUID} from "crypto"
|
|
4
|
+
import TableData from "../database/table-data/index.js"
|
|
5
|
+
|
|
6
|
+
const DEFAULT_RETENTION_SIZE = 10000
|
|
7
|
+
const DEFAULT_PAGE_SIZE = 100
|
|
8
|
+
const MAX_PAGE_SIZE = 1000
|
|
9
|
+
const TABLE_NAME = "frontend_model_sync_changes"
|
|
10
|
+
const stores = new WeakMap()
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Shared server change-feed store for a configuration.
|
|
14
|
+
* @param {import("../configuration.js").default} configuration - Configuration.
|
|
15
|
+
* @returns {ServerChangeFeedStore} - Store.
|
|
16
|
+
*/
|
|
17
|
+
export function serverChangeFeedStoreForConfiguration(configuration) {
|
|
18
|
+
let store = stores.get(configuration)
|
|
19
|
+
|
|
20
|
+
if (!store) {
|
|
21
|
+
store = new ServerChangeFeedStore({configuration})
|
|
22
|
+
stores.set(configuration, store)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return store
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default class ServerChangeFeedStore {
|
|
29
|
+
/**
|
|
30
|
+
* Runs constructor.
|
|
31
|
+
* @param {object} args - Options.
|
|
32
|
+
* @param {import("../configuration.js").default} args.configuration - Configuration.
|
|
33
|
+
* @param {string} [args.databaseIdentifier] - Database identifier.
|
|
34
|
+
* @param {number} [args.retentionSize] - Number of feed entries to retain.
|
|
35
|
+
*/
|
|
36
|
+
constructor({configuration, databaseIdentifier = "default", retentionSize}) {
|
|
37
|
+
const syncConfiguration = configuration.getSyncConfiguration()
|
|
38
|
+
|
|
39
|
+
this.configuration = configuration
|
|
40
|
+
this.databaseIdentifier = databaseIdentifier
|
|
41
|
+
this.retentionSize = retentionSize || syncConfiguration.changeFeedRetentionSize || DEFAULT_RETENTION_SIZE
|
|
42
|
+
/** @type {ServerChangeFeedEntry[]} */
|
|
43
|
+
this._memoryChanges = []
|
|
44
|
+
this._memorySequence = 0
|
|
45
|
+
this._isReady = false
|
|
46
|
+
this._readyPromise = null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Ensures the backing table exists.
|
|
51
|
+
* @returns {Promise<void>} - Resolves when ready.
|
|
52
|
+
*/
|
|
53
|
+
async ensureReady() {
|
|
54
|
+
if (this._usesMemoryStorage()) {
|
|
55
|
+
this._isReady = true
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (await this._schemaReady()) return
|
|
60
|
+
if (this._readyPromise) return await this._readyPromise
|
|
61
|
+
|
|
62
|
+
this._readyPromise = (async () => {
|
|
63
|
+
this.configuration.setCurrent()
|
|
64
|
+
await this._withDb(async (db) => {
|
|
65
|
+
await this._ensureChangesTable(db)
|
|
66
|
+
})
|
|
67
|
+
this._isReady = true
|
|
68
|
+
})()
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await this._readyPromise
|
|
72
|
+
} finally {
|
|
73
|
+
if (!this._isReady) this._readyPromise = null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Appends a change and assigns the next server sequence.
|
|
79
|
+
* @param {Omit<ServerChangeFeedEntry, "createdAt" | "id" | "serverSequence"> & {createdAt?: string, id?: string}} change - Change payload.
|
|
80
|
+
* @returns {Promise<ServerChangeFeedEntry>} - Persisted change.
|
|
81
|
+
*/
|
|
82
|
+
async append(change) {
|
|
83
|
+
await this.ensureReady()
|
|
84
|
+
|
|
85
|
+
const id = change.id || randomUUID()
|
|
86
|
+
const createdAt = change.createdAt || new Date().toISOString()
|
|
87
|
+
|
|
88
|
+
if (this._usesMemoryStorage()) return this._appendMemory({...change, createdAt, id})
|
|
89
|
+
|
|
90
|
+
return await this._withDb(async (db) => {
|
|
91
|
+
await db.insert({
|
|
92
|
+
tableName: TABLE_NAME,
|
|
93
|
+
data: {
|
|
94
|
+
actor_device_id: change.actorDeviceId,
|
|
95
|
+
actor_user_id: change.actorUserId,
|
|
96
|
+
attributes_json: JSON.stringify(change.attributes || null),
|
|
97
|
+
created_at: new Date(createdAt),
|
|
98
|
+
id,
|
|
99
|
+
idempotency_key: change.idempotencyKey,
|
|
100
|
+
model: change.model,
|
|
101
|
+
operation: change.operation,
|
|
102
|
+
payload_json: JSON.stringify(change.payload || null),
|
|
103
|
+
record_id: change.recordId === null || change.recordId === undefined ? null : String(change.recordId),
|
|
104
|
+
response_json: JSON.stringify(change.response || null),
|
|
105
|
+
scope_json: stableJsonStringify(change.scope || null)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const row = await this._changeById(db, id)
|
|
110
|
+
if (!row) throw new Error("Failed to persist server change-feed entry")
|
|
111
|
+
|
|
112
|
+
await this._pruneRetainedChanges(db, row.serverSequence)
|
|
113
|
+
|
|
114
|
+
return row
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Returns current latest server sequence.
|
|
120
|
+
* @returns {Promise<number>} - Latest sequence.
|
|
121
|
+
*/
|
|
122
|
+
async latestSequence() {
|
|
123
|
+
await this.ensureReady()
|
|
124
|
+
|
|
125
|
+
if (this._usesMemoryStorage()) return this._memorySequence
|
|
126
|
+
|
|
127
|
+
return await this._withDb(async (db) => {
|
|
128
|
+
const rows = await db
|
|
129
|
+
.newQuery()
|
|
130
|
+
.from(TABLE_NAME)
|
|
131
|
+
.order("server_sequence DESC")
|
|
132
|
+
.limit(1)
|
|
133
|
+
.results()
|
|
134
|
+
const row = /** @type {{server_sequence?: number | string} | undefined} */ (rows[0])
|
|
135
|
+
|
|
136
|
+
return row ? Number(row.server_sequence) : 0
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Returns oldest retained server sequence.
|
|
142
|
+
* @returns {Promise<number | null>} - Oldest retained sequence.
|
|
143
|
+
*/
|
|
144
|
+
async oldestSequence() {
|
|
145
|
+
await this.ensureReady()
|
|
146
|
+
|
|
147
|
+
if (this._usesMemoryStorage()) return this._memoryChanges[0]?.serverSequence || null
|
|
148
|
+
|
|
149
|
+
return await this._withDb(async (db) => await this._oldestSequence(db))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Returns ordered changes after a cursor.
|
|
154
|
+
* @param {object} args - Arguments.
|
|
155
|
+
* @param {number} args.afterSequence - Exclusive lower bound.
|
|
156
|
+
* @param {number} [args.limit] - Maximum number of changes.
|
|
157
|
+
* @param {number} [args.upToSequence] - Inclusive upper bound.
|
|
158
|
+
* @param {Record<string, ?>} [args.scope] - Caller sync scope.
|
|
159
|
+
* @returns {Promise<{changes: ServerChangeFeedEntry[], hasMore: boolean, nextSequence: number, oldestSequence: number | null, snapshotRequired: boolean, upToSequence: number}>} - Ordered page.
|
|
160
|
+
*/
|
|
161
|
+
async changesAfter({afterSequence, limit = DEFAULT_PAGE_SIZE, scope, upToSequence}) {
|
|
162
|
+
await this.ensureReady()
|
|
163
|
+
|
|
164
|
+
const pageSize = normalizeLimit(limit)
|
|
165
|
+
|
|
166
|
+
if (this._usesMemoryStorage()) return this._memoryChangesAfter({afterSequence, limit: pageSize, scope, upToSequence})
|
|
167
|
+
|
|
168
|
+
return await this._withDb(async (db) => {
|
|
169
|
+
const latestSequence = typeof upToSequence === "number" ? upToSequence : await this._latestSequence(db)
|
|
170
|
+
const oldestSequence = await this._oldestSequence(db)
|
|
171
|
+
const snapshotRequired = afterSequence > 0 && oldestSequence !== null && oldestSequence > afterSequence + 1
|
|
172
|
+
|
|
173
|
+
if (snapshotRequired) {
|
|
174
|
+
return {
|
|
175
|
+
changes: [],
|
|
176
|
+
hasMore: false,
|
|
177
|
+
nextSequence: afterSequence,
|
|
178
|
+
oldestSequence,
|
|
179
|
+
snapshotRequired: true,
|
|
180
|
+
upToSequence: latestSequence
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const rows = /** @type {ServerChangeFeedRow[]} */ (await db
|
|
185
|
+
.newQuery()
|
|
186
|
+
.from(TABLE_NAME)
|
|
187
|
+
.where(`server_sequence > ${db.quote(afterSequence)}`)
|
|
188
|
+
.where(`server_sequence <= ${db.quote(latestSequence)}`)
|
|
189
|
+
.where(scope === undefined ? "1 = 1" : {scope_json: stableJsonStringify(scope)})
|
|
190
|
+
.order("server_sequence ASC")
|
|
191
|
+
.limit(pageSize + 1)
|
|
192
|
+
.results())
|
|
193
|
+
const hasMore = rows.length > pageSize
|
|
194
|
+
const pageRows = rows.slice(0, pageSize)
|
|
195
|
+
const changes = pageRows.map((row) => this._normalizeChangeRow(row))
|
|
196
|
+
const lastChange = changes[changes.length - 1]
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
changes,
|
|
200
|
+
hasMore,
|
|
201
|
+
nextSequence: lastChange ? lastChange.serverSequence : afterSequence,
|
|
202
|
+
oldestSequence,
|
|
203
|
+
snapshotRequired: false,
|
|
204
|
+
upToSequence: latestSequence
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Ensures schema is still present.
|
|
211
|
+
* @returns {Promise<boolean>} - Whether ready.
|
|
212
|
+
*/
|
|
213
|
+
async _schemaReady() {
|
|
214
|
+
if (this._usesMemoryStorage()) return this._isReady
|
|
215
|
+
if (!this._isReady) return false
|
|
216
|
+
if (await this._withDb(async (db) => await db.tableExists(TABLE_NAME))) return true
|
|
217
|
+
|
|
218
|
+
this._isReady = false
|
|
219
|
+
this._readyPromise = null
|
|
220
|
+
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Ensures changes table exists.
|
|
226
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
227
|
+
* @returns {Promise<void>} - Resolves when complete.
|
|
228
|
+
*/
|
|
229
|
+
async _ensureChangesTable(db) {
|
|
230
|
+
if (await db.tableExists(TABLE_NAME)) return
|
|
231
|
+
|
|
232
|
+
const table = new TableData(TABLE_NAME, {ifNotExists: true})
|
|
233
|
+
|
|
234
|
+
table.integer("server_sequence", {autoIncrement: true, null: false, primaryKey: true})
|
|
235
|
+
table.string("id", {index: true, null: false})
|
|
236
|
+
table.string("model", {index: true, null: false})
|
|
237
|
+
table.string("operation", {null: false})
|
|
238
|
+
table.string("record_id", {index: true, null: true})
|
|
239
|
+
table.text("payload_json", {null: true})
|
|
240
|
+
table.text("attributes_json", {null: true})
|
|
241
|
+
table.string("idempotency_key", {index: true, null: true})
|
|
242
|
+
table.string("actor_user_id", {index: true, null: true})
|
|
243
|
+
table.string("actor_device_id", {index: true, null: true})
|
|
244
|
+
table.text("scope_json", {null: true})
|
|
245
|
+
table.text("response_json", {null: true})
|
|
246
|
+
table.datetime("created_at", {index: true, null: false})
|
|
247
|
+
|
|
248
|
+
await db.createTable(table)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Resolves a persisted change by id.
|
|
253
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
254
|
+
* @param {string} id - Entry id.
|
|
255
|
+
* @returns {Promise<ServerChangeFeedEntry | null>} - Entry or null.
|
|
256
|
+
*/
|
|
257
|
+
async _changeById(db, id) {
|
|
258
|
+
const rows = /** @type {ServerChangeFeedRow[]} */ (await db
|
|
259
|
+
.newQuery()
|
|
260
|
+
.from(TABLE_NAME)
|
|
261
|
+
.where({id})
|
|
262
|
+
.limit(1)
|
|
263
|
+
.results())
|
|
264
|
+
|
|
265
|
+
return rows[0] ? this._normalizeChangeRow(rows[0]) : null
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Resolves current latest sequence without readiness checks.
|
|
270
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
271
|
+
* @returns {Promise<number>} - Latest sequence.
|
|
272
|
+
*/
|
|
273
|
+
async _latestSequence(db) {
|
|
274
|
+
const rows = /** @type {Array<{server_sequence?: number | string}>} */ (await db
|
|
275
|
+
.newQuery()
|
|
276
|
+
.from(TABLE_NAME)
|
|
277
|
+
.order("server_sequence DESC")
|
|
278
|
+
.limit(1)
|
|
279
|
+
.results())
|
|
280
|
+
|
|
281
|
+
return rows[0] ? Number(rows[0].server_sequence) : 0
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Resolves current oldest sequence without readiness checks.
|
|
286
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
287
|
+
* @returns {Promise<number | null>} - Oldest sequence.
|
|
288
|
+
*/
|
|
289
|
+
async _oldestSequence(db) {
|
|
290
|
+
const rows = /** @type {Array<{server_sequence?: number | string}>} */ (await db
|
|
291
|
+
.newQuery()
|
|
292
|
+
.from(TABLE_NAME)
|
|
293
|
+
.order("server_sequence ASC")
|
|
294
|
+
.limit(1)
|
|
295
|
+
.results())
|
|
296
|
+
|
|
297
|
+
return rows[0] ? Number(rows[0].server_sequence) : null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Prunes old retained changes.
|
|
302
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
303
|
+
* @param {number} latestSequence - Latest sequence after append.
|
|
304
|
+
* @returns {Promise<void>} - Resolves when complete.
|
|
305
|
+
*/
|
|
306
|
+
async _pruneRetainedChanges(db, latestSequence) {
|
|
307
|
+
if (!Number.isInteger(this.retentionSize) || this.retentionSize < 1) return
|
|
308
|
+
|
|
309
|
+
const pruneBeforeOrAt = latestSequence - this.retentionSize
|
|
310
|
+
if (pruneBeforeOrAt < 1) return
|
|
311
|
+
|
|
312
|
+
const rows = /** @type {Array<{id: string}>} */ (await db
|
|
313
|
+
.newQuery()
|
|
314
|
+
.from(TABLE_NAME)
|
|
315
|
+
.where(`server_sequence <= ${db.quote(pruneBeforeOrAt)}`)
|
|
316
|
+
.results())
|
|
317
|
+
|
|
318
|
+
for (const row of rows) {
|
|
319
|
+
await db.delete({conditions: {id: row.id}, tableName: TABLE_NAME})
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Normalizes a change row.
|
|
325
|
+
* @param {ServerChangeFeedRow} row - Raw database row.
|
|
326
|
+
* @returns {ServerChangeFeedEntry} - Normalized change.
|
|
327
|
+
*/
|
|
328
|
+
_normalizeChangeRow(row) {
|
|
329
|
+
const createdAtValue = row.created_at
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
actorDeviceId: row.actor_device_id || null,
|
|
333
|
+
actorUserId: row.actor_user_id || null,
|
|
334
|
+
attributes: parseJsonOrNull(row.attributes_json),
|
|
335
|
+
createdAt: createdAtValue instanceof Date ? createdAtValue.toISOString() : new Date(createdAtValue).toISOString(),
|
|
336
|
+
id: row.id,
|
|
337
|
+
idempotencyKey: row.idempotency_key || null,
|
|
338
|
+
model: row.model,
|
|
339
|
+
operation: row.operation,
|
|
340
|
+
payload: parseJsonOrNull(row.payload_json),
|
|
341
|
+
recordId: row.record_id || null,
|
|
342
|
+
response: parseJsonOrNull(row.response_json),
|
|
343
|
+
scope: parseJsonOrNull(row.scope_json),
|
|
344
|
+
serverSequence: Number(row.server_sequence)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Whether this store should use process-local memory because no database identifier is configured.
|
|
350
|
+
* @returns {boolean} - Whether memory storage is active.
|
|
351
|
+
*/
|
|
352
|
+
_usesMemoryStorage() {
|
|
353
|
+
try {
|
|
354
|
+
return !this.configuration.getDatabaseConfiguration()[this.databaseIdentifier]
|
|
355
|
+
} catch {
|
|
356
|
+
return true
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Appends a process-local memory entry when no database is configured.
|
|
362
|
+
* @param {Omit<ServerChangeFeedEntry, "serverSequence">} change - Change payload.
|
|
363
|
+
* @returns {ServerChangeFeedEntry} - Appended entry.
|
|
364
|
+
*/
|
|
365
|
+
_appendMemory(change) {
|
|
366
|
+
const entry = /** @type {ServerChangeFeedEntry} */ ({
|
|
367
|
+
actorDeviceId: change.actorDeviceId,
|
|
368
|
+
actorUserId: change.actorUserId,
|
|
369
|
+
attributes: change.attributes || null,
|
|
370
|
+
createdAt: change.createdAt,
|
|
371
|
+
id: change.id,
|
|
372
|
+
idempotencyKey: change.idempotencyKey,
|
|
373
|
+
model: change.model,
|
|
374
|
+
operation: change.operation,
|
|
375
|
+
payload: change.payload || null,
|
|
376
|
+
recordId: change.recordId === null || change.recordId === undefined ? null : String(change.recordId),
|
|
377
|
+
response: change.response || null,
|
|
378
|
+
scope: change.scope || null,
|
|
379
|
+
serverSequence: ++this._memorySequence
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
this._memoryChanges.push(entry)
|
|
383
|
+
if (this._memoryChanges.length > this.retentionSize) this._memoryChanges.splice(0, this._memoryChanges.length - this.retentionSize)
|
|
384
|
+
|
|
385
|
+
return entry
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Returns a process-local memory change page.
|
|
390
|
+
* @param {object} args - Arguments.
|
|
391
|
+
* @param {number} args.afterSequence - Exclusive lower bound.
|
|
392
|
+
* @param {number} args.limit - Page size.
|
|
393
|
+
* @param {number} [args.upToSequence] - Inclusive upper bound.
|
|
394
|
+
* @param {Record<string, ?>} [args.scope] - Caller sync scope.
|
|
395
|
+
* @returns {{changes: ServerChangeFeedEntry[], hasMore: boolean, nextSequence: number, oldestSequence: number | null, snapshotRequired: boolean, upToSequence: number}} - Ordered page.
|
|
396
|
+
*/
|
|
397
|
+
_memoryChangesAfter({afterSequence, limit, scope, upToSequence}) {
|
|
398
|
+
const latestSequence = typeof upToSequence === "number" ? upToSequence : this._memorySequence
|
|
399
|
+
const oldestSequence = this._memoryChanges[0]?.serverSequence || null
|
|
400
|
+
const snapshotRequired = afterSequence > 0 && oldestSequence !== null && oldestSequence > afterSequence + 1
|
|
401
|
+
|
|
402
|
+
if (snapshotRequired) {
|
|
403
|
+
return {changes: [], hasMore: false, nextSequence: afterSequence, oldestSequence, snapshotRequired: true, upToSequence: latestSequence}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const rows = this._memoryChanges.filter((change) => {
|
|
407
|
+
return change.serverSequence > afterSequence && change.serverSequence <= latestSequence && scopesEqual(change.scope, scope)
|
|
408
|
+
})
|
|
409
|
+
const hasMore = rows.length > limit
|
|
410
|
+
const changes = rows.slice(0, limit)
|
|
411
|
+
const lastChange = changes[changes.length - 1]
|
|
412
|
+
|
|
413
|
+
return {changes, hasMore, nextSequence: lastChange ? lastChange.serverSequence : afterSequence, oldestSequence, snapshotRequired: false, upToSequence: latestSequence}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Runs with db.
|
|
418
|
+
* @param {(db: import("../database/drivers/base.js").default) => Promise<?>} callback - Callback.
|
|
419
|
+
* @returns {Promise<?>} - Callback result.
|
|
420
|
+
*/
|
|
421
|
+
async _withDb(callback) {
|
|
422
|
+
return await this.configuration.ensureConnections({name: "Server change-feed store"}, async (dbs) => {
|
|
423
|
+
const db = dbs[this.databaseIdentifier]
|
|
424
|
+
|
|
425
|
+
if (!db) throw new Error(`No database connection available for identifier: ${this.databaseIdentifier}`)
|
|
426
|
+
|
|
427
|
+
return await callback(db)
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Normalizes page limit.
|
|
434
|
+
* @param {?} limit - Requested limit.
|
|
435
|
+
* @returns {number} - Page size.
|
|
436
|
+
*/
|
|
437
|
+
function normalizeLimit(limit) {
|
|
438
|
+
if (typeof limit === "number" && Number.isInteger(limit) && limit > 0) return Math.min(limit, MAX_PAGE_SIZE)
|
|
439
|
+
if (typeof limit === "string" && /^\d+$/.test(limit)) return Math.min(Number(limit), MAX_PAGE_SIZE)
|
|
440
|
+
|
|
441
|
+
return DEFAULT_PAGE_SIZE
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Parses JSON-ish values.
|
|
446
|
+
* @param {?} value - JSON string.
|
|
447
|
+
* @returns {?} - Parsed value.
|
|
448
|
+
*/
|
|
449
|
+
function parseJsonOrNull(value) {
|
|
450
|
+
if (typeof value !== "string" || value.length < 1) return null
|
|
451
|
+
|
|
452
|
+
return JSON.parse(value)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Compares sync scopes by stable JSON representation.
|
|
457
|
+
* @param {Record<string, ?> | null} changeScope - Persisted change scope.
|
|
458
|
+
* @param {Record<string, ?> | undefined} requestedScope - Caller scope.
|
|
459
|
+
* @returns {boolean} - Whether the change is visible for the requested scope.
|
|
460
|
+
*/
|
|
461
|
+
function scopesEqual(changeScope, requestedScope) {
|
|
462
|
+
if (requestedScope === undefined) return true
|
|
463
|
+
|
|
464
|
+
return stableJsonStringify(changeScope || null) === stableJsonStringify(requestedScope)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Stable JSON serialization for persisted sync scopes.
|
|
469
|
+
* @param {?} value - JSON-compatible value.
|
|
470
|
+
* @returns {string} - Stable JSON representation.
|
|
471
|
+
*/
|
|
472
|
+
function stableJsonStringify(value) {
|
|
473
|
+
return JSON.stringify(stableJsonValue(value))
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Produces a recursively key-sorted JSON value.
|
|
478
|
+
* @param {?} value - JSON-compatible value.
|
|
479
|
+
* @returns {?} - Stable JSON-compatible value.
|
|
480
|
+
*/
|
|
481
|
+
function stableJsonValue(value) {
|
|
482
|
+
if (Array.isArray(value)) return value.map((item) => stableJsonValue(item))
|
|
483
|
+
if (!value || typeof value !== "object") return value
|
|
484
|
+
|
|
485
|
+
return Object.keys(value).sort().reduce((memo, key) => {
|
|
486
|
+
memo[key] = stableJsonValue(value[key])
|
|
487
|
+
|
|
488
|
+
return memo
|
|
489
|
+
}, /** @type {Record<string, ?>} */ ({}))
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* @typedef {object} ServerChangeFeedEntry
|
|
494
|
+
* @property {string | null} actorDeviceId - Signed mutation actor device id when available.
|
|
495
|
+
* @property {string | null} actorUserId - Signed mutation actor user id when available.
|
|
496
|
+
* @property {Record<string, ?> | null} attributes - Serialized mutation attributes.
|
|
497
|
+
* @property {string} createdAt - Server change creation timestamp.
|
|
498
|
+
* @property {string} id - Server change id.
|
|
499
|
+
* @property {string | null} idempotencyKey - Mutation idempotency key when available.
|
|
500
|
+
* @property {string} model - Frontend model name.
|
|
501
|
+
* @property {string} operation - Mutation operation.
|
|
502
|
+
* @property {Record<string, ?> | null} payload - Serialized mutation payload.
|
|
503
|
+
* @property {string | null} recordId - Changed record id when known.
|
|
504
|
+
* @property {Record<string, ?> | null} response - Command response payload.
|
|
505
|
+
* @property {Record<string, ?> | null} scope - Offline grant scope.
|
|
506
|
+
* @property {number} serverSequence - Monotonic server sequence.
|
|
507
|
+
*/
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* @typedef {object} ServerChangeFeedRow
|
|
511
|
+
* @property {string | null} actor_device_id - Actor device id.
|
|
512
|
+
* @property {string | null} actor_user_id - Actor user id.
|
|
513
|
+
* @property {string | null} attributes_json - Attributes JSON.
|
|
514
|
+
* @property {Date | string} created_at - Creation time.
|
|
515
|
+
* @property {string} id - Entry id.
|
|
516
|
+
* @property {string | null} idempotency_key - Mutation idempotency key.
|
|
517
|
+
* @property {string} model - Frontend model name.
|
|
518
|
+
* @property {string} operation - Mutation operation.
|
|
519
|
+
* @property {string | null} payload_json - Mutation payload JSON.
|
|
520
|
+
* @property {string | null} record_id - Record id.
|
|
521
|
+
* @property {string | null} response_json - Response JSON.
|
|
522
|
+
* @property {string | null} scope_json - Scope JSON.
|
|
523
|
+
* @property {number | string} server_sequence - Server sequence.
|
|
524
|
+
*/
|