velocious 1.0.488 → 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 (108) hide show
  1. package/README.md +2 -0
  2. package/build/application.js +3 -0
  3. package/build/cli/commands/db/tenants/create.js +7 -5
  4. package/build/cli/commands/db/tenants/drop.js +6 -6
  5. package/build/configuration-types.js +9 -0
  6. package/build/configuration.js +58 -0
  7. package/build/database/query/index.js +30 -0
  8. package/build/database/query/model-class-query.js +17 -0
  9. package/build/database/record/index.js +16 -0
  10. package/build/environment-handlers/node.js +22 -6
  11. package/build/src/application.d.ts.map +1 -1
  12. package/build/src/application.js +3 -1
  13. package/build/src/cli/commands/db/tenants/create.d.ts.map +1 -1
  14. package/build/src/cli/commands/db/tenants/create.js +8 -5
  15. package/build/src/cli/commands/db/tenants/drop.d.ts +1 -1
  16. package/build/src/cli/commands/db/tenants/drop.d.ts.map +1 -1
  17. package/build/src/cli/commands/db/tenants/drop.js +7 -6
  18. package/build/src/configuration-types.d.ts +29 -0
  19. package/build/src/configuration-types.d.ts.map +1 -1
  20. package/build/src/configuration-types.js +9 -1
  21. package/build/src/configuration.d.ts +17 -0
  22. package/build/src/configuration.d.ts.map +1 -1
  23. package/build/src/configuration.js +52 -1
  24. package/build/src/database/query/index.d.ts +25 -0
  25. package/build/src/database/query/index.d.ts.map +1 -1
  26. package/build/src/database/query/index.js +26 -1
  27. package/build/src/database/query/model-class-query.d.ts +10 -0
  28. package/build/src/database/query/model-class-query.d.ts.map +1 -1
  29. package/build/src/database/query/model-class-query.js +16 -1
  30. package/build/src/database/record/index.d.ts +7 -0
  31. package/build/src/database/record/index.d.ts.map +1 -1
  32. package/build/src/database/record/index.js +15 -1
  33. package/build/src/environment-handlers/node.d.ts.map +1 -1
  34. package/build/src/environment-handlers/node.js +20 -8
  35. package/build/src/sync/query-scope.d.ts +18 -0
  36. package/build/src/sync/query-scope.d.ts.map +1 -0
  37. package/build/src/sync/query-scope.js +77 -0
  38. package/build/src/sync/server-change-feed.d.ts.map +1 -1
  39. package/build/src/sync/server-change-feed.js +2 -24
  40. package/build/src/sync/stable-json.d.ts +9 -0
  41. package/build/src/sync/stable-json.d.ts.map +1 -0
  42. package/build/src/sync/stable-json.js +27 -0
  43. package/build/src/sync/sync-api-client.d.ts +185 -5
  44. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  45. package/build/src/sync/sync-api-client.js +309 -17
  46. package/build/src/sync/sync-api-controller.d.ts +8 -0
  47. package/build/src/sync/sync-api-controller.d.ts.map +1 -1
  48. package/build/src/sync/sync-api-controller.js +17 -1
  49. package/build/src/sync/sync-client-registry.d.ts +12 -0
  50. package/build/src/sync/sync-client-registry.d.ts.map +1 -0
  51. package/build/src/sync/sync-client-registry.js +27 -0
  52. package/build/src/sync/sync-client-types.d.ts +127 -0
  53. package/build/src/sync/sync-client-types.d.ts.map +1 -0
  54. package/build/src/sync/sync-client-types.js +3 -0
  55. package/build/src/sync/sync-client.d.ts +176 -0
  56. package/build/src/sync/sync-client.d.ts.map +1 -0
  57. package/build/src/sync/sync-client.js +380 -0
  58. package/build/src/sync/sync-envelope-replay-service.d.ts +38 -4
  59. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  60. package/build/src/sync/sync-envelope-replay-service.js +73 -6
  61. package/build/src/sync/sync-resource-base.d.ts +98 -0
  62. package/build/src/sync/sync-resource-base.d.ts.map +1 -0
  63. package/build/src/sync/sync-resource-base.js +122 -0
  64. package/build/src/sync/sync-scope-store.d.ts +128 -0
  65. package/build/src/sync/sync-scope-store.d.ts.map +1 -0
  66. package/build/src/sync/sync-scope-store.js +282 -0
  67. package/build/src/tenants/default-tenant-database-provisioning.d.ts +21 -0
  68. package/build/src/tenants/default-tenant-database-provisioning.d.ts.map +1 -0
  69. package/build/src/tenants/default-tenant-database-provisioning.js +106 -0
  70. package/build/src/tenants/tenant.d.ts.map +1 -1
  71. package/build/src/tenants/tenant.js +6 -5
  72. package/build/sync/query-scope.js +84 -0
  73. package/build/sync/server-change-feed.js +1 -25
  74. package/build/sync/stable-json.js +28 -0
  75. package/build/sync/sync-api-client.js +331 -17
  76. package/build/sync/sync-api-controller.js +20 -0
  77. package/build/sync/sync-client-registry.js +30 -0
  78. package/build/sync/sync-client-types.js +41 -0
  79. package/build/sync/sync-client.js +433 -0
  80. package/build/sync/sync-envelope-replay-service.js +77 -5
  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 +9 -0
  91. package/src/configuration.js +58 -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/environment-handlers/node.js +22 -6
  96. package/src/sync/query-scope.js +84 -0
  97. package/src/sync/server-change-feed.js +1 -25
  98. package/src/sync/stable-json.js +28 -0
  99. package/src/sync/sync-api-client.js +331 -17
  100. package/src/sync/sync-api-controller.js +20 -0
  101. package/src/sync/sync-client-registry.js +30 -0
  102. package/src/sync/sync-client-types.js +41 -0
  103. package/src/sync/sync-client.js +433 -0
  104. package/src/sync/sync-envelope-replay-service.js +77 -5
  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,84 @@
1
+ // @ts-check
2
+
3
+ import stableJsonStringify from "./stable-json.js"
4
+
5
+ /**
6
+ * Serializes a model query into a transportable sync scope.
7
+ *
8
+ * Only plain attribute equality conditions are supported: the scope must be
9
+ * expressible as `{resourceType, conditions}` so servers can match it against
10
+ * their change feeds. Anything else (raw SQL, negations, joins, orders,
11
+ * limits, offsets, groups) fails loudly.
12
+ * @param {import("../database/query/model-class-query.js").default<?>} query - Model query declaring the sync scope.
13
+ * @returns {import("./sync-client-types.js").SerializedSyncScope} Serialized sync scope.
14
+ */
15
+ export function serializedScopeFromQuery(query) {
16
+ if (query.getJoins().length > 0) throw new Error("sync(query) does not support joins")
17
+ if (query.getOrders().length > 0) throw new Error("sync(query) does not support orders")
18
+ if (query.getLimit() !== null && query.getLimit() !== undefined) throw new Error("sync(query) does not support limit")
19
+ if (query.getOffset() !== null && query.getOffset() !== undefined) throw new Error("sync(query) does not support offset")
20
+ if (query.getGroups().length > 0) throw new Error("sync(query) does not support groups")
21
+
22
+ const modelClass = query.getModelClass()
23
+ const conditions = /** @type {Record<string, ?>} */ ({})
24
+
25
+ for (const where of query.getWheres()) {
26
+ const whereHash = /** @type {{hash?: Record<string, ?>}} */ (where).hash
27
+
28
+ if (!whereHash || typeof whereHash !== "object" || Array.isArray(whereHash) || /** @type {{where?: ?}} */ (where).where) {
29
+ throw new Error(`sync(query) only supports plain attribute conditions, got: ${where.constructor.name}`)
30
+ }
31
+
32
+ for (const [attributeName, value] of Object.entries(whereHash)) {
33
+ const conditionValue = scalarConditionValue(attributeName, value)
34
+
35
+ if (attributeName in conditions && stableJsonStringify(conditions[attributeName]) !== stableJsonStringify(conditionValue)) {
36
+ throw new Error(`sync(query) got conflicting conditions for: ${attributeName}`)
37
+ }
38
+
39
+ conditions[attributeName] = conditionValue
40
+ }
41
+ }
42
+
43
+ return {conditions, resourceType: modelClass.getModelName()}
44
+ }
45
+
46
+ /**
47
+ * Returns a stable canonical key identifying a sync scope.
48
+ * @param {import("./sync-client-types.js").SerializedSyncScope} scope - Serialized sync scope.
49
+ * @returns {string} Stable scope key.
50
+ */
51
+ export function scopeKey(scope) {
52
+ return `${scope.resourceType}:${stableJsonStringify(scope.conditions)}`
53
+ }
54
+
55
+ /**
56
+ * Validates one scope condition value as a scalar or array of scalars.
57
+ * @param {string} attributeName - Condition attribute name for error messages.
58
+ * @param {?} value - Condition value.
59
+ * @returns {?} Validated condition value.
60
+ */
61
+ function scalarConditionValue(attributeName, value) {
62
+ if (Array.isArray(value)) {
63
+ for (const item of value) validateScalar(attributeName, item)
64
+
65
+ return value
66
+ }
67
+
68
+ validateScalar(attributeName, value)
69
+
70
+ return value
71
+ }
72
+
73
+ /**
74
+ * Validates one scalar condition value.
75
+ * @param {string} attributeName - Condition attribute name for error messages.
76
+ * @param {?} value - Condition value.
77
+ * @returns {void}
78
+ */
79
+ function validateScalar(attributeName, value) {
80
+ if (value === null) return
81
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return
82
+
83
+ throw new Error(`sync(query) condition values must be scalar, got ${typeof value} for: ${attributeName}`)
84
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {randomUUID} from "crypto"
4
4
  import TableData from "../database/table-data/index.js"
5
+ import stableJsonStringify from "./stable-json.js"
5
6
 
6
7
  const DEFAULT_RETENTION_SIZE = 10000
7
8
  const DEFAULT_PAGE_SIZE = 100
@@ -464,31 +465,6 @@ function scopesEqual(changeScope, requestedScope) {
464
465
  return stableJsonStringify(changeScope || null) === stableJsonStringify(requestedScope)
465
466
  }
466
467
 
467
- /**
468
- * Stable JSON serialization for persisted sync scopes.
469
- * @param {?} value - JSON-compatible value.
470
- * @returns {string} - Stable JSON representation.
471
- */
472
- function stableJsonStringify(value) {
473
- return JSON.stringify(stableJsonValue(value))
474
- }
475
-
476
- /**
477
- * Produces a recursively key-sorted JSON value.
478
- * @param {?} value - JSON-compatible value.
479
- * @returns {?} - Stable JSON-compatible value.
480
- */
481
- function stableJsonValue(value) {
482
- if (Array.isArray(value)) return value.map((item) => stableJsonValue(item))
483
- if (!value || typeof value !== "object") return value
484
-
485
- return Object.keys(value).sort().reduce((memo, key) => {
486
- memo[key] = stableJsonValue(value[key])
487
-
488
- return memo
489
- }, /** @type {Record<string, ?>} */ ({}))
490
- }
491
-
492
468
  /**
493
469
  * @typedef {object} ServerChangeFeedEntry
494
470
  * @property {string | null} actorDeviceId - Signed mutation actor device id when available.
@@ -0,0 +1,28 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Serializes a JSON-compatible value with recursively sorted object keys, so
5
+ * equal values always produce byte-identical strings (used for sync scope and
6
+ * change-feed identity comparisons).
7
+ * @param {?} value - JSON-compatible value.
8
+ * @returns {string} - Stable JSON string.
9
+ */
10
+ export default function stableJsonStringify(value) {
11
+ return JSON.stringify(stableJsonValue(value))
12
+ }
13
+
14
+ /**
15
+ * Produces a recursively key-sorted JSON value.
16
+ * @param {?} value - JSON-compatible value.
17
+ * @returns {?} - Stable JSON-compatible value.
18
+ */
19
+ function stableJsonValue(value) {
20
+ if (Array.isArray(value)) return value.map((item) => stableJsonValue(item))
21
+ if (!value || typeof value !== "object") return value
22
+
23
+ return Object.keys(value).sort().reduce((memo, key) => {
24
+ memo[key] = stableJsonValue(value[key])
25
+
26
+ return memo
27
+ }, /** @type {Record<string, ?>} */ ({}))
28
+ }
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
 
3
- import {optionalInteger} from "typanic"
3
+ import {optionalBoolean, optionalInteger} from "typanic"
4
+
5
+ const syncTaskPromises = new Map()
4
6
 
5
7
  /** @typedef {import("./sync-api-client-types.js").SyncChangeApplyResult} SyncChangeApplyResult */
6
8
  /** @typedef {import("./sync-api-client-types.js").SyncChangeEnvelope} SyncChangeEnvelope */
@@ -18,6 +20,81 @@ import {optionalInteger} from "typanic"
18
20
  * persistence/auth hooks.
19
21
  */
20
22
  export default class SyncApiClient {
23
+ /**
24
+ * Serializes sync work with the same key so callers do not have to keep app-local locks.
25
+ * @param {string} key - Lock key.
26
+ * @param {() => Promise<void>} callback - Work to run once previous work finished.
27
+ * @returns {Promise<void>}
28
+ */
29
+ static async singleFlight(key, callback) {
30
+ while (syncTaskPromises.has(key)) {
31
+ try {
32
+ await syncTaskPromises.get(key)
33
+ } catch (_error) {
34
+ // The failed flight's own caller observes that rejection; callers queued
35
+ // behind it still run their own work so pending rows retry after the lock clears.
36
+ }
37
+ }
38
+
39
+ const promise = callback()
40
+ syncTaskPromises.set(key, promise)
41
+
42
+ try {
43
+ await promise
44
+ } finally {
45
+ if (syncTaskPromises.get(key) === promise) syncTaskPromises.delete(key)
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Pulls backend sync changes with a framework-managed cursor row.
51
+ * @param {object} args - Pull args.
52
+ * @param {string} args.authenticationToken - Auth token to send with change requests.
53
+ * @param {number} [args.batchSize] - Max syncs per request.
54
+ * @param {?} args.cursorModel - Model that responds to findBy/findOrInitializeBy for cursor persistence.
55
+ * @param {string} args.cursorKey - Cursor option key.
56
+ * @param {(payload: SyncChangesRequest) => Promise<SyncChangesResponse>} args.postChanges - Posts one changes request.
57
+ * @param {Record<string, SyncResourceConfig>} args.resources - Resource policies.
58
+ * @param {(progress: {pages: number, syncedCount: number}) => void} [args.onProgress] - Progress callback.
59
+ * @returns {Promise<SyncChangesResult>} Pull result.
60
+ */
61
+ static async pullChangesWithCursor(args) {
62
+ return await this.pullChanges({
63
+ authenticationToken: args.authenticationToken,
64
+ batchSize: args.batchSize,
65
+ loadCursor: async () => await this.loadSyncCursor({cursorKey: args.cursorKey, cursorModel: args.cursorModel}),
66
+ saveCursor: async (cursor) => await this.saveSyncCursor({cursor, cursorKey: args.cursorKey, cursorModel: args.cursorModel}),
67
+ postChanges: args.postChanges,
68
+ applySync: this.resourceApplier(args.resources),
69
+ onProgress: args.onProgress
70
+ })
71
+ }
72
+
73
+ /**
74
+ * Loads a persisted sync cursor from a model row with a value column.
75
+ * @param {{cursorKey: string, cursorModel: ?}} args - Cursor args.
76
+ * @returns {Promise<string | null>} Persisted cursor payload.
77
+ */
78
+ static async loadSyncCursor({cursorKey, cursorModel}) {
79
+ const option = await cursorModel.findBy({key: cursorKey})
80
+
81
+ return option ? option.value() : null
82
+ }
83
+
84
+ /**
85
+ * Saves a persisted sync cursor to a model row with a value column.
86
+ * @param {{cursor: SyncCursor, cursorKey: string, cursorModel: ?}} args - Cursor args.
87
+ * @returns {Promise<void>}
88
+ */
89
+ static async saveSyncCursor({cursor, cursorKey, cursorModel}) {
90
+ if (!cursor) return
91
+
92
+ const option = await cursorModel.findOrInitializeBy({key: cursorKey})
93
+
94
+ option.assign({value: JSON.stringify(cursor)})
95
+ if (option.isChanged()) await option.save()
96
+ }
97
+
21
98
  /**
22
99
  * Pulls backend sync changes in stable pages, applies them locally, and stores
23
100
  * the acknowledged cursor. Apps provide only auth, persistence, transport, and
@@ -183,43 +260,49 @@ export default class SyncApiClient {
183
260
  * mechanics stay here; apps only declare which models/attributes/hooks are
184
261
  * allowed for each resource type.
185
262
  * @param {Record<string, SyncResourceConfig>} resources - Resource policy map.
263
+ * @param {(record: ?) => () => void} [onRecord] - Called with each record about to be written; returns a release callback invoked after the write (used for echo suppression).
186
264
  * @returns {(sync: SyncChangeEnvelope) => Promise<SyncChangeApplyResult>} Sync apply callback.
187
265
  */
188
- static resourceApplier(resources) {
189
- return async (sync) => await this.applyResourceSync({resources, sync})
266
+ static resourceApplier(resources, onRecord) {
267
+ return async (sync) => await this.applyResourceSync({onRecord, resources, sync})
190
268
  }
191
269
 
192
270
  /**
193
271
  * Applies one sync row using declarative resource policy.
194
- * @param {{resources: Record<string, SyncResourceConfig>, sync: SyncChangeEnvelope}} args - Apply args.
272
+ * @param {{resources: Record<string, SyncResourceConfig>, sync: SyncChangeEnvelope, onRecord?: (record: ?) => () => void}} args - Apply args.
195
273
  * @returns {Promise<SyncChangeApplyResult>} Apply result.
196
274
  */
197
- static async applyResourceSync({resources, sync}) {
275
+ static async applyResourceSync({onRecord, resources, sync}) {
198
276
  const resourceType = sync.resourceType()
199
277
  const resource = resourceType ? resources[resourceType] : undefined
200
278
 
201
279
  if (!resource || !resource.enabled) return {changed: false, resourceType}
202
280
 
203
281
  if (sync.syncType() === "delete") {
204
- return {changed: await this.destroySyncedResource({resource, sync}), resourceType}
282
+ return {changed: await this.destroySyncedResource({onRecord, resource, sync}), resourceType}
205
283
  }
206
284
 
207
285
  const data = this.syncData(sync)
208
286
  const record = resource.findRecord ? await resource.findRecord({data, resourceId: sync.resourceId(), sync}) : await resource.modelClass.findOrInitializeBy({id: data.id ?? sync.resourceId()})
209
287
  const attributes = await resource.attributes({data, record, sync})
288
+ const releaseRecord = onRecord ? onRecord(record) : null
210
289
  let changed = false
211
290
 
212
- record.assign(attributes)
291
+ try {
292
+ record.assign(attributes)
213
293
 
214
- if (record.isChanged()) {
215
- await record.save()
216
- changed = true
217
- }
294
+ if (record.isChanged()) {
295
+ await record.save()
296
+ changed = true
297
+ }
218
298
 
219
- if (resource.afterApply) {
220
- const hookChanged = await resource.afterApply({attributes, data, record, sync})
299
+ if (resource.afterApply) {
300
+ const hookChanged = await resource.afterApply({attributes, data, record, sync})
221
301
 
222
- changed ||= hookChanged === true
302
+ changed ||= hookChanged === true
303
+ }
304
+ } finally {
305
+ if (releaseRecord) releaseRecord()
223
306
  }
224
307
 
225
308
  return {changed, resourceType}
@@ -227,16 +310,23 @@ export default class SyncApiClient {
227
310
 
228
311
  /**
229
312
  * Destroys a synced resource via its declared model policy.
230
- * @param {{resource: SyncResourceConfig, sync: SyncChangeEnvelope}} args - Destroy args.
313
+ * @param {{resource: SyncResourceConfig, sync: SyncChangeEnvelope, onRecord?: (record: ?) => () => void}} args - Destroy args.
231
314
  * @returns {Promise<boolean>} Whether a local row was destroyed.
232
315
  */
233
- static async destroySyncedResource({resource, sync}) {
316
+ static async destroySyncedResource({onRecord, resource, sync}) {
234
317
  const id = sync.resourceId()
235
318
  const record = resource.findRecordForDelete ? await resource.findRecordForDelete({resourceId: id, sync}) : await resource.modelClass.findBy({id})
236
319
 
237
320
  if (!record) return false
238
321
 
239
- await record.destroy()
322
+ const releaseRecord = onRecord ? onRecord(record) : null
323
+
324
+ try {
325
+ await record.destroy()
326
+ } finally {
327
+ if (releaseRecord) releaseRecord()
328
+ }
329
+
240
330
  return true
241
331
  }
242
332
 
@@ -255,6 +345,230 @@ export default class SyncApiClient {
255
345
  throw new Error(`Sync ${sync.id()} has invalid data`)
256
346
  }
257
347
 
348
+ /**
349
+ * Drains pending sync records from a local Velocious model in stable order.
350
+ * @param {object} args - Replay args.
351
+ * @param {string} args.authenticationToken - Auth token to send with replay requests.
352
+ * @param {number} [args.batchSize] - Max syncs per request.
353
+ * @param {?} args.syncModel - Local Sync model class.
354
+ * @param {(payload: {authenticationToken: string, syncs: Array<Record<string, ?>>}) => Promise<SyncReplayResponse>} args.postReplay - Replay poster.
355
+ * @returns {Promise<void>}
356
+ */
357
+ static async replayLocalSyncs(args) {
358
+ const postedSnapshotsBySyncId = new Map()
359
+
360
+ await this.replayPending({
361
+ authenticationToken: args.authenticationToken,
362
+ batchSize: args.batchSize,
363
+ markSuccessful: async (sync) => {
364
+ const syncId = (/** @type {{id: () => string | number | null | undefined}} */ (sync)).id()
365
+ // Reload with the resource preloaded so rows relying on the resource-attributes
366
+ // fallback in localSyncData compare against the same snapshot they posted.
367
+ const currentSync = await args.syncModel.preload({resource: true}).where({id: syncId}).first()
368
+
369
+ if (!currentSync) return
370
+ // A row edited while its old payload was in flight stays pending, so the
371
+ // newer local change replays on the next drain instead of being lost.
372
+ if (this.localSyncReplaySnapshot(currentSync) !== postedSnapshotsBySyncId.get(String(syncId))) return
373
+
374
+ await currentSync.update({state: "success"})
375
+ },
376
+ pendingSyncs: async () => await args.syncModel.preload({resource: true}).where({state: "pending"}).order("created_at").toArray(),
377
+ postReplay: args.postReplay,
378
+ syncId: (sync) => (/** @type {{id: () => string | number | null | undefined}} */ (sync)).id(),
379
+ syncPayload: (sync) => {
380
+ postedSnapshotsBySyncId.set(String((/** @type {{id: () => string | number | null | undefined}} */ (sync)).id()), this.localSyncReplaySnapshot(sync))
381
+
382
+ return this.localSyncPayload(sync)
383
+ }
384
+ })
385
+ }
386
+
387
+ /**
388
+ * Serializes the replay-relevant state of a local sync row for in-flight comparisons.
389
+ * @param {?} sync - Local sync row.
390
+ * @returns {string} Stable snapshot of the row's replayed payload.
391
+ */
392
+ static localSyncReplaySnapshot(sync) {
393
+ return JSON.stringify({data: this.localSyncData(sync), syncType: sync.syncType()})
394
+ }
395
+
396
+ /**
397
+ * Builds one replay envelope from a local sync row.
398
+ * @param {?} sync - Local sync row.
399
+ * @returns {{clientUpdatedAt?: string, data: Record<string, unknown>, id: number, resourceId: string, resourceType: string, syncType: string}} Sync replay envelope.
400
+ */
401
+ static localSyncPayload(sync) {
402
+ const clientUpdatedAt = sync.updatedAt() || sync.createdAt()
403
+
404
+ return {
405
+ clientUpdatedAt: clientUpdatedAt ? clientUpdatedAt.toISOString() : undefined,
406
+ data: this.localSyncData(sync),
407
+ id: /** @type {number} */ (/** @type {unknown} */ (sync.id())),
408
+ resourceId: String(sync.resourceId()),
409
+ resourceType: sync.resourceType() || "",
410
+ syncType: sync.syncType()
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Resolves one local sync row payload, falling back to preloaded resource attributes.
416
+ * @param {?} sync - Local sync row.
417
+ * @returns {Record<string, unknown>} Sync data.
418
+ */
419
+ static localSyncData(sync) {
420
+ let syncData = /** @type {string | Record<string, unknown>} */ (sync.data() || {})
421
+
422
+ if (typeof syncData === "string") {
423
+ try {
424
+ syncData = /** @type {Record<string, unknown>} */ (JSON.parse(syncData))
425
+ } catch (_error) {
426
+ syncData = {}
427
+ }
428
+ }
429
+
430
+ if (Object.keys(syncData).length > 0) return syncData
431
+
432
+ try {
433
+ return /** @type {Record<string, unknown>} */ (sync.resource().attributes())
434
+ } catch (_error) {
435
+ return {}
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Queues a local sync row for a Velocious model resource.
441
+ * @param {object} args - Queue args.
442
+ * @param {?} args.resource - Resource being synced.
443
+ * @param {?} args.syncModel - Local Sync model class.
444
+ * @param {Record<string, unknown>} [args.data] - Explicit sync data.
445
+ * @param {string} [args.syncType] - Sync operation type.
446
+ * @param {string[]} [args.localOnlyAttributes] - Attributes to strip from queued payloads.
447
+ * @param {string[]} [args.booleanAttributes] - Attributes to coerce through sync boolean parsing.
448
+ * @param {(data: Record<string, unknown>) => Record<string, unknown>} [args.normalizeData] - App-specific data normalizer.
449
+ * @returns {Promise<?>} Local sync row.
450
+ */
451
+ static async queueLocalSync(args) {
452
+ const resourceRecordId = args.resource.id()
453
+ const modelClass = args.resource.constructor
454
+
455
+ if (typeof modelClass.getModelName !== "function") {
456
+ throw new Error("The resource model class must implement static getModelName() to queue sync data - class names are not stable across explicit model names and minified bundles")
457
+ }
458
+
459
+ const resourceType = modelClass.getModelName()
460
+ const syncData = this.queuedSyncData(args)
461
+ const syncType = args.syncType || "update"
462
+
463
+ if (!resourceRecordId) throw new Error("resource.id() is required to queue sync data")
464
+
465
+ const resourceId = String(resourceRecordId)
466
+ const existingSync = await args.syncModel.findBy({resourceId, resourceType})
467
+
468
+ if (existingSync) {
469
+ await existingSync.update({
470
+ data: syncData,
471
+ state: "pending",
472
+ syncType
473
+ })
474
+
475
+ return existingSync
476
+ }
477
+
478
+ return await args.syncModel.create({
479
+ data: syncData,
480
+ resourceId,
481
+ resourceType,
482
+ state: "pending",
483
+ syncType
484
+ })
485
+ }
486
+
487
+ /**
488
+ * Builds backend-safe queued sync data without mutating caller data.
489
+ * @param {{resource: ?, data?: Record<string, unknown>, localOnlyAttributes?: string[], booleanAttributes?: string[], normalizeData?: (data: Record<string, unknown>) => Record<string, unknown>}} args - Data args.
490
+ * @returns {Record<string, unknown>} Queued data.
491
+ */
492
+ static queuedSyncData(args) {
493
+ const inputData = args.data ?? /** @type {Record<string, unknown>} */ (args.resource.attributes())
494
+ const normalizedData = args.normalizeData ? args.normalizeData(inputData) : inputData
495
+ const syncData = {...normalizedData}
496
+
497
+ for (const attributeName of args.localOnlyAttributes || []) delete syncData[attributeName]
498
+ for (const attributeName of args.booleanAttributes || []) {
499
+ if (Object.hasOwn(syncData, attributeName)) syncData[attributeName] = this.optionalBooleanSyncValue(syncData[attributeName], attributeName)
500
+ }
501
+
502
+ return syncData
503
+ }
504
+
505
+ /**
506
+ * Builds a small app-facing local sync queue facade from declarative model config.
507
+ * @param {object} args - Queue config.
508
+ * @param {?} args.syncModel - Local Sync model class.
509
+ * @param {string} args.singleFlightKey - Key used to serialize backend replay.
510
+ * @param {() => Promise<void>} args.syncPending - Backend replay callback.
511
+ * @param {(resource: ?) => string[]} [args.localOnlyAttributes] - Resource-specific local-only attributes.
512
+ * @param {(resource: ?) => string[]} [args.booleanAttributes] - Resource-specific SQLite boolean attributes.
513
+ * @returns {{queue: (queueArgs: {resource: ?, data?: Record<string, unknown>, syncType?: string}) => Promise<?>, syncPending: () => Promise<void>}} Configured local sync queue.
514
+ */
515
+ static localSyncQueue(args) {
516
+ return {
517
+ queue: async (queueArgs) => await this.queueLocalSync({
518
+ ...queueArgs,
519
+ booleanAttributes: args.booleanAttributes ? args.booleanAttributes(queueArgs.resource) : [],
520
+ localOnlyAttributes: args.localOnlyAttributes ? args.localOnlyAttributes(queueArgs.resource) : [],
521
+ syncModel: args.syncModel
522
+ }),
523
+ syncPending: async () => await this.singleFlight(args.singleFlightKey, args.syncPending)
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Parses booleans commonly used by SQLite/offline sync payloads.
529
+ * @param {unknown} value - Sync decision value.
530
+ * @param {string} [description] - Error context.
531
+ * @returns {boolean | null} Parsed boolean-like backend/local value.
532
+ */
533
+ static optionalBooleanSyncValue(value, description = "sync boolean") {
534
+ if (value == null) return null
535
+ if (value === 1) return true
536
+ if (value === 0) return false
537
+
538
+ return optionalBoolean(value, description)
539
+ }
540
+
541
+ /**
542
+ * Converts a boolean sync value to SQLite boolean storage.
543
+ * @param {boolean | null} value - Sync boolean value.
544
+ * @returns {0 | 1} SQLite-compatible boolean value.
545
+ */
546
+ static sqliteBooleanSyncValue(value) {
547
+ return value === true ? 1 : 0
548
+ }
549
+
550
+ /**
551
+ * Projects generic sync counters into app-specific result keys.
552
+ * @param {object} args - Result args.
553
+ * @param {SyncChangesResult} args.result - Generic Velocious sync result.
554
+ * @param {Record<string, {changedKey: string, countKey: string}>} args.resources - Resource result key map.
555
+ * @returns {Record<string, unknown>} Projected result.
556
+ */
557
+ static syncResultForResources({result, resources}) {
558
+ const syncResult = /** @type {Record<string, unknown>} */ ({
559
+ changed: result.changed,
560
+ pages: result.pages,
561
+ syncedCount: result.syncedCount
562
+ })
563
+
564
+ for (const [resourceType, keys] of Object.entries(resources)) {
565
+ syncResult[keys.countKey] = result.resourceCounts[resourceType] || 0
566
+ syncResult[keys.changedKey] = result.resourceChanged[resourceType] || false
567
+ }
568
+
569
+ return syncResult
570
+ }
571
+
258
572
  /**
259
573
  * Drains pending sync records in stable order and marks acknowledged rows.
260
574
  * @param {object} args - Replay args.
@@ -3,6 +3,9 @@
3
3
  import Controller from "../controller.js"
4
4
  import FrontendModelBaseResource from "../frontend-model-resource/base-resource.js"
5
5
 
6
+ /** Configurations whose sync.api routes have already been mounted. */
7
+ const mountedConfigurations = new WeakSet()
8
+
6
9
  /**
7
10
  * Generic `/velocious/sync` transport controller.
8
11
  *
@@ -99,6 +102,23 @@ export default class SyncApiController extends Controller {
99
102
  })
100
103
  }
101
104
 
105
+ /**
106
+ * Auto-mounts the sync endpoints configured through `sync.api` on a configuration.
107
+ * No-op when `sync.api` is absent; guarded so repeated server boots with the
108
+ * same configuration register the routes only once.
109
+ * @param {import("../configuration.js").default} configuration - Configuration instance.
110
+ * @returns {void}
111
+ */
112
+ static mountFromConfiguration(configuration) {
113
+ const api = configuration.getSyncConfiguration().api
114
+
115
+ if (!api || mountedConfigurations.has(configuration)) return
116
+
117
+ mountedConfigurations.add(configuration)
118
+
119
+ this.mountInto({at: api.mountPath, configuration, syncResourceClass: api.resourceClass})
120
+ }
121
+
102
122
  /**
103
123
  * Normalizes a sync mount path.
104
124
  * @param {string} at - Mount path.
@@ -0,0 +1,30 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Registry for the app's current sync client.
5
+ *
6
+ * Kept dependency-free so client-bundled modules (like the query layer) can
7
+ * delegate to a configured sync client without importing the sync stack.
8
+ */
9
+
10
+ /** @type {?} */
11
+ let currentClient = null
12
+
13
+ /**
14
+ * Registers the current sync client.
15
+ * @param {?} client - Configured sync client (or null to clear).
16
+ * @returns {void}
17
+ */
18
+ export function setCurrentSyncClient(client) {
19
+ currentClient = client
20
+ }
21
+
22
+ /**
23
+ * Returns the current sync client.
24
+ * @returns {?} Current sync client.
25
+ */
26
+ export function currentSyncClient() {
27
+ if (!currentClient) throw new Error("No sync client configured - create a SyncClient and call setCurrent() on it first")
28
+
29
+ return currentClient
30
+ }
@@ -0,0 +1,41 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Client-declared sync scope serialized from a model query.
5
+ * @typedef {object} SerializedSyncScope
6
+ * @property {Record<string, ?>} conditions - Plain attribute conditions from the query.
7
+ * @property {string} resourceType - Resource/model name the scope was declared for.
8
+ */
9
+
10
+ /**
11
+ * Declarative per-resource sync policy.
12
+ * @typedef {object} SyncClientResourceConfig
13
+ * @property {?} modelClass - Local model class for this resource.
14
+ * @property {import("./sync-api-client-types.js").SyncResourceConfig["attributes"]} [attributes] - Pull-apply attribute mapper. Required for resources that receive pulled changes.
15
+ * @property {import("./sync-api-client-types.js").SyncResourceConfig["findRecord"]} [findRecord] - Custom pull-apply record resolver.
16
+ * @property {import("./sync-api-client-types.js").SyncResourceConfig["findRecordForDelete"]} [findRecordForDelete] - Custom pull-apply delete resolver.
17
+ * @property {import("./sync-api-client-types.js").SyncResourceConfig["afterApply"]} [afterApply] - Post-apply hook.
18
+ * @property {string[]} [booleanAttributes] - Attributes coerced through sync boolean parsing when queueing.
19
+ * @property {string[]} [localOnlyAttributes] - Attributes stripped from queued payloads.
20
+ * @property {(args: {operation: "create" | "update" | "destroy", record: ?}) => string} [syncType] - Maps a mutation operation to a sync type. Defaults to the operation name with destroy mapped to "delete".
21
+ * @property {(args: {operation: "create" | "update" | "destroy", record: ?}) => Record<string, ?>} [trackedData] - Custom queued-payload builder for tracked mutations.
22
+ * @property {boolean | {operations: Array<"create" | "update" | "destroy">}} [track] - Enables automatic mutation tracking through model lifecycle callbacks.
23
+ */
24
+
25
+ /**
26
+ * Declarative sync client configuration.
27
+ * @typedef {object} SyncClientConfig
28
+ * @property {() => string | Promise<string>} authenticationToken - Resolves the auth token sent with sync requests.
29
+ * @property {number} [batchSize] - Max syncs per request.
30
+ * @property {import("../configuration.js").default} [configuration] - Configuration owning the scope-store database. Defaults to the current configuration.
31
+ * @property {() => boolean | Promise<boolean>} [isOnline] - Connectivity gate for pulls and replays. Defaults to always online.
32
+ * @property {(args: {scope: SerializedSyncScope}) => string | null | Promise<string | null>} [legacyCursor] - Seeds a newly declared scope's cursor (e.g. from a pre-scope cursor store) so devices don't re-pull everything.
33
+ * @property {(error: Error) => void} [onError] - Reports background replay/pull failures. Defaults to rethrowing.
34
+ * @property {(payload: import("./sync-api-client-types.js").SyncChangesRequest & {scope: SerializedSyncScope}) => Promise<import("./sync-api-client-types.js").SyncChangesResponse>} postChanges - Posts one changes request.
35
+ * @property {(payload: {authenticationToken: string, syncs: Array<Record<string, ?>>}) => Promise<import("./sync-api-client-types.js").SyncReplayResponse>} postReplay - Posts one replay request.
36
+ * @property {Record<string, SyncClientResourceConfig>} resources - Declarative resource policies keyed by resource/model name.
37
+ * @property {import("./sync-scope-store.js").default} [scopeStore] - Scope store override.
38
+ * @property {?} syncModel - Local pending-sync model class.
39
+ */
40
+
41
+ export {}