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.
Files changed (60) hide show
  1. package/build/application.js +2 -0
  2. package/build/configuration-types.js +8 -8
  3. package/build/configuration.js +2 -2
  4. package/build/src/application.d.ts.map +1 -1
  5. package/build/src/application.js +3 -1
  6. package/build/src/configuration-types.d.ts +17 -17
  7. package/build/src/configuration-types.d.ts.map +1 -1
  8. package/build/src/configuration-types.js +9 -9
  9. package/build/src/configuration.d.ts +4 -4
  10. package/build/src/configuration.d.ts.map +1 -1
  11. package/build/src/configuration.js +3 -3
  12. package/build/src/sync/sync-channel-name.d.ts +9 -0
  13. package/build/src/sync/sync-channel-name.d.ts.map +1 -0
  14. package/build/src/sync/sync-channel-name.js +10 -0
  15. package/build/src/sync/sync-client-types.d.ts +10 -7
  16. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  17. package/build/src/sync/sync-client-types.js +1 -1
  18. package/build/src/sync/sync-client.d.ts +1 -1
  19. package/build/src/sync/sync-client.js +2 -2
  20. package/build/src/sync/sync-model-change-feed-service.d.ts +6 -1
  21. package/build/src/sync/sync-model-change-feed-service.d.ts.map +1 -1
  22. package/build/src/sync/sync-model-change-feed-service.js +15 -4
  23. package/build/src/sync/sync-publisher-types.d.ts +53 -21
  24. package/build/src/sync/sync-publisher-types.d.ts.map +1 -1
  25. package/build/src/sync/sync-publisher-types.js +1 -1
  26. package/build/src/sync/sync-publisher.d.ts +32 -9
  27. package/build/src/sync/sync-publisher.d.ts.map +1 -1
  28. package/build/src/sync/sync-publisher.js +170 -37
  29. package/build/src/sync/sync-realtime-bridge.d.ts +33 -10
  30. package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
  31. package/build/src/sync/sync-realtime-bridge.js +51 -12
  32. package/build/src/sync/sync-scope-attributes.d.ts +15 -0
  33. package/build/src/sync/sync-scope-attributes.d.ts.map +1 -0
  34. package/build/src/sync/sync-scope-attributes.js +24 -0
  35. package/build/src/sync/sync-websocket-channel.d.ts +58 -0
  36. package/build/src/sync/sync-websocket-channel.d.ts.map +1 -0
  37. package/build/src/sync/sync-websocket-channel.js +131 -0
  38. package/build/sync/sync-channel-name.js +10 -0
  39. package/build/sync/sync-client-types.js +12 -6
  40. package/build/sync/sync-client.js +1 -1
  41. package/build/sync/sync-model-change-feed-service.js +17 -3
  42. package/build/sync/sync-publisher-types.js +32 -12
  43. package/build/sync/sync-publisher.js +192 -41
  44. package/build/sync/sync-realtime-bridge.js +56 -11
  45. package/build/sync/sync-scope-attributes.js +26 -0
  46. package/build/sync/sync-websocket-channel.js +146 -0
  47. package/build/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +1 -1
  49. package/src/application.js +2 -0
  50. package/src/configuration-types.js +8 -8
  51. package/src/configuration.js +2 -2
  52. package/src/sync/sync-channel-name.js +10 -0
  53. package/src/sync/sync-client-types.js +12 -6
  54. package/src/sync/sync-client.js +1 -1
  55. package/src/sync/sync-model-change-feed-service.js +17 -3
  56. package/src/sync/sync-publisher-types.js +32 -12
  57. package/src/sync/sync-publisher.js +192 -41
  58. package/src/sync/sync-realtime-bridge.js +56 -11
  59. package/src/sync/sync-scope-attributes.js +26 -0
  60. package/src/sync/sync-websocket-channel.js +146 -0
@@ -1,8 +1,8 @@
1
1
  // @ts-check
2
2
 
3
3
  /**
4
- * Args resolved against a publish declaration's broadcasts after a published
5
- * server-side mutation commits.
4
+ * Args resolved against a publish declaration's deprecated broadcasts after a
5
+ * published server-side mutation commits.
6
6
  * @typedef {object} SyncPublishBroadcastArgs
7
7
  * @property {Record<string, ?>} data - Payload snapshotted through the declaration's `serialize(record)` at mutation time.
8
8
  * @property {"create" | "update" | "destroy"} operation - Mutation operation that published.
@@ -16,6 +16,7 @@
16
16
  /**
17
17
  * One declarative broadcast fanned out after a published server-side mutation
18
18
  * commits — same shape the replay service's injected broadcaster consumes.
19
+ * @deprecated Publishing broadcasts the standard sync envelope on the framework sync channel automatically; declare app-channel broadcasts only for legacy channels old app versions still subscribe.
19
20
  * @typedef {object} SyncPublishBroadcast
20
21
  * @property {string | ((args: SyncPublishBroadcastArgs) => string)} channel - Channel name or resolver.
21
22
  * @property {(args: SyncPublishBroadcastArgs) => Record<string, ?>} broadcastParams - Channel routing params.
@@ -27,38 +28,57 @@
27
28
  * Server-side publish declaration on a model's `static sync`, consumed by
28
29
  * `SyncPublisher.fromConfiguration(...)`. Publishing is on for models
29
30
  * declaring it (server-side creates and updates write to the sync change
30
- * feed and broadcast automatically once their transaction commits);
31
- * `publish: false` opts a model out explicitly.
31
+ * feed and broadcast the standard sync envelope on the framework sync
32
+ * channel automatically once their transaction commits); `publish: true`
33
+ * opts in with all defaults and `publish: false` opts a model out explicitly.
34
+ * @template [TModel=any]
32
35
  * @typedef {object} SyncPublishDeclarationConfig
33
- * @property {(record: ?) => Record<string, ?> | Promise<Record<string, ?>>} serialize - Builds the published payload snapshot from the mutated record (snapshotted at mutation time).
34
- * @property {(record: ?) => string | number | null | Promise<string | number | null>} [eventId] - Resolves the event scope persisted to the sync row's event_id column.
35
- * @property {SyncPublishBroadcast[]} [broadcasts] - Declarative broadcasts fanned out after the sync row is upserted.
36
+ * @property {(record: TModel) => Record<string, ?> | Promise<Record<string, ?>>} [serialize] - Builds the published payload snapshot from the mutated record (snapshotted at mutation time). Defaults to the record's attributes with Date values serialized to ISO strings.
37
+ * @property {Record<string, string>} [scopeAttributes] - Record-attribute name overrides per scope attribute declared on the sync model's `static syncScopeAttributes`. By default each declared scope attribute reads the record's attribute of the same name when the model has one, else the record's own id (scope-root models).
38
+ * @property {string | ((record: TModel) => string | number | null | Promise<string | number | null>)} [eventId] - Deprecated 1.0.503 form: attribute-name string (or resolver function) persisted to a fixed event_id sync-row column and broadcast as a fixed `eventId` scoping param. Declare `static syncScopeAttributes` on the sync model plus `scopeAttributes` overrides instead.
39
+ * @property {SyncPublishBroadcast[]} [broadcasts] - Deprecated: declarative app-channel broadcasts fanned out after the framework sync channel broadcast. The framework broadcast happens automatically; keep this only for legacy channels old app versions still subscribe.
36
40
  * @property {Array<"create" | "update" | "destroy">} [operations] - Published operations. Defaults to creates and updates; destroys are opt-in because a server destroy is often cleanup rather than a synced delete.
37
41
  * @property {string} [resourceType] - Published resource type. Defaults to the model name.
38
42
  */
39
43
 
40
- /** @typedef {false | SyncPublishDeclarationConfig} SyncPublishDeclaration */
44
+ /**
45
+ * Model-level publish declaration value: `true` publishes with all defaults,
46
+ * `false` opts out explicitly, an object customizes the published payload and scoping.
47
+ * @template [TModel=any]
48
+ * @typedef {boolean | SyncPublishDeclarationConfig<TModel>} SyncPublishDeclaration
49
+ */
41
50
 
42
51
  /**
43
52
  * Options for building a sync publisher. Published resources are derived from
44
53
  * the configuration's registered models (`static sync` publish declarations).
45
54
  * @typedef {object} SyncPublisherOptions
46
55
  * @property {string} [actorForeignKeyColumn] - Sync model column linking rows to a device actor. Published server-origin rows set it to null (no device to echo). Defaults to "authentication_token_id".
47
- * @property {(broadcast: {channel: string, params: Record<string, ?>, body: ?}) => Promise<void>} [broadcaster] - Delivers declared broadcasts. Defaults to the configuration's channel broadcast.
56
+ * @property {(broadcast: {channel: string, params: Record<string, ?>, body: ?}) => Promise<void>} [broadcaster] - Delivers the framework sync channel broadcast and any deprecated declared broadcasts. Defaults to the configuration's channel broadcast.
48
57
  * @property {import("../configuration.js").default} [configuration] - Configuration owning the registered models. Defaults to the current configuration.
49
58
  * @property {(error: Error) => void} [onError] - Reports post-commit publish failures. Defaults to loud logging.
50
59
  * @property {?} [syncModel] - Sync/change model override. Defaults to the registered "Sync" model.
51
60
  */
52
61
 
62
+ /**
63
+ * One derived scope-partition source for a published resource — not an
64
+ * app-facing API. The value is persisted to the sync row's `columnName` and
65
+ * broadcast under `scopeAttribute` on the framework sync channel.
66
+ * @typedef {object} SyncPublisherScopePlanEntry
67
+ * @property {string} columnName - Sync-row column persisting the scope value.
68
+ * @property {string | null} recordAttribute - Record attribute read for the scope value, or null when the record's own id is the scope (scope-root models).
69
+ * @property {((record: ?) => string | number | null | Promise<string | number | null>) | undefined} resolver - Deprecated eventId resolver function.
70
+ * @property {string} scopeAttribute - Scope attribute name broadcast as the framework channel scoping param.
71
+ */
72
+
53
73
  /**
54
74
  * Internal derived publish policy for one resource — not an app-facing API.
55
75
  * @typedef {object} SyncPublisherResourceConfig
56
- * @property {SyncPublishBroadcast[] | undefined} broadcasts - Declared broadcasts.
57
- * @property {SyncPublishDeclarationConfig["eventId"] | undefined} eventId - Event scope resolver.
76
+ * @property {SyncPublishBroadcast[] | undefined} broadcasts - Deprecated declared app-channel broadcasts.
58
77
  * @property {?} modelClass - Server model class for this resource.
59
78
  * @property {Array<"create" | "update" | "destroy">} operations - Published operations.
79
+ * @property {Array<SyncPublisherScopePlanEntry>} scopePlan - Derived scope-partition plan.
80
+ * @property {(record: ?) => Record<string, ?> | Promise<Record<string, ?>>} serialize - Payload snapshot builder (the declaration's serialize or the default attribute serializer).
60
81
  * @property {string} resourceType - Published resource type.
61
- * @property {SyncPublishDeclarationConfig["serialize"]} serialize - Payload snapshot builder.
62
82
  */
63
83
 
64
84
  export {}
@@ -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
- * fans out the declared broadcasts, so devices receive server-origin changes
33
- * without app code calling manual upsert/broadcast helpers:
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
- * publish: {
37
- * serialize: (event) => ({id: event.id(), eventPin: event.eventPin()}),
38
- * eventId: (event) => event.id(),
39
- * broadcasts: [{channel: "ticket-scans", broadcastParams: ..., body: ...}]
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
- /** @type {Record<string, import("./sync-publisher-types.js").SyncPublisherResourceConfig>} */
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 (Object.keys(resources).length === 0) {
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").SyncPublishDeclarationConfig | null} Active publish declaration, or null.
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
- * @param {{modelClass: ?, publish: import("./sync-publisher-types.js").SyncPublishDeclarationConfig}} args - Declaration args.
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 (!publish || typeof publish !== "object" || Array.isArray(publish)) {
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} = publish
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 requires a serialize(record) function building the published payload`)
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
- serialize
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 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
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 like eventId).
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: 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.
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 realtime channels - declare sync.client.realtime.channels or static sync = {realtime: {channel}} on a model")
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
+ }