velocious 1.0.476 → 1.0.478

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.
Files changed (56) hide show
  1. package/README.md +1 -0
  2. package/build/configuration-types.js +3 -0
  3. package/build/configuration.js +24 -0
  4. package/build/frontend-model-controller.js +368 -11
  5. package/build/frontend-models/base.js +5 -1
  6. package/build/frontend-models/resource-definition.js +20 -3
  7. package/build/http-server/client/websocket-session.js +93 -0
  8. package/build/routes/hooks/frontend-model-command-route-hook.js +16 -0
  9. package/build/src/configuration-types.d.ts +15 -0
  10. package/build/src/configuration-types.d.ts.map +1 -1
  11. package/build/src/configuration-types.js +4 -1
  12. package/build/src/configuration.d.ts +13 -0
  13. package/build/src/configuration.d.ts.map +1 -1
  14. package/build/src/configuration.js +23 -1
  15. package/build/src/frontend-model-controller.d.ts +96 -2
  16. package/build/src/frontend-model-controller.d.ts.map +1 -1
  17. package/build/src/frontend-model-controller.js +319 -12
  18. package/build/src/frontend-models/base.d.ts +5 -0
  19. package/build/src/frontend-models/base.d.ts.map +1 -1
  20. package/build/src/frontend-models/base.js +6 -2
  21. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  22. package/build/src/frontend-models/resource-definition.js +20 -4
  23. package/build/src/http-server/client/websocket-session.d.ts +39 -0
  24. package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
  25. package/build/src/http-server/client/websocket-session.js +85 -1
  26. package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
  27. package/build/src/routes/hooks/frontend-model-command-route-hook.js +15 -1
  28. package/build/src/sync/conflict-strategy.d.ts +83 -0
  29. package/build/src/sync/conflict-strategy.d.ts.map +1 -0
  30. package/build/src/sync/conflict-strategy.js +215 -0
  31. package/build/src/sync/peer-mutation-bundle.d.ts +97 -0
  32. package/build/src/sync/peer-mutation-bundle.d.ts.map +1 -0
  33. package/build/src/sync/peer-mutation-bundle.js +215 -0
  34. package/build/src/sync/server-change-feed.d.ts +265 -0
  35. package/build/src/sync/server-change-feed.d.ts.map +1 -0
  36. package/build/src/sync/server-change-feed.js +475 -0
  37. package/build/src/sync/sync-envelope-replay-service.d.ts +213 -0
  38. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -0
  39. package/build/src/sync/sync-envelope-replay-service.js +229 -0
  40. package/build/sync/conflict-strategy.js +225 -0
  41. package/build/sync/peer-mutation-bundle.js +232 -0
  42. package/build/sync/server-change-feed.js +524 -0
  43. package/build/sync/sync-envelope-replay-service.js +263 -0
  44. package/build/tsconfig.tsbuildinfo +1 -1
  45. package/package.json +1 -1
  46. package/src/configuration-types.js +3 -0
  47. package/src/configuration.js +24 -0
  48. package/src/frontend-model-controller.js +368 -11
  49. package/src/frontend-models/base.js +5 -1
  50. package/src/frontend-models/resource-definition.js +20 -3
  51. package/src/http-server/client/websocket-session.js +93 -0
  52. package/src/routes/hooks/frontend-model-command-route-hook.js +16 -0
  53. package/src/sync/conflict-strategy.js +225 -0
  54. package/src/sync/peer-mutation-bundle.js +232 -0
  55. package/src/sync/server-change-feed.js +524 -0
  56. package/src/sync/sync-envelope-replay-service.js +263 -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
+ */