velocious 1.0.487 → 1.0.489

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 (37) hide show
  1. package/README.md +2 -0
  2. package/build/configuration-types.js +1 -0
  3. package/build/configuration.js +29 -0
  4. package/build/environment-handlers/node.js +22 -6
  5. package/build/src/configuration-types.d.ts +5 -0
  6. package/build/src/configuration-types.d.ts.map +1 -1
  7. package/build/src/configuration-types.js +2 -1
  8. package/build/src/configuration.d.ts +11 -0
  9. package/build/src/configuration.d.ts.map +1 -1
  10. package/build/src/configuration.js +27 -1
  11. package/build/src/environment-handlers/node.d.ts.map +1 -1
  12. package/build/src/environment-handlers/node.js +20 -8
  13. package/build/src/sync/sync-api-client-types.d.ts +182 -0
  14. package/build/src/sync/sync-api-client-types.d.ts.map +1 -0
  15. package/build/src/sync/sync-api-client-types.js +3 -0
  16. package/build/src/sync/sync-api-client.d.ts +188 -0
  17. package/build/src/sync/sync-api-client.d.ts.map +1 -0
  18. package/build/src/sync/sync-api-client.js +307 -0
  19. package/build/src/sync/sync-api-controller.d.ts +78 -0
  20. package/build/src/sync/sync-api-controller.d.ts.map +1 -0
  21. package/build/src/sync/sync-api-controller.js +142 -0
  22. package/build/src/sync/sync-model-change-feed-service.d.ts +131 -0
  23. package/build/src/sync/sync-model-change-feed-service.d.ts.map +1 -0
  24. package/build/src/sync/sync-model-change-feed-service.js +212 -0
  25. package/build/sync/sync-api-client-types.js +77 -0
  26. package/build/sync/sync-api-client.js +342 -0
  27. package/build/sync/sync-api-controller.js +161 -0
  28. package/build/sync/sync-model-change-feed-service.js +244 -0
  29. package/build/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +4 -4
  31. package/src/configuration-types.js +1 -0
  32. package/src/configuration.js +29 -0
  33. package/src/environment-handlers/node.js +22 -6
  34. package/src/sync/sync-api-client-types.js +77 -0
  35. package/src/sync/sync-api-client.js +342 -0
  36. package/src/sync/sync-api-controller.js +161 -0
  37. package/src/sync/sync-model-change-feed-service.js +244 -0
@@ -0,0 +1,342 @@
1
+ // @ts-check
2
+
3
+ import {optionalInteger} from "typanic"
4
+
5
+ /** @typedef {import("./sync-api-client-types.js").SyncChangeApplyResult} SyncChangeApplyResult */
6
+ /** @typedef {import("./sync-api-client-types.js").SyncChangeEnvelope} SyncChangeEnvelope */
7
+ /** @typedef {import("./sync-api-client-types.js").SyncChangesRequest} SyncChangesRequest */
8
+ /** @typedef {import("./sync-api-client-types.js").SyncChangesResponse} SyncChangesResponse */
9
+ /** @typedef {import("./sync-api-client-types.js").SyncChangesResult} SyncChangesResult */
10
+ /** @typedef {import("./sync-api-client-types.js").SyncCursor} SyncCursor */
11
+ /** @typedef {import("./sync-api-client-types.js").SyncReplayItem} SyncReplayItem */
12
+ /** @typedef {import("./sync-api-client-types.js").SyncReplayResponse} SyncReplayResponse */
13
+ /** @typedef {import("./sync-api-client-types.js").SyncResourceConfig} SyncResourceConfig */
14
+
15
+ /**
16
+ * Generic client-side helper for replaying pending sync envelopes through the
17
+ * framework-owned `/velocious/sync/replay` endpoint. Apps provide only local
18
+ * persistence/auth hooks.
19
+ */
20
+ export default class SyncApiClient {
21
+ /**
22
+ * Pulls backend sync changes in stable pages, applies them locally, and stores
23
+ * the acknowledged cursor. Apps provide only auth, persistence, transport, and
24
+ * resource policy hooks.
25
+ * @param {object} args - Pull args.
26
+ * @param {string} args.authenticationToken - Auth token to send with change requests.
27
+ * @param {number} [args.batchSize] - Max syncs per request. Defaults to 100.
28
+ * @param {() => Promise<SyncCursor | string | null | undefined>} args.loadCursor - Loads the persisted local cursor.
29
+ * @param {(cursor: SyncCursor) => Promise<void>} args.saveCursor - Persists the final acknowledged cursor.
30
+ * @param {(payload: SyncChangesRequest) => Promise<SyncChangesResponse>} args.postChanges - Posts one changes request.
31
+ * @param {(sync: SyncChangeEnvelope) => Promise<SyncChangeApplyResult>} args.applySync - Applies one normalized sync row locally.
32
+ * @param {(progress: {pages: number, syncedCount: number}) => void} [args.onProgress] - Progress callback.
33
+ * @returns {Promise<SyncChangesResult>} Pull result.
34
+ */
35
+ static async pullChanges(args) {
36
+ let afterCursor = this.syncCursorFromPayload(await args.loadCursor())
37
+ let upToCursor = null
38
+ let pages = 0
39
+ let syncedCount = 0
40
+ let changed = false
41
+ const resourceCounts = /** @type {Record<string, number>} */ ({})
42
+ const resourceChanged = /** @type {Record<string, boolean>} */ ({})
43
+ const batchSize = this.normalizedBatchSize(args.batchSize)
44
+
45
+ while (true) {
46
+ const changesResponse = await this.changesPage({...args, afterCursor, batchSize, upToCursor})
47
+ const syncs = changesResponse.syncs
48
+
49
+ if (!upToCursor) upToCursor = changesResponse.upToCursor
50
+ if (syncs.length === 0) break
51
+
52
+ pages += 1
53
+
54
+ for (const sync of syncs) {
55
+ const applyResult = await args.applySync(sync)
56
+ const resourceType = applyResult.resourceType ?? sync.resourceType()
57
+
58
+ changed ||= applyResult.changed === true
59
+ syncedCount += 1
60
+
61
+ if (resourceType) {
62
+ resourceCounts[resourceType] = (resourceCounts[resourceType] || 0) + 1
63
+ resourceChanged[resourceType] ||= applyResult.changed === true
64
+ }
65
+ }
66
+
67
+ afterCursor = changesResponse.nextCursor
68
+
69
+ if (args.onProgress) args.onProgress({pages, syncedCount})
70
+ if (syncs.length < batchSize) break
71
+ }
72
+
73
+ if (afterCursor) await args.saveCursor(afterCursor)
74
+
75
+ return {changed, pages, resourceChanged, resourceCounts, syncedCount}
76
+ }
77
+
78
+ /**
79
+ * Fetches and validates one backend sync changes page.
80
+ * @param {object} args - Page args.
81
+ * @param {SyncCursor} args.afterCursor - Last acknowledged cursor.
82
+ * @param {string} args.authenticationToken - Auth token.
83
+ * @param {number} args.batchSize - Page size.
84
+ * @param {(payload: SyncChangesRequest) => Promise<SyncChangesResponse>} args.postChanges - Changes poster.
85
+ * @param {SyncCursor} args.upToCursor - Snapshot upper-bound cursor.
86
+ * @returns {Promise<{nextCursor: SyncCursor, syncs: SyncChangeEnvelope[], upToCursor: SyncCursor}>} Normalized changes page.
87
+ */
88
+ static async changesPage({afterCursor, authenticationToken, batchSize, postChanges, upToCursor}) {
89
+ const response = await postChanges({
90
+ authenticationToken,
91
+ limit: batchSize,
92
+ ...this.cursorPayload("after", afterCursor),
93
+ ...this.cursorPayload("upTo", upToCursor)
94
+ })
95
+
96
+ this.ensureSuccessfulChangesResponse(response)
97
+
98
+ const syncs = /** @type {unknown[]} */ (response.syncs)
99
+
100
+ return {
101
+ nextCursor: this.syncCursorFromPayload(response.nextCursor ?? null),
102
+ syncs: syncs.map((syncPayload) => this.syncEnvelopeFromPayload(syncPayload)),
103
+ upToCursor: this.syncCursorFromPayload(response.upToCursor ?? null)
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Checks API response status and shape for change-feed pulls.
109
+ * @param {SyncChangesResponse} response - Changes response.
110
+ * @returns {void}
111
+ */
112
+ static ensureSuccessfulChangesResponse(response) {
113
+ if (response.status === "error") throw new Error(response.errorMessage || "Sync changes failed")
114
+ if (!Array.isArray(response.syncs)) throw new Error("Sync changes response missing syncs")
115
+ }
116
+
117
+ /**
118
+ * Converts a cursor into request params with the given prefix.
119
+ * @param {"after" | "upTo"} prefix - Request field prefix.
120
+ * @param {SyncCursor} cursor - Cursor to serialize.
121
+ * @returns {Record<string, string | number | null>} Request params.
122
+ */
123
+ static cursorPayload(prefix, cursor) {
124
+ if (!cursor) return {}
125
+
126
+ return {
127
+ [`${prefix}Id`]: cursor.id,
128
+ ...(cursor.serverSequence ? {[`${prefix}ServerSequence`]: cursor.serverSequence} : {}),
129
+ [`${prefix}UpdatedAt`]: cursor.updatedAt
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Parses a persisted or response cursor payload.
135
+ * @param {SyncCursor | string | Record<string, ?> | null | undefined} payload - Cursor payload.
136
+ * @returns {SyncCursor} Parsed cursor.
137
+ */
138
+ static syncCursorFromPayload(payload) {
139
+ if (!payload) return null
140
+
141
+ if (typeof payload === "string") {
142
+ try {
143
+ return this.syncCursorFromPayload(JSON.parse(payload))
144
+ } catch (_error) {
145
+ return {id: null, serverSequence: null, updatedAt: payload}
146
+ }
147
+ }
148
+
149
+ if (typeof payload !== "object" || Array.isArray(payload)) return null
150
+
151
+ const updatedAt = typeof payload.updatedAt === "string" ? payload.updatedAt : null
152
+
153
+ if (!updatedAt) return null
154
+
155
+ return {
156
+ id: payload.id === null || payload.id === undefined ? null : String(payload.id),
157
+ serverSequence: optionalInteger(payload.serverSequence === "" ? null : payload.serverSequence),
158
+ updatedAt
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Builds a normalized sync row adapter.
164
+ * @param {?} payload - Raw sync payload.
165
+ * @returns {SyncChangeEnvelope} Sync row adapter.
166
+ */
167
+ static syncEnvelopeFromPayload(payload) {
168
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("Sync changes entry must be an object")
169
+
170
+ const syncPayload = /** @type {Record<string, ?>} */ (payload)
171
+
172
+ return {
173
+ data: () => syncPayload.data,
174
+ id: () => syncPayload.id,
175
+ resourceId: () => syncPayload.resourceId,
176
+ resourceType: () => syncPayload.resourceType === null || syncPayload.resourceType === undefined ? null : String(syncPayload.resourceType),
177
+ syncType: () => syncPayload.syncType === null || syncPayload.syncType === undefined ? "" : String(syncPayload.syncType)
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Builds an app-configured resource applier for pulled sync rows. The sync
183
+ * mechanics stay here; apps only declare which models/attributes/hooks are
184
+ * allowed for each resource type.
185
+ * @param {Record<string, SyncResourceConfig>} resources - Resource policy map.
186
+ * @returns {(sync: SyncChangeEnvelope) => Promise<SyncChangeApplyResult>} Sync apply callback.
187
+ */
188
+ static resourceApplier(resources) {
189
+ return async (sync) => await this.applyResourceSync({resources, sync})
190
+ }
191
+
192
+ /**
193
+ * Applies one sync row using declarative resource policy.
194
+ * @param {{resources: Record<string, SyncResourceConfig>, sync: SyncChangeEnvelope}} args - Apply args.
195
+ * @returns {Promise<SyncChangeApplyResult>} Apply result.
196
+ */
197
+ static async applyResourceSync({resources, sync}) {
198
+ const resourceType = sync.resourceType()
199
+ const resource = resourceType ? resources[resourceType] : undefined
200
+
201
+ if (!resource || !resource.enabled) return {changed: false, resourceType}
202
+
203
+ if (sync.syncType() === "delete") {
204
+ return {changed: await this.destroySyncedResource({resource, sync}), resourceType}
205
+ }
206
+
207
+ const data = this.syncData(sync)
208
+ const record = resource.findRecord ? await resource.findRecord({data, resourceId: sync.resourceId(), sync}) : await resource.modelClass.findOrInitializeBy({id: data.id ?? sync.resourceId()})
209
+ const attributes = await resource.attributes({data, record, sync})
210
+ let changed = false
211
+
212
+ record.assign(attributes)
213
+
214
+ if (record.isChanged()) {
215
+ await record.save()
216
+ changed = true
217
+ }
218
+
219
+ if (resource.afterApply) {
220
+ const hookChanged = await resource.afterApply({attributes, data, record, sync})
221
+
222
+ changed ||= hookChanged === true
223
+ }
224
+
225
+ return {changed, resourceType}
226
+ }
227
+
228
+ /**
229
+ * Destroys a synced resource via its declared model policy.
230
+ * @param {{resource: SyncResourceConfig, sync: SyncChangeEnvelope}} args - Destroy args.
231
+ * @returns {Promise<boolean>} Whether a local row was destroyed.
232
+ */
233
+ static async destroySyncedResource({resource, sync}) {
234
+ const id = sync.resourceId()
235
+ const record = resource.findRecordForDelete ? await resource.findRecordForDelete({resourceId: id, sync}) : await resource.modelClass.findBy({id})
236
+
237
+ if (!record) return false
238
+
239
+ await record.destroy()
240
+ return true
241
+ }
242
+
243
+ /**
244
+ * Parses the embedded sync data JSON/object.
245
+ * @param {SyncChangeEnvelope} sync - Sync row.
246
+ * @returns {Record<string, unknown>} Sync data object.
247
+ */
248
+ static syncData(sync) {
249
+ const data = sync.data()
250
+
251
+ if (!data) throw new Error(`Sync ${sync.id()} is missing data`)
252
+ if (typeof data === "string") return /** @type {Record<string, unknown>} */ (JSON.parse(data))
253
+ if (typeof data === "object" && !Array.isArray(data)) return /** @type {Record<string, unknown>} */ (data)
254
+
255
+ throw new Error(`Sync ${sync.id()} has invalid data`)
256
+ }
257
+
258
+ /**
259
+ * Drains pending sync records in stable order and marks acknowledged rows.
260
+ * @param {object} args - Replay args.
261
+ * @param {string} args.authenticationToken - Auth token to send with replay requests.
262
+ * @param {number} [args.batchSize] - Max syncs per request. Defaults to 100.
263
+ * @param {() => Promise<Array<unknown>>} args.pendingSyncs - Loads pending local sync rows in replay order.
264
+ * @param {(sync: unknown) => string | number | null | undefined} args.syncId - Returns the local sync id.
265
+ * @param {(sync: unknown) => Record<string, ?>} args.syncPayload - Builds the API sync envelope.
266
+ * @param {(payload: {authenticationToken: string, syncs: Array<Record<string, ?>>}) => Promise<SyncReplayResponse>} args.postReplay - Posts one replay request.
267
+ * @param {(sync: unknown, response: SyncReplayItem) => Promise<void>} args.markSuccessful - Marks one sync as successful locally.
268
+ * @returns {Promise<void>} Resolves after all batches are replayed.
269
+ */
270
+ static async replayPending(args) {
271
+ const pendingSyncs = await args.pendingSyncs()
272
+ const batchSize = this.normalizedBatchSize(args.batchSize)
273
+
274
+ for (let offset = 0; offset < pendingSyncs.length; offset += batchSize) {
275
+ await this.replayBatch({...args, pendingSyncs: pendingSyncs.slice(offset, offset + batchSize)})
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Replays one batch of syncs.
281
+ * @param {object} args - Replay args.
282
+ * @param {string} args.authenticationToken - Auth token.
283
+ * @param {Array<unknown>} args.pendingSyncs - Batch syncs.
284
+ * @param {(sync: unknown) => string | number | null | undefined} args.syncId - Sync id getter.
285
+ * @param {(sync: unknown) => Record<string, ?>} args.syncPayload - Payload builder.
286
+ * @param {(payload: {authenticationToken: string, syncs: Array<Record<string, ?>>}) => Promise<SyncReplayResponse>} args.postReplay - Replay poster.
287
+ * @param {(sync: unknown, response: SyncReplayItem) => Promise<void>} args.markSuccessful - Success hook.
288
+ * @returns {Promise<void>} Resolves after the batch is acknowledged.
289
+ */
290
+ static async replayBatch(args) {
291
+ const {authenticationToken, markSuccessful, pendingSyncs, postReplay, syncId, syncPayload} = args
292
+
293
+ if (pendingSyncs.length === 0) return
294
+
295
+ const syncsById = new Map()
296
+
297
+ for (const sync of pendingSyncs) {
298
+ const id = syncId(sync)
299
+
300
+ if (id !== undefined && id !== null) syncsById.set(String(id), sync)
301
+ }
302
+
303
+ const response = await postReplay({
304
+ authenticationToken,
305
+ syncs: pendingSyncs.map((sync) => syncPayload(sync))
306
+ })
307
+
308
+ this.ensureSuccessfulResponse(response)
309
+
310
+ for (const syncResponse of response.syncs || []) {
311
+ const sync = syncsById.get(String(syncResponse.id))
312
+
313
+ if (!sync) continue
314
+ if (syncResponse.syncState !== "successful") {
315
+ throw new Error(`Invalid sync state returned for sync ${String(syncResponse.id)}: ${String(syncResponse.syncState)}`)
316
+ }
317
+
318
+ await markSuccessful(sync, syncResponse)
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Checks API response status and shape.
324
+ * @param {SyncReplayResponse} response - Replay response.
325
+ * @returns {void}
326
+ */
327
+ static ensureSuccessfulResponse(response) {
328
+ if (response.status === "error") throw new Error(response.errorMessage || "Sync failed")
329
+ if (!Array.isArray(response.syncs)) throw new Error("Sync response missing syncs")
330
+ }
331
+
332
+ /**
333
+ * Normalizes a positive batch size.
334
+ * @param {number | undefined} batchSize - Batch size.
335
+ * @returns {number} Positive batch size.
336
+ */
337
+ static normalizedBatchSize(batchSize) {
338
+ if (typeof batchSize !== "number" || !Number.isFinite(batchSize) || batchSize < 1) return 100
339
+
340
+ return Math.floor(batchSize)
341
+ }
342
+ }
@@ -0,0 +1,161 @@
1
+ // @ts-check
2
+
3
+ import Controller from "../controller.js"
4
+ import FrontendModelBaseResource from "../frontend-model-resource/base-resource.js"
5
+
6
+ /**
7
+ * Generic `/velocious/sync` transport controller.
8
+ *
9
+ * Apps provide a sync resource class; Velocious owns endpoint shape and
10
+ * rendering. The app resource owns auth, scoping, and domain-specific
11
+ * replay/change hooks.
12
+ */
13
+ export default class SyncApiController extends Controller {
14
+ /**
15
+ * Renders a replay response from the configured sync resource.
16
+ * @returns {Promise<void>}
17
+ */
18
+ async replay() {
19
+ const resource = /** @type {FrontendModelBaseResource & {replay: () => Promise<unknown>}} */ (this.syncResource(this.params()))
20
+
21
+ await this.render({json: /** @type {object} */ (await resource.replay())})
22
+ }
23
+
24
+ /**
25
+ * Renders a change-feed response from the configured sync resource.
26
+ * @returns {Promise<void>}
27
+ */
28
+ async changes() {
29
+ const resource = /** @type {FrontendModelBaseResource & {changes: () => Promise<unknown>}} */ (this.syncResource(this.params()))
30
+
31
+ await this.render({json: /** @type {object} */ (await resource.changes())})
32
+ }
33
+
34
+ /**
35
+ * Builds the sync resource that backs the transport endpoint.
36
+ * @param {Record<string, unknown>} params - Request params/body.
37
+ * @returns {FrontendModelBaseResource} Sync resource instance.
38
+ */
39
+ syncResource(params) {
40
+ const ResourceClass = this.syncResourceClass()
41
+ const ability = this.currentAbility()
42
+
43
+ return new ResourceClass({
44
+ ability,
45
+ controller: /** @type {import("../frontend-model-resource/base-resource.js").FrontendModelResourceController} */ (/** @type {unknown} */ (this)),
46
+ context: {
47
+ ...(ability?.getContext() || {}),
48
+ params: this.params(),
49
+ request: this.request()
50
+ },
51
+ locals: ability?.getLocals() || {},
52
+ modelClass: this.syncModelClass(),
53
+ modelName: this.syncModelName(),
54
+ params: /** @type {import("../configuration-types.js").VelociousParams} */ (/** @type {unknown} */ (params)),
55
+ resourceConfiguration: this.syncResourceConfiguration(ResourceClass)
56
+ })
57
+ }
58
+
59
+ /**
60
+ * Returns the app-provided sync resource class.
61
+ * @returns {import("../configuration-types.js").FrontendModelResourceClassType} Sync resource class.
62
+ */
63
+ syncResourceClass() {
64
+ return this.missingSyncResourceClass()
65
+ }
66
+
67
+ /**
68
+ * Builds a sync API controller class bound to the given resource.
69
+ * @param {import("../configuration-types.js").FrontendModelResourceClassType} ResourceClass - Sync resource class.
70
+ * @returns {typeof SyncApiController} Controller class for the resource.
71
+ */
72
+ static withSyncResourceClass(ResourceClass) {
73
+ return class ConfiguredSyncApiController extends this {
74
+ /**
75
+ * Returns the configured sync resource class.
76
+ * @returns {import("../configuration-types.js").FrontendModelResourceClassType} Sync resource class.
77
+ */
78
+ syncResourceClass() {
79
+ return ResourceClass
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Mounts the standard Velocious sync endpoints into a route configuration.
86
+ * @param {{configuration?: import("../configuration.js").default, at?: string, syncResourceClass?: import("../configuration-types.js").FrontendModelResourceClassType}} args - Mount args.
87
+ * @returns {void}
88
+ */
89
+ static mountInto(args) {
90
+ const {configuration, syncResourceClass} = args
91
+ const ControllerClass = syncResourceClass ? this.withSyncResourceClass(syncResourceClass) : this
92
+ const at = this.normalizedMountPath(args.at || "/velocious/sync")
93
+
94
+ if (!configuration) throw new Error("SyncApiController.mountInto requires configuration")
95
+
96
+ configuration.routes((routes) => {
97
+ routes.post(`${at}/changes`, {to: [ControllerClass, "changes"]})
98
+ routes.post(`${at}/replay`, {to: [ControllerClass, "replay"]})
99
+ })
100
+ }
101
+
102
+ /**
103
+ * Normalizes a sync mount path.
104
+ * @param {string} at - Mount path.
105
+ * @returns {string} Normalized mount path without trailing slash.
106
+ */
107
+ static normalizedMountPath(at) {
108
+ if (typeof at !== "string" || !at.startsWith("/")) {
109
+ throw new Error(`SyncApiController mount path must start with '/', got: ${String(at)}`)
110
+ }
111
+
112
+ return at.replace(/\/+$/u, "") || "/"
113
+ }
114
+
115
+ /**
116
+ * Raises a configuration error for subclasses that do not provide a resource.
117
+ * @returns {import("../configuration-types.js").FrontendModelResourceClassType} Sync resource class.
118
+ */
119
+ missingSyncResourceClass() {
120
+ return /** @type {typeof FrontendModelBaseResource} */ (/** @type {unknown} */ (this.raiseMissingSyncResourceClass()))
121
+ }
122
+
123
+ /** Raises a configuration error for subclasses that do not provide a resource. */
124
+ raiseMissingSyncResourceClass() {
125
+ throw new Error("SyncApiController.syncResourceClass must be implemented")
126
+ }
127
+
128
+ /**
129
+ * Returns the model class exposed by the sync resource.
130
+ * @returns {typeof import("../database/record/index.js").default} Sync model class.
131
+ */
132
+ syncModelClass() {
133
+ const ResourceClass = this.syncResourceClass()
134
+ const modelClass = ResourceClass.ModelClass
135
+
136
+ if (!modelClass) throw new Error("Sync resource class must define static ModelClass")
137
+
138
+ return modelClass
139
+ }
140
+
141
+ /**
142
+ * Returns the model name used to initialize the resource.
143
+ * @returns {string} Sync model name.
144
+ */
145
+ syncModelName() {
146
+ return this.syncModelClass().name
147
+ }
148
+
149
+ /**
150
+ * Builds the minimal resource configuration needed by the sync resource.
151
+ * @param {import("../configuration-types.js").FrontendModelResourceClassType} ResourceClass - Sync resource class.
152
+ * @returns {import("../configuration-types.js").FrontendModelResourceConfiguration} Resource configuration.
153
+ */
154
+ syncResourceConfiguration(ResourceClass) {
155
+ return /** @type {import("../configuration-types.js").FrontendModelResourceConfiguration} */ ({
156
+ attributes: ResourceClass.attributes || {},
157
+ sync: {enabled: true}
158
+ })
159
+ }
160
+ }
161
+