velocious 1.0.503 → 1.0.504
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 +2 -0
- package/build/configuration-types.js +8 -8
- package/build/configuration.js +2 -2
- package/build/src/application.d.ts.map +1 -1
- package/build/src/application.js +3 -1
- package/build/src/configuration-types.d.ts +17 -17
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +9 -9
- package/build/src/configuration.d.ts +4 -4
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +3 -3
- package/build/src/sync/sync-channel-name.d.ts +9 -0
- package/build/src/sync/sync-channel-name.d.ts.map +1 -0
- package/build/src/sync/sync-channel-name.js +10 -0
- package/build/src/sync/sync-client-types.d.ts +10 -7
- 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 +1 -1
- package/build/src/sync/sync-client.js +2 -2
- package/build/src/sync/sync-model-change-feed-service.d.ts +6 -1
- package/build/src/sync/sync-model-change-feed-service.d.ts.map +1 -1
- package/build/src/sync/sync-model-change-feed-service.js +15 -4
- package/build/src/sync/sync-publisher-types.d.ts +53 -21
- package/build/src/sync/sync-publisher-types.d.ts.map +1 -1
- package/build/src/sync/sync-publisher-types.js +1 -1
- package/build/src/sync/sync-publisher.d.ts +32 -9
- package/build/src/sync/sync-publisher.d.ts.map +1 -1
- package/build/src/sync/sync-publisher.js +170 -37
- package/build/src/sync/sync-realtime-bridge.d.ts +33 -10
- package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
- package/build/src/sync/sync-realtime-bridge.js +51 -12
- package/build/src/sync/sync-scope-attributes.d.ts +15 -0
- package/build/src/sync/sync-scope-attributes.d.ts.map +1 -0
- package/build/src/sync/sync-scope-attributes.js +24 -0
- package/build/src/sync/sync-websocket-channel.d.ts +58 -0
- package/build/src/sync/sync-websocket-channel.d.ts.map +1 -0
- package/build/src/sync/sync-websocket-channel.js +131 -0
- package/build/sync/sync-channel-name.js +10 -0
- package/build/sync/sync-client-types.js +12 -6
- package/build/sync/sync-client.js +1 -1
- package/build/sync/sync-model-change-feed-service.js +17 -3
- package/build/sync/sync-publisher-types.js +32 -12
- package/build/sync/sync-publisher.js +192 -41
- package/build/sync/sync-realtime-bridge.js +56 -11
- package/build/sync/sync-scope-attributes.js +26 -0
- package/build/sync/sync-websocket-channel.js +146 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/application.js +2 -0
- package/src/configuration-types.js +8 -8
- package/src/configuration.js +2 -2
- package/src/sync/sync-channel-name.js +10 -0
- package/src/sync/sync-client-types.js +12 -6
- package/src/sync/sync-client.js +1 -1
- package/src/sync/sync-model-change-feed-service.js +17 -3
- package/src/sync/sync-publisher-types.js +32 -12
- package/src/sync/sync-publisher.js +192 -41
- package/src/sync/sync-realtime-bridge.js +56 -11
- package/src/sync/sync-scope-attributes.js +26 -0
- package/src/sync/sync-websocket-channel.js +146 -0
|
@@ -4,8 +4,10 @@ import Configuration from "../configuration.js"
|
|
|
4
4
|
import Logger from "../logger.js"
|
|
5
5
|
import restArgsError from "../utils/rest-args-error.js"
|
|
6
6
|
|
|
7
|
+
import {declaredSyncScopeAttributes} from "./sync-scope-attributes.js"
|
|
7
8
|
import {deliverDeclaredBroadcasts, upsertSyncRow} from "./sync-change-fanout.js"
|
|
8
9
|
import {isPublishingSuppressed} from "./sync-publish-suppression.js"
|
|
10
|
+
import {VELOCIOUS_SYNC_CHANNEL} from "./sync-channel-name.js"
|
|
9
11
|
|
|
10
12
|
/** @type {{create: "afterCreate", update: "afterUpdate", destroy: "afterDestroy"}} */
|
|
11
13
|
const PUBLISHED_CALLBACK_NAMES = {create: "afterCreate", destroy: "afterDestroy", update: "afterUpdate"}
|
|
@@ -29,16 +31,23 @@ const startedPublishersByConfiguration = new WeakMap()
|
|
|
29
31
|
* Server models declare what to publish through `static sync`'s `publish`
|
|
30
32
|
* key, and Velocious writes every committed server-side change to the sync
|
|
31
33
|
* change feed (model-backed Sync-row upsert with server re-sequencing) and
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
+
* broadcasts the standard sync envelope (`{echoOrigin, syncs: [...]}`) on the
|
|
35
|
+
* framework sync channel ({@link VELOCIOUS_SYNC_CHANNEL}) scoped by the
|
|
36
|
+
* change's derived scope-partition values, so devices receive server-origin
|
|
37
|
+
* changes without app code declaring channels or calling manual
|
|
38
|
+
* upsert/broadcast helpers:
|
|
34
39
|
*
|
|
35
|
-
* static sync = {
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
40
|
+
* static sync = {publish: true} // default payload (attributes) + default scope partition
|
|
41
|
+
* static sync = {publish: {serialize: (record) => ({id: record.id(), pin: record.pin()})}}
|
|
42
|
+
*
|
|
43
|
+
* The scope partition comes from the sync model's `static
|
|
44
|
+
* syncScopeAttributes` declaration (for example `["eventId"]` or
|
|
45
|
+
* `["accountId"]` — Velocious has no built-in partition name): each declared
|
|
46
|
+
* scope attribute reads the record's attribute of the same name when the
|
|
47
|
+
* model has one, else the record's own id (scope-root models), overridable
|
|
48
|
+
* per model through `publish: {scopeAttributes: {accountId: "ownerId"}}`.
|
|
49
|
+
* The pre-framework-channel `broadcasts` list and the `eventId`
|
|
50
|
+
* string/resolver-function declaration forms keep working but are deprecated.
|
|
42
51
|
*
|
|
43
52
|
* Replayed device mutations never double-publish: the framework's routed
|
|
44
53
|
* replay apply marks its written records through `markServerApply(record)`
|
|
@@ -61,20 +70,9 @@ export default class SyncPublisher {
|
|
|
61
70
|
restArgsError(restOptions)
|
|
62
71
|
|
|
63
72
|
const modelClasses = configuration.getModelClasses()
|
|
64
|
-
|
|
65
|
-
const resources = {}
|
|
66
|
-
|
|
67
|
-
for (const modelClass of Object.values(modelClasses)) {
|
|
68
|
-
const publish = publishDeclarationFor(modelClass)
|
|
69
|
-
|
|
70
|
-
if (!publish) continue
|
|
71
|
-
|
|
72
|
-
const resourceConfig = resourceConfigFromPublishDeclaration({modelClass, publish})
|
|
73
|
-
|
|
74
|
-
resources[resourceConfig.resourceType] = resourceConfig
|
|
75
|
-
}
|
|
73
|
+
const publishingModelClasses = Object.values(modelClasses).filter((modelClass) => publishDeclarationFor(modelClass))
|
|
76
74
|
|
|
77
|
-
if (
|
|
75
|
+
if (publishingModelClasses.length === 0) {
|
|
78
76
|
throw new Error("SyncPublisher found no registered models declaring static sync publish - declare `static sync = {publish: {serialize}}` on the models whose server-side changes should publish to the sync feed")
|
|
79
77
|
}
|
|
80
78
|
|
|
@@ -84,6 +82,17 @@ export default class SyncPublisher {
|
|
|
84
82
|
throw new Error("SyncPublisher requires a registered \"Sync\" model for published sync change rows (or pass options.syncModel)")
|
|
85
83
|
}
|
|
86
84
|
|
|
85
|
+
const scopeAttributes = declaredSyncScopeAttributes(resolvedSyncModel)
|
|
86
|
+
/** @type {Record<string, import("./sync-publisher-types.js").SyncPublisherResourceConfig>} */
|
|
87
|
+
const resources = {}
|
|
88
|
+
|
|
89
|
+
for (const modelClass of publishingModelClasses) {
|
|
90
|
+
const publish = publishDeclarationFor(modelClass)
|
|
91
|
+
const resourceConfig = resourceConfigFromPublishDeclaration({modelClass, publish, scopeAttributes, syncModel: resolvedSyncModel})
|
|
92
|
+
|
|
93
|
+
resources[resourceConfig.resourceType] = resourceConfig
|
|
94
|
+
}
|
|
95
|
+
|
|
87
96
|
/** @type {{actorForeignKeyColumn: string, broadcaster: import("./sync-publisher-types.js").SyncPublisherOptions["broadcaster"], configuration: Configuration, onError: import("./sync-publisher-types.js").SyncPublisherOptions["onError"], resources: Record<string, import("./sync-publisher-types.js").SyncPublisherResourceConfig>, syncModel: ?}} */
|
|
88
97
|
this.config = {actorForeignKeyColumn, broadcaster, configuration, onError, resources, syncModel: resolvedSyncModel}
|
|
89
98
|
/** @type {Array<{callback: (record: ?) => Promise<void>, callbackName: "afterCreate" | "afterUpdate" | "afterDestroy", modelClass: ?}>} */
|
|
@@ -183,6 +192,7 @@ export default class SyncPublisher {
|
|
|
183
192
|
const data = await resourceConfig.serialize(record)
|
|
184
193
|
const resourceId = String(record.id())
|
|
185
194
|
const syncType = operation === "destroy" ? "delete" : "update"
|
|
195
|
+
const scopeValues = await this.publishedScopeValues({record, resourceConfig})
|
|
186
196
|
/** @type {Record<string, ?>} */
|
|
187
197
|
const attributes = {
|
|
188
198
|
[this.config.actorForeignKeyColumn]: null,
|
|
@@ -190,19 +200,23 @@ export default class SyncPublisher {
|
|
|
190
200
|
data: JSON.stringify(data),
|
|
191
201
|
resource_id: resourceId,
|
|
192
202
|
resource_type: resourceConfig.resourceType,
|
|
193
|
-
sync_type: syncType
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (resourceConfig.eventId) {
|
|
197
|
-
const eventId = await resourceConfig.eventId(record)
|
|
198
|
-
|
|
199
|
-
attributes.event_id = eventId === undefined || eventId === null ? null : String(eventId)
|
|
203
|
+
sync_type: syncType,
|
|
204
|
+
...scopeValues.columns
|
|
200
205
|
}
|
|
201
206
|
|
|
202
207
|
await resourceConfig.modelClass.connection().afterCommit(async () => {
|
|
203
208
|
try {
|
|
204
209
|
const syncRow = await this.upsertPublishedSyncRow(attributes)
|
|
205
210
|
|
|
211
|
+
await this.broadcaster()({
|
|
212
|
+
body: {
|
|
213
|
+
echoOrigin: null,
|
|
214
|
+
syncs: [{data, resourceId, resourceType: resourceConfig.resourceType, syncType}]
|
|
215
|
+
},
|
|
216
|
+
channel: VELOCIOUS_SYNC_CHANNEL,
|
|
217
|
+
params: {...scopeValues.params, resourceType: resourceConfig.resourceType}
|
|
218
|
+
})
|
|
219
|
+
|
|
206
220
|
if (resourceConfig.broadcasts) {
|
|
207
221
|
await deliverDeclaredBroadcasts({
|
|
208
222
|
args: {data, operation, record, resourceId, resourceType: resourceConfig.resourceType, syncRow, syncType},
|
|
@@ -217,6 +231,42 @@ export default class SyncPublisher {
|
|
|
217
231
|
}
|
|
218
232
|
}
|
|
219
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Resolves the scope-partition values for one published mutation from the
|
|
236
|
+
* resource's derived scope plan: each entry reads its record attribute (or
|
|
237
|
+
* the record's own id for scope-root models, or the deprecated resolver
|
|
238
|
+
* function). The values are persisted onto the sync row's partition columns
|
|
239
|
+
* and broadcast as the framework sync channel's scoping params.
|
|
240
|
+
* @param {{record: ?, resourceConfig: import("./sync-publisher-types.js").SyncPublisherResourceConfig}} args - Mutated record and resource config.
|
|
241
|
+
* @returns {Promise<{columns: Record<string, string | null>, params: Record<string, string | null>}>} Scope values keyed by sync-row column and by scope attribute.
|
|
242
|
+
*/
|
|
243
|
+
async publishedScopeValues({record, resourceConfig}) {
|
|
244
|
+
/** @type {Record<string, string | null>} */
|
|
245
|
+
const columns = {}
|
|
246
|
+
/** @type {Record<string, string | null>} */
|
|
247
|
+
const params = {}
|
|
248
|
+
|
|
249
|
+
for (const scopePlanEntry of resourceConfig.scopePlan) {
|
|
250
|
+
/** @type {?} */
|
|
251
|
+
let rawValue
|
|
252
|
+
|
|
253
|
+
if (scopePlanEntry.resolver) {
|
|
254
|
+
rawValue = await scopePlanEntry.resolver(record)
|
|
255
|
+
} else if (scopePlanEntry.recordAttribute) {
|
|
256
|
+
rawValue = record.readAttribute(scopePlanEntry.recordAttribute)
|
|
257
|
+
} else {
|
|
258
|
+
rawValue = record.id()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const value = rawValue === undefined || rawValue === null ? null : String(rawValue)
|
|
262
|
+
|
|
263
|
+
columns[scopePlanEntry.columnName] = value
|
|
264
|
+
params[scopePlanEntry.scopeAttribute] = value
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {columns, params}
|
|
268
|
+
}
|
|
269
|
+
|
|
220
270
|
/**
|
|
221
271
|
* Upserts the published server-origin sync row for a resource identity:
|
|
222
272
|
* server-origin rows carry a null actor column (no device to echo the
|
|
@@ -296,7 +346,7 @@ export default class SyncPublisher {
|
|
|
296
346
|
* Opted-out (`publish: false`) and undeclared models resolve to null; every
|
|
297
347
|
* other declared value flows into loud declaration validation.
|
|
298
348
|
* @param {?} modelClass - Registered model class.
|
|
299
|
-
* @returns {import("./sync-publisher-types.js").
|
|
349
|
+
* @returns {import("./sync-publisher-types.js").SyncPublishDeclaration | null} Active publish declaration, or null.
|
|
300
350
|
*/
|
|
301
351
|
function publishDeclarationFor(modelClass) {
|
|
302
352
|
const declaration = modelClass.sync
|
|
@@ -308,25 +358,27 @@ function publishDeclarationFor(modelClass) {
|
|
|
308
358
|
|
|
309
359
|
/**
|
|
310
360
|
* Builds one published resource config from a model's `static sync` publish
|
|
311
|
-
* declaration.
|
|
312
|
-
*
|
|
361
|
+
* declaration. `publish: true` opts in with all defaults (attribute payload,
|
|
362
|
+
* derived scope partition, created/updated operations).
|
|
363
|
+
* @param {{modelClass: ?, publish: import("./sync-publisher-types.js").SyncPublishDeclaration | null, scopeAttributes: string[] | null, syncModel: ?}} args - Declaration args plus the sync model's declared scope attributes.
|
|
313
364
|
* @returns {import("./sync-publisher-types.js").SyncPublisherResourceConfig} Derived resource config.
|
|
314
365
|
*/
|
|
315
|
-
function resourceConfigFromPublishDeclaration({modelClass, publish}) {
|
|
366
|
+
function resourceConfigFromPublishDeclaration({modelClass, publish, scopeAttributes: syncScopeAttributes, syncModel}) {
|
|
316
367
|
const modelName = modelClass.getModelName()
|
|
368
|
+
const normalizedPublish = publish === true ? {} : publish
|
|
317
369
|
|
|
318
|
-
if (!
|
|
319
|
-
throw new Error(`${modelName} static sync publish must be false or a publish declaration object, got: ${String(publish)}`)
|
|
370
|
+
if (!normalizedPublish || typeof normalizedPublish !== "object" || Array.isArray(normalizedPublish)) {
|
|
371
|
+
throw new Error(`${modelName} static sync publish must be true, false or a publish declaration object, got: ${String(publish)}`)
|
|
320
372
|
}
|
|
321
373
|
|
|
322
|
-
const {broadcasts, eventId, operations, resourceType, serialize, ...restDeclaration} =
|
|
374
|
+
const {broadcasts, eventId, operations, resourceType, scopeAttributes, serialize, ...restDeclaration} = normalizedPublish
|
|
323
375
|
const unknownKeys = Object.keys(restDeclaration)
|
|
324
376
|
|
|
325
377
|
if (unknownKeys.length > 0) {
|
|
326
|
-
throw new Error(`${modelName} static sync publish received unknown keys: ${unknownKeys.join(", ")} (supported: broadcasts, eventId, operations, resourceType, serialize)`)
|
|
378
|
+
throw new Error(`${modelName} static sync publish received unknown keys: ${unknownKeys.join(", ")} (supported: broadcasts, eventId (deprecated), operations, resourceType, scopeAttributes, serialize)`)
|
|
327
379
|
}
|
|
328
|
-
if (typeof serialize !== "function") {
|
|
329
|
-
throw new Error(`${modelName} static sync publish
|
|
380
|
+
if (serialize !== undefined && typeof serialize !== "function") {
|
|
381
|
+
throw new Error(`${modelName} static sync publish serialize must be a function building the published payload, got: ${String(serialize)}`)
|
|
330
382
|
}
|
|
331
383
|
if (operations !== undefined) {
|
|
332
384
|
if (!Array.isArray(operations) || operations.length === 0) {
|
|
@@ -342,10 +394,109 @@ function resourceConfigFromPublishDeclaration({modelClass, publish}) {
|
|
|
342
394
|
|
|
343
395
|
return {
|
|
344
396
|
broadcasts,
|
|
345
|
-
eventId,
|
|
346
397
|
modelClass,
|
|
347
398
|
operations: operations === undefined ? DEFAULT_PUBLISHED_OPERATIONS : operations,
|
|
348
399
|
resourceType: resourceType === undefined ? modelName : resourceType,
|
|
349
|
-
|
|
400
|
+
scopePlan: scopePlanFor({eventId, modelClass, modelName, scopeAttributes, syncModel, syncScopeAttributes}),
|
|
401
|
+
serialize: serialize === undefined ? defaultSerializedAttributes : serialize
|
|
350
402
|
}
|
|
351
403
|
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Derives the scope plan partitioning a published model's changes: one entry
|
|
407
|
+
* per scope attribute declared on the sync model (`static
|
|
408
|
+
* syncScopeAttributes`), each reading the record attribute named like the
|
|
409
|
+
* scope attribute (overridable through the declaration's `scopeAttributes`
|
|
410
|
+
* name map), or the record's own id when the model has no such attribute
|
|
411
|
+
* (scope-root models). The deprecated `eventId` declaration forms map to a
|
|
412
|
+
* fixed `eventId`/`event_id` plan for 1.0.503 compatibility.
|
|
413
|
+
* @param {{eventId: import("./sync-publisher-types.js").SyncPublishDeclarationConfig["eventId"], modelClass: ?, modelName: string, scopeAttributes: Record<string, string> | undefined, syncModel: ?, syncScopeAttributes: string[] | null}} args - Declaration and sync-model scope args.
|
|
414
|
+
* @returns {Array<import("./sync-publisher-types.js").SyncPublisherScopePlanEntry>} Derived scope plan.
|
|
415
|
+
*/
|
|
416
|
+
function scopePlanFor({eventId, modelClass, modelName, scopeAttributes, syncModel, syncScopeAttributes}) {
|
|
417
|
+
const attributeNames = Object.values(modelClass.getColumnNameToAttributeNameMap())
|
|
418
|
+
|
|
419
|
+
if (eventId !== undefined) {
|
|
420
|
+
if (scopeAttributes !== undefined) {
|
|
421
|
+
throw new Error(`${modelName} static sync publish can't declare both scopeAttributes and the deprecated eventId form`)
|
|
422
|
+
}
|
|
423
|
+
if (typeof eventId === "function") {
|
|
424
|
+
return [{columnName: "event_id", recordAttribute: null, resolver: eventId, scopeAttribute: "eventId"}]
|
|
425
|
+
}
|
|
426
|
+
if (typeof eventId !== "string") {
|
|
427
|
+
throw new Error(`${modelName} static sync publish eventId must be an attribute-name string (or a deprecated resolver function), got: ${String(eventId)}`)
|
|
428
|
+
}
|
|
429
|
+
if (!attributeNames.includes(eventId)) {
|
|
430
|
+
throw new Error(`${modelName} static sync publish eventId attribute doesn't exist on the model: ${eventId}`)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return [{columnName: "event_id", recordAttribute: eventId, resolver: undefined, scopeAttribute: "eventId"}]
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (scopeAttributes !== undefined && !syncScopeAttributes) {
|
|
437
|
+
throw new Error(`${modelName} static sync publish declares scopeAttributes but the sync model declares no static syncScopeAttributes`)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (!syncScopeAttributes) return []
|
|
441
|
+
|
|
442
|
+
if (scopeAttributes !== undefined && (typeof scopeAttributes !== "object" || Array.isArray(scopeAttributes))) {
|
|
443
|
+
throw new Error(`${modelName} static sync publish scopeAttributes must be an object mapping scope attributes to record attribute names, got: ${String(scopeAttributes)}`)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
for (const scopeAttribute of Object.keys(scopeAttributes || {})) {
|
|
447
|
+
if (!syncScopeAttributes.includes(scopeAttribute)) {
|
|
448
|
+
throw new Error(`${modelName} static sync publish scopeAttributes received unknown scope attribute: ${scopeAttribute} (the sync model declares: ${syncScopeAttributes.join(", ")})`)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return syncScopeAttributes.map((scopeAttribute) => {
|
|
453
|
+
const declaredRecordAttribute = scopeAttributes?.[scopeAttribute]
|
|
454
|
+
|
|
455
|
+
if (declaredRecordAttribute !== undefined) {
|
|
456
|
+
if (typeof declaredRecordAttribute !== "string" || !attributeNames.includes(declaredRecordAttribute)) {
|
|
457
|
+
throw new Error(`${modelName} static sync publish scopeAttributes.${scopeAttribute} must name an existing record attribute, got: ${String(declaredRecordAttribute)}`)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return {columnName: syncScopeColumnName({scopeAttribute, syncModel}), recordAttribute: declaredRecordAttribute, resolver: undefined, scopeAttribute}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
columnName: syncScopeColumnName({scopeAttribute, syncModel}),
|
|
465
|
+
recordAttribute: attributeNames.includes(scopeAttribute) ? scopeAttribute : null,
|
|
466
|
+
resolver: undefined,
|
|
467
|
+
scopeAttribute
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Resolves the sync-row column persisting a declared scope attribute.
|
|
474
|
+
* @param {{scopeAttribute: string, syncModel: ?}} args - Scope attribute and sync model.
|
|
475
|
+
* @returns {string} Sync-row column name.
|
|
476
|
+
*/
|
|
477
|
+
function syncScopeColumnName({scopeAttribute, syncModel}) {
|
|
478
|
+
const columnName = syncModel.getAttributeNameToColumnNameMap()[scopeAttribute]
|
|
479
|
+
|
|
480
|
+
if (!columnName) {
|
|
481
|
+
throw new Error(`${syncModel.name} declares the sync scope attribute ${scopeAttribute} but has no matching column for it`)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return columnName
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Default publish serializer: the record's attributes with Date values
|
|
489
|
+
* serialized to ISO strings.
|
|
490
|
+
* @param {?} record - Mutated server model record.
|
|
491
|
+
* @returns {Record<string, ?>} Serialized attributes payload.
|
|
492
|
+
*/
|
|
493
|
+
function defaultSerializedAttributes(record) {
|
|
494
|
+
/** @type {Record<string, ?>} */
|
|
495
|
+
const attributes = {...record.attributes()}
|
|
496
|
+
|
|
497
|
+
for (const [attributeName, value] of Object.entries(attributes)) {
|
|
498
|
+
if (value instanceof Date) attributes[attributeName] = value.toISOString()
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return attributes
|
|
502
|
+
}
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import SyncApiClient from "./sync-api-client.js"
|
|
4
|
+
import {VELOCIOUS_SYNC_CHANNEL} from "./sync-channel-name.js"
|
|
4
5
|
|
|
5
6
|
/** @typedef {import("../configuration-types.js").VelociousSyncRealtimeChannelDescriptor} VelociousSyncRealtimeChannelDescriptor */
|
|
6
7
|
/** @typedef {import("../configuration-types.js").VelociousSyncRealtimeWebsocketClient} VelociousSyncRealtimeWebsocketClient */
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
* Derived realtime push bridge for the sync client. Subscribes
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* Derived realtime push bridge for the sync client. Subscribes every declared
|
|
11
|
+
* pull scope to the framework sync channel ({@link VELOCIOUS_SYNC_CHANNEL})
|
|
12
|
+
* automatically — the subscribe params mirror the scope's
|
|
13
|
+
* `{resourceType, conditions}` so the server authorizes them through the same
|
|
14
|
+
* sync resource authorization as pulls — plus any deprecated legacy channels
|
|
15
|
+
* (config `sync.client.realtime.channels` callback and model-level
|
|
16
|
+
* `static sync = {realtime: {channel}}` declarations). Pushed changes apply
|
|
17
|
+
* through the same derived resource applier as pulls (with echo suppression
|
|
18
|
+
* against tracked re-queueing), own-device messages are dropped by echo
|
|
19
|
+
* origin, and a coalesced `pull()` fires when subscriptions become ready or
|
|
15
20
|
* resume so offline gaps close.
|
|
16
21
|
*/
|
|
17
22
|
export default class SyncRealtimeBridge {
|
|
@@ -41,7 +46,7 @@ export default class SyncRealtimeBridge {
|
|
|
41
46
|
* Subscribes the derived realtime channels (idempotent and single-flighted):
|
|
42
47
|
* an active subscription is kept as-is and a concurrent subscribe awaits the
|
|
43
48
|
* 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
|
|
49
|
+
* @param {?} [context] - App context passed to the deprecated `sync.client.realtime.channels` callback (runtime scope values).
|
|
45
50
|
* @returns {Promise<void>}
|
|
46
51
|
*/
|
|
47
52
|
async subscribe(context) {
|
|
@@ -204,9 +209,12 @@ export default class SyncRealtimeBridge {
|
|
|
204
209
|
}
|
|
205
210
|
|
|
206
211
|
/**
|
|
207
|
-
* Derives the channel descriptors to subscribe:
|
|
208
|
-
*
|
|
209
|
-
*
|
|
212
|
+
* Derives the channel descriptors to subscribe: one framework sync channel
|
|
213
|
+
* subscription per declared pull scope (the params mirror the scope's
|
|
214
|
+
* `{resourceType, conditions}`), plus the deprecated legacy paths —
|
|
215
|
+
* model-level static realtime declarations and the config channels callback.
|
|
216
|
+
* Fails loudly when nothing is subscribable.
|
|
217
|
+
* @param {?} context - App context passed to the deprecated channels callback.
|
|
210
218
|
* @returns {Promise<Array<VelociousSyncRealtimeChannelDescriptor>>} Channel descriptors.
|
|
211
219
|
*/
|
|
212
220
|
async channelDescriptors(context) {
|
|
@@ -214,6 +222,13 @@ export default class SyncRealtimeBridge {
|
|
|
214
222
|
/** @type {Array<VelociousSyncRealtimeChannelDescriptor>} */
|
|
215
223
|
const channelDescriptors = []
|
|
216
224
|
|
|
225
|
+
for (const scopeRow of await this.syncClient.scopeStore().activeScopes()) {
|
|
226
|
+
channelDescriptors.push({
|
|
227
|
+
channel: VELOCIOUS_SYNC_CHANNEL,
|
|
228
|
+
params: {conditions: this.attributeNamedConditions(scopeRow), resourceType: scopeRow.resourceType}
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
217
232
|
for (const [resourceType, resourceConfig] of Object.entries(this.syncClient.config.resources)) {
|
|
218
233
|
if (!resourceConfig.realtime) continue
|
|
219
234
|
|
|
@@ -225,12 +240,42 @@ export default class SyncRealtimeBridge {
|
|
|
225
240
|
}
|
|
226
241
|
|
|
227
242
|
if (channelDescriptors.length === 0) {
|
|
228
|
-
throw new Error("subscribeRealtime found no
|
|
243
|
+
throw new Error("subscribeRealtime found no channels to subscribe - declare a sync scope (syncClient().sync(query)) so its framework sync channel subscription can be derived, or the deprecated sync.client.realtime.channels callback")
|
|
229
244
|
}
|
|
230
245
|
|
|
231
246
|
return channelDescriptors
|
|
232
247
|
}
|
|
233
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Translates a persisted scope's condition keys to the model's attribute
|
|
251
|
+
* names so the framework channel subscription matches the publisher's
|
|
252
|
+
* attribute-named scoping params: `serializedScopeFromQuery` persists the
|
|
253
|
+
* query's model-normalized column names (for example `project_id`), while
|
|
254
|
+
* scope-partition broadcasts carry attribute names (`projectId`). Keys
|
|
255
|
+
* without a column mapping are already attribute names and pass through;
|
|
256
|
+
* scopes on models without a declared sync resource fail loudly because no
|
|
257
|
+
* attribute mapping exists for them.
|
|
258
|
+
* @param {{conditions: Record<string, ?>, resourceType: string}} scopeRow - Active scope row.
|
|
259
|
+
* @returns {Record<string, ?>} Attribute-named scope conditions.
|
|
260
|
+
*/
|
|
261
|
+
attributeNamedConditions(scopeRow) {
|
|
262
|
+
const resourceConfig = this.syncClient.config.resources[scopeRow.resourceType]
|
|
263
|
+
|
|
264
|
+
if (!resourceConfig) {
|
|
265
|
+
throw new Error(`subscribeRealtime can't derive attribute names for the sync scope declared on ${scopeRow.resourceType} - declare static sync on that model so its resource is registered`)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const columnNameToAttributeName = resourceConfig.modelClass.getColumnNameToAttributeNameMap()
|
|
269
|
+
/** @type {Record<string, ?>} */
|
|
270
|
+
const conditions = {}
|
|
271
|
+
|
|
272
|
+
for (const [conditionName, conditionValue] of Object.entries(scopeRow.conditions)) {
|
|
273
|
+
conditions[columnNameToAttributeName[conditionName] || conditionName] = conditionValue
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return conditions
|
|
277
|
+
}
|
|
278
|
+
|
|
234
279
|
/**
|
|
235
280
|
* Chains one pushed message onto the serialized apply queue so changes apply
|
|
236
281
|
* in arrival order; failures go to the sync client's error reporting.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads and validates a sync/change model's declared scope-partition
|
|
5
|
+
* attributes (`static syncScopeAttributes`).
|
|
6
|
+
*
|
|
7
|
+
* The declaration names the attribute(s) partitioning the app's sync feed —
|
|
8
|
+
* the same attribute names client pull scopes use as conditions (for example
|
|
9
|
+
* `["eventId"]` or `["accountId"]`). The publisher persists them onto every
|
|
10
|
+
* published sync row and broadcasts them as the framework sync channel's
|
|
11
|
+
* scoping params, and the change feed serializes them onto every changes row
|
|
12
|
+
* under their own names. Velocious itself has no built-in partition name.
|
|
13
|
+
* @param {?} syncModel - Sync/change model class.
|
|
14
|
+
* @returns {string[] | null} Declared scope attributes, or null when the model declares none.
|
|
15
|
+
*/
|
|
16
|
+
export function declaredSyncScopeAttributes(syncModel) {
|
|
17
|
+
const declared = syncModel.syncScopeAttributes
|
|
18
|
+
|
|
19
|
+
if (declared === undefined || declared === null) return null
|
|
20
|
+
|
|
21
|
+
if (!Array.isArray(declared) || declared.length === 0 || declared.some((attributeName) => typeof attributeName !== "string" || !attributeName)) {
|
|
22
|
+
throw new Error(`${syncModel.name} static syncScopeAttributes must be a non-empty array of attribute-name strings, got: ${String(declared)}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return declared
|
|
26
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import VelociousWebsocketChannel from "../http-server/websocket-channel.js"
|
|
4
|
+
import {VELOCIOUS_SYNC_CHANNEL} from "./sync-channel-name.js"
|
|
5
|
+
|
|
6
|
+
/** Configurations whose framework sync channel has already been registered. */
|
|
7
|
+
const registeredConfigurations = new WeakSet()
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Framework-owned websocket channel for synced resources
|
|
11
|
+
* ({@link VELOCIOUS_SYNC_CHANNEL}).
|
|
12
|
+
*
|
|
13
|
+
* Subscribe params mirror a declared pull scope — `{resourceType, conditions}`
|
|
14
|
+
* plus the client-injected `authenticationToken` — and subscribe-time
|
|
15
|
+
* authorization delegates to the app sync resource's existing
|
|
16
|
+
* `authorizeChanges({params, scope})` (the `sync.api.resourceClass`), so apps
|
|
17
|
+
* hook in through the authorization they already declared instead of writing
|
|
18
|
+
* their own channel classes. Broadcast routing matches the publisher's
|
|
19
|
+
* scoping params against the subscription's scope conditions.
|
|
20
|
+
*/
|
|
21
|
+
export default class SyncWebsocketChannel extends VelociousWebsocketChannel {
|
|
22
|
+
/**
|
|
23
|
+
* Scope the subscription was authorized for, set by {@link SyncWebsocketChannel#canSubscribe}.
|
|
24
|
+
* @type {import("./sync-resource-base.js").SerializedChangesScope | null}
|
|
25
|
+
*/
|
|
26
|
+
_scope = null
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Registers the framework sync channel on a configuration declaring a
|
|
30
|
+
* `sync.api` block (guarded so repeated server boots with the same
|
|
31
|
+
* configuration register it only once). No-op without `sync.api` — the
|
|
32
|
+
* channel authorizes through the app's sync resource class.
|
|
33
|
+
* @param {import("../configuration.js").default} configuration - Configuration instance.
|
|
34
|
+
* @returns {void}
|
|
35
|
+
*/
|
|
36
|
+
static registerFromConfiguration(configuration) {
|
|
37
|
+
if (!configuration.getSyncConfiguration().api || registeredConfigurations.has(configuration)) return
|
|
38
|
+
|
|
39
|
+
registeredConfigurations.add(configuration)
|
|
40
|
+
configuration.registerWebsocketChannel(VELOCIOUS_SYNC_CHANNEL, this)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Authorizes the subscription through the app sync resource: the subscribe
|
|
45
|
+
* params are parsed into the same serialized scope the changes endpoint
|
|
46
|
+
* consumes and passed to the resource's `authorizeChanges({params, scope})`.
|
|
47
|
+
* Denials and malformed scopes throw, rejecting the subscription.
|
|
48
|
+
* @returns {Promise<boolean>} Whether the subscription is allowed.
|
|
49
|
+
*/
|
|
50
|
+
async canSubscribe() {
|
|
51
|
+
const resource = await this.buildSyncResource()
|
|
52
|
+
const scope = resource.changesScope({scope: {conditions: this.params.conditions, resourceType: this.params.resourceType}})
|
|
53
|
+
|
|
54
|
+
await resource.authorizeChanges({params: this.params, scope})
|
|
55
|
+
|
|
56
|
+
this._scope = scope
|
|
57
|
+
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Builds the app sync resource authorizing this subscription, mirroring the
|
|
63
|
+
* sync transport controller's resource construction with the ability
|
|
64
|
+
* resolved from the subscribe params (which carry the client-injected
|
|
65
|
+
* authenticationToken).
|
|
66
|
+
* @returns {Promise<import("./sync-resource-base.js").default>} App sync resource instance.
|
|
67
|
+
*/
|
|
68
|
+
async buildSyncResource() {
|
|
69
|
+
const configuration = this.session.configuration
|
|
70
|
+
const api = configuration.getSyncConfiguration().api
|
|
71
|
+
|
|
72
|
+
if (!api) {
|
|
73
|
+
throw new Error(`The ${VELOCIOUS_SYNC_CHANNEL} channel requires a sync.api configuration block with the app's sync resource class`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Narrows the configured resource class to the sync resource contract
|
|
77
|
+
// (changesScope/authorizeChanges) the sync.api validation requires.
|
|
78
|
+
const ResourceClass = /** @type {typeof import("./sync-resource-base.js").default} */ (api.resourceClass)
|
|
79
|
+
// Narrows the websocket subscribe params to the resource params contract.
|
|
80
|
+
const params = /** @type {import("../configuration-types.js").VelociousParams} */ (/** @type {unknown} */ (this.params))
|
|
81
|
+
const request = this.session.upgradeRequest
|
|
82
|
+
const ability = await configuration.resolveAbility({params, request})
|
|
83
|
+
|
|
84
|
+
return new ResourceClass({
|
|
85
|
+
ability,
|
|
86
|
+
context: {
|
|
87
|
+
...(ability?.getContext() || {}),
|
|
88
|
+
params,
|
|
89
|
+
request
|
|
90
|
+
},
|
|
91
|
+
locals: ability?.getLocals() || {},
|
|
92
|
+
modelClass: ResourceClass.ModelClass,
|
|
93
|
+
modelName: ResourceClass.ModelClass?.name,
|
|
94
|
+
params,
|
|
95
|
+
resourceConfiguration: /** @type {import("../configuration-types.js").FrontendModelResourceConfiguration} */ ({
|
|
96
|
+
attributes: ResourceClass.attributes || {},
|
|
97
|
+
sync: {enabled: true}
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Routes a publisher broadcast to this subscription when the published
|
|
104
|
+
* resource type equals the type the subscription was authorized for and the
|
|
105
|
+
* scoping params satisfy every scope condition: each condition key must be
|
|
106
|
+
* present in the broadcast params and match by string comparison (array
|
|
107
|
+
* conditions match by membership). Broadcasts without a resource type and
|
|
108
|
+
* conditions the publisher's scoping params do not carry never match, so a
|
|
109
|
+
* subscription cannot receive changes outside its authorized scope.
|
|
110
|
+
* @param {import("../http-server/websocket-channel.js").WebsocketJsonValue} broadcastParams - Publisher scoping params (the published resourceType plus the change's scope-partition values).
|
|
111
|
+
* @returns {boolean} Whether the broadcast belongs to this subscription's scope.
|
|
112
|
+
*/
|
|
113
|
+
matches(broadcastParams) {
|
|
114
|
+
const scope = this._scope
|
|
115
|
+
|
|
116
|
+
if (!scope) return false
|
|
117
|
+
|
|
118
|
+
const scopingParams = broadcastParams && typeof broadcastParams === "object" && !Array.isArray(broadcastParams)
|
|
119
|
+
? /** @type {Record<string, ?>} */ (broadcastParams)
|
|
120
|
+
: {}
|
|
121
|
+
|
|
122
|
+
if (!Object.hasOwn(scopingParams, "resourceType") || String(scopingParams.resourceType) !== String(scope.resourceType)) return false
|
|
123
|
+
|
|
124
|
+
for (const [conditionName, conditionValue] of Object.entries(scope.conditions)) {
|
|
125
|
+
if (!Object.hasOwn(scopingParams, conditionName)) return false
|
|
126
|
+
|
|
127
|
+
const scopingValue = scopingParams[conditionName]
|
|
128
|
+
|
|
129
|
+
if (Array.isArray(conditionValue)) {
|
|
130
|
+
if (!conditionValue.some((value) => String(value) === String(scopingValue))) return false
|
|
131
|
+
} else if (String(conditionValue) !== String(scopingValue)) {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Returns the authorized scope for debug snapshots.
|
|
141
|
+
* @returns {Record<string, ?>} Debug-safe subscription details.
|
|
142
|
+
*/
|
|
143
|
+
debugSnapshot() {
|
|
144
|
+
return {scope: this._scope}
|
|
145
|
+
}
|
|
146
|
+
}
|