velocious 1.0.489 → 1.0.493

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 (108) hide show
  1. package/build/application.js +3 -0
  2. package/build/cli/commands/db/tenants/create.js +7 -5
  3. package/build/cli/commands/db/tenants/drop.js +6 -6
  4. package/build/configuration-types.js +8 -0
  5. package/build/configuration.js +29 -0
  6. package/build/database/query/index.js +30 -0
  7. package/build/database/query/model-class-query.js +17 -0
  8. package/build/database/record/index.js +16 -0
  9. package/build/src/application.d.ts.map +1 -1
  10. package/build/src/application.js +3 -1
  11. package/build/src/cli/commands/db/tenants/create.d.ts.map +1 -1
  12. package/build/src/cli/commands/db/tenants/create.js +8 -5
  13. package/build/src/cli/commands/db/tenants/drop.d.ts +1 -1
  14. package/build/src/cli/commands/db/tenants/drop.d.ts.map +1 -1
  15. package/build/src/cli/commands/db/tenants/drop.js +7 -6
  16. package/build/src/configuration-types.d.ts +24 -0
  17. package/build/src/configuration-types.d.ts.map +1 -1
  18. package/build/src/configuration-types.js +8 -1
  19. package/build/src/configuration.d.ts +6 -0
  20. package/build/src/configuration.d.ts.map +1 -1
  21. package/build/src/configuration.js +26 -1
  22. package/build/src/database/query/index.d.ts +25 -0
  23. package/build/src/database/query/index.d.ts.map +1 -1
  24. package/build/src/database/query/index.js +26 -1
  25. package/build/src/database/query/model-class-query.d.ts +10 -0
  26. package/build/src/database/query/model-class-query.d.ts.map +1 -1
  27. package/build/src/database/query/model-class-query.js +16 -1
  28. package/build/src/database/record/index.d.ts +7 -0
  29. package/build/src/database/record/index.d.ts.map +1 -1
  30. package/build/src/database/record/index.js +15 -1
  31. package/build/src/sync/query-scope.d.ts +18 -0
  32. package/build/src/sync/query-scope.d.ts.map +1 -0
  33. package/build/src/sync/query-scope.js +77 -0
  34. package/build/src/sync/server-change-feed.d.ts.map +1 -1
  35. package/build/src/sync/server-change-feed.js +2 -24
  36. package/build/src/sync/stable-json.d.ts +9 -0
  37. package/build/src/sync/stable-json.d.ts.map +1 -0
  38. package/build/src/sync/stable-json.js +27 -0
  39. package/build/src/sync/sync-api-client.d.ts +185 -5
  40. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  41. package/build/src/sync/sync-api-client.js +309 -17
  42. package/build/src/sync/sync-api-controller.d.ts +8 -0
  43. package/build/src/sync/sync-api-controller.d.ts.map +1 -1
  44. package/build/src/sync/sync-api-controller.js +17 -1
  45. package/build/src/sync/sync-client-registry.d.ts +12 -0
  46. package/build/src/sync/sync-client-registry.d.ts.map +1 -0
  47. package/build/src/sync/sync-client-registry.js +27 -0
  48. package/build/src/sync/sync-client-types.d.ts +127 -0
  49. package/build/src/sync/sync-client-types.d.ts.map +1 -0
  50. package/build/src/sync/sync-client-types.js +3 -0
  51. package/build/src/sync/sync-client.d.ts +176 -0
  52. package/build/src/sync/sync-client.d.ts.map +1 -0
  53. package/build/src/sync/sync-client.js +380 -0
  54. package/build/src/sync/sync-envelope-replay-service.d.ts +153 -10
  55. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  56. package/build/src/sync/sync-envelope-replay-service.js +174 -14
  57. package/build/src/sync/sync-replay-upsert-applier.d.ts +102 -0
  58. package/build/src/sync/sync-replay-upsert-applier.d.ts.map +1 -0
  59. package/build/src/sync/sync-replay-upsert-applier.js +158 -0
  60. package/build/src/sync/sync-resource-base.d.ts +98 -0
  61. package/build/src/sync/sync-resource-base.d.ts.map +1 -0
  62. package/build/src/sync/sync-resource-base.js +122 -0
  63. package/build/src/sync/sync-scope-store.d.ts +128 -0
  64. package/build/src/sync/sync-scope-store.d.ts.map +1 -0
  65. package/build/src/sync/sync-scope-store.js +282 -0
  66. package/build/src/tenants/default-tenant-database-provisioning.d.ts +21 -0
  67. package/build/src/tenants/default-tenant-database-provisioning.d.ts.map +1 -0
  68. package/build/src/tenants/default-tenant-database-provisioning.js +106 -0
  69. package/build/src/tenants/tenant.d.ts.map +1 -1
  70. package/build/src/tenants/tenant.js +6 -5
  71. package/build/sync/query-scope.js +84 -0
  72. package/build/sync/server-change-feed.js +1 -25
  73. package/build/sync/stable-json.js +28 -0
  74. package/build/sync/sync-api-client.js +331 -17
  75. package/build/sync/sync-api-controller.js +20 -0
  76. package/build/sync/sync-client-registry.js +30 -0
  77. package/build/sync/sync-client-types.js +41 -0
  78. package/build/sync/sync-client.js +433 -0
  79. package/build/sync/sync-envelope-replay-service.js +190 -13
  80. package/build/sync/sync-replay-upsert-applier.js +179 -0
  81. package/build/sync/sync-resource-base.js +143 -0
  82. package/build/sync/sync-scope-store.js +327 -0
  83. package/build/tenants/default-tenant-database-provisioning.js +124 -0
  84. package/build/tenants/tenant.js +5 -5
  85. package/build/tsconfig.tsbuildinfo +1 -1
  86. package/package.json +1 -1
  87. package/src/application.js +3 -0
  88. package/src/cli/commands/db/tenants/create.js +7 -5
  89. package/src/cli/commands/db/tenants/drop.js +6 -6
  90. package/src/configuration-types.js +8 -0
  91. package/src/configuration.js +29 -0
  92. package/src/database/query/index.js +30 -0
  93. package/src/database/query/model-class-query.js +17 -0
  94. package/src/database/record/index.js +16 -0
  95. package/src/sync/query-scope.js +84 -0
  96. package/src/sync/server-change-feed.js +1 -25
  97. package/src/sync/stable-json.js +28 -0
  98. package/src/sync/sync-api-client.js +331 -17
  99. package/src/sync/sync-api-controller.js +20 -0
  100. package/src/sync/sync-client-registry.js +30 -0
  101. package/src/sync/sync-client-types.js +41 -0
  102. package/src/sync/sync-client.js +433 -0
  103. package/src/sync/sync-envelope-replay-service.js +190 -13
  104. package/src/sync/sync-replay-upsert-applier.js +179 -0
  105. package/src/sync/sync-resource-base.js +143 -0
  106. package/src/sync/sync-scope-store.js +327 -0
  107. package/src/tenants/default-tenant-database-provisioning.js +124 -0
  108. package/src/tenants/tenant.js +5 -5
@@ -0,0 +1,433 @@
1
+ // @ts-check
2
+
3
+ import {forcedFunction} from "typanic"
4
+
5
+ import Configuration from "../configuration.js"
6
+
7
+ import {serializedScopeFromQuery} from "./query-scope.js"
8
+ import SyncApiClient from "./sync-api-client.js"
9
+ import SyncScopeStore from "./sync-scope-store.js"
10
+ import {currentSyncClient, setCurrentSyncClient} from "./sync-client-registry.js"
11
+
12
+ let clientCounter = 0
13
+
14
+ /** @type {{create: "afterCreate", update: "afterUpdate", destroy: "afterDestroy"}} */
15
+ const TRACKED_CALLBACK_NAMES = {create: "afterCreate", destroy: "afterDestroy", update: "afterUpdate"}
16
+
17
+ /**
18
+ * Declarative client-side sync driver.
19
+ *
20
+ * Apps configure resources, transport, auth, and connectivity once; Velocious
21
+ * owns scope persistence, per-scope cursors, pull paging/apply, local queueing,
22
+ * and online-gated replay. Declare sync interest from queries:
23
+ *
24
+ * const syncClient = new SyncClient({...})
25
+ * syncClient.setCurrent()
26
+ * await syncClient.sync(Event.where({partnerId}))
27
+ */
28
+ export default class SyncClient {
29
+ /**
30
+ * Creates a declarative sync client.
31
+ * @param {import("./sync-client-types.js").SyncClientConfig} config - Sync client configuration.
32
+ */
33
+ constructor(config) {
34
+ if (!config || typeof config !== "object") throw new Error("SyncClient requires a configuration object")
35
+
36
+ forcedFunction(config.postChanges, "SyncClient config.postChanges")
37
+ forcedFunction(config.postReplay, "SyncClient config.postReplay")
38
+ forcedFunction(config.authenticationToken, "SyncClient config.authenticationToken")
39
+
40
+ if (!config.syncModel) throw new Error("SyncClient requires config.syncModel")
41
+ if (!config.resources || typeof config.resources !== "object" || Object.keys(config.resources).length === 0) {
42
+ throw new Error("SyncClient requires at least one entry in config.resources")
43
+ }
44
+
45
+ for (const [resourceType, resource] of Object.entries(config.resources)) {
46
+ if (!resource || typeof resource !== "object" || !resource.modelClass) {
47
+ throw new Error(`SyncClient resource ${resourceType} must declare a modelClass`)
48
+ }
49
+ }
50
+
51
+ this.config = config
52
+ this._clientNumber = ++clientCounter
53
+ /** @type {import("./sync-scope-store.js").default | null} */
54
+ this._scopeStore = config.scopeStore || null
55
+ /** @type {Promise<void> | null} */
56
+ this._scheduledReplay = null
57
+ /** @type {Record<string, import("./sync-api-client-types.js").SyncResourceConfig> | null} */
58
+ this._pullResourceConfigs = null
59
+ /** @type {Array<{callback: (record: ?) => Promise<void>, callbackName: "afterCreate" | "afterUpdate" | "afterDestroy", modelClass: ?}>} */
60
+ this._trackedCallbacks = []
61
+ /** @type {WeakSet<object>} */
62
+ this._remoteApplyRecords = new WeakSet()
63
+ this._started = false
64
+ }
65
+
66
+ /**
67
+ * Registers automatic mutation tracking for every resource with `track` enabled:
68
+ * local model creates/updates/destroys queue pending sync rows and schedule an
69
+ * immediate replay attempt, without app-side queue calls.
70
+ * @returns {Promise<void>}
71
+ */
72
+ async start() {
73
+ if (this._started) return
74
+
75
+ this._started = true
76
+
77
+ for (const [resourceType, resourceConfig] of Object.entries(this.config.resources)) {
78
+ const operations = this.trackedOperations({resourceConfig, resourceType})
79
+
80
+ for (const operation of operations) {
81
+ const callbackName = TRACKED_CALLBACK_NAMES[operation]
82
+ const callback = this.trackedMutationCallback({operation, resourceConfig})
83
+
84
+ resourceConfig.modelClass[callbackName](callback)
85
+ this._trackedCallbacks.push({callback, callbackName, modelClass: resourceConfig.modelClass})
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Unregisters all tracking callbacks (tests, sign-out, hot reload).
92
+ * @returns {void}
93
+ */
94
+ stop() {
95
+ for (const {callback, callbackName, modelClass} of this._trackedCallbacks) {
96
+ modelClass.unregisterLifecycleCallback(callbackName, callback)
97
+ }
98
+
99
+ this._trackedCallbacks = []
100
+ this._started = false
101
+ }
102
+
103
+ /**
104
+ * Resolves and validates the tracked operations for a resource config.
105
+ * @param {{resourceConfig: import("./sync-client-types.js").SyncClientResourceConfig, resourceType: string}} args - Resource config and name.
106
+ * @returns {Array<"create" | "update" | "destroy">} Tracked operations.
107
+ */
108
+ trackedOperations({resourceConfig, resourceType}) {
109
+ const track = resourceConfig.track
110
+
111
+ if (track === undefined || track === false) return []
112
+ if (track === true) return ["create", "update", "destroy"]
113
+
114
+ if (!track || typeof track !== "object" || !Array.isArray(track.operations) || track.operations.length === 0) {
115
+ throw new Error(`SyncClient resource ${resourceType} track must be true or {operations: [...]}`)
116
+ }
117
+
118
+ for (const operation of track.operations) {
119
+ if (!(operation in TRACKED_CALLBACK_NAMES)) {
120
+ throw new Error(`SyncClient resource ${resourceType} track.operations must be create/update/destroy, got: ${String(operation)}`)
121
+ }
122
+ }
123
+
124
+ return track.operations
125
+ }
126
+
127
+ /**
128
+ * Builds the lifecycle callback queueing one tracked mutation.
129
+ * @param {{operation: "create" | "update" | "destroy", resourceConfig: import("./sync-client-types.js").SyncClientResourceConfig}} args - Operation and resource config.
130
+ * @returns {(record: ?) => Promise<void>} Lifecycle callback.
131
+ */
132
+ trackedMutationCallback({operation, resourceConfig}) {
133
+ return async (record) => {
134
+ if (this.isRemoteApply(record)) return
135
+
136
+ await SyncApiClient.queueLocalSync({
137
+ booleanAttributes: resourceConfig.booleanAttributes || [],
138
+ data: resourceConfig.trackedData ? resourceConfig.trackedData({operation, record}) : undefined,
139
+ localOnlyAttributes: resourceConfig.localOnlyAttributes || [],
140
+ resource: record,
141
+ syncModel: this.config.syncModel,
142
+ syncType: this.defaultSyncType({operation, record, resourceConfig})
143
+ })
144
+
145
+ this.scheduleReplay()
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Whether a record is currently being written by pull-apply (echo suppression).
151
+ * @param {?} record - Local model record.
152
+ * @returns {boolean} Whether the record write originates from a remote change.
153
+ */
154
+ isRemoteApply(record) {
155
+ return this._remoteApplyRecords.has(record)
156
+ }
157
+
158
+ /**
159
+ * Registers this client as the app's current sync client.
160
+ * @returns {void}
161
+ */
162
+ setCurrent() {
163
+ setCurrentSyncClient(this)
164
+ }
165
+
166
+ /**
167
+ * Returns the app's current sync client.
168
+ * @returns {SyncClient} Current sync client.
169
+ */
170
+ static current() {
171
+ return /** @type {SyncClient} */ (currentSyncClient())
172
+ }
173
+
174
+ /**
175
+ * Declares (or re-activates) a sync scope from a model query and pulls it when online.
176
+ * @param {import("../database/query/model-class-query.js").default<?>} query - Query declaring the sync scope.
177
+ * @returns {Promise<{scope: import("./sync-client-types.js").SerializedSyncScope, pulled: import("./sync-api-client-types.js").SyncChangesResult | null}>} Declared scope and pull result (null while offline).
178
+ */
179
+ async sync(query) {
180
+ const scope = serializedScopeFromQuery(query)
181
+ const scopeStore = this.scopeStore()
182
+ const scopeRow = await scopeStore.findOrCreateScope(scope)
183
+
184
+ if (!scopeRow.cursorPayload && this.config.legacyCursor) {
185
+ const legacyCursorPayload = await this.config.legacyCursor({scope})
186
+ const legacyCursor = SyncApiClient.syncCursorFromPayload(legacyCursorPayload)
187
+
188
+ if (legacyCursor) await scopeStore.saveCursor(scopeRow, legacyCursor)
189
+ }
190
+
191
+ return {pulled: await this.pull(), scope}
192
+ }
193
+
194
+ /**
195
+ * Deactivates the sync scope declared by a model query.
196
+ * @param {import("../database/query/model-class-query.js").default<?>} query - Query whose scope should stop syncing.
197
+ * @returns {Promise<void>}
198
+ */
199
+ async unsync(query) {
200
+ await this.scopeStore().deactivate(serializedScopeFromQuery(query))
201
+ }
202
+
203
+ /**
204
+ * Pulls changes for every active scope with per-scope cursors (single-flighted, online-gated).
205
+ * @returns {Promise<import("./sync-api-client-types.js").SyncChangesResult | null>} Combined pull result, or null while offline.
206
+ */
207
+ async pull() {
208
+ if (!(await this.isOnline())) return null
209
+
210
+ /** @type {import("./sync-api-client-types.js").SyncChangesResult | null} */
211
+ let combinedResult = null
212
+
213
+ await SyncApiClient.singleFlight(`velocious-sync-client-pull-${this._clientNumber}`, async () => {
214
+ const authenticationToken = await this.config.authenticationToken()
215
+ const scopeStore = this.scopeStore()
216
+ const pullResourceConfigs = this.pullResourceConfigs()
217
+ const applier = SyncApiClient.resourceApplier(pullResourceConfigs, (record) => {
218
+ this._remoteApplyRecords.add(record)
219
+
220
+ return () => this._remoteApplyRecords.delete(record)
221
+ })
222
+ /**
223
+ * Applies one pulled change, failing loudly instead of silently skipping unconfigured resources.
224
+ * @param {import("./sync-api-client-types.js").SyncChangeEnvelope} sync - Pulled change.
225
+ * @returns {Promise<import("./sync-api-client-types.js").SyncChangeApplyResult>} Apply result.
226
+ */
227
+ const applySync = async (sync) => {
228
+ const resourceType = sync.resourceType()
229
+
230
+ if (!resourceType || !pullResourceConfigs[resourceType]) {
231
+ throw new Error(`No sync resource with pull attributes configured for pulled change: ${String(resourceType)}`)
232
+ }
233
+
234
+ return await applier(sync)
235
+ }
236
+ const result = {
237
+ changed: false,
238
+ pages: 0,
239
+ resourceChanged: /** @type {Record<string, boolean>} */ ({}),
240
+ resourceCounts: /** @type {Record<string, number>} */ ({}),
241
+ syncedCount: 0
242
+ }
243
+
244
+ for (const scopeRow of await scopeStore.activeScopes()) {
245
+ const scopeResult = await SyncApiClient.pullChanges({
246
+ applySync,
247
+ authenticationToken,
248
+ batchSize: this.config.batchSize,
249
+ loadCursor: async () => await scopeStore.loadCursor(scopeRow),
250
+ postChanges: async (payload) => await this.config.postChanges({
251
+ ...payload,
252
+ scope: {conditions: scopeRow.conditions, resourceType: scopeRow.resourceType}
253
+ }),
254
+ saveCursor: async (cursor) => await scopeStore.saveCursor(scopeRow, cursor)
255
+ })
256
+
257
+ result.changed ||= scopeResult.changed
258
+ result.pages += scopeResult.pages
259
+ result.syncedCount += scopeResult.syncedCount
260
+
261
+ for (const [resourceType, count] of Object.entries(scopeResult.resourceCounts)) {
262
+ result.resourceCounts[resourceType] = (result.resourceCounts[resourceType] || 0) + count
263
+ }
264
+ for (const [resourceType, changed] of Object.entries(scopeResult.resourceChanged)) {
265
+ result.resourceChanged[resourceType] ||= changed
266
+ }
267
+ }
268
+
269
+ combinedResult = result
270
+ })
271
+
272
+ return combinedResult
273
+ }
274
+
275
+ /**
276
+ * Queues a local model change as a pending sync row and schedules an immediate
277
+ * replay attempt (kept pending while offline or when the backend rejects it).
278
+ * @param {{resource: ?, data?: Record<string, ?>, syncType?: string}} args - Queue args.
279
+ * @returns {Promise<?>} Pending local sync row.
280
+ */
281
+ async queue({data, resource, syncType}) {
282
+ const resourceConfig = this.resourceConfigFor(resource)
283
+ const syncRow = await SyncApiClient.queueLocalSync({
284
+ booleanAttributes: resourceConfig.booleanAttributes || [],
285
+ data,
286
+ localOnlyAttributes: resourceConfig.localOnlyAttributes || [],
287
+ resource,
288
+ syncModel: this.config.syncModel,
289
+ syncType: syncType ?? this.defaultSyncType({operation: "update", record: resource, resourceConfig})
290
+ })
291
+
292
+ this.scheduleReplay()
293
+
294
+ return syncRow
295
+ }
296
+
297
+ /**
298
+ * Drains pending local sync rows to the backend (single-flighted, online-gated).
299
+ * Rows are only marked successful after the backend acknowledges them.
300
+ * @returns {Promise<void>}
301
+ */
302
+ async replayPending() {
303
+ if (!(await this.isOnline())) return
304
+
305
+ await SyncApiClient.singleFlight(`velocious-sync-client-replay-${this._clientNumber}`, async () => {
306
+ await SyncApiClient.replayLocalSyncs({
307
+ authenticationToken: await this.config.authenticationToken(),
308
+ batchSize: this.config.batchSize,
309
+ postReplay: this.config.postReplay,
310
+ syncModel: this.config.syncModel
311
+ })
312
+ })
313
+ }
314
+
315
+ /**
316
+ * Schedules a background replay attempt without blocking the caller.
317
+ * Failures go to config.onError (or rethrow when none is configured).
318
+ * @returns {void}
319
+ */
320
+ scheduleReplay() {
321
+ this._scheduledReplay = (async () => {
322
+ try {
323
+ await this.replayPending()
324
+ } catch (error) {
325
+ this.reportError(/** @type {Error} */ (error))
326
+ }
327
+ })()
328
+ }
329
+
330
+ /**
331
+ * Awaits the last scheduled background replay (useful in tests and shutdown flows).
332
+ * @returns {Promise<void>}
333
+ */
334
+ async waitForScheduledReplay() {
335
+ if (this._scheduledReplay) await this._scheduledReplay
336
+ }
337
+
338
+ /**
339
+ * Reports a background sync failure.
340
+ * @param {Error} error - Background failure.
341
+ * @returns {void}
342
+ */
343
+ reportError(error) {
344
+ if (this.config.onError) {
345
+ this.config.onError(error)
346
+ return
347
+ }
348
+
349
+ throw error
350
+ }
351
+
352
+ /**
353
+ * Resolves connectivity through the configured gate.
354
+ * @returns {Promise<boolean>} Whether the backend is considered reachable.
355
+ */
356
+ async isOnline() {
357
+ if (!this.config.isOnline) return true
358
+
359
+ return (await this.config.isOnline()) !== false
360
+ }
361
+
362
+ /**
363
+ * Returns the scope store backing declared scopes and cursors.
364
+ * @returns {import("./sync-scope-store.js").default} Scope store.
365
+ */
366
+ scopeStore() {
367
+ this._scopeStore ||= new SyncScopeStore({configuration: this.config.configuration || Configuration.current()})
368
+
369
+ return this._scopeStore
370
+ }
371
+
372
+ /**
373
+ * Resolves the declared resource config for a local record.
374
+ * @param {?} resource - Local model record.
375
+ * @returns {import("./sync-client-types.js").SyncClientResourceConfig} Declared resource config.
376
+ */
377
+ resourceConfigFor(resource) {
378
+ const modelClass = resource?.constructor
379
+
380
+ if (typeof modelClass?.getModelName !== "function") {
381
+ throw new Error(`Sync resources must be model records with a static getModelName(), got: ${String(resource)}`)
382
+ }
383
+
384
+ const resourceType = modelClass.getModelName()
385
+ const resourceConfig = this.config.resources[resourceType]
386
+
387
+ if (!resourceConfig) throw new Error(`No sync resource configured for: ${resourceType}`)
388
+
389
+ return resourceConfig
390
+ }
391
+
392
+ /**
393
+ * Resolves the sync type for a mutation through the resource config.
394
+ * @param {{operation: "create" | "update" | "destroy", record: ?, resourceConfig: import("./sync-client-types.js").SyncClientResourceConfig}} args - Mutation args.
395
+ * @returns {string} Sync type.
396
+ */
397
+ defaultSyncType({operation, record, resourceConfig}) {
398
+ if (resourceConfig.syncType) return resourceConfig.syncType({operation, record})
399
+ if (operation === "destroy") return "delete"
400
+
401
+ return operation
402
+ }
403
+
404
+ /**
405
+ * Derives the pull-apply resource configs from the declared resources.
406
+ * @returns {Record<string, import("./sync-api-client-types.js").SyncResourceConfig>} Pull-apply resource configs.
407
+ */
408
+ pullResourceConfigs() {
409
+ this._pullResourceConfigs ||= /** @type {Record<string, import("./sync-api-client-types.js").SyncResourceConfig>} */ (Object.fromEntries(
410
+ Object.entries(this.config.resources)
411
+ .filter(([, resource]) => Boolean(resource.attributes))
412
+ .map(([resourceType, resource]) => [resourceType, {
413
+ afterApply: resource.afterApply,
414
+ attributes: /** @type {import("./sync-api-client-types.js").SyncResourceConfig["attributes"]} */ (resource.attributes),
415
+ enabled: true,
416
+ findRecord: resource.findRecord,
417
+ findRecordForDelete: resource.findRecordForDelete,
418
+ modelClass: resource.modelClass
419
+ }])
420
+ ))
421
+
422
+ return this._pullResourceConfigs
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Declares a sync scope on the current sync client.
428
+ * @param {import("../database/query/model-class-query.js").default<?>} query - Query declaring the sync scope.
429
+ * @returns {Promise<{scope: import("./sync-client-types.js").SerializedSyncScope, pulled: import("./sync-api-client-types.js").SyncChangesResult | null}>} Declared scope and pull result.
430
+ */
431
+ export async function sync(query) {
432
+ return await SyncClient.current().sync(query)
433
+ }
@@ -1,5 +1,16 @@
1
1
  // @ts-check
2
2
 
3
+ import SyncReplayUpsertApplier from "./sync-replay-upsert-applier.js"
4
+
5
+ /**
6
+ * One declarative broadcast fanned out after a mutation applies.
7
+ * @typedef {object} SyncReplayBroadcast
8
+ * @property {string | ((args: Record<string, ?>) => string)} channel - Channel name or resolver.
9
+ * @property {(args: Record<string, ?>) => Record<string, ?>} broadcastParams - Channel routing params.
10
+ * @property {(args: Record<string, ?>) => ?} body - Broadcast body.
11
+ * @property {(args: Record<string, ?>) => boolean} [when] - Optional gate; skipped when it returns false.
12
+ */
13
+
3
14
  /**
4
15
  * Replays client sync envelopes through project supplied authentication,
5
16
  * authorization, application, and persistence hooks.
@@ -12,11 +23,60 @@
12
23
  export default class SyncEnvelopeReplayService {
13
24
  /**
14
25
  * Creates a sync envelope replay service.
26
+ *
27
+ * When a sync model is given, `findExistingReplaySync` and
28
+ * `persistReplayMutation` get model-backed default implementations. The sync
29
+ * model must expose `findBy`/`create` statics plus instance
30
+ * `assign`/`save`/`clientUpdatedAt` and `advanceServerSequence` (the
31
+ * change-feed sequence contract), and the actor returned from
32
+ * `authenticateReplay` must expose an `id()` method.
15
33
  * @param {object} [args] - Constructor arguments.
16
34
  * @param {{debug?: (...args: Array<unknown>) => void, warn?: (...args: Array<unknown>) => void}} [args.logger] - Logger used for normalization warnings.
35
+ * @param {?} [args.syncModel] - Sync/change model enabling model-backed default hooks.
36
+ * @param {string} [args.actorForeignKeyColumn] - Sync model column linking rows to the replay actor.
37
+ * @param {?} [args.authenticationTokenModel] - Token model enabling the default token-lookup authenticateReplay.
38
+ * @param {string} [args.authenticationTokenColumn] - Token model column holding the token. Defaults to "token".
39
+ * @param {string} [args.authenticationTokenParam] - Request param carrying the token. Defaults to "authenticationToken".
40
+ * @param {Record<string, ((args: Record<string, ?>) => Promise<?>) | ConstructorParameters<typeof SyncReplayUpsertApplier>[0]>} [args.applyHandlers] - Per-resourceType apply handlers (functions or declarative upsert-applier specs) enabling the default applyReplayMutation dispatch.
41
+ * @param {(args: Record<string, ?>) => Record<string, ?>} [args.persistExtraAttributes] - Extra attributes merged into the model-backed persisted row (e.g. an event scope column).
42
+ * @param {(args: {mutation: ?, applyResult: ?}) => ?} [args.persistSerializedData] - Overrides the persisted data payload (object results are JSON stringified).
43
+ * @param {(broadcast: {channel: string, params: Record<string, ?>, body: ?}) => Promise<void>} [args.broadcaster] - Delivers declarative broadcasts. Required when broadcasts are configured.
44
+ * @param {SyncReplayBroadcast[]} [args.broadcasts] - Broadcasts fanned out by the default afterReplayMutation.
17
45
  */
18
46
  constructor(args = {}) {
19
47
  this.logger = args.logger || console
48
+ this.syncModel = args.syncModel || null
49
+ this.actorForeignKeyColumn = args.actorForeignKeyColumn || "authentication_token_id"
50
+ this.authenticationTokenModel = args.authenticationTokenModel || null
51
+ this.authenticationTokenColumn = args.authenticationTokenColumn || "token"
52
+ this.authenticationTokenParam = args.authenticationTokenParam || "authenticationToken"
53
+ this.persistExtraAttributes = args.persistExtraAttributes || null
54
+ this.persistSerializedData = args.persistSerializedData || null
55
+ this.broadcaster = args.broadcaster || null
56
+ this.broadcasts = args.broadcasts || null
57
+ this.applyHandlers = args.applyHandlers ? this.builtApplyHandlers(args.applyHandlers) : null
58
+
59
+ if (args.actorForeignKeyColumn !== undefined && (typeof args.actorForeignKeyColumn !== "string" || args.actorForeignKeyColumn.length < 1)) {
60
+ throw new Error(`actorForeignKeyColumn must be a non-blank string, got: ${String(args.actorForeignKeyColumn)}`)
61
+ }
62
+ if (this.broadcasts && !this.broadcaster) {
63
+ throw new Error("SyncEnvelopeReplayService broadcasts require a broadcaster option delivering them")
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Wraps declarative apply-handler specs in upsert appliers.
69
+ * @param {Record<string, ?>} applyHandlers - Raw apply handlers.
70
+ * @returns {Record<string, (args: Record<string, ?>) => Promise<?>>} Callable handlers by resource type.
71
+ */
72
+ builtApplyHandlers(applyHandlers) {
73
+ return Object.fromEntries(Object.entries(applyHandlers).map(([resourceType, handler]) => {
74
+ if (typeof handler === "function") return [resourceType, handler]
75
+
76
+ const applier = new SyncReplayUpsertApplier(handler)
77
+
78
+ return [resourceType, (/** @type {Record<string, ?>} */ applyArgs) => applier.apply(/** @type {?} */ (applyArgs))]
79
+ }))
20
80
  }
21
81
 
22
82
  /**
@@ -76,11 +136,30 @@ export default class SyncEnvelopeReplayService {
76
136
 
77
137
  /**
78
138
  * Authenticates the sync batch actor.
79
- * @param {Record<string, ?>} _params - Request params.
139
+ *
140
+ * Defaults to a token-model lookup when `authenticationTokenModel` is
141
+ * configured; otherwise apps override this hook.
142
+ * @param {Record<string, ?>} params - Request params.
80
143
  * @returns {Promise<{authenticated: true, actor: ?} | {authenticated: false, errorCode: string, errorMessage: string}>} Auth result.
81
144
  */
82
- async authenticateReplay(_params) {
83
- throw new Error("SyncEnvelopeReplayService.authenticateReplay must be implemented")
145
+ async authenticateReplay(params) {
146
+ if (!this.authenticationTokenModel) {
147
+ throw new Error("SyncEnvelopeReplayService.authenticateReplay must be implemented (or configure authenticationTokenModel)")
148
+ }
149
+
150
+ const token = params[this.authenticationTokenParam]
151
+
152
+ if (!token) {
153
+ return {authenticated: false, errorCode: "missing-authentication-token", errorMessage: "Missing authentication token"}
154
+ }
155
+
156
+ const actor = await this.authenticationTokenModel.findBy({[this.authenticationTokenColumn]: token})
157
+
158
+ if (!actor) {
159
+ return {authenticated: false, errorCode: "invalid-authentication-token", errorMessage: "Invalid authentication token"}
160
+ }
161
+
162
+ return {actor, authenticated: true}
84
163
  }
85
164
 
86
165
  /**
@@ -178,11 +257,33 @@ export default class SyncEnvelopeReplayService {
178
257
 
179
258
  /**
180
259
  * Loads the previously stored sync/change row for stale-client comparison.
181
- * @param {{actor: ?, context: Record<string, ?>, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} _args - Actor, batch context, and mutation.
260
+ *
261
+ * Defaults to a sync-model lookup by actor and resource identity when a sync
262
+ * model is configured; otherwise apps override this hook.
263
+ * @param {{actor: ?, context: Record<string, ?>, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} args - Actor, batch context, and mutation.
182
264
  * @returns {Promise<?>} Existing sync row.
183
265
  */
184
- async findExistingReplaySync(_args) {
185
- return null
266
+ async findExistingReplaySync({actor, mutation}) {
267
+ if (!this.syncModel) return null
268
+
269
+ return await this.syncModel.findBy({
270
+ [this.actorForeignKeyColumn]: this.replayActorId(actor),
271
+ resource_id: mutation.resourceId,
272
+ resource_type: mutation.resourceType
273
+ })
274
+ }
275
+
276
+ /**
277
+ * Resolves the persisted actor id used by model-backed default hooks.
278
+ * @param {?} actor - Actor returned from authenticateReplay.
279
+ * @returns {?} Actor id.
280
+ */
281
+ replayActorId(actor) {
282
+ if (!actor || typeof actor !== "object" || typeof actor.id !== "function") {
283
+ throw new Error("SyncEnvelopeReplayService model-backed defaults require an actor with an id() method from authenticateReplay")
284
+ }
285
+
286
+ return actor.id()
186
287
  }
187
288
 
188
289
  /**
@@ -220,11 +321,20 @@ export default class SyncEnvelopeReplayService {
220
321
 
221
322
  /**
222
323
  * Applies one normalized mutation to domain models.
223
- * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} _args - Actor, batch context, existing sync row, and mutation.
324
+ *
325
+ * Defaults to dispatching through the configured apply-handler registry;
326
+ * mutations without a registered handler fail loudly.
327
+ * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} args - Actor, batch context, existing sync row, and mutation.
224
328
  * @returns {Promise<?>} Project-specific apply result.
225
329
  */
226
- async applyReplayMutation(_args) {
227
- return null
330
+ async applyReplayMutation(args) {
331
+ if (!this.applyHandlers) return null
332
+
333
+ const applyHandler = this.applyHandlers[args.mutation.resourceType]
334
+
335
+ if (!applyHandler) throw new Error(`No sync apply handler registered for: ${args.mutation.resourceType}`)
336
+
337
+ return await applyHandler(args)
228
338
  }
229
339
 
230
340
  /**
@@ -238,17 +348,84 @@ export default class SyncEnvelopeReplayService {
238
348
 
239
349
  /**
240
350
  * Persists one normalized mutation into the app sync/change store.
241
- * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, applyResult: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation, shouldApply: boolean}} _args - Replay persistence arguments.
351
+ *
352
+ * Defaults to a stale-guarded sync-model upsert (with server re-sequencing on
353
+ * updates) when a sync model is configured; otherwise apps override this hook.
354
+ * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, applyResult: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation, shouldApply: boolean}} args - Replay persistence arguments.
242
355
  * @returns {Promise<void>}
243
356
  */
244
- async persistReplayMutation(_args) {}
357
+ async persistReplayMutation({actor, applyResult, context, existingSync, mutation, shouldApply}) {
358
+ if (!this.syncModel) return
359
+
360
+ const attributes = this.replayPersistAttributes({actor, mutation})
361
+
362
+ // Stale replays never applied anything, so the applyResult-driven extension
363
+ // hooks must not run against the default null skipped result.
364
+ if (this.persistExtraAttributes && shouldApply) {
365
+ Object.assign(attributes, this.persistExtraAttributes({actor, applyResult, context, existingSync, mutation, shouldApply}))
366
+ }
367
+
368
+ if (this.persistSerializedData && shouldApply) {
369
+ const serializedData = this.persistSerializedData({applyResult, mutation})
370
+
371
+ if (serializedData !== undefined && serializedData !== null) {
372
+ attributes.data = typeof serializedData === "string" ? serializedData : JSON.stringify(serializedData)
373
+ }
374
+ }
375
+
376
+ if (existingSync) {
377
+ const existingClientUpdatedAt = this.existingReplaySyncClientUpdatedAt(existingSync)
378
+
379
+ if (existingClientUpdatedAt && mutation.clientUpdatedAt <= existingClientUpdatedAt) return
380
+
381
+ existingSync.assign(attributes)
382
+ await existingSync.advanceServerSequence()
383
+ await existingSync.save()
384
+ } else {
385
+ await this.syncModel.create(attributes)
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Builds the sync-model attributes persisted by the model-backed default.
391
+ * @param {{actor: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} args - Actor and mutation.
392
+ * @returns {Record<string, ?>} Sync row attributes.
393
+ */
394
+ replayPersistAttributes({actor, mutation}) {
395
+ return {
396
+ [this.actorForeignKeyColumn]: this.replayActorId(actor),
397
+ client_updated_at: mutation.clientUpdatedAt,
398
+ data: mutation.serializedData,
399
+ resource_id: mutation.resourceId,
400
+ resource_type: mutation.resourceType,
401
+ sync_type: mutation.syncType
402
+ }
403
+ }
245
404
 
246
405
  /**
247
406
  * Runs side effects after a successful mutation replay and persistence.
248
- * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, applyResult: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation, shouldApply: boolean}} _args - Replay side-effect arguments.
407
+ *
408
+ * Defaults to fanning the applied result out through the configured
409
+ * declarative broadcasts.
410
+ * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, applyResult: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation, shouldApply: boolean}} args - Replay side-effect arguments.
249
411
  * @returns {Promise<void>}
250
412
  */
251
- async afterReplayMutation(_args) {}
413
+ async afterReplayMutation(args) {
414
+ if (!this.broadcasts || !this.broadcaster) return
415
+ // Stale replays never applied anything - broadcasting their skipped results
416
+ // would fan out stale side effects (or crash on the default null applyResult).
417
+ if (!args.shouldApply) return
418
+
419
+ for (const broadcast of this.broadcasts) {
420
+ if (broadcast.when && !broadcast.when(args)) continue
421
+
422
+ await this.broadcaster({
423
+ body: broadcast.body(args),
424
+ channel: typeof broadcast.channel === "function" ? broadcast.channel(args) : broadcast.channel,
425
+ params: broadcast.broadcastParams(args)
426
+ })
427
+ }
428
+ }
252
429
  }
253
430
 
254
431
  /**