velocious 1.0.489 → 1.0.492

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 (103) 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 +38 -4
  55. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  56. package/build/src/sync/sync-envelope-replay-service.js +73 -6
  57. package/build/src/sync/sync-resource-base.d.ts +98 -0
  58. package/build/src/sync/sync-resource-base.d.ts.map +1 -0
  59. package/build/src/sync/sync-resource-base.js +122 -0
  60. package/build/src/sync/sync-scope-store.d.ts +128 -0
  61. package/build/src/sync/sync-scope-store.d.ts.map +1 -0
  62. package/build/src/sync/sync-scope-store.js +282 -0
  63. package/build/src/tenants/default-tenant-database-provisioning.d.ts +21 -0
  64. package/build/src/tenants/default-tenant-database-provisioning.d.ts.map +1 -0
  65. package/build/src/tenants/default-tenant-database-provisioning.js +106 -0
  66. package/build/src/tenants/tenant.d.ts.map +1 -1
  67. package/build/src/tenants/tenant.js +6 -5
  68. package/build/sync/query-scope.js +84 -0
  69. package/build/sync/server-change-feed.js +1 -25
  70. package/build/sync/stable-json.js +28 -0
  71. package/build/sync/sync-api-client.js +331 -17
  72. package/build/sync/sync-api-controller.js +20 -0
  73. package/build/sync/sync-client-registry.js +30 -0
  74. package/build/sync/sync-client-types.js +41 -0
  75. package/build/sync/sync-client.js +433 -0
  76. package/build/sync/sync-envelope-replay-service.js +77 -5
  77. package/build/sync/sync-resource-base.js +143 -0
  78. package/build/sync/sync-scope-store.js +327 -0
  79. package/build/tenants/default-tenant-database-provisioning.js +124 -0
  80. package/build/tenants/tenant.js +5 -5
  81. package/build/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +1 -1
  83. package/src/application.js +3 -0
  84. package/src/cli/commands/db/tenants/create.js +7 -5
  85. package/src/cli/commands/db/tenants/drop.js +6 -6
  86. package/src/configuration-types.js +8 -0
  87. package/src/configuration.js +29 -0
  88. package/src/database/query/index.js +30 -0
  89. package/src/database/query/model-class-query.js +17 -0
  90. package/src/database/record/index.js +16 -0
  91. package/src/sync/query-scope.js +84 -0
  92. package/src/sync/server-change-feed.js +1 -25
  93. package/src/sync/stable-json.js +28 -0
  94. package/src/sync/sync-api-client.js +331 -17
  95. package/src/sync/sync-api-controller.js +20 -0
  96. package/src/sync/sync-client-registry.js +30 -0
  97. package/src/sync/sync-client-types.js +41 -0
  98. package/src/sync/sync-client.js +433 -0
  99. package/src/sync/sync-envelope-replay-service.js +77 -5
  100. package/src/sync/sync-resource-base.js +143 -0
  101. package/src/sync/sync-scope-store.js +327 -0
  102. package/src/tenants/default-tenant-database-provisioning.js +124 -0
  103. 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
+ }
@@ -12,11 +12,26 @@
12
12
  export default class SyncEnvelopeReplayService {
13
13
  /**
14
14
  * Creates a sync envelope replay service.
15
+ *
16
+ * When a sync model is given, `findExistingReplaySync` and
17
+ * `persistReplayMutation` get model-backed default implementations. The sync
18
+ * model must expose `findBy`/`create` statics plus instance
19
+ * `assign`/`save`/`clientUpdatedAt` and `advanceServerSequence` (the
20
+ * change-feed sequence contract), and the actor returned from
21
+ * `authenticateReplay` must expose an `id()` method.
15
22
  * @param {object} [args] - Constructor arguments.
16
23
  * @param {{debug?: (...args: Array<unknown>) => void, warn?: (...args: Array<unknown>) => void}} [args.logger] - Logger used for normalization warnings.
24
+ * @param {?} [args.syncModel] - Sync/change model enabling model-backed default hooks.
25
+ * @param {string} [args.actorForeignKeyColumn] - Sync model column linking rows to the replay actor.
17
26
  */
18
27
  constructor(args = {}) {
19
28
  this.logger = args.logger || console
29
+ this.syncModel = args.syncModel || null
30
+ this.actorForeignKeyColumn = args.actorForeignKeyColumn || "authentication_token_id"
31
+
32
+ if (args.actorForeignKeyColumn !== undefined && (typeof args.actorForeignKeyColumn !== "string" || args.actorForeignKeyColumn.length < 1)) {
33
+ throw new Error(`actorForeignKeyColumn must be a non-blank string, got: ${String(args.actorForeignKeyColumn)}`)
34
+ }
20
35
  }
21
36
 
22
37
  /**
@@ -178,11 +193,33 @@ export default class SyncEnvelopeReplayService {
178
193
 
179
194
  /**
180
195
  * 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.
196
+ *
197
+ * Defaults to a sync-model lookup by actor and resource identity when a sync
198
+ * model is configured; otherwise apps override this hook.
199
+ * @param {{actor: ?, context: Record<string, ?>, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} args - Actor, batch context, and mutation.
182
200
  * @returns {Promise<?>} Existing sync row.
183
201
  */
184
- async findExistingReplaySync(_args) {
185
- return null
202
+ async findExistingReplaySync({actor, mutation}) {
203
+ if (!this.syncModel) return null
204
+
205
+ return await this.syncModel.findBy({
206
+ [this.actorForeignKeyColumn]: this.replayActorId(actor),
207
+ resource_id: mutation.resourceId,
208
+ resource_type: mutation.resourceType
209
+ })
210
+ }
211
+
212
+ /**
213
+ * Resolves the persisted actor id used by model-backed default hooks.
214
+ * @param {?} actor - Actor returned from authenticateReplay.
215
+ * @returns {?} Actor id.
216
+ */
217
+ replayActorId(actor) {
218
+ if (!actor || typeof actor !== "object" || typeof actor.id !== "function") {
219
+ throw new Error("SyncEnvelopeReplayService model-backed defaults require an actor with an id() method from authenticateReplay")
220
+ }
221
+
222
+ return actor.id()
186
223
  }
187
224
 
188
225
  /**
@@ -238,10 +275,45 @@ export default class SyncEnvelopeReplayService {
238
275
 
239
276
  /**
240
277
  * 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.
278
+ *
279
+ * Defaults to a stale-guarded sync-model upsert (with server re-sequencing on
280
+ * updates) when a sync model is configured; otherwise apps override this hook.
281
+ * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, applyResult: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation, shouldApply: boolean}} args - Replay persistence arguments.
242
282
  * @returns {Promise<void>}
243
283
  */
244
- async persistReplayMutation(_args) {}
284
+ async persistReplayMutation({actor, existingSync, mutation}) {
285
+ if (!this.syncModel) return
286
+
287
+ const attributes = this.replayPersistAttributes({actor, mutation})
288
+
289
+ if (existingSync) {
290
+ const existingClientUpdatedAt = this.existingReplaySyncClientUpdatedAt(existingSync)
291
+
292
+ if (existingClientUpdatedAt && mutation.clientUpdatedAt <= existingClientUpdatedAt) return
293
+
294
+ existingSync.assign(attributes)
295
+ await existingSync.advanceServerSequence()
296
+ await existingSync.save()
297
+ } else {
298
+ await this.syncModel.create(attributes)
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Builds the sync-model attributes persisted by the model-backed default.
304
+ * @param {{actor: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} args - Actor and mutation.
305
+ * @returns {Record<string, ?>} Sync row attributes.
306
+ */
307
+ replayPersistAttributes({actor, mutation}) {
308
+ return {
309
+ [this.actorForeignKeyColumn]: this.replayActorId(actor),
310
+ client_updated_at: mutation.clientUpdatedAt,
311
+ data: mutation.serializedData,
312
+ resource_id: mutation.resourceId,
313
+ resource_type: mutation.resourceType,
314
+ sync_type: mutation.syncType
315
+ }
316
+ }
245
317
 
246
318
  /**
247
319
  * Runs side effects after a successful mutation replay and persistence.
@@ -0,0 +1,143 @@
1
+ // @ts-check
2
+
3
+ import {forcedNonBlankStringParam} from "typanic"
4
+
5
+ import FrontendModelBaseResource from "../frontend-model-resource/base-resource.js"
6
+ import SyncModelChangeFeedService from "./sync-model-change-feed-service.js"
7
+
8
+ /**
9
+ * Optional client-declared sync scope carried on a changes request.
10
+ * @typedef {object} SerializedChangesScope
11
+ * @property {Record<string, ?>} conditions - Plain attribute conditions from the client query.
12
+ * @property {string} resourceType - Client resource/model name the scope was declared for.
13
+ */
14
+
15
+ /**
16
+ * Base resource for Velocious sync endpoints.
17
+ *
18
+ * Velocious owns the changes/replay orchestration (scope parsing, feed paging,
19
+ * replay delegation, response shape) while apps subclass and only declare
20
+ * authorization, feed scoping, and their replay service.
21
+ * @template {typeof import("../database/record/index.js").default} [TModelClass=typeof import("../database/record/index.js").default]
22
+ * @augments {FrontendModelBaseResource<TModelClass>}
23
+ */
24
+ export default class SyncResourceBase extends FrontendModelBaseResource {
25
+ /**
26
+ * Returns a stable change-feed page after app authorization.
27
+ * @returns {Promise<Record<string, ?>>} Change-feed page result.
28
+ */
29
+ async changes() {
30
+ const params = this.params()
31
+ const scope = this.changesScope(params)
32
+
33
+ await this.authorizeChanges({params, scope})
34
+
35
+ return await this.changeFeedService({params, scope}).changes()
36
+ }
37
+
38
+ /**
39
+ * Replays client sync envelopes through the app replay service.
40
+ * @returns {Promise<Record<string, ?>>} Replay result with per-sync states.
41
+ */
42
+ async replay() {
43
+ const result = await this.buildReplayService().replay(this.params())
44
+
45
+ if (result.status === "error") return result
46
+
47
+ return {status: "success", syncs: result.syncs}
48
+ }
49
+
50
+ /**
51
+ * Parses the optional client-declared scope from request params.
52
+ * @param {Record<string, ?>} params - Request params.
53
+ * @returns {SerializedChangesScope | null} Parsed scope, or null when the client sent none.
54
+ */
55
+ changesScope(params) {
56
+ const scope = params.scope
57
+
58
+ if (scope === undefined || scope === null) return null
59
+
60
+ if (typeof scope !== "object" || Array.isArray(scope)) {
61
+ throw new Error(`Sync changes scope must be an object, got: ${String(scope)}`)
62
+ }
63
+
64
+ const scopeParams = /** @type {Record<string, ?>} */ (scope)
65
+ const resourceType = forcedNonBlankStringParam(scopeParams, "resourceType")
66
+ const conditions = scopeParams.conditions
67
+
68
+ if (!conditions || typeof conditions !== "object" || Array.isArray(conditions)) {
69
+ throw new Error(`Sync changes scope.conditions must be an object, got: ${String(conditions)}`)
70
+ }
71
+
72
+ return {conditions: /** @type {Record<string, ?>} */ (conditions), resourceType}
73
+ }
74
+
75
+ /**
76
+ * Builds the change-feed service serving this changes request.
77
+ * @param {{params: Record<string, ?>, scope: SerializedChangesScope | null}} args - Request params and parsed scope.
78
+ * @returns {{changes: () => Promise<Record<string, ?>>}} Change-feed service.
79
+ */
80
+ changeFeedService({params, scope}) {
81
+ return new SyncModelChangeFeedService({
82
+ modelClass: this.syncModelClass(),
83
+ params,
84
+ scopeQuery: ({query}) => this.scopeChangesQuery({params, query, scope})
85
+ })
86
+ }
87
+
88
+ /**
89
+ * Builds the app replay service handling this replay request.
90
+ * @returns {import("./sync-envelope-replay-service.js").default} Replay service instance.
91
+ */
92
+ buildReplayService() {
93
+ const ReplayServiceClass = this.replayServiceClass()
94
+
95
+ return new ReplayServiceClass(this.replayServiceArgs())
96
+ }
97
+
98
+ /**
99
+ * Returns constructor args for the app replay service.
100
+ * @returns {Record<string, ?>} Replay service constructor args.
101
+ */
102
+ replayServiceArgs() {
103
+ return {}
104
+ }
105
+
106
+ /**
107
+ * Returns the sync model class backing the change feed.
108
+ * @returns {typeof import("../database/record/index.js").default} Sync model class.
109
+ */
110
+ syncModelClass() {
111
+ const modelClass = /** @type {typeof SyncResourceBase} */ (this.constructor).ModelClass
112
+
113
+ if (!modelClass) throw new Error(`${this.constructor.name} must define static ModelClass`)
114
+
115
+ return modelClass
116
+ }
117
+
118
+ /**
119
+ * Authorizes the current context for reading the requested changes.
120
+ * @param {{params: Record<string, ?>, scope: SerializedChangesScope | null}} _args - Request params and parsed scope.
121
+ * @returns {Promise<void>} Resolves when access is allowed; throws otherwise.
122
+ */
123
+ async authorizeChanges(_args) {
124
+ throw new Error("SyncResourceBase#authorizeChanges must be implemented")
125
+ }
126
+
127
+ /**
128
+ * Applies app visibility scoping onto the change-feed query.
129
+ * @param {{params: Record<string, ?>, query: import("../database/query/model-class-query.js").default, scope: SerializedChangesScope | null}} _args - Request params, feed query, and parsed scope.
130
+ * @returns {void}
131
+ */
132
+ scopeChangesQuery(_args) {
133
+ throw new Error("SyncResourceBase#scopeChangesQuery must be implemented")
134
+ }
135
+
136
+ /**
137
+ * Returns the app replay service class handling replay mutations.
138
+ * @returns {typeof import("./sync-envelope-replay-service.js").default} Replay service class.
139
+ */
140
+ replayServiceClass() {
141
+ throw new Error("SyncResourceBase#replayServiceClass must be implemented")
142
+ }
143
+ }