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.
- package/build/configuration-types.js +2 -0
- package/build/configuration.js +11 -3
- package/build/database/drivers/sqlite/table-rebuilder.js +35 -0
- package/build/src/configuration-types.d.ts +10 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +3 -1
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +12 -4
- package/build/src/database/drivers/sqlite/table-rebuilder.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/table-rebuilder.js +31 -1
- package/build/src/sync/query-scope.d.ts +6 -1
- package/build/src/sync/query-scope.d.ts.map +1 -1
- package/build/src/sync/query-scope.js +9 -3
- package/build/src/sync/sync-client-types.d.ts +18 -0
- 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 +61 -0
- package/build/src/sync/sync-client.d.ts.map +1 -1
- package/build/src/sync/sync-client.js +112 -2
- package/build/src/sync/sync-realtime-bridge.d.ts +20 -6
- package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
- package/build/src/sync/sync-realtime-bridge.js +52 -18
- package/build/src/sync/sync-resource-base.d.ts +20 -0
- package/build/src/sync/sync-resource-base.d.ts.map +1 -1
- package/build/src/sync/sync-resource-base.js +19 -1
- package/build/src/sync/sync-websocket-channel.d.ts +32 -0
- package/build/src/sync/sync-websocket-channel.d.ts.map +1 -1
- package/build/src/sync/sync-websocket-channel.js +73 -2
- package/build/sync/query-scope.js +9 -2
- package/build/sync/sync-client-types.js +10 -0
- package/build/sync/sync-client.js +125 -1
- package/build/sync/sync-realtime-bridge.js +52 -17
- package/build/sync/sync-resource-base.js +21 -0
- package/build/sync/sync-websocket-channel.js +80 -1
- package/package.json +1 -1
- package/src/configuration-types.js +2 -0
- package/src/configuration.js +11 -3
- package/src/database/drivers/sqlite/table-rebuilder.js +35 -0
- package/src/sync/query-scope.js +9 -2
- package/src/sync/sync-client-types.js +10 -0
- package/src/sync/sync-client.js +125 -1
- package/src/sync/sync-realtime-bridge.js +52 -17
- package/src/sync/sync-resource-base.js +21 -0
- 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
|
/**
|
package/src/configuration.js
CHANGED
|
@@ -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}) ` +
|
package/src/sync/query-scope.js
CHANGED
|
@@ -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
|
-
|
|
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 {}
|
package/src/sync/sync-client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
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
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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,
|
|
199
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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()
|
|
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
|
}
|