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.
- package/README.md +2 -0
- package/build/application.js +3 -0
- package/build/cli/commands/db/tenants/create.js +7 -5
- package/build/cli/commands/db/tenants/drop.js +6 -6
- package/build/configuration-types.js +9 -0
- package/build/configuration.js +58 -0
- package/build/database/query/index.js +30 -0
- package/build/database/query/model-class-query.js +17 -0
- package/build/database/record/index.js +16 -0
- package/build/environment-handlers/node.js +22 -6
- package/build/src/application.d.ts.map +1 -1
- package/build/src/application.js +3 -1
- package/build/src/cli/commands/db/tenants/create.d.ts.map +1 -1
- package/build/src/cli/commands/db/tenants/create.js +8 -5
- package/build/src/cli/commands/db/tenants/drop.d.ts +1 -1
- package/build/src/cli/commands/db/tenants/drop.d.ts.map +1 -1
- package/build/src/cli/commands/db/tenants/drop.js +7 -6
- package/build/src/configuration-types.d.ts +29 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +9 -1
- package/build/src/configuration.d.ts +17 -0
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +52 -1
- package/build/src/database/query/index.d.ts +25 -0
- package/build/src/database/query/index.d.ts.map +1 -1
- package/build/src/database/query/index.js +26 -1
- package/build/src/database/query/model-class-query.d.ts +10 -0
- package/build/src/database/query/model-class-query.d.ts.map +1 -1
- package/build/src/database/query/model-class-query.js +16 -1
- package/build/src/database/record/index.d.ts +7 -0
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +15 -1
- package/build/src/environment-handlers/node.d.ts.map +1 -1
- package/build/src/environment-handlers/node.js +20 -8
- package/build/src/sync/query-scope.d.ts +18 -0
- package/build/src/sync/query-scope.d.ts.map +1 -0
- package/build/src/sync/query-scope.js +77 -0
- package/build/src/sync/server-change-feed.d.ts.map +1 -1
- package/build/src/sync/server-change-feed.js +2 -24
- package/build/src/sync/stable-json.d.ts +9 -0
- package/build/src/sync/stable-json.d.ts.map +1 -0
- package/build/src/sync/stable-json.js +27 -0
- package/build/src/sync/sync-api-client.d.ts +185 -5
- package/build/src/sync/sync-api-client.d.ts.map +1 -1
- package/build/src/sync/sync-api-client.js +309 -17
- package/build/src/sync/sync-api-controller.d.ts +8 -0
- package/build/src/sync/sync-api-controller.d.ts.map +1 -1
- package/build/src/sync/sync-api-controller.js +17 -1
- package/build/src/sync/sync-client-registry.d.ts +12 -0
- package/build/src/sync/sync-client-registry.d.ts.map +1 -0
- package/build/src/sync/sync-client-registry.js +27 -0
- package/build/src/sync/sync-client-types.d.ts +127 -0
- package/build/src/sync/sync-client-types.d.ts.map +1 -0
- package/build/src/sync/sync-client-types.js +3 -0
- package/build/src/sync/sync-client.d.ts +176 -0
- package/build/src/sync/sync-client.d.ts.map +1 -0
- package/build/src/sync/sync-client.js +380 -0
- package/build/src/sync/sync-envelope-replay-service.d.ts +38 -4
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +73 -6
- package/build/src/sync/sync-resource-base.d.ts +98 -0
- package/build/src/sync/sync-resource-base.d.ts.map +1 -0
- package/build/src/sync/sync-resource-base.js +122 -0
- package/build/src/sync/sync-scope-store.d.ts +128 -0
- package/build/src/sync/sync-scope-store.d.ts.map +1 -0
- package/build/src/sync/sync-scope-store.js +282 -0
- package/build/src/tenants/default-tenant-database-provisioning.d.ts +21 -0
- package/build/src/tenants/default-tenant-database-provisioning.d.ts.map +1 -0
- package/build/src/tenants/default-tenant-database-provisioning.js +106 -0
- package/build/src/tenants/tenant.d.ts.map +1 -1
- package/build/src/tenants/tenant.js +6 -5
- package/build/sync/query-scope.js +84 -0
- package/build/sync/server-change-feed.js +1 -25
- package/build/sync/stable-json.js +28 -0
- package/build/sync/sync-api-client.js +331 -17
- package/build/sync/sync-api-controller.js +20 -0
- package/build/sync/sync-client-registry.js +30 -0
- package/build/sync/sync-client-types.js +41 -0
- package/build/sync/sync-client.js +433 -0
- package/build/sync/sync-envelope-replay-service.js +77 -5
- package/build/sync/sync-resource-base.js +143 -0
- package/build/sync/sync-scope-store.js +327 -0
- package/build/tenants/default-tenant-database-provisioning.js +124 -0
- package/build/tenants/tenant.js +5 -5
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/application.js +3 -0
- package/src/cli/commands/db/tenants/create.js +7 -5
- package/src/cli/commands/db/tenants/drop.js +6 -6
- package/src/configuration-types.js +9 -0
- package/src/configuration.js +58 -0
- package/src/database/query/index.js +30 -0
- package/src/database/query/model-class-query.js +17 -0
- package/src/database/record/index.js +16 -0
- package/src/environment-handlers/node.js +22 -6
- package/src/sync/query-scope.js +84 -0
- package/src/sync/server-change-feed.js +1 -25
- package/src/sync/stable-json.js +28 -0
- package/src/sync/sync-api-client.js +331 -17
- package/src/sync/sync-api-controller.js +20 -0
- package/src/sync/sync-client-registry.js +30 -0
- package/src/sync/sync-client-types.js +41 -0
- package/src/sync/sync-client.js +433 -0
- package/src/sync/sync-envelope-replay-service.js +77 -5
- package/src/sync/sync-resource-base.js +143 -0
- package/src/sync/sync-scope-store.js +327 -0
- package/src/tenants/default-tenant-database-provisioning.js +124 -0
- 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
|
-
|
|
291
|
+
try {
|
|
292
|
+
record.assign(attributes)
|
|
213
293
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
294
|
+
if (record.isChanged()) {
|
|
295
|
+
await record.save()
|
|
296
|
+
changed = true
|
|
297
|
+
}
|
|
218
298
|
|
|
219
|
-
|
|
220
|
-
|
|
299
|
+
if (resource.afterApply) {
|
|
300
|
+
const hookChanged = await resource.afterApply({attributes, data, record, sync})
|
|
221
301
|
|
|
222
|
-
|
|
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
|
-
|
|
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 {}
|