velocious 1.0.500 → 1.0.502
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/configuration-types.js +39 -0
- package/build/configuration.js +3 -2
- package/build/src/configuration-types.d.ts +123 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +36 -1
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +4 -3
- package/build/src/sync/sync-client-types.d.ts +29 -2
- package/build/src/sync/sync-client-types.d.ts.map +1 -1
- package/build/src/sync/sync-client-types.js +1 -1
- package/build/src/sync/sync-client.d.ts +113 -4
- package/build/src/sync/sync-client.d.ts.map +1 -1
- package/build/src/sync/sync-client.js +185 -31
- package/build/src/sync/sync-realtime-bridge.d.ts +125 -0
- package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -0
- package/build/src/sync/sync-realtime-bridge.js +262 -0
- package/build/sync/sync-client-types.js +14 -2
- package/build/sync/sync-client.js +204 -33
- package/build/sync/sync-realtime-bridge.js +299 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/configuration-types.js +39 -0
- package/src/configuration.js +3 -2
- package/src/sync/sync-client-types.js +14 -2
- package/src/sync/sync-client.js +204 -33
- package/src/sync/sync-realtime-bridge.js +299 -0
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import Configuration from "../configuration.js"
|
|
4
4
|
import {isBooleanColumnType} from "../database/column-types.js"
|
|
5
|
+
import Logger from "../logger.js"
|
|
5
6
|
import restArgsError from "../utils/rest-args-error.js"
|
|
6
7
|
|
|
7
8
|
import {serializedScopeFromQuery} from "./query-scope.js"
|
|
8
9
|
import SyncApiClient from "./sync-api-client.js"
|
|
10
|
+
import SyncRealtimeBridge from "./sync-realtime-bridge.js"
|
|
9
11
|
import SyncScopeStore from "./sync-scope-store.js"
|
|
10
12
|
import {currentSyncClient, setCurrentSyncClient} from "./sync-client-registry.js"
|
|
11
13
|
|
|
@@ -14,6 +16,14 @@ let clientCounter = 0
|
|
|
14
16
|
/** @type {{create: "afterCreate", update: "afterUpdate", destroy: "afterDestroy"}} */
|
|
15
17
|
const TRACKED_CALLBACK_NAMES = {create: "afterCreate", destroy: "afterDestroy", update: "afterUpdate"}
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Operations tracked by default for models declaring `static sync` without a
|
|
21
|
+
* `track` key: local creates and updates queue automatically. Destroys are not
|
|
22
|
+
* tracked by default because a local destroy is often cache eviction rather
|
|
23
|
+
* than a server delete; opt in with `track: true` or an operations list.
|
|
24
|
+
* @type {Array<"create" | "update" | "destroy">} */
|
|
25
|
+
const DEFAULT_TRACKED_OPERATIONS = ["create", "update"]
|
|
26
|
+
|
|
17
27
|
/** Attribute names treated as client-local sync bookkeeping when deriving localOnlyAttributes. */
|
|
18
28
|
const LOCAL_BOOKKEEPING_ATTRIBUTE_NAMES = ["createdAt", "updatedAt", "lastSyncChangeAt"]
|
|
19
29
|
|
|
@@ -87,10 +97,13 @@ export default class SyncClient {
|
|
|
87
97
|
onError: clientConfiguration.onError,
|
|
88
98
|
postChanges: transportPoster({path: `${clientConfiguration.mountPath}/changes`, transport: clientConfiguration.transport}),
|
|
89
99
|
postReplay: transportPoster({path: `${clientConfiguration.mountPath}/replay`, transport: clientConfiguration.transport}),
|
|
100
|
+
realtime: clientConfiguration.realtime,
|
|
90
101
|
resources,
|
|
91
102
|
syncModel: resolvedSyncModel
|
|
92
103
|
}
|
|
93
104
|
this._clientNumber = ++clientCounter
|
|
105
|
+
/** @type {SyncRealtimeBridge | null} */
|
|
106
|
+
this._realtimeBridge = null
|
|
94
107
|
/** @type {import("./sync-scope-store.js").default | null} */
|
|
95
108
|
this._scopeStore = scopeStore || null
|
|
96
109
|
/** @type {Promise<void> | null} */
|
|
@@ -101,13 +114,18 @@ export default class SyncClient {
|
|
|
101
114
|
this._trackedCallbacks = []
|
|
102
115
|
/** @type {WeakSet<object>} */
|
|
103
116
|
this._remoteApplyRecords = new WeakSet()
|
|
117
|
+
this._withoutTrackingDepth = 0
|
|
118
|
+
/** @type {Logger | {error: (...messages: Array<?>) => Promise<void>} | null} */
|
|
119
|
+
this._logger = null
|
|
104
120
|
this._started = false
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
/**
|
|
108
|
-
* Registers automatic mutation tracking for every resource
|
|
109
|
-
* local
|
|
110
|
-
* immediate replay attempt, without
|
|
124
|
+
* Registers automatic mutation tracking for every declared resource (on by
|
|
125
|
+
* default: local creates and updates queue pending sync rows once their
|
|
126
|
+
* transaction commits and schedule an immediate replay attempt, without
|
|
127
|
+
* app-side queue calls). `track: false` resources are skipped; `track: true`
|
|
128
|
+
* adds destroys; an operations list narrows the tracked operations.
|
|
111
129
|
* @returns {Promise<void>}
|
|
112
130
|
*/
|
|
113
131
|
async start() {
|
|
@@ -143,13 +161,17 @@ export default class SyncClient {
|
|
|
143
161
|
|
|
144
162
|
/**
|
|
145
163
|
* Resolves and validates the tracked operations for a resource config.
|
|
164
|
+
* Tracking is on by default: models declaring `static sync` without a `track`
|
|
165
|
+
* key queue local creates and updates automatically; `track: false` opts a
|
|
166
|
+
* model out (for models written by non-user flows).
|
|
146
167
|
* @param {{resourceConfig: import("./sync-client-types.js").SyncClientResourceConfig, resourceType: string}} args - Resource config and name.
|
|
147
168
|
* @returns {Array<"create" | "update" | "destroy">} Tracked operations.
|
|
148
169
|
*/
|
|
149
170
|
trackedOperations({resourceConfig, resourceType}) {
|
|
150
171
|
const track = resourceConfig.track
|
|
151
172
|
|
|
152
|
-
if (track ===
|
|
173
|
+
if (track === false) return []
|
|
174
|
+
if (track === undefined) return DEFAULT_TRACKED_OPERATIONS
|
|
153
175
|
if (track === true) return ["create", "update", "destroy"]
|
|
154
176
|
|
|
155
177
|
if (!track || typeof track !== "object" || !Array.isArray(track.operations) || track.operations.length === 0) {
|
|
@@ -166,27 +188,78 @@ export default class SyncClient {
|
|
|
166
188
|
}
|
|
167
189
|
|
|
168
190
|
/**
|
|
169
|
-
* Builds the lifecycle callback queueing one tracked mutation.
|
|
191
|
+
* Builds the lifecycle callback queueing one tracked mutation. The queued
|
|
192
|
+
* payload and sync type are snapshotted at mutation-callback time, so
|
|
193
|
+
* afterSave hooks assigning unsaved attributes (or any later drift on the
|
|
194
|
+
* record) cannot change what gets queued vs what was committed. Queueing is
|
|
195
|
+
* deferred through the model connection's afterCommit hook so it only runs
|
|
196
|
+
* once the mutation's transaction has committed (immediately when no
|
|
197
|
+
* transaction is open) - queued syncs never reference rolled-back rows.
|
|
198
|
+
* Post-commit queue failures are reported without rethrowing into the
|
|
199
|
+
* driver's afterCommit chain (see reportAfterCommitError).
|
|
170
200
|
* @param {{operation: "create" | "update" | "destroy", resourceConfig: import("./sync-client-types.js").SyncClientResourceConfig}} args - Operation and resource config.
|
|
171
201
|
* @returns {(record: ?) => Promise<void>} Lifecycle callback.
|
|
172
202
|
*/
|
|
173
203
|
trackedMutationCallback({operation, resourceConfig}) {
|
|
174
204
|
return async (record) => {
|
|
175
|
-
if (this.
|
|
205
|
+
if (this.isTrackingSuppressed(record)) return
|
|
176
206
|
|
|
177
|
-
|
|
207
|
+
const data = SyncApiClient.queuedSyncData({
|
|
178
208
|
booleanAttributes: resourceConfig.booleanAttributes || [],
|
|
179
209
|
data: resourceConfig.trackedData ? resourceConfig.trackedData({operation, record}) : undefined,
|
|
180
210
|
localOnlyAttributes: resourceConfig.localOnlyAttributes || [],
|
|
181
|
-
resource: record
|
|
182
|
-
syncModel: this.config.syncModel,
|
|
183
|
-
syncType: this.defaultSyncType({operation, record, resourceConfig})
|
|
211
|
+
resource: record
|
|
184
212
|
})
|
|
213
|
+
const syncType = this.defaultSyncType({operation, record, resourceConfig})
|
|
214
|
+
|
|
215
|
+
await resourceConfig.modelClass.connection().afterCommit(async () => {
|
|
216
|
+
try {
|
|
217
|
+
await SyncApiClient.queueLocalSync({
|
|
218
|
+
data,
|
|
219
|
+
resource: record,
|
|
220
|
+
syncModel: this.config.syncModel,
|
|
221
|
+
syncType
|
|
222
|
+
})
|
|
223
|
+
} catch (error) {
|
|
224
|
+
await this.reportAfterCommitError(/** @type {Error} */ (error))
|
|
225
|
+
|
|
226
|
+
return
|
|
227
|
+
}
|
|
185
228
|
|
|
186
|
-
|
|
229
|
+
this.scheduleReplay()
|
|
230
|
+
})
|
|
187
231
|
}
|
|
188
232
|
}
|
|
189
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Reports a post-commit tracked-queueing failure. The transaction has already
|
|
236
|
+
* committed when afterCommit callbacks run, so rethrowing here would poison
|
|
237
|
+
* the driver's awaited afterCommit chain (breaking unrelated callbacks) -
|
|
238
|
+
* instead the failure goes to the configured sync.client.onError hook, or is
|
|
239
|
+
* logged loudly through the client's logger when none is configured.
|
|
240
|
+
* @param {Error} error - Post-commit queueing failure.
|
|
241
|
+
* @returns {Promise<void>}
|
|
242
|
+
*/
|
|
243
|
+
async reportAfterCommitError(error) {
|
|
244
|
+
if (this.config.onError) {
|
|
245
|
+
this.config.onError(error)
|
|
246
|
+
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
await this.logger().error("SyncClient failed to queue a tracked mutation after commit", error)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Returns the lazily built client logger.
|
|
255
|
+
* @returns {Logger | {error: (...messages: Array<?>) => Promise<void>}} Client logger.
|
|
256
|
+
*/
|
|
257
|
+
logger() {
|
|
258
|
+
this._logger ||= new Logger("SyncClient", {configuration: this.config.configuration})
|
|
259
|
+
|
|
260
|
+
return this._logger
|
|
261
|
+
}
|
|
262
|
+
|
|
190
263
|
/**
|
|
191
264
|
* Whether a record is currently being written by pull-apply (echo suppression).
|
|
192
265
|
* @param {?} record - Local model record.
|
|
@@ -196,6 +269,54 @@ export default class SyncClient {
|
|
|
196
269
|
return this._remoteApplyRecords.has(record)
|
|
197
270
|
}
|
|
198
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Whether tracked mutation queueing is currently suppressed for a record:
|
|
274
|
+
* either the record was marked as a remote apply (`markRemoteApply`, used by
|
|
275
|
+
* pull and realtime applies) or a `withoutTracking` callback is running on
|
|
276
|
+
* this client.
|
|
277
|
+
* @param {?} record - Local model record.
|
|
278
|
+
* @returns {boolean} Whether tracked queueing is suppressed for the record.
|
|
279
|
+
*/
|
|
280
|
+
isTrackingSuppressed(record) {
|
|
281
|
+
return this._withoutTrackingDepth > 0 || this.isRemoteApply(record)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Runs a callback with tracked mutation queueing suppressed on this client -
|
|
286
|
+
* for code applying server-originated data outside the derived pull/realtime
|
|
287
|
+
* appliers (legacy pull paths, importers, sign-in backfills), so their writes
|
|
288
|
+
* are not echoed back to the server as device changes. Suppression covers the
|
|
289
|
+
* whole async duration of the callback (nested calls stack) and is
|
|
290
|
+
* client-wide while it runs: mutations from concurrently running tasks are
|
|
291
|
+
* also suppressed for that window, so prefer `markRemoteApply(record)` when
|
|
292
|
+
* writes from other flows can interleave.
|
|
293
|
+
* @template T
|
|
294
|
+
* @param {() => Promise<T> | T} callback - Work whose model writes should not queue tracked syncs.
|
|
295
|
+
* @returns {Promise<T>} The callback result.
|
|
296
|
+
*/
|
|
297
|
+
async withoutTracking(callback) {
|
|
298
|
+
this._withoutTrackingDepth++
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
return await callback()
|
|
302
|
+
} finally {
|
|
303
|
+
this._withoutTrackingDepth--
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Marks one record as being written from server-originated data so tracked
|
|
309
|
+
* mutation queueing skips it (record-precise suppression). The derived pull
|
|
310
|
+
* and realtime appliers use this internally around every applied write.
|
|
311
|
+
* @param {?} record - Local model record about to be written.
|
|
312
|
+
* @returns {() => void} Release callback re-enabling tracking for the record.
|
|
313
|
+
*/
|
|
314
|
+
markRemoteApply(record) {
|
|
315
|
+
this._remoteApplyRecords.add(record)
|
|
316
|
+
|
|
317
|
+
return () => this._remoteApplyRecords.delete(record)
|
|
318
|
+
}
|
|
319
|
+
|
|
199
320
|
/**
|
|
200
321
|
* Registers this client as the app's current sync client.
|
|
201
322
|
* @returns {void}
|
|
@@ -265,26 +386,7 @@ export default class SyncClient {
|
|
|
265
386
|
await SyncApiClient.singleFlight(`velocious-sync-client-pull-${this._clientNumber}`, async () => {
|
|
266
387
|
const authenticationToken = await this.config.authenticationToken()
|
|
267
388
|
const scopeStore = this.scopeStore()
|
|
268
|
-
const
|
|
269
|
-
const applier = SyncApiClient.resourceApplier(pullResourceConfigs, (record) => {
|
|
270
|
-
this._remoteApplyRecords.add(record)
|
|
271
|
-
|
|
272
|
-
return () => this._remoteApplyRecords.delete(record)
|
|
273
|
-
})
|
|
274
|
-
/**
|
|
275
|
-
* Applies one pulled change, failing loudly instead of silently skipping unconfigured resources.
|
|
276
|
-
* @param {import("./sync-api-client-types.js").SyncChangeEnvelope} sync - Pulled change.
|
|
277
|
-
* @returns {Promise<import("./sync-api-client-types.js").SyncChangeApplyResult>} Apply result.
|
|
278
|
-
*/
|
|
279
|
-
const applySync = async (sync) => {
|
|
280
|
-
const resourceType = sync.resourceType()
|
|
281
|
-
|
|
282
|
-
if (!resourceType || !pullResourceConfigs[resourceType]) {
|
|
283
|
-
throw new Error(`No sync resource with pull attributes configured for pulled change: ${String(resourceType)}`)
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
return await applier(sync)
|
|
287
|
-
}
|
|
389
|
+
const applySync = this.remoteApplySync()
|
|
288
390
|
const result = {
|
|
289
391
|
changed: false,
|
|
290
392
|
pages: 0,
|
|
@@ -324,6 +426,74 @@ export default class SyncClient {
|
|
|
324
426
|
return combinedResult
|
|
325
427
|
}
|
|
326
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Builds the derived remote-change applier shared by pulls and realtime pushes:
|
|
431
|
+
* applies through the declared resource configs, registers each written record
|
|
432
|
+
* for echo suppression (tracked resources do not re-queue applied changes), and
|
|
433
|
+
* fails loudly instead of silently skipping unconfigured resources.
|
|
434
|
+
* @param {{source?: string}} [args] - Error context describing where the change came from.
|
|
435
|
+
* @returns {(sync: import("./sync-api-client-types.js").SyncChangeEnvelope) => Promise<import("./sync-api-client-types.js").SyncChangeApplyResult>} Loud remote-change applier.
|
|
436
|
+
*/
|
|
437
|
+
remoteApplySync({source = "pulled change"} = {}) {
|
|
438
|
+
const pullResourceConfigs = this.pullResourceConfigs()
|
|
439
|
+
const applier = SyncApiClient.resourceApplier(pullResourceConfigs, (record) => this.markRemoteApply(record))
|
|
440
|
+
|
|
441
|
+
return async (sync) => {
|
|
442
|
+
const resourceType = sync.resourceType()
|
|
443
|
+
|
|
444
|
+
if (!resourceType || !pullResourceConfigs[resourceType]) {
|
|
445
|
+
throw new Error(`No sync resource with pull attributes configured for ${source}: ${String(resourceType)}`)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return await applier(sync)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Subscribes the derived realtime channels so pushed websocket changes apply
|
|
454
|
+
* through the same derived applier as pulls (idempotent, single-flighted).
|
|
455
|
+
* @param {?} [context] - App context passed to the `sync.client.realtime.channels` callback (runtime values like eventId).
|
|
456
|
+
* @returns {Promise<void>}
|
|
457
|
+
*/
|
|
458
|
+
async subscribeRealtime(context) {
|
|
459
|
+
await this.realtimeBridge().subscribe(context)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Unsubscribes the realtime channels and disconnects the websocket client (idempotent).
|
|
464
|
+
* @returns {Promise<void>}
|
|
465
|
+
*/
|
|
466
|
+
async unsubscribeRealtime() {
|
|
467
|
+
await this.realtimeBridge().unsubscribe()
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Reports the realtime subscription state and per-channel readiness.
|
|
472
|
+
* @returns {ReturnType<SyncRealtimeBridge["status"]>} Realtime status.
|
|
473
|
+
*/
|
|
474
|
+
realtimeStatus() {
|
|
475
|
+
return this.realtimeBridge().status()
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Awaits all pending realtime message applies and any scheduled
|
|
480
|
+
* pull-on-reconnect (useful in tests and shutdown flows).
|
|
481
|
+
* @returns {Promise<void>}
|
|
482
|
+
*/
|
|
483
|
+
async waitForRealtimeApplied() {
|
|
484
|
+
await this.realtimeBridge().waitForApplied()
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Returns the lazily built realtime bridge.
|
|
489
|
+
* @returns {SyncRealtimeBridge} Realtime bridge.
|
|
490
|
+
*/
|
|
491
|
+
realtimeBridge() {
|
|
492
|
+
this._realtimeBridge ||= new SyncRealtimeBridge({syncClient: this})
|
|
493
|
+
|
|
494
|
+
return this._realtimeBridge
|
|
495
|
+
}
|
|
496
|
+
|
|
327
497
|
/**
|
|
328
498
|
* Queues a local model change as a pending sync row and schedules an immediate
|
|
329
499
|
* replay attempt (kept pending while offline or when the backend rejects it).
|
|
@@ -491,11 +661,11 @@ function resourceConfigFromSyncDeclaration({declaration, modelClass, resourceTyp
|
|
|
491
661
|
throw new Error(`${resourceType} static sync must be true or a sync declaration object, got: ${String(declaration)}`)
|
|
492
662
|
}
|
|
493
663
|
|
|
494
|
-
const {afterApply, attributes, booleanAttributes, findRecord, findRecordForDelete, localOnlyAttributes, syncType, track, trackedData, ...restDeclaration} = normalizedDeclaration
|
|
664
|
+
const {afterApply, attributes, booleanAttributes, findRecord, findRecordForDelete, localOnlyAttributes, realtime, syncType, track, trackedData, ...restDeclaration} = normalizedDeclaration
|
|
495
665
|
const unknownKeys = Object.keys(restDeclaration)
|
|
496
666
|
|
|
497
667
|
if (unknownKeys.length > 0) {
|
|
498
|
-
throw new Error(`${resourceType} static sync received unknown keys: ${unknownKeys.join(", ")} (supported: afterApply, attributes, booleanAttributes, findRecord, findRecordForDelete, localOnlyAttributes, syncType, track, trackedData)`)
|
|
668
|
+
throw new Error(`${resourceType} static sync received unknown keys: ${unknownKeys.join(", ")} (supported: afterApply, attributes, booleanAttributes, findRecord, findRecordForDelete, localOnlyAttributes, realtime, syncType, track, trackedData)`)
|
|
499
669
|
}
|
|
500
670
|
if (syncType !== undefined && typeof syncType !== "function" && syncType !== "upsert") {
|
|
501
671
|
throw new Error(`${resourceType} static sync syncType must be a function or the string "upsert", got: ${String(syncType)}`)
|
|
@@ -511,6 +681,7 @@ function resourceConfigFromSyncDeclaration({declaration, modelClass, resourceTyp
|
|
|
511
681
|
findRecordForDelete,
|
|
512
682
|
localOnlyAttributes: mergedAttributeNames(derived.localOnlyAttributes, localOnlyAttributes),
|
|
513
683
|
modelClass,
|
|
684
|
+
realtime,
|
|
514
685
|
syncType,
|
|
515
686
|
track: normalizedTrack(track),
|
|
516
687
|
trackedData
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import SyncApiClient from "./sync-api-client.js"
|
|
4
|
+
|
|
5
|
+
/** @typedef {import("../configuration-types.js").VelociousSyncRealtimeChannelDescriptor} VelociousSyncRealtimeChannelDescriptor */
|
|
6
|
+
/** @typedef {import("../configuration-types.js").VelociousSyncRealtimeWebsocketClient} VelociousSyncRealtimeWebsocketClient */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Derived realtime push bridge for the sync client. Subscribes the declared
|
|
10
|
+
* websocket channels (config `sync.client.realtime.channels` callback plus
|
|
11
|
+
* model-level `static sync = {realtime: {channel}}` declarations), applies
|
|
12
|
+
* pushed changes through the same derived resource applier as pulls (with echo
|
|
13
|
+
* suppression against tracked re-queueing), drops own-device messages by echo
|
|
14
|
+
* origin, and fires a coalesced `pull()` when subscriptions become ready or
|
|
15
|
+
* resume so offline gaps close.
|
|
16
|
+
*/
|
|
17
|
+
export default class SyncRealtimeBridge {
|
|
18
|
+
/**
|
|
19
|
+
* Builds the bridge for a sync client.
|
|
20
|
+
* @param {{syncClient: import("./sync-client.js").default}} args - Bridge args.
|
|
21
|
+
*/
|
|
22
|
+
constructor({syncClient}) {
|
|
23
|
+
this.syncClient = syncClient
|
|
24
|
+
/** @type {Array<{channel: string, resourceType: string | null, subscription: import("../configuration-types.js").VelociousSyncRealtimeSubscription}>} */
|
|
25
|
+
this._channels = []
|
|
26
|
+
/** @type {VelociousSyncRealtimeWebsocketClient | null} */
|
|
27
|
+
this._client = null
|
|
28
|
+
/** @type {Promise<void>} */
|
|
29
|
+
this._applyPromise = Promise.resolve()
|
|
30
|
+
/** @type {number} Subscription generation - bumped by unsubscribe so in-flight subscribes detect they became stale. */
|
|
31
|
+
this._generation = 0
|
|
32
|
+
/** @type {Promise<void> | null} */
|
|
33
|
+
this._scheduledPull = null
|
|
34
|
+
/** @type {Promise<void> | null} */
|
|
35
|
+
this._subscribePromise = null
|
|
36
|
+
/** @type {"subscribed" | "subscribing" | "unsubscribed"} */
|
|
37
|
+
this._state = "unsubscribed"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Subscribes the derived realtime channels (idempotent and single-flighted):
|
|
42
|
+
* an active subscription is kept as-is and a concurrent subscribe awaits the
|
|
43
|
+
* in-flight attempt. Call `unsubscribe()` first to change the context.
|
|
44
|
+
* @param {?} [context] - App context passed to the `sync.client.realtime.channels` callback (runtime values like eventId).
|
|
45
|
+
* @returns {Promise<void>}
|
|
46
|
+
*/
|
|
47
|
+
async subscribe(context) {
|
|
48
|
+
if (this._state === "subscribed") return
|
|
49
|
+
|
|
50
|
+
if (!this._subscribePromise) {
|
|
51
|
+
this._subscribePromise = this._subscribe(context).finally(() => {
|
|
52
|
+
this._subscribePromise = null
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await this._subscribePromise
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Connects the websocket client, subscribes every derived channel, and waits
|
|
61
|
+
* for each subscription's server acknowledgement before the gap-closing pull,
|
|
62
|
+
* so no change can land between the pull and the subscriptions going live.
|
|
63
|
+
* Locally created resources are only promoted to the bridge once everything
|
|
64
|
+
* is live; an unsubscribe arriving during any await marks this attempt stale
|
|
65
|
+
* and it tears its own resources down instead of resubscribing.
|
|
66
|
+
* @param {?} context - App context passed to the channels callback.
|
|
67
|
+
* @returns {Promise<void>}
|
|
68
|
+
*/
|
|
69
|
+
async _subscribe(context) {
|
|
70
|
+
const generation = this._generation
|
|
71
|
+
|
|
72
|
+
this._state = "subscribing"
|
|
73
|
+
|
|
74
|
+
/** @type {Array<{channel: string, resourceType: string | null, subscription: import("../configuration-types.js").VelociousSyncRealtimeSubscription}>} */
|
|
75
|
+
const channels = []
|
|
76
|
+
/** @type {VelociousSyncRealtimeWebsocketClient | null} */
|
|
77
|
+
let client = null
|
|
78
|
+
/**
|
|
79
|
+
* Tears down everything this stale/failed subscribe attempt created itself.
|
|
80
|
+
* @returns {Promise<void>}
|
|
81
|
+
*/
|
|
82
|
+
const teardown = async () => {
|
|
83
|
+
for (const {subscription} of channels) {
|
|
84
|
+
subscription.close()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (client) await client.disconnectAndStopReconnect()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const realtime = this.realtimeConfiguration()
|
|
92
|
+
const channelDescriptors = await this.channelDescriptors(context)
|
|
93
|
+
|
|
94
|
+
if (generation !== this._generation) return
|
|
95
|
+
|
|
96
|
+
client = await realtime.createClient()
|
|
97
|
+
|
|
98
|
+
if (generation !== this._generation) return
|
|
99
|
+
|
|
100
|
+
await client.connect()
|
|
101
|
+
|
|
102
|
+
if (generation !== this._generation) {
|
|
103
|
+
await teardown()
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const authenticationToken = await this.syncClient.config.authenticationToken()
|
|
108
|
+
|
|
109
|
+
if (generation !== this._generation) {
|
|
110
|
+
await teardown()
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const channelDescriptor of channelDescriptors) {
|
|
115
|
+
if (channelDescriptor.params && "authenticationToken" in channelDescriptor.params) {
|
|
116
|
+
throw new Error(`Realtime channel "${channelDescriptor.channel}" params must not include authenticationToken - the framework injects the sync.client authenticationToken automatically`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const resourceType = channelDescriptor.resourceType ?? null
|
|
120
|
+
const subscription = client.subscribeChannel(channelDescriptor.channel, {
|
|
121
|
+
onMessage: (body) => this.enqueueApply({body, resourceType}),
|
|
122
|
+
onResume: () => this.schedulePull(),
|
|
123
|
+
params: {...channelDescriptor.params, authenticationToken}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
channels.push({channel: channelDescriptor.channel, resourceType, subscription})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await Promise.all(channels.map(({subscription}) => subscription.waitForReady()))
|
|
130
|
+
|
|
131
|
+
if (generation !== this._generation) {
|
|
132
|
+
await teardown()
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this._channels = channels
|
|
137
|
+
this._client = client
|
|
138
|
+
this._state = "subscribed"
|
|
139
|
+
this.schedulePull()
|
|
140
|
+
} catch (error) {
|
|
141
|
+
await teardown()
|
|
142
|
+
|
|
143
|
+
if (generation === this._generation) this._state = "unsubscribed"
|
|
144
|
+
|
|
145
|
+
throw error
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Closes every channel subscription and disconnects the websocket client
|
|
151
|
+
* (idempotent). Also marks any in-flight subscribe attempt stale so it tears
|
|
152
|
+
* itself down instead of finishing the subscription afterwards.
|
|
153
|
+
* @returns {Promise<void>}
|
|
154
|
+
*/
|
|
155
|
+
async unsubscribe() {
|
|
156
|
+
this._generation += 1
|
|
157
|
+
|
|
158
|
+
const channels = this._channels
|
|
159
|
+
const client = this._client
|
|
160
|
+
|
|
161
|
+
this._channels = []
|
|
162
|
+
this._client = null
|
|
163
|
+
this._state = "unsubscribed"
|
|
164
|
+
|
|
165
|
+
for (const {subscription} of channels) {
|
|
166
|
+
subscription.close()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (client) await client.disconnectAndStopReconnect()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Reports the bridge subscription state and per-channel readiness.
|
|
174
|
+
* @returns {{channels: Array<{channel: string, ready: boolean, resourceType: string | null}>, state: "subscribed" | "subscribing" | "unsubscribed"}} Realtime status.
|
|
175
|
+
*/
|
|
176
|
+
status() {
|
|
177
|
+
return {
|
|
178
|
+
channels: this._channels.map(({channel, resourceType, subscription}) => ({channel, ready: subscription.isReady(), resourceType})),
|
|
179
|
+
state: this._state
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Awaits all enqueued message applies and any scheduled pull (tests, shutdown flows).
|
|
185
|
+
* @returns {Promise<void>}
|
|
186
|
+
*/
|
|
187
|
+
async waitForApplied() {
|
|
188
|
+
await this._applyPromise
|
|
189
|
+
if (this._scheduledPull) await this._scheduledPull
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Resolves the realtime configuration, failing loudly when it is missing.
|
|
194
|
+
* @returns {import("../configuration-types.js").VelociousSyncClientRealtimeConfiguration} Realtime configuration.
|
|
195
|
+
*/
|
|
196
|
+
realtimeConfiguration() {
|
|
197
|
+
const realtime = this.syncClient.config.realtime
|
|
198
|
+
|
|
199
|
+
if (!realtime) {
|
|
200
|
+
throw new Error("subscribeRealtime requires a sync.client.realtime configuration block with a createClient callback")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return realtime
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Derives the channel descriptors to subscribe: model-level static realtime
|
|
208
|
+
* declarations plus the config channels callback, failing loudly when none exist.
|
|
209
|
+
* @param {?} context - App context passed to the channels callback.
|
|
210
|
+
* @returns {Promise<Array<VelociousSyncRealtimeChannelDescriptor>>} Channel descriptors.
|
|
211
|
+
*/
|
|
212
|
+
async channelDescriptors(context) {
|
|
213
|
+
const realtime = this.realtimeConfiguration()
|
|
214
|
+
/** @type {Array<VelociousSyncRealtimeChannelDescriptor>} */
|
|
215
|
+
const channelDescriptors = []
|
|
216
|
+
|
|
217
|
+
for (const [resourceType, resourceConfig] of Object.entries(this.syncClient.config.resources)) {
|
|
218
|
+
if (!resourceConfig.realtime) continue
|
|
219
|
+
|
|
220
|
+
channelDescriptors.push({channel: resourceConfig.realtime.channel, params: resourceConfig.realtime.params, resourceType})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (realtime.channels) {
|
|
224
|
+
channelDescriptors.push(...await realtime.channels(context))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (channelDescriptors.length === 0) {
|
|
228
|
+
throw new Error("subscribeRealtime found no realtime channels - declare sync.client.realtime.channels or static sync = {realtime: {channel}} on a model")
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return channelDescriptors
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Chains one pushed message onto the serialized apply queue so changes apply
|
|
236
|
+
* in arrival order; failures go to the sync client's error reporting.
|
|
237
|
+
* @param {{body: ?, resourceType: string | null}} args - Message args.
|
|
238
|
+
* @returns {void}
|
|
239
|
+
*/
|
|
240
|
+
enqueueApply({body, resourceType}) {
|
|
241
|
+
this._applyPromise = this._applyPromise.then(async () => {
|
|
242
|
+
try {
|
|
243
|
+
await this.applyMessage({body, resourceType})
|
|
244
|
+
} catch (error) {
|
|
245
|
+
this.syncClient.reportError(/** @type {Error} */ (error))
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Applies one pushed message through the derived resource applier: drops
|
|
252
|
+
* own-device messages by echo origin, defaults the channel's resourceType onto
|
|
253
|
+
* envelopes without one, and fails loudly on unknown resource types.
|
|
254
|
+
* @param {{body: ?, resourceType: string | null}} args - Message args.
|
|
255
|
+
* @returns {Promise<void>}
|
|
256
|
+
*/
|
|
257
|
+
async applyMessage({body, resourceType}) {
|
|
258
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
259
|
+
throw new Error(`Realtime sync messages must be envelope objects, got: ${JSON.stringify(body)}`)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const realtime = this.realtimeConfiguration()
|
|
263
|
+
|
|
264
|
+
if (realtime.localOrigin && body.echoOrigin !== undefined && body.echoOrigin !== null) {
|
|
265
|
+
const localOrigin = String(await realtime.localOrigin())
|
|
266
|
+
|
|
267
|
+
if (String(body.echoOrigin) === localOrigin) return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const syncPayloads = Array.isArray(body.syncs) ? body.syncs : [body]
|
|
271
|
+
const applySync = this.syncClient.remoteApplySync({source: "remote change"})
|
|
272
|
+
|
|
273
|
+
for (const syncPayload of syncPayloads) {
|
|
274
|
+
const sync = SyncApiClient.syncEnvelopeFromPayload({resourceType, ...syncPayload})
|
|
275
|
+
|
|
276
|
+
await applySync(sync)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Schedules a coalesced background pull closing offline gaps after
|
|
282
|
+
* (re)subscription readiness. Resumes arriving while a pull is already
|
|
283
|
+
* scheduled or in flight coalesce into that pull instead of stacking.
|
|
284
|
+
* @returns {void}
|
|
285
|
+
*/
|
|
286
|
+
schedulePull() {
|
|
287
|
+
if (this.realtimeConfiguration().pullOnReconnect === false) return
|
|
288
|
+
|
|
289
|
+
this._scheduledPull ||= (async () => {
|
|
290
|
+
try {
|
|
291
|
+
await this.syncClient.pull()
|
|
292
|
+
} catch (error) {
|
|
293
|
+
this.syncClient.reportError(/** @type {Error} */ (error))
|
|
294
|
+
} finally {
|
|
295
|
+
this._scheduledPull = null
|
|
296
|
+
}
|
|
297
|
+
})()
|
|
298
|
+
}
|
|
299
|
+
}
|