velocious 1.0.502 → 1.0.504
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/application.js +4 -0
- package/build/configuration-types.js +8 -8
- package/build/configuration.js +2 -2
- package/build/src/application.d.ts.map +1 -1
- package/build/src/application.js +5 -1
- package/build/src/configuration-types.d.ts +17 -17
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +9 -9
- package/build/src/configuration.d.ts +4 -4
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +3 -3
- package/build/src/sync/sync-change-fanout.d.ts +71 -0
- package/build/src/sync/sync-change-fanout.d.ts.map +1 -0
- package/build/src/sync/sync-change-fanout.js +54 -0
- package/build/src/sync/sync-channel-name.d.ts +9 -0
- package/build/src/sync/sync-channel-name.d.ts.map +1 -0
- package/build/src/sync/sync-channel-name.js +10 -0
- package/build/src/sync/sync-client-types.d.ts +13 -6
- package/build/src/sync/sync-client-types.d.ts.map +1 -1
- package/build/src/sync/sync-client-types.js +1 -1
- package/build/src/sync/sync-client.d.ts +1 -1
- package/build/src/sync/sync-client.d.ts.map +1 -1
- package/build/src/sync/sync-client.js +8 -4
- package/build/src/sync/sync-envelope-replay-service.d.ts +15 -4
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +60 -42
- package/build/src/sync/sync-model-change-feed-service.d.ts +6 -1
- package/build/src/sync/sync-model-change-feed-service.d.ts.map +1 -1
- package/build/src/sync/sync-model-change-feed-service.js +15 -4
- package/build/src/sync/sync-publish-suppression.d.ts +31 -0
- package/build/src/sync/sync-publish-suppression.d.ts.map +1 -0
- package/build/src/sync/sync-publish-suppression.js +54 -0
- package/build/src/sync/sync-publisher-types.d.ts +178 -0
- package/build/src/sync/sync-publisher-types.d.ts.map +1 -0
- package/build/src/sync/sync-publisher-types.js +3 -0
- package/build/src/sync/sync-publisher.d.ts +160 -0
- package/build/src/sync/sync-publisher.d.ts.map +1 -0
- package/build/src/sync/sync-publisher.js +438 -0
- package/build/src/sync/sync-realtime-bridge.d.ts +33 -10
- package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
- package/build/src/sync/sync-realtime-bridge.js +51 -12
- package/build/src/sync/sync-scope-attributes.d.ts +15 -0
- package/build/src/sync/sync-scope-attributes.d.ts.map +1 -0
- package/build/src/sync/sync-scope-attributes.js +24 -0
- package/build/src/sync/sync-websocket-channel.d.ts +58 -0
- package/build/src/sync/sync-websocket-channel.d.ts.map +1 -0
- package/build/src/sync/sync-websocket-channel.js +131 -0
- package/build/src/testing/test-runner.d.ts +8 -0
- package/build/src/testing/test-runner.d.ts.map +1 -1
- package/build/src/testing/test-runner.js +64 -2
- package/build/sync/sync-change-fanout.js +58 -0
- package/build/sync/sync-channel-name.js +10 -0
- package/build/sync/sync-client-types.js +12 -5
- package/build/sync/sync-client.js +8 -3
- package/build/sync/sync-envelope-replay-service.js +60 -41
- package/build/sync/sync-model-change-feed-service.js +17 -3
- package/build/sync/sync-publish-suppression.js +59 -0
- package/build/sync/sync-publisher-types.js +84 -0
- package/build/sync/sync-publisher.js +502 -0
- package/build/sync/sync-realtime-bridge.js +56 -11
- package/build/sync/sync-scope-attributes.js +26 -0
- package/build/sync/sync-websocket-channel.js +146 -0
- package/build/testing/test-runner.js +69 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/application.js +4 -0
- package/src/configuration-types.js +8 -8
- package/src/configuration.js +2 -2
- package/src/sync/sync-change-fanout.js +58 -0
- package/src/sync/sync-channel-name.js +10 -0
- package/src/sync/sync-client-types.js +12 -5
- package/src/sync/sync-client.js +8 -3
- package/src/sync/sync-envelope-replay-service.js +60 -41
- package/src/sync/sync-model-change-feed-service.js +17 -3
- package/src/sync/sync-publish-suppression.js +59 -0
- package/src/sync/sync-publisher-types.js +84 -0
- package/src/sync/sync-publisher.js +502 -0
- package/src/sync/sync-realtime-bridge.js +56 -11
- package/src/sync/sync-scope-attributes.js +26 -0
- package/src/sync/sync-websocket-channel.js +146 -0
- package/src/testing/test-runner.js +69 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import VelociousWebsocketChannel from "../http-server/websocket-channel.js"
|
|
4
|
+
import {VELOCIOUS_SYNC_CHANNEL} from "./sync-channel-name.js"
|
|
5
|
+
|
|
6
|
+
/** Configurations whose framework sync channel has already been registered. */
|
|
7
|
+
const registeredConfigurations = new WeakSet()
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Framework-owned websocket channel for synced resources
|
|
11
|
+
* ({@link VELOCIOUS_SYNC_CHANNEL}).
|
|
12
|
+
*
|
|
13
|
+
* Subscribe params mirror a declared pull scope — `{resourceType, conditions}`
|
|
14
|
+
* plus the client-injected `authenticationToken` — and subscribe-time
|
|
15
|
+
* authorization delegates to the app sync resource's existing
|
|
16
|
+
* `authorizeChanges({params, scope})` (the `sync.api.resourceClass`), so apps
|
|
17
|
+
* hook in through the authorization they already declared instead of writing
|
|
18
|
+
* their own channel classes. Broadcast routing matches the publisher's
|
|
19
|
+
* scoping params against the subscription's scope conditions.
|
|
20
|
+
*/
|
|
21
|
+
export default class SyncWebsocketChannel extends VelociousWebsocketChannel {
|
|
22
|
+
/**
|
|
23
|
+
* Scope the subscription was authorized for, set by {@link SyncWebsocketChannel#canSubscribe}.
|
|
24
|
+
* @type {import("./sync-resource-base.js").SerializedChangesScope | null}
|
|
25
|
+
*/
|
|
26
|
+
_scope = null
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Registers the framework sync channel on a configuration declaring a
|
|
30
|
+
* `sync.api` block (guarded so repeated server boots with the same
|
|
31
|
+
* configuration register it only once). No-op without `sync.api` — the
|
|
32
|
+
* channel authorizes through the app's sync resource class.
|
|
33
|
+
* @param {import("../configuration.js").default} configuration - Configuration instance.
|
|
34
|
+
* @returns {void}
|
|
35
|
+
*/
|
|
36
|
+
static registerFromConfiguration(configuration) {
|
|
37
|
+
if (!configuration.getSyncConfiguration().api || registeredConfigurations.has(configuration)) return
|
|
38
|
+
|
|
39
|
+
registeredConfigurations.add(configuration)
|
|
40
|
+
configuration.registerWebsocketChannel(VELOCIOUS_SYNC_CHANNEL, this)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Authorizes the subscription through the app sync resource: the subscribe
|
|
45
|
+
* params are parsed into the same serialized scope the changes endpoint
|
|
46
|
+
* consumes and passed to the resource's `authorizeChanges({params, scope})`.
|
|
47
|
+
* Denials and malformed scopes throw, rejecting the subscription.
|
|
48
|
+
* @returns {Promise<boolean>} Whether the subscription is allowed.
|
|
49
|
+
*/
|
|
50
|
+
async canSubscribe() {
|
|
51
|
+
const resource = await this.buildSyncResource()
|
|
52
|
+
const scope = resource.changesScope({scope: {conditions: this.params.conditions, resourceType: this.params.resourceType}})
|
|
53
|
+
|
|
54
|
+
await resource.authorizeChanges({params: this.params, scope})
|
|
55
|
+
|
|
56
|
+
this._scope = scope
|
|
57
|
+
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Builds the app sync resource authorizing this subscription, mirroring the
|
|
63
|
+
* sync transport controller's resource construction with the ability
|
|
64
|
+
* resolved from the subscribe params (which carry the client-injected
|
|
65
|
+
* authenticationToken).
|
|
66
|
+
* @returns {Promise<import("./sync-resource-base.js").default>} App sync resource instance.
|
|
67
|
+
*/
|
|
68
|
+
async buildSyncResource() {
|
|
69
|
+
const configuration = this.session.configuration
|
|
70
|
+
const api = configuration.getSyncConfiguration().api
|
|
71
|
+
|
|
72
|
+
if (!api) {
|
|
73
|
+
throw new Error(`The ${VELOCIOUS_SYNC_CHANNEL} channel requires a sync.api configuration block with the app's sync resource class`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Narrows the configured resource class to the sync resource contract
|
|
77
|
+
// (changesScope/authorizeChanges) the sync.api validation requires.
|
|
78
|
+
const ResourceClass = /** @type {typeof import("./sync-resource-base.js").default} */ (api.resourceClass)
|
|
79
|
+
// Narrows the websocket subscribe params to the resource params contract.
|
|
80
|
+
const params = /** @type {import("../configuration-types.js").VelociousParams} */ (/** @type {unknown} */ (this.params))
|
|
81
|
+
const request = this.session.upgradeRequest
|
|
82
|
+
const ability = await configuration.resolveAbility({params, request})
|
|
83
|
+
|
|
84
|
+
return new ResourceClass({
|
|
85
|
+
ability,
|
|
86
|
+
context: {
|
|
87
|
+
...(ability?.getContext() || {}),
|
|
88
|
+
params,
|
|
89
|
+
request
|
|
90
|
+
},
|
|
91
|
+
locals: ability?.getLocals() || {},
|
|
92
|
+
modelClass: ResourceClass.ModelClass,
|
|
93
|
+
modelName: ResourceClass.ModelClass?.name,
|
|
94
|
+
params,
|
|
95
|
+
resourceConfiguration: /** @type {import("../configuration-types.js").FrontendModelResourceConfiguration} */ ({
|
|
96
|
+
attributes: ResourceClass.attributes || {},
|
|
97
|
+
sync: {enabled: true}
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Routes a publisher broadcast to this subscription when the published
|
|
104
|
+
* resource type equals the type the subscription was authorized for and the
|
|
105
|
+
* scoping params satisfy every scope condition: each condition key must be
|
|
106
|
+
* present in the broadcast params and match by string comparison (array
|
|
107
|
+
* conditions match by membership). Broadcasts without a resource type and
|
|
108
|
+
* conditions the publisher's scoping params do not carry never match, so a
|
|
109
|
+
* subscription cannot receive changes outside its authorized scope.
|
|
110
|
+
* @param {import("../http-server/websocket-channel.js").WebsocketJsonValue} broadcastParams - Publisher scoping params (the published resourceType plus the change's scope-partition values).
|
|
111
|
+
* @returns {boolean} Whether the broadcast belongs to this subscription's scope.
|
|
112
|
+
*/
|
|
113
|
+
matches(broadcastParams) {
|
|
114
|
+
const scope = this._scope
|
|
115
|
+
|
|
116
|
+
if (!scope) return false
|
|
117
|
+
|
|
118
|
+
const scopingParams = broadcastParams && typeof broadcastParams === "object" && !Array.isArray(broadcastParams)
|
|
119
|
+
? /** @type {Record<string, ?>} */ (broadcastParams)
|
|
120
|
+
: {}
|
|
121
|
+
|
|
122
|
+
if (!Object.hasOwn(scopingParams, "resourceType") || String(scopingParams.resourceType) !== String(scope.resourceType)) return false
|
|
123
|
+
|
|
124
|
+
for (const [conditionName, conditionValue] of Object.entries(scope.conditions)) {
|
|
125
|
+
if (!Object.hasOwn(scopingParams, conditionName)) return false
|
|
126
|
+
|
|
127
|
+
const scopingValue = scopingParams[conditionName]
|
|
128
|
+
|
|
129
|
+
if (Array.isArray(conditionValue)) {
|
|
130
|
+
if (!conditionValue.some((value) => String(value) === String(scopingValue))) return false
|
|
131
|
+
} else if (String(conditionValue) !== String(scopingValue)) {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Returns the authorized scope for debug snapshots.
|
|
141
|
+
* @returns {Record<string, ?>} Debug-safe subscription details.
|
|
142
|
+
*/
|
|
143
|
+
debugSnapshot() {
|
|
144
|
+
return {scope: this._scope}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -13,8 +13,20 @@ import {testConfig, testEvents, tests} from "./test.js"
|
|
|
13
13
|
import {pathToFileURL} from "url"
|
|
14
14
|
import {clearDeliveries} from "../mailer.js"
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Marks the error thrown by {@link runWithTimeout} so the caller can tell a
|
|
18
|
+
* lifecycle timeout (the promise is still running detached) apart from an
|
|
19
|
+
* ordinary test failure (the promise already settled).
|
|
20
|
+
* @typedef {Error & {velociousTestTimeout?: true}} TestTimeoutError
|
|
21
|
+
*/
|
|
22
|
+
|
|
16
23
|
/**
|
|
17
24
|
* Runs run with timeout.
|
|
25
|
+
*
|
|
26
|
+
* On timeout the wrapped `promise` is NOT cancelled — it keeps running detached.
|
|
27
|
+
* The rejected error is tagged with `velociousTestTimeout` so the runner knows
|
|
28
|
+
* the lifecycle (and its afterEach database cleanup) is still in flight and can
|
|
29
|
+
* wait for it to settle before the next test reuses the shared connection.
|
|
18
30
|
* @param {Promise<?> | ?} promise - Promise or value.
|
|
19
31
|
* @param {number} timeoutMs - Timeout in milliseconds.
|
|
20
32
|
* @param {string} testDescription - Test description.
|
|
@@ -22,7 +34,9 @@ import {clearDeliveries} from "../mailer.js"
|
|
|
22
34
|
*/
|
|
23
35
|
function runWithTimeout(promise, timeoutMs, testDescription) {
|
|
24
36
|
const timeoutSeconds = (timeoutMs / 1000).toFixed(3).replace(/\.?0+$/, "")
|
|
37
|
+
/** @type {TestTimeoutError} */
|
|
25
38
|
const timeoutError = new Error(`Timed out after ${timeoutSeconds}s: ${testDescription}`)
|
|
39
|
+
timeoutError.velociousTestTimeout = true
|
|
26
40
|
|
|
27
41
|
return new Promise((resolve, reject) => {
|
|
28
42
|
const timeout = setTimeout(() => reject(timeoutError), timeoutMs)
|
|
@@ -37,6 +51,38 @@ function runWithTimeout(promise, timeoutMs, testDescription) {
|
|
|
37
51
|
})
|
|
38
52
|
}
|
|
39
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Waits for an abandoned (timed-out) test lifecycle to settle, bounded by a
|
|
56
|
+
* grace period, so its afterEach database cleanup runs on the shared connection
|
|
57
|
+
* before the next test reuses it. Resolves early the moment the lifecycle
|
|
58
|
+
* settles; otherwise resolves once the grace elapses (never rejects, and never
|
|
59
|
+
* keeps the process alive on its own).
|
|
60
|
+
* @param {Promise<?>} lifecycle - The abandoned per-test lifecycle promise.
|
|
61
|
+
* @param {number} graceMs - Maximum time to wait for the lifecycle to settle.
|
|
62
|
+
* @returns {Promise<boolean>} - Whether the lifecycle settled within the grace period.
|
|
63
|
+
*/
|
|
64
|
+
function awaitSettledOrGrace(lifecycle, graceMs) {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
let settled = false
|
|
67
|
+
const graceTimer = setTimeout(() => {
|
|
68
|
+
if (settled) return
|
|
69
|
+
|
|
70
|
+
settled = true
|
|
71
|
+
resolve(false)
|
|
72
|
+
}, graceMs)
|
|
73
|
+
|
|
74
|
+
if (typeof graceTimer.unref === "function") graceTimer.unref()
|
|
75
|
+
|
|
76
|
+
Promise.resolve(lifecycle).then(() => {}, () => {}).then(() => {
|
|
77
|
+
if (settled) return
|
|
78
|
+
|
|
79
|
+
settled = true
|
|
80
|
+
clearTimeout(graceTimer)
|
|
81
|
+
resolve(true)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
40
86
|
/**
|
|
41
87
|
* ConsoleMethodName type.
|
|
42
88
|
* @typedef {"log" | "info" | "warn" | "error" | "debug"} ConsoleMethodName */
|
|
@@ -862,6 +908,11 @@ export default class TestRunner {
|
|
|
862
908
|
* @type {?} */
|
|
863
909
|
let lastError
|
|
864
910
|
let willRetry = false
|
|
911
|
+
/**
|
|
912
|
+
* The per-test lifecycle promise, hoisted so the timeout branch can
|
|
913
|
+
* still wait for it to settle after runWithTimeout has abandoned it.
|
|
914
|
+
* @type {Promise<?> | undefined} */
|
|
915
|
+
let testLifecycle
|
|
865
916
|
const stopConsoleCapture = this.startConsoleCapture({
|
|
866
917
|
passthrough: testConfig.consoleOutput === "live"
|
|
867
918
|
})
|
|
@@ -870,7 +921,7 @@ export default class TestRunner {
|
|
|
870
921
|
// Run the whole per-test lifecycle (dummy/server startup, connection
|
|
871
922
|
// acquisition, beforeEach hooks, the test body and afterEach hooks) as
|
|
872
923
|
// one promise so the timeout below can cover all of it.
|
|
873
|
-
|
|
924
|
+
testLifecycle = this.runWithDummyIfNeeded(testArgs, async () => {
|
|
874
925
|
// Pin one connection per test so beforeEach, the test body and afterEach
|
|
875
926
|
// all run on the SAME connection. This is required for transaction-based
|
|
876
927
|
// database cleaning (beforeEach starts a transaction, afterEach rolls it
|
|
@@ -917,6 +968,23 @@ export default class TestRunner {
|
|
|
917
968
|
} catch (error) {
|
|
918
969
|
caughtError = error
|
|
919
970
|
lastError = error
|
|
971
|
+
|
|
972
|
+
// A timeout REJECTS while the lifecycle keeps running detached on the
|
|
973
|
+
// shared per-suite connection — including its afterEach database
|
|
974
|
+
// cleanup (e.g. transaction rollback). If the next test starts before
|
|
975
|
+
// that rollback runs, its own startTransaction() implicitly COMMITS
|
|
976
|
+
// the timed-out test's rows on the shared connection, poisoning every
|
|
977
|
+
// later test in the shard (duplicate-key / foreign-key cascades from
|
|
978
|
+
// leaked fixtures). Wait — bounded — for the abandoned lifecycle to
|
|
979
|
+
// settle so its cleanup lands first. Bounded so a genuinely hung test
|
|
980
|
+
// still can't stall the whole run: if it will not settle within the
|
|
981
|
+
// grace, we proceed exactly as before (no worse than today).
|
|
982
|
+
const timedOut = Boolean(/** @type {TestTimeoutError} */ (error)?.velociousTestTimeout)
|
|
983
|
+
|
|
984
|
+
if (timedOut && testLifecycle) {
|
|
985
|
+
await awaitSettledOrGrace(testLifecycle, timeoutMs ?? 60000)
|
|
986
|
+
}
|
|
987
|
+
|
|
920
988
|
willRetry = retriesUsed < retryCount
|
|
921
989
|
|
|
922
990
|
if (willRetry) {
|