velocious 1.0.504 → 1.0.506

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 (44) hide show
  1. package/build/configuration-types.js +2 -0
  2. package/build/configuration.js +11 -3
  3. package/build/database/drivers/sqlite/table-rebuilder.js +35 -0
  4. package/build/src/configuration-types.d.ts +10 -0
  5. package/build/src/configuration-types.d.ts.map +1 -1
  6. package/build/src/configuration-types.js +3 -1
  7. package/build/src/configuration.d.ts.map +1 -1
  8. package/build/src/configuration.js +12 -4
  9. package/build/src/database/drivers/sqlite/table-rebuilder.d.ts.map +1 -1
  10. package/build/src/database/drivers/sqlite/table-rebuilder.js +31 -1
  11. package/build/src/sync/query-scope.d.ts +6 -1
  12. package/build/src/sync/query-scope.d.ts.map +1 -1
  13. package/build/src/sync/query-scope.js +9 -3
  14. package/build/src/sync/sync-client-types.d.ts +18 -0
  15. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  16. package/build/src/sync/sync-client-types.js +1 -1
  17. package/build/src/sync/sync-client.d.ts +61 -0
  18. package/build/src/sync/sync-client.d.ts.map +1 -1
  19. package/build/src/sync/sync-client.js +112 -2
  20. package/build/src/sync/sync-realtime-bridge.d.ts +20 -6
  21. package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
  22. package/build/src/sync/sync-realtime-bridge.js +52 -18
  23. package/build/src/sync/sync-resource-base.d.ts +20 -0
  24. package/build/src/sync/sync-resource-base.d.ts.map +1 -1
  25. package/build/src/sync/sync-resource-base.js +19 -1
  26. package/build/src/sync/sync-websocket-channel.d.ts +32 -0
  27. package/build/src/sync/sync-websocket-channel.d.ts.map +1 -1
  28. package/build/src/sync/sync-websocket-channel.js +73 -2
  29. package/build/sync/query-scope.js +9 -2
  30. package/build/sync/sync-client-types.js +10 -0
  31. package/build/sync/sync-client.js +125 -1
  32. package/build/sync/sync-realtime-bridge.js +52 -17
  33. package/build/sync/sync-resource-base.js +21 -0
  34. package/build/sync/sync-websocket-channel.js +80 -1
  35. package/package.json +1 -1
  36. package/src/configuration-types.js +2 -0
  37. package/src/configuration.js +11 -3
  38. package/src/database/drivers/sqlite/table-rebuilder.js +35 -0
  39. package/src/sync/query-scope.js +9 -2
  40. package/src/sync/sync-client-types.js +10 -0
  41. package/src/sync/sync-client.js +125 -1
  42. package/src/sync/sync-realtime-bridge.js +52 -17
  43. package/src/sync/sync-resource-base.js +21 -0
  44. package/src/sync/sync-websocket-channel.js +80 -1
@@ -419,6 +419,8 @@
419
419
  * @property {(error: Error) => void} [onError] - Reports background replay/pull failures. Defaults to rethrowing.
420
420
  * @property {VelociousSyncClientRealtimeConfiguration} [realtime] - Realtime push configuration consumed by `subscribeRealtime(...)`.
421
421
  * @property {VelociousSyncClientTransport} transport - Transport posting to the framework sync endpoints (e.g. the frontend-model websocket client).
422
+ * @property {VelociousSyncRealtimeWebsocketClient} [websocketClient] - Shared app-lifetime websocket client (the low-level form) that all sync traffic rides. Provide the same instance the frontend-model transport uses so one connection carries everything. When set, the realtime bridge subscribes channels on it and never owns its lifecycle: unsubscribing closes only channel subscriptions, leaving the socket connected.
423
+ * @property {string | (() => string | null | undefined)} [websocketUrl] - Shared app-lifetime websocket URL. When set (and no `websocketClient` is given), the framework builds and owns one reconnecting `VelociousWebsocketClient` for all sync traffic, connected on first use.
422
424
  */
423
425
 
424
426
  /**
@@ -463,11 +463,11 @@ export default class VelociousConfiguration {
463
463
  throw new Error("sync.client must be an object with transport and authenticationToken")
464
464
  }
465
465
 
466
- const {authenticationToken, batchSize, isOnline, mountPath, onError, realtime, transport, ...restClient} = client
466
+ const {authenticationToken, batchSize, isOnline, mountPath, onError, realtime, transport, websocketClient, websocketUrl, ...restClient} = client
467
467
  const restClientKeys = Object.keys(restClient)
468
468
 
469
469
  if (restClientKeys.length > 0) {
470
- throw new Error(`sync.client received unknown keys: ${restClientKeys.join(", ")} (supported: authenticationToken, batchSize, isOnline, mountPath, onError, realtime, transport)`)
470
+ throw new Error(`sync.client received unknown keys: ${restClientKeys.join(", ")} (supported: authenticationToken, batchSize, isOnline, mountPath, onError, realtime, transport, websocketClient, websocketUrl)`)
471
471
  }
472
472
  if (!transport || typeof transport !== "object" || typeof transport.post !== "function") {
473
473
  throw new Error("sync.client.transport must be an object with a post(path, body) method (like the frontend-model websocket client)")
@@ -487,6 +487,12 @@ export default class VelociousConfiguration {
487
487
  if (mountPath !== undefined && (typeof mountPath !== "string" || !mountPath.startsWith("/"))) {
488
488
  throw new Error(`sync.client.mountPath must start with '/', got: ${String(mountPath)}`)
489
489
  }
490
+ if (websocketClient !== undefined && (typeof websocketClient !== "object" || websocketClient === null || typeof websocketClient.subscribeChannel !== "function")) {
491
+ throw new Error("sync.client.websocketClient must be a websocket client with a subscribeChannel method (like VelociousWebsocketClient)")
492
+ }
493
+ if (websocketUrl !== undefined && typeof websocketUrl !== "string" && typeof websocketUrl !== "function") {
494
+ throw new Error(`sync.client.websocketUrl must be a URL string or a function resolving one, got: ${String(websocketUrl)}`)
495
+ }
490
496
 
491
497
  return {
492
498
  authenticationToken,
@@ -495,7 +501,9 @@ export default class VelociousConfiguration {
495
501
  mountPath: (mountPath || "/velocious/sync").replace(/\/+$/u, "") || "/",
496
502
  onError,
497
503
  realtime,
498
- transport
504
+ transport,
505
+ websocketClient,
506
+ websocketUrl
499
507
  }
500
508
  }
501
509
 
@@ -52,6 +52,23 @@ export default class VelociousDatabaseDriversSqliteTableRebuilder {
52
52
  const targetTableData = this.targetTableData
53
53
  const previousTargetName = targetTableData.getName()
54
54
 
55
+ // Column-level `index: true` indexes are named after the table they are created with, and
56
+ // ALTER TABLE ... RENAME does not rename indexes - creating them inside the temp table's
57
+ // CREATE would leak the temp rebuild name permanently. Strip the flags for the temp CREATE
58
+ // and create those indexes explicitly on the temp table with their FINAL names BEFORE the
59
+ // copy: index names are database-global and survive the rename, and a violation (e.g. a new
60
+ // unique index over rows that all receive the same default) fails during INSERT...SELECT,
61
+ // while the original table still exists - never after the swap.
62
+ /** @type {Array<{column: import("../../table-data/table-column.js").default, index: ?, indexArgs: ?}>} */
63
+ const strippedColumnIndexes = []
64
+
65
+ for (const column of targetTableData.getColumns()) {
66
+ if (!column.getIndex()) continue
67
+
68
+ strippedColumnIndexes.push({column, index: column.getIndex(), indexArgs: column.getIndexArgs()})
69
+ column.setIndex(false)
70
+ }
71
+
55
72
  targetTableData.setName(tempTableName)
56
73
 
57
74
  let createTableSQLs
@@ -60,6 +77,8 @@ export default class VelociousDatabaseDriversSqliteTableRebuilder {
60
77
  createTableSQLs = await driver.createTableSql(targetTableData)
61
78
  } finally {
62
79
  targetTableData.setName(previousTargetName)
80
+
81
+ for (const {column, index} of strippedColumnIndexes) column.setIndex(index)
63
82
  }
64
83
 
65
84
  const newColumnsSQL = this.columnPairs.map(([, newName]) => options.quoteColumnName(newName)).join(", ")
@@ -69,6 +88,22 @@ export default class VelociousDatabaseDriversSqliteTableRebuilder {
69
88
 
70
89
  for (const sql of createTableSQLs) sqls.push(sql)
71
90
 
91
+ for (const {column, indexArgs} of strippedColumnIndexes) {
92
+ const {unique, ...restIndexArgs} = indexArgs || {}
93
+
94
+ restArgsError(restIndexArgs)
95
+
96
+ const createIndexSQLs = await new CreateIndexBase({
97
+ columns: [column.getName()],
98
+ driver,
99
+ name: `index_on_${originalTableName}_${column.getName()}`,
100
+ tableName: tempTableName,
101
+ unique
102
+ }).toSQLs()
103
+
104
+ for (const sql of createIndexSQLs) sqls.push(sql)
105
+ }
106
+
72
107
  if (this.columnPairs.length > 0) {
73
108
  sqls.push(
74
109
  `INSERT INTO ${options.quoteTableName(tempTableName)} (${newColumnsSQL}) ` +
@@ -44,12 +44,19 @@ export function serializedScopeFromQuery(query) {
44
44
  }
45
45
 
46
46
  /**
47
- * Returns a stable canonical key identifying a sync scope.
47
+ * Returns a stable canonical key identifying a sync scope. When an `owner` is
48
+ * present (the authenticated identity that declared the scope locally), it
49
+ * participates in the key so the same wire scope owned by a different user gets
50
+ * its own local identity and cursor — a user scope's empty-conditions cursor
51
+ * never leaks across accounts on a shared device, while the same user
52
+ * reconnecting keeps continuity. Owner-less scopes keep their pre-owner key.
48
53
  * @param {import("./sync-client-types.js").SerializedSyncScope} scope - Serialized sync scope.
49
54
  * @returns {string} Stable scope key.
50
55
  */
51
56
  export function scopeKey(scope) {
52
- return `${scope.resourceType}:${stableJsonStringify(scope.conditions)}`
57
+ const ownerPrefix = scope.owner === undefined || scope.owner === null ? "" : `owner=${stableJsonStringify(scope.owner)}|`
58
+
59
+ return `${ownerPrefix}${scope.resourceType}:${stableJsonStringify(scope.conditions)}`
53
60
  }
54
61
 
55
62
  /**
@@ -5,6 +5,7 @@
5
5
  * @typedef {object} SerializedSyncScope
6
6
  * @property {Record<string, ?>} conditions - Plain attribute conditions from the query.
7
7
  * @property {string} resourceType - Resource/model name the scope was declared for.
8
+ * @property {string} [owner] - Local partition key: the authenticated identity that declared the scope. Used only to partition the local scope/cursor store (never sent to the server as scope conditions), so a user scope's empty-conditions cursor does not leak across accounts on a shared device.
8
9
  */
9
10
 
10
11
  /**
@@ -58,6 +59,13 @@
58
59
  * @typedef {boolean | ModelSyncDeclarationConfig<TModel>} ModelSyncDeclaration
59
60
  */
60
61
 
62
+ /**
63
+ * Shared app-lifetime websocket connection all sync traffic rides. Matches the
64
+ * realtime websocket client contract; the sync client rides it without owning
65
+ * its connect/disconnect lifecycle.
66
+ * @typedef {import("../configuration-types.js").VelociousSyncRealtimeWebsocketClient} SyncClientSharedConnection
67
+ */
68
+
61
69
  /**
62
70
  * Options for building a sync client. Everything else — resources, transport
63
71
  * POSTers, auth, connectivity, batch size — is derived from the configuration's
@@ -84,6 +92,8 @@
84
92
  * @property {import("../configuration-types.js").VelociousSyncClientRealtimeConfiguration} [realtime] - Realtime push configuration consumed by `subscribeRealtime(...)`.
85
93
  * @property {Record<string, SyncClientResourceConfig>} resources - Derived resource policies keyed by resource/model name.
86
94
  * @property {?} syncModel - Local pending-sync model class.
95
+ * @property {SyncClientSharedConnection} [websocketClient] - Shared app-lifetime websocket client instance (the low-level shared-connection form).
96
+ * @property {string | (() => string | null | undefined)} [websocketUrl] - Shared app-lifetime websocket URL the framework builds a client from.
87
97
  */
88
98
 
89
99
  export {}
@@ -4,6 +4,7 @@ import Configuration from "../configuration.js"
4
4
  import {isBooleanColumnType} from "../database/column-types.js"
5
5
  import Logger from "../logger.js"
6
6
  import restArgsError from "../utils/rest-args-error.js"
7
+ import VelociousWebsocketClient from "../http-client/websocket-client.js"
7
8
 
8
9
  import {serializedScopeFromQuery} from "./query-scope.js"
9
10
  import SyncApiClient from "./sync-api-client.js"
@@ -99,11 +100,19 @@ export default class SyncClient {
99
100
  postReplay: transportPoster({path: `${clientConfiguration.mountPath}/replay`, transport: clientConfiguration.transport}),
100
101
  realtime: clientConfiguration.realtime,
101
102
  resources,
102
- syncModel: resolvedSyncModel
103
+ syncModel: resolvedSyncModel,
104
+ websocketClient: clientConfiguration.websocketClient,
105
+ websocketUrl: clientConfiguration.websocketUrl
103
106
  }
104
107
  this._clientNumber = ++clientCounter
105
108
  /** @type {SyncRealtimeBridge | null} */
106
109
  this._realtimeBridge = null
110
+ /** @type {import("./sync-client-types.js").SyncClientSharedConnection | null | undefined} Shared app-lifetime websocket connection (undefined until first resolved, null when none is configured). */
111
+ this._syncConnection = undefined
112
+ /** @type {Promise<void> | null} */
113
+ this._subscribeUserScopePromise = null
114
+ /** @type {"subscribed" | "subscribing" | "unsubscribed"} */
115
+ this._userScopeState = "unsubscribed"
107
116
  /** @type {import("./sync-scope-store.js").default | null} */
108
117
  this._scopeStore = scopeStore || null
109
118
  /** @type {Promise<void> | null} */
@@ -449,6 +458,33 @@ export default class SyncClient {
449
458
  }
450
459
  }
451
460
 
461
+ /**
462
+ * Resolves the shared app-lifetime websocket connection all sync traffic
463
+ * rides, or null when none is configured. Built once and memoized (per
464
+ * client): an app-provided `sync.client.websocketClient` instance wins (the
465
+ * frontend-model transport can pass its own client so one socket carries
466
+ * everything), else a framework-owned reconnecting {@link VelociousWebsocketClient}
467
+ * built from `sync.client.websocketUrl`. The realtime bridge rides this
468
+ * connection without owning its lifecycle; when neither is configured the
469
+ * bridge falls back to the deprecated per-cycle `realtime.createClient`.
470
+ * @returns {import("./sync-client-types.js").SyncClientSharedConnection | null} Shared websocket connection, or null.
471
+ */
472
+ syncConnection() {
473
+ if (this._syncConnection !== undefined) return this._syncConnection
474
+
475
+ if (this.config.websocketClient) {
476
+ this._syncConnection = this.config.websocketClient
477
+ } else if (this.config.websocketUrl) {
478
+ const url = typeof this.config.websocketUrl === "function" ? this.config.websocketUrl() : this.config.websocketUrl
479
+
480
+ this._syncConnection = url ? new VelociousWebsocketClient({url}) : null
481
+ } else {
482
+ this._syncConnection = null
483
+ }
484
+
485
+ return this._syncConnection
486
+ }
487
+
452
488
  /**
453
489
  * Subscribes the derived realtime channels so pushed websocket changes apply
454
490
  * through the same derived applier as pulls (idempotent, single-flighted).
@@ -459,6 +495,94 @@ export default class SyncClient {
459
495
  await this.realtimeBridge().subscribe(context)
460
496
  }
461
497
 
498
+ /**
499
+ * Subscribes the server-enumerated user scope: "everything my ability can
500
+ * see". Declares a user scope (empty conditions) for every pullable synced
501
+ * resource type, subscribes realtime so their framework sync channel
502
+ * subscriptions go live, and pulls so the device catches up. The server
503
+ * authorizes each empty-conditions scope through the app sync resource's
504
+ * `authorizeChanges` and re-checks record access per delivery, so the client
505
+ * subscribes with just its token and the server decides membership.
506
+ * Idempotent and single-flighted like {@link SyncClient#subscribeRealtime}.
507
+ * @returns {Promise<void>}
508
+ */
509
+ async subscribeUserScope() {
510
+ if (this._userScopeState === "subscribed") return
511
+
512
+ if (!this._subscribeUserScopePromise) {
513
+ this._subscribeUserScopePromise = this._subscribeUserScope().finally(() => {
514
+ this._subscribeUserScopePromise = null
515
+ })
516
+ }
517
+
518
+ await this._subscribeUserScopePromise
519
+ }
520
+
521
+ /**
522
+ * Declares and activates the user scope for every pullable resource, then
523
+ * subscribes realtime and pulls.
524
+ * @returns {Promise<void>}
525
+ */
526
+ async _subscribeUserScope() {
527
+ this._userScopeState = "subscribing"
528
+
529
+ const scopeStore = this.scopeStore()
530
+ const owner = await this.userScopeOwner()
531
+
532
+ for (const resourceType of this.userScopeResourceTypes()) {
533
+ await scopeStore.findOrCreateScope({conditions: {}, owner, resourceType})
534
+ }
535
+
536
+ await this.subscribeRealtime()
537
+ await this.pull()
538
+
539
+ this._userScopeState = "subscribed"
540
+ }
541
+
542
+ /**
543
+ * Unsubscribes the user scope: deactivates the per-resource user scopes and
544
+ * closes the realtime channel subscriptions. The shared websocket connection
545
+ * stays open when one is configured (sign-out drops subscriptions without
546
+ * disconnecting), so a subsequent sign-in resubscribes over the same socket.
547
+ * @returns {Promise<void>}
548
+ */
549
+ async unsubscribeUserScope() {
550
+ const scopeStore = this.scopeStore()
551
+ const owner = await this.userScopeOwner()
552
+
553
+ for (const resourceType of this.userScopeResourceTypes()) {
554
+ await scopeStore.deactivate({conditions: {}, owner, resourceType})
555
+ }
556
+
557
+ await this.unsubscribeRealtime()
558
+
559
+ this._userScopeState = "unsubscribed"
560
+ this._subscribeUserScopePromise = null
561
+ }
562
+
563
+ /**
564
+ * The resource types a user scope covers: every declared resource that
565
+ * receives pulled changes (has pull `attributes`).
566
+ * @returns {string[]} Pullable resource type names.
567
+ */
568
+ userScopeResourceTypes() {
569
+ return Object.keys(this.pullResourceConfigs())
570
+ }
571
+
572
+ /**
573
+ * Resolves the local partition key for the user scope: the currently
574
+ * configured authenticated identity (the sync auth token). Partitioning the
575
+ * user scope's local scope/cursor rows by this owner keeps the
576
+ * empty-conditions cursor from leaking across accounts on a shared device
577
+ * (account B signing in after account A gets a fresh cursor) while the same
578
+ * account reconnecting keeps its cursor continuity. The owner is a local
579
+ * partition key only — pulls still post empty conditions to the server.
580
+ * @returns {Promise<string>} User-scope owner partition key.
581
+ */
582
+ async userScopeOwner() {
583
+ return String(await this.config.authenticationToken())
584
+ }
585
+
462
586
  /**
463
587
  * Unsubscribes the realtime channels and disconnects the websocket client (idempotent).
464
588
  * @returns {Promise<void>}
@@ -30,6 +30,8 @@ export default class SyncRealtimeBridge {
30
30
  this._channels = []
31
31
  /** @type {VelociousSyncRealtimeWebsocketClient | null} */
32
32
  this._client = null
33
+ /** @type {boolean} Whether the bridge created its own client (deprecated per-cycle path) and must disconnect it on unsubscribe. A shared connection is never owned. */
34
+ this._ownsClient = false
33
35
  /** @type {Promise<void>} */
34
36
  this._applyPromise = Promise.resolve()
35
37
  /** @type {number} Subscription generation - bumped by unsubscribe so in-flight subscribes detect they became stale. */
@@ -80,8 +82,13 @@ export default class SyncRealtimeBridge {
80
82
  const channels = []
81
83
  /** @type {VelociousSyncRealtimeWebsocketClient | null} */
82
84
  let client = null
85
+ /** @type {boolean} Whether this attempt created its own client and must disconnect it on teardown. */
86
+ let ownsClient = false
83
87
  /**
84
- * Tears down everything this stale/failed subscribe attempt created itself.
88
+ * Tears down everything this stale/failed subscribe attempt created itself:
89
+ * always closes its channel subscriptions, and disconnects the websocket
90
+ * only when the bridge owns it (deprecated per-cycle path); a shared
91
+ * connection stays open.
85
92
  * @returns {Promise<void>}
86
93
  */
87
94
  const teardown = async () => {
@@ -89,18 +96,28 @@ export default class SyncRealtimeBridge {
89
96
  subscription.close()
90
97
  }
91
98
 
92
- if (client) await client.disconnectAndStopReconnect()
99
+ if (client && ownsClient) await client.disconnectAndStopReconnect()
93
100
  }
94
101
 
95
102
  try {
96
- const realtime = this.realtimeConfiguration()
103
+ const sharedClient = this.syncClient.syncConnection()
104
+ const realtime = this.requireClientSource(sharedClient)
97
105
  const channelDescriptors = await this.channelDescriptors(context)
98
106
 
99
107
  if (generation !== this._generation) return
100
108
 
101
- client = await realtime.createClient()
109
+ if (sharedClient) {
110
+ client = sharedClient
111
+ } else {
112
+ // requireClientSource guaranteed realtime.createClient when there is no shared connection.
113
+ client = await /** @type {import("../configuration-types.js").VelociousSyncClientRealtimeConfiguration} */ (realtime).createClient()
114
+ ownsClient = true
115
+ }
102
116
 
103
- if (generation !== this._generation) return
117
+ if (generation !== this._generation) {
118
+ await teardown()
119
+ return
120
+ }
104
121
 
105
122
  await client.connect()
106
123
 
@@ -140,6 +157,7 @@ export default class SyncRealtimeBridge {
140
157
 
141
158
  this._channels = channels
142
159
  this._client = client
160
+ this._ownsClient = ownsClient
143
161
  this._state = "subscribed"
144
162
  this.schedulePull()
145
163
  } catch (error) {
@@ -152,9 +170,12 @@ export default class SyncRealtimeBridge {
152
170
  }
153
171
 
154
172
  /**
155
- * Closes every channel subscription and disconnects the websocket client
156
- * (idempotent). Also marks any in-flight subscribe attempt stale so it tears
157
- * itself down instead of finishing the subscription afterwards.
173
+ * Closes every channel subscription (idempotent). The websocket is
174
+ * disconnected only when the bridge owns it (deprecated per-cycle
175
+ * `realtime.createClient` path); a shared app-lifetime connection stays open
176
+ * so unsubscribing drops subscriptions without tearing down the socket. Also
177
+ * marks any in-flight subscribe attempt stale so it tears itself down instead
178
+ * of finishing the subscription afterwards.
158
179
  * @returns {Promise<void>}
159
180
  */
160
181
  async unsubscribe() {
@@ -162,16 +183,18 @@ export default class SyncRealtimeBridge {
162
183
 
163
184
  const channels = this._channels
164
185
  const client = this._client
186
+ const ownsClient = this._ownsClient
165
187
 
166
188
  this._channels = []
167
189
  this._client = null
190
+ this._ownsClient = false
168
191
  this._state = "unsubscribed"
169
192
 
170
193
  for (const {subscription} of channels) {
171
194
  subscription.close()
172
195
  }
173
196
 
174
- if (client) await client.disconnectAndStopReconnect()
197
+ if (client && ownsClient) await client.disconnectAndStopReconnect()
175
198
  }
176
199
 
177
200
  /**
@@ -195,14 +218,26 @@ export default class SyncRealtimeBridge {
195
218
  }
196
219
 
197
220
  /**
198
- * Resolves the realtime configuration, failing loudly when it is missing.
199
- * @returns {import("../configuration-types.js").VelociousSyncClientRealtimeConfiguration} Realtime configuration.
221
+ * Resolves the realtime configuration block, or null when the app declared
222
+ * none (valid when a shared connection is configured).
223
+ * @returns {import("../configuration-types.js").VelociousSyncClientRealtimeConfiguration | null} Realtime configuration, or null.
200
224
  */
201
225
  realtimeConfiguration() {
202
- const realtime = this.syncClient.config.realtime
226
+ return this.syncClient.config.realtime || null
227
+ }
228
+
229
+ /**
230
+ * Resolves the realtime configuration and asserts a websocket client source
231
+ * exists: a shared connection rides its own lifecycle, otherwise the
232
+ * deprecated per-cycle `realtime.createClient` must be configured.
233
+ * @param {import("../configuration-types.js").VelociousSyncRealtimeWebsocketClient | null} sharedClient - Shared connection, or null.
234
+ * @returns {import("../configuration-types.js").VelociousSyncClientRealtimeConfiguration | null} Realtime configuration, or null.
235
+ */
236
+ requireClientSource(sharedClient) {
237
+ const realtime = this.realtimeConfiguration()
203
238
 
204
- if (!realtime) {
205
- throw new Error("subscribeRealtime requires a sync.client.realtime configuration block with a createClient callback")
239
+ if (!sharedClient && typeof realtime?.createClient !== "function") {
240
+ throw new Error("subscribeRealtime requires a shared connection (sync.client.websocketUrl or sync.client.websocketClient) or the deprecated sync.client.realtime.createClient callback")
206
241
  }
207
242
 
208
243
  return realtime
@@ -235,7 +270,7 @@ export default class SyncRealtimeBridge {
235
270
  channelDescriptors.push({channel: resourceConfig.realtime.channel, params: resourceConfig.realtime.params, resourceType})
236
271
  }
237
272
 
238
- if (realtime.channels) {
273
+ if (realtime?.channels) {
239
274
  channelDescriptors.push(...await realtime.channels(context))
240
275
  }
241
276
 
@@ -306,7 +341,7 @@ export default class SyncRealtimeBridge {
306
341
 
307
342
  const realtime = this.realtimeConfiguration()
308
343
 
309
- if (realtime.localOrigin && body.echoOrigin !== undefined && body.echoOrigin !== null) {
344
+ if (realtime?.localOrigin && body.echoOrigin !== undefined && body.echoOrigin !== null) {
310
345
  const localOrigin = String(await realtime.localOrigin())
311
346
 
312
347
  if (String(body.echoOrigin) === localOrigin) return
@@ -329,7 +364,7 @@ export default class SyncRealtimeBridge {
329
364
  * @returns {void}
330
365
  */
331
366
  schedulePull() {
332
- if (this.realtimeConfiguration().pullOnReconnect === false) return
367
+ if (this.realtimeConfiguration()?.pullOnReconnect === false) return
333
368
 
334
369
  this._scheduledPull ||= (async () => {
335
370
  try {
@@ -215,6 +215,27 @@ export default class SyncResourceBase extends FrontendModelBaseResource {
215
215
  throw new Error("SyncResourceBase#scopeChangesQuery must be implemented")
216
216
  }
217
217
 
218
+ /**
219
+ * Decides whether one published change is deliverable to a user-scope
220
+ * subscription (the framework sync channel's per-delivery access re-check).
221
+ * The default reuses the app's ability scoping: it applies
222
+ * {@link SyncResourceBase#scopeChangesQuery} to the change-feed model — which
223
+ * for an empty-conditions user scope falls back to ability scoping — and
224
+ * checks whether the published change's feed row is visible within that
225
+ * scope. Apps get this for free from the scoping they already declared;
226
+ * override only for custom per-delivery rules.
227
+ * @param {{params: Record<string, ?>, scope: SerializedChangesScope | null, sync: {resourceId: string, resourceType: string}}} args - Request params, subscription scope, and the published change's identity.
228
+ * @returns {Promise<boolean>} Whether the change may be delivered to this subscription.
229
+ */
230
+ async changeDeliverable({params, scope, sync}) {
231
+ const query = this.syncModelClass().where({})
232
+
233
+ this.scopeChangesQuery({params, query, scope})
234
+ query.where({resource_id: String(sync.resourceId), resource_type: String(sync.resourceType)})
235
+
236
+ return Boolean(await query.first())
237
+ }
238
+
218
239
  /**
219
240
  * Resolves the replay service class handling replay mutations: the
220
241
  * declarative {@link SyncResourceBase.ReplayServiceClass} static (shared
@@ -136,11 +136,90 @@ export default class SyncWebsocketChannel extends VelociousWebsocketChannel {
136
136
  return true
137
137
  }
138
138
 
139
+ /**
140
+ * Delivers a matched broadcast. Scoped subscriptions (with explicit
141
+ * conditions) already routed through {@link SyncWebsocketChannel#matches}, so
142
+ * the change is in scope and delivers unchanged. User-scope subscriptions
143
+ * (empty conditions, "everything my ability can see") match every broadcast
144
+ * of the resource type, so each published change is re-checked against the
145
+ * subscriber's ability at fan-out through the app sync resource's
146
+ * `changeDeliverable`; only accessible changes are delivered, and a broadcast
147
+ * with no accessible change is dropped.
148
+ * @param {import("../http-server/websocket-channel.js").WebsocketJsonValue} body - Broadcast body (sync envelope).
149
+ * @param {{eventId?: string}} [meta] - Optional event metadata.
150
+ * @returns {Promise<void>}
151
+ */
152
+ async deliverBroadcast(body, meta) {
153
+ if (!this._isUserScope()) {
154
+ this.sendMessage(body, meta)
155
+
156
+ return
157
+ }
158
+
159
+ const deliverableBody = await this._userScopeDeliverableBody(body)
160
+
161
+ if (deliverableBody !== null) this.sendMessage(deliverableBody, meta)
162
+ }
163
+
164
+ /**
165
+ * Whether this subscription is a user scope: authorized with empty conditions
166
+ * ("everything my ability can see").
167
+ * @returns {boolean} Whether the subscription is a user scope.
168
+ */
169
+ _isUserScope() {
170
+ return Boolean(this._scope) && Object.keys(/** @type {import("./sync-resource-base.js").SerializedChangesScope} */ (this._scope).conditions).length === 0
171
+ }
172
+
173
+ /**
174
+ * Filters a user-scope broadcast to the sync entries the subscriber's ability
175
+ * can access, re-checking each through the app sync resource's
176
+ * `changeDeliverable`. Returns the broadcast narrowed to accessible entries,
177
+ * or null when none are accessible. Non-envelope bodies and entries without a
178
+ * resource id are dropped (fail closed).
179
+ * @param {import("../http-server/websocket-channel.js").WebsocketJsonValue} body - Broadcast body.
180
+ * @returns {Promise<import("../http-server/websocket-channel.js").WebsocketJsonValue | null>} Deliverable body, or null.
181
+ */
182
+ async _userScopeDeliverableBody(body) {
183
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null
184
+
185
+ const envelope = /** @type {Record<string, ?>} */ (body)
186
+ const scope = /** @type {import("./sync-resource-base.js").SerializedChangesScope} */ (this._scope)
187
+ const syncs = Array.isArray(envelope.syncs) ? envelope.syncs : [envelope]
188
+ const configuration = this.session.configuration
189
+ /** @type {Array<Record<string, ?>>} */
190
+ const deliverableSyncs = []
191
+
192
+ // Broadcast fan-out runs through `withoutCurrentConnectionContexts` (see
193
+ // Configuration#_broadcastToChannelLocal), so there is no ambient database
194
+ // connection here. Resolve the resource's ability and run the per-delivery
195
+ // access query inside a checked-out connection context, mirroring how other
196
+ // broadcast-time DB work (the frontend-model channel) obtains connections.
197
+ await configuration.ensureConnections({name: `${VELOCIOUS_SYNC_CHANNEL} user-scope delivery access check`}, async () => {
198
+ const resource = await this.buildSyncResource()
199
+
200
+ for (const sync of syncs) {
201
+ const resourceId = sync?.resourceId
202
+ const resourceType = sync?.resourceType ?? scope.resourceType
203
+
204
+ if (resourceId === undefined || resourceId === null) continue
205
+
206
+ if (await resource.changeDeliverable({params: this.params, scope, sync: {resourceId: String(resourceId), resourceType: String(resourceType)}})) {
207
+ deliverableSyncs.push(sync)
208
+ }
209
+ }
210
+ })
211
+
212
+ if (deliverableSyncs.length === 0) return null
213
+ if (Array.isArray(envelope.syncs)) return {...envelope, syncs: deliverableSyncs}
214
+
215
+ return deliverableSyncs[0]
216
+ }
217
+
139
218
  /**
140
219
  * Returns the authorized scope for debug snapshots.
141
220
  * @returns {Record<string, ?>} Debug-safe subscription details.
142
221
  */
143
222
  debugSnapshot() {
144
- return {scope: this._scope}
223
+ return {scope: this._scope, userScope: this._isUserScope()}
145
224
  }
146
225
  }