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