velocious 1.0.667 → 1.0.668

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 (87) hide show
  1. package/README.md +27 -7
  2. package/build/configuration.js +23 -1
  3. package/build/environment-handlers/node/cli/commands/test/timing-manifest/merge.js +2 -48
  4. package/build/environment-handlers/node/cli/commands/test.js +17 -69
  5. package/build/frontend-models/websocket-channel.js +18 -0
  6. package/build/http-server/client/websocket-session.js +41 -4
  7. package/build/http-server/websocket-channel.js +14 -0
  8. package/build/http-server/websocket-event-log-store.js +85 -12
  9. package/build/http-server/websocket-events-host.js +14 -6
  10. package/build/src/configuration.d.ts +18 -1
  11. package/build/src/configuration.d.ts.map +1 -1
  12. package/build/src/configuration.js +22 -2
  13. package/build/src/environment-handlers/node/cli/commands/test/timing-manifest/merge.d.ts.map +1 -1
  14. package/build/src/environment-handlers/node/cli/commands/test/timing-manifest/merge.js +3 -47
  15. package/build/src/environment-handlers/node/cli/commands/test.d.ts +2 -28
  16. package/build/src/environment-handlers/node/cli/commands/test.d.ts.map +1 -1
  17. package/build/src/environment-handlers/node/cli/commands/test.js +10 -61
  18. package/build/src/frontend-models/websocket-channel.d.ts +9 -0
  19. package/build/src/frontend-models/websocket-channel.d.ts.map +1 -1
  20. package/build/src/frontend-models/websocket-channel.js +16 -1
  21. package/build/src/http-server/client/websocket-session.d.ts +25 -2
  22. package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
  23. package/build/src/http-server/client/websocket-session.js +40 -5
  24. package/build/src/http-server/websocket-channel.d.ts +11 -0
  25. package/build/src/http-server/websocket-channel.d.ts.map +1 -1
  26. package/build/src/http-server/websocket-channel.js +14 -1
  27. package/build/src/http-server/websocket-event-log-store.d.ts +60 -41
  28. package/build/src/http-server/websocket-event-log-store.d.ts.map +1 -1
  29. package/build/src/http-server/websocket-event-log-store.js +79 -12
  30. package/build/src/http-server/websocket-events-host.d.ts +10 -3
  31. package/build/src/http-server/websocket-events-host.d.ts.map +1 -1
  32. package/build/src/http-server/websocket-events-host.js +14 -7
  33. package/build/src/testing/test-files-finder.d.ts +13 -116
  34. package/build/src/testing/test-files-finder.d.ts.map +1 -1
  35. package/build/src/testing/test-files-finder.js +26 -296
  36. package/build/src/testing/test-filter-parser.d.ts +32 -22
  37. package/build/src/testing/test-filter-parser.d.ts.map +1 -1
  38. package/build/src/testing/test-filter-parser.js +25 -217
  39. package/build/src/testing/test-profile-activity.d.ts +1 -6
  40. package/build/src/testing/test-profile-activity.d.ts.map +1 -1
  41. package/build/src/testing/test-profile-activity.js +2 -12
  42. package/build/src/testing/test-profile-output.d.ts +1 -40
  43. package/build/src/testing/test-profile-output.d.ts.map +1 -1
  44. package/build/src/testing/test-profile-output.js +2 -217
  45. package/build/src/testing/test-profiler.d.ts +15 -659
  46. package/build/src/testing/test-profiler.d.ts.map +1 -1
  47. package/build/src/testing/test-profiler.js +35 -833
  48. package/build/src/testing/test-runner.d.ts +10 -1
  49. package/build/src/testing/test-runner.d.ts.map +1 -1
  50. package/build/src/testing/test-runner.js +26 -4
  51. package/build/src/testing/test-suite-splitter.d.ts +1 -128
  52. package/build/src/testing/test-suite-splitter.d.ts.map +1 -1
  53. package/build/src/testing/test-suite-splitter.js +2 -259
  54. package/build/src/testing/timing-manifest.d.ts +5 -93
  55. package/build/src/testing/timing-manifest.d.ts.map +1 -1
  56. package/build/src/testing/timing-manifest.js +4 -283
  57. package/build/src/testing/velocious-attempt-executor.d.ts.map +1 -1
  58. package/build/src/testing/velocious-attempt-executor.js +8 -2
  59. package/build/testing/test-files-finder.js +29 -345
  60. package/build/testing/test-filter-parser.js +29 -248
  61. package/build/testing/test-profile-activity.js +1 -12
  62. package/build/testing/test-profile-output.js +8 -253
  63. package/build/testing/test-profiler.js +38 -895
  64. package/build/testing/test-runner.js +33 -9
  65. package/build/testing/test-suite-splitter.js +1 -301
  66. package/build/testing/timing-manifest.js +10 -344
  67. package/build/testing/velocious-attempt-executor.js +7 -1
  68. package/package.json +3 -3
  69. package/scripts/test-browser.js +29 -6
  70. package/scripts/verify-docker-dev-environment.js +13 -1
  71. package/src/configuration.js +23 -1
  72. package/src/environment-handlers/node/cli/commands/test/timing-manifest/merge.js +2 -48
  73. package/src/environment-handlers/node/cli/commands/test.js +17 -69
  74. package/src/frontend-models/websocket-channel.js +18 -0
  75. package/src/http-server/client/websocket-session.js +41 -4
  76. package/src/http-server/websocket-channel.js +14 -0
  77. package/src/http-server/websocket-event-log-store.js +85 -12
  78. package/src/http-server/websocket-events-host.js +14 -6
  79. package/src/testing/test-files-finder.js +29 -345
  80. package/src/testing/test-filter-parser.js +29 -248
  81. package/src/testing/test-profile-activity.js +1 -12
  82. package/src/testing/test-profile-output.js +8 -253
  83. package/src/testing/test-profiler.js +38 -895
  84. package/src/testing/test-runner.js +33 -9
  85. package/src/testing/test-suite-splitter.js +1 -301
  86. package/src/testing/timing-manifest.js +10 -344
  87. package/src/testing/velocious-attempt-executor.js +7 -1
package/README.md CHANGED
@@ -32,6 +32,7 @@
32
32
  * Per-record ability checks via `.abilities(...)` on frontend queries + `record.can(action)` (see [docs/abilities.md](docs/abilities.md))
33
33
  * Translated model attributes with current-locale relationship sorting (see [docs/translations.md](docs/translations.md))
34
34
  * Cross-process broadcast bus for `broadcastToChannel` via `velocious beacon`, including background job runner processes (see [docs/beacon.md](docs/beacon.md))
35
+ * Websocket channel primitives for 1:N pub/sub over the shared session socket (`channel-subscribe` protocol): `matches()`-routed streams, interest-gated event-log persistence with 10-minute retention, stream-scoped `lastEventId` replay with `channel-replay-gap` gap reporting, and `{liveOnly: true}` channels that never reach the replay log (see [docs/websocket-channels.md](docs/websocket-channels.md))
35
36
  * Rails-style application process initializer teardown with immutable process identity, reverse idempotent shutdown, and explicit HTTP/background-job ownership (see [docs/application-process-lifecycle.md](docs/application-process-lifecycle.md))
36
37
  * Configurable HTTP server worker handlers plus backpressured, descriptor-only file responses with completion callbacks (see [docs/http-server.md](docs/http-server.md))
37
38
  * Explicit database-free in-process HTTP applications with optional request and buffered-response byte limits (see [docs/http-server.md](docs/http-server.md#database-free-applications))
@@ -125,7 +126,7 @@ Maintainers cutting a package release must follow the [Velocious release runbook
125
126
 
126
127
  # Docker development environment
127
128
 
128
- The checked-in root `Dockerfile` and `compose.yml` define one canonical `dev` service used by humans, CI, and agent systems alike (see [docs/docker-development-environment.md](docs/docker-development-environment.md)). The image is Ubuntu 26.04 LTS (pinned by digest) with Node.js 24.x from signed NodeSource, the universal apt coding/debugging baseline, and the newest published provider CLIs; it is source-independent — no project source is copied and no project dependencies are installed at image build time.
129
+ The checked-in root `Dockerfile` and `compose.yml` define one canonical `dev` service used by humans, CI, and agent systems alike (see [docs/docker-development-environment.md](docs/docker-development-environment.md)). The image is Ubuntu 26.04 LTS (pinned by digest) with Node.js 24.x from signed NodeSource, the universal apt coding/debugging baseline, the newest published retained provider CLIs, and owner-pinned native Qwen Code 0.23.3; it is source-independent — no project source is copied and no project dependencies are installed at image build time.
129
130
 
130
131
  Prerequisites: Docker with the Compose v2 plugin, and this repository checked out at `$DEV_HOME_PATH/velocious` (default `DEV_HOME_PATH`: `/home/dev`).
131
132
 
@@ -179,10 +180,13 @@ Baselines are generated against a fresh checkout (no generated dummy `configurat
179
180
  # Testing
180
181
 
181
182
  Application tests may import the testing DSL from the independent public package.
182
- `@velocious/testing` `0.0.12` is the declaration and execution engine. Compatible
183
- installed copies share one protocol-1/schema-3 default registry. Velocious adapts each
184
- package-owned attempt with its database, request, profiling, and cleanup behavior; the
185
- existing Velocious facade exports the same declaration DSL and remains supported.
183
+ `@velocious/testing` `0.0.14` owns framework-neutral discovery, filtering,
184
+ execution, reporting, deterministic weighted grouping, profiling, and timing
185
+ manifests. Compatible installed copies share one protocol-1/schema-3 default registry.
186
+ Velocious retains application/configuration startup, database and transaction
187
+ lifecycle, browser execution, environment handling, framework profile spans, and
188
+ report translation. Existing Velocious import paths remain supported as thin
189
+ delegating compatibility facades.
186
190
 
187
191
  ```js
188
192
  import {describe, expect, it} from "@velocious/testing"
@@ -245,7 +249,9 @@ export default async function configureTesting() {
245
249
  }
246
250
  ```
247
251
 
248
- Retry flaky tests by setting a retry count on the test args.
252
+ Retry flaky tests by setting a retry count on the test args, or set CLI defaults for
253
+ retries and lifecycle timeout. Import repeatable global setup before the testing
254
+ configuration and test declarations with `--setup`.
249
255
 
250
256
  ```js
251
257
  describe("Tasks", () => {
@@ -253,6 +259,10 @@ it("retries a flaky check", {retry: 2}, async () => {})
253
259
  })
254
260
  ```
255
261
 
262
+ ```bash
263
+ npx velocious test --retry=2 --timeout=30000 --setup=spec/setup.js spec/
264
+ ```
265
+
256
266
  Velocious prints the slowest tests after every run so suite hotspots are easy to spot. Each line shows the duration, full description and `file:line`.
257
267
 
258
268
  ```bash
@@ -295,7 +305,7 @@ npx velocious test:timing-manifest:merge --output tmp/test-timings.json \
295
305
  See [test profiling](docs/test-profiling.md) for lifecycle accounting, custom
296
306
  activity spans, schema, and privacy guarantees.
297
307
 
298
- Prefer waiting for a real signal or condition over sleeping a fixed duration. `waitForEvent(emitter, eventName, {timeoutMs, filter})` resolves the instant a matching event fires (a background job finishing, a model update, a websocket message) and rejects on timeout; for polling an arbitrary condition, use awaitery's `waitFor`. Both `@velocious/testing` and the backward-compatible `velocious/build/src/testing/test.js` facade are supported imports; the Velocious runner consumes public-package declarations and adds the framework-specific database, request, profiling, and cleanup behavior.
308
+ Prefer waiting for a real signal or condition over sleeping a fixed duration. `waitForEvent(emitter, eventName, {timeoutMs, filter})` resolves the instant a matching event fires (a background job finishing, a model update, a websocket message) and rejects on timeout; for polling an arbitrary condition, use awaitery's `waitFor`. Both `@velocious/testing` and the backward-compatible `velocious/build/src/testing/test.js` facade are supported imports; the Velocious runner consumes public-package declarations and adds the framework-specific startup, database, request, browser, profile-span, and cleanup behavior.
299
309
 
300
310
  ```js
301
311
  import {waitForEvent} from "velocious/build/src/testing/test.js"
@@ -2224,6 +2234,16 @@ socket.send(JSON.stringify({
2224
2234
  }))
2225
2235
  ```
2226
2236
 
2237
+ ### V2 channel registration, replay, and live-only channels
2238
+
2239
+ `configuration.registerWebsocketChannel(name, ChannelClass)` registers a channel class for the `channel-subscribe` protocol — distinct from the `websocketChannelResolver` above, which serves the legacy `{type: "subscribe"}` path. Subscribers are routed by the class's `matches(broadcastParams)`, and publishers emit with `configuration.broadcastToChannel(name, broadcastParams, body)`.
2240
+
2241
+ - **Stream-scoped replay.** Delivered `channel-message` frames carry the server `eventId`; a client can pass its last-seen `lastEventId` on (re)subscribe and the server replays only the persisted events belonging to that subscription's stream (10-minute retention, written only while the channel is interested). A checkpoint that expired or belongs to a different stream reports `channel-replay-gap` and the subscription continues with live delivery.
2242
+ - **Live-only channels.** `registerWebsocketChannel(name, ChannelClass, {liveOnly: true})` declares a channel that must never reach the replay event log; marking one interested throws, and the persistence decision rejects it even if stale interest state exists.
2243
+ - **Replayable params.** Channel classes can override the static `replayableBroadcastParams(broadcastParams)` hook to control which broadcast params are persisted for replay (for example, stripping server-only values).
2244
+
2245
+ See [docs/websocket-channels.md](docs/websocket-channels.md) for the full wire protocol, V1/V2 gap semantics, and lifecycle guarantees.
2246
+
2227
2247
  ## Raw websocket handlers
2228
2248
 
2229
2249
  If you need to accept custom websocket message formats (for example, a vendor that does not use the Velocious request/subscribe protocol), provide a `websocketMessageHandlerResolver` in your configuration. It receives the upgrade request and can return a handler object with `onOpen`, `onMessage`, `onClose`, and `onError` hooks:
@@ -408,6 +408,13 @@ export default class VelociousConfiguration {
408
408
  * @type {Map<string, typeof import("./http-server/websocket-channel.js").default>} */
409
409
  this._websocketChannelClasses = new Map()
410
410
 
411
+ /**
412
+ * Channel types registered with `{liveOnly: true}`: their traffic is
413
+ * never persisted for replay, and `markChannelInterested` rejects the
414
+ * name.
415
+ * @type {Set<string>} */
416
+ this._liveOnlyWebsocketChannels = new Set()
417
+
411
418
  /**
412
419
  * Stores the websocket channel subscriptions value.
413
420
  * @type {Map<string, Set<import("./http-server/websocket-channel.js").default>>} - channelType → live subscriptions across all sessions.
@@ -1126,6 +1133,7 @@ export default class VelociousConfiguration {
1126
1133
  }
1127
1134
 
1128
1135
  return {
1136
+ liveOnlyChannels: Array.from(this._liveOnlyWebsocketChannels),
1129
1137
  pausedSessions: this._pausedWebsocketSessions.size,
1130
1138
  registeredChannels: Array.from(this._websocketChannelClasses.keys()),
1131
1139
  registeredConnections: Array.from(this._websocketConnectionClasses.keys()),
@@ -2966,12 +2974,15 @@ export default class VelociousConfiguration {
2966
2974
  * Clients subscribe via `{type: "channel-subscribe", channelType: name, ...}`.
2967
2975
  * @param {string} name - Client-facing channel type name.
2968
2976
  * @param {typeof import("./http-server/websocket-channel.js").default} ChannelClass - Websocket channel class.
2977
+ * @param {{liveOnly?: boolean}} [options] - Registration options.
2969
2978
  * @returns {void}
2970
2979
  */
2971
- registerWebsocketChannel(name, ChannelClass) {
2980
+ registerWebsocketChannel(name, ChannelClass, {liveOnly = false} = {}) {
2972
2981
  if (!name) throw new Error("Channel name is required")
2973
2982
  if (!ChannelClass) throw new Error("ChannelClass is required")
2974
2983
  this._websocketChannelClasses.set(name, ChannelClass)
2984
+
2985
+ if (liveOnly) this._liveOnlyWebsocketChannels.add(name)
2975
2986
  }
2976
2987
 
2977
2988
  /**
@@ -2983,6 +2994,17 @@ export default class VelociousConfiguration {
2983
2994
  return this._websocketChannelClasses.get(name)
2984
2995
  }
2985
2996
 
2997
+ /**
2998
+ * Whether a channel type was registered with `{liveOnly: true}`.
2999
+ * Live-only channels are never persisted for replay: the event-log
3000
+ * store's `markChannelInterested` throws for their names.
3001
+ * @param {string} name - Channel type name to look up.
3002
+ * @returns {boolean} - Whether the channel is declared live-only.
3003
+ */
3004
+ isWebsocketChannelLiveOnly(name) {
3005
+ return this._liveOnlyWebsocketChannels.has(name)
3006
+ }
3007
+
2986
3008
  /**
2987
3009
  * Tracks a live channel subscription in the global routing registry.
2988
3010
  * Called by the session when `canSubscribe()` resolves truthy; the
@@ -1,8 +1,8 @@
1
1
  // @ts-check
2
2
 
3
3
  import BaseCommand from "../../../../../../cli/base-command.js"
4
+ import { parseTimingManifestMergeArguments as parsePackageTimingManifestMergeArguments } from "@velocious/testing/node"
4
5
  import fs from "node:fs/promises"
5
- import path from "node:path"
6
6
  import { writeTimingManifest } from "../../../../../../testing/test-profile-output.js"
7
7
  import { mergeTestProfileTimingManifests } from "../../../../../../testing/timing-manifest.js"
8
8
 
@@ -51,24 +51,6 @@ export default class TestTimingManifestMerge extends BaseCommand {
51
51
  }
52
52
  }
53
53
 
54
- /**
55
- * Recognizes one output option spelling.
56
- * @param {string} argument - Current argument.
57
- * @param {string | undefined} nextArgument - Following argument.
58
- * @returns {{matched: boolean, skipNext: boolean, value: string | undefined}} - Parsed output option.
59
- */
60
- function timingManifestOutputArgument(argument, nextArgument) {
61
- if (argument === "--output") {
62
- return {matched: true, skipNext: true, value: nextArgument}
63
- }
64
-
65
- if (argument.startsWith("--output=")) {
66
- return {matched: true, skipNext: false, value: argument.slice("--output=".length)}
67
- }
68
-
69
- return {matched: false, skipNext: false, value: undefined}
70
- }
71
-
72
54
  /**
73
55
  * Parses strict merge arguments and resolves their paths.
74
56
  * @param {string[]} processArgs - Raw CLI arguments, including command name.
@@ -76,33 +58,5 @@ function timingManifestOutputArgument(argument, nextArgument) {
76
58
  * @returns {TimingManifestMergeArguments} - Validated resolved paths.
77
59
  */
78
60
  export function parseTimingManifestMergeArguments(processArgs, cwd) {
79
- const commandName = processArgs[0] || "test:timing-manifest:merge"
80
- const inputPaths = []
81
- let outputPath
82
-
83
- for (let index = 1; index < processArgs.length; index++) {
84
- const argument = processArgs[index]
85
- const outputArgument = timingManifestOutputArgument(argument, processArgs[index + 1])
86
-
87
- if (outputArgument.matched) {
88
- if (!outputArgument.value || outputArgument.value.startsWith("-")) throw new Error("Missing value for --output")
89
- if (outputPath) throw new Error("--output may only be provided once")
90
- outputPath = path.resolve(cwd, outputArgument.value)
91
- if (outputArgument.skipNext) index++
92
- continue
93
- }
94
-
95
- if (argument.startsWith("-")) throw new Error(`Unknown argument for ${commandName}: ${argument}`)
96
- inputPaths.push(path.resolve(cwd, argument))
97
- }
98
-
99
- if (!outputPath) throw new Error("--output is required")
100
- if (inputPaths.length === 0) throw new Error("At least one rich test profile input is required")
101
-
102
- const uniqueInputPaths = new Set(inputPaths)
103
-
104
- if (uniqueInputPaths.size !== inputPaths.length) throw new Error("Each rich test profile input must be provided once")
105
- if (uniqueInputPaths.has(outputPath)) throw new Error("Timing manifest output must not overwrite an input profile")
106
-
107
- return {inputPaths, outputPath}
61
+ return parsePackageTimingManifestMergeArguments(processArgs.slice(1), {cwd})
108
62
  }
@@ -1,19 +1,22 @@
1
1
  // @ts-check
2
2
 
3
3
  import BaseCommand from "../../../../cli/base-command.js"
4
- import fs from "fs/promises"
5
4
  import path from "node:path"
6
5
  import picocolors from "picocolors"
7
6
  import TestFilesFinder from "../../../../testing/test-files-finder.js"
8
7
  import TestProfiler from "../../../../testing/test-profiler.js"
9
- import { formatTestProfileSummary, writeTestProfileOutputs } from "../../../../testing/test-profile-output.js"
8
+ import {
9
+ formatTestProfileSummary,
10
+ loadTimingManifest,
11
+ resolveTestProfileOptions,
12
+ writeTestProfileOutputs
13
+ } from "../../../../testing/test-profile-output.js"
10
14
  import TestRunner from "../../../../testing/test-runner.js"
11
15
  import TestSuiteSplitter from "../../../../testing/test-suite-splitter.js"
12
16
  import { normalizeExamplePatterns, parseFilters } from "../../../../testing/test-filter-parser.js"
13
17
  import {
14
18
  canonicalTimingManifestPath,
15
- timingManifestFileSetHash,
16
- validateTimingManifest
19
+ timingManifestFileSetHash
17
20
  } from "../../../../testing/timing-manifest.js"
18
21
  import { prepareSourcePeerPackage } from "../../source-peer-package.js"
19
22
 
@@ -44,8 +47,11 @@ export default class VelociousCliCommandsTest extends BaseCommand {
44
47
  groupNumber,
45
48
  profile,
46
49
  profileJsonPath,
50
+ retries,
51
+ setupFiles,
47
52
  timingManifestPath,
48
- timingManifestOutputPath
53
+ timingManifestOutputPath,
54
+ timeoutMs
49
55
  } = parseFilters(this.processArgs || [])
50
56
  const profileOptions = resolveTestProfileOptions({
51
57
  cwd: process.cwd(),
@@ -70,7 +76,7 @@ export default class VelociousCliCommandsTest extends BaseCommand {
70
76
 
71
77
  /**
72
78
  * Finalizes requested outputs once for every command outcome.
73
- * @param {string} status - Run status.
79
+ * @param {import("@velocious/testing/node").TestProfileStatus} status - Run status.
74
80
  * @returns {Promise<void>} - Resolves after requested outputs are written.
75
81
  */
76
82
  const finalizeProfile = async (status) => {
@@ -157,7 +163,10 @@ export default class VelociousCliCommandsTest extends BaseCommand {
157
163
  testFiles,
158
164
  lineFilters: testFilesFinder.getLineFiltersByFile(),
159
165
  examplePatterns: normalizeExamplePatterns(examplePatterns),
160
- profiler
166
+ profiler,
167
+ retries,
168
+ setupFiles: setupFiles.map((setupFile) => path.resolve(process.cwd(), setupFile)),
169
+ timeoutMs
161
170
  })
162
171
  const activeTestRunner = testRunner
163
172
  let signalHandled = false
@@ -272,68 +281,7 @@ export default class VelociousCliCommandsTest extends BaseCommand {
272
281
  }
273
282
  }
274
283
 
275
- /**
276
- * Resolves and validates profiling paths before test discovery starts.
277
- * @param {object} args - Raw profiling options.
278
- * @param {string} args.cwd - Command working directory.
279
- * @param {boolean} args.profile - Whether console profiling was requested.
280
- * @param {string} [args.profileJsonPath] - Rich profile output path.
281
- * @param {string} [args.timingManifestPath] - Timing manifest input path.
282
- * @param {string} [args.timingManifestOutputPath] - Timing manifest output path.
283
- * @returns {{profile: boolean, profileJsonPath: string | undefined, timingManifestPath: string | undefined, timingManifestOutputPath: string | undefined}} - Resolved profiling options.
284
- */
285
- export function resolveTestProfileOptions({cwd, profile, profileJsonPath, timingManifestPath, timingManifestOutputPath}) {
286
- const resolvedProfileJsonPath = profileJsonPath ? path.resolve(cwd, profileJsonPath) : undefined
287
- const resolvedTimingManifestPath = timingManifestPath ? path.resolve(cwd, timingManifestPath) : undefined
288
- const resolvedTimingManifestOutputPath = timingManifestOutputPath
289
- ? path.resolve(cwd, timingManifestOutputPath)
290
- : undefined
291
-
292
- if (resolvedProfileJsonPath && resolvedTimingManifestOutputPath && resolvedProfileJsonPath === resolvedTimingManifestOutputPath) {
293
- throw new Error("Test profiling output paths must be different")
294
- }
295
-
296
- if (resolvedTimingManifestPath && (
297
- resolvedProfileJsonPath === resolvedTimingManifestPath ||
298
- resolvedTimingManifestOutputPath === resolvedTimingManifestPath
299
- )) {
300
- throw new Error("Test profiling outputs must not overwrite --timing-manifest input")
301
- }
302
-
303
- return {
304
- profile: profile || Boolean(resolvedProfileJsonPath || resolvedTimingManifestOutputPath),
305
- profileJsonPath: resolvedProfileJsonPath,
306
- timingManifestPath: resolvedTimingManifestPath,
307
- timingManifestOutputPath: resolvedTimingManifestOutputPath
308
- }
309
- }
310
-
311
- /**
312
- * Loads and validates an explicitly supplied plain JSON timing manifest.
313
- * @param {string | undefined} timingManifestPath - Timing manifest path.
314
- * @returns {Promise<Record<string, number> | undefined>} - Canonical manifest, or undefined when not requested.
315
- */
316
- export async function loadTimingManifest(timingManifestPath) {
317
- if (!timingManifestPath) return undefined
318
-
319
- let content
320
-
321
- try {
322
- content = await fs.readFile(timingManifestPath, "utf8")
323
- } catch (error) {
324
- throw new Error(`Failed to read timing manifest: ${timingManifestPath}`, {cause: error})
325
- }
326
-
327
- let parsed
328
-
329
- try {
330
- parsed = JSON.parse(content)
331
- } catch (error) {
332
- throw new Error(`Failed to parse timing manifest: ${timingManifestPath}`, {cause: error})
333
- }
334
-
335
- return validateTimingManifest(parsed, {source: `Timing manifest ${timingManifestPath}`})
336
- }
284
+ export { loadTimingManifest, resolveTestProfileOptions }
337
285
 
338
286
  /**
339
287
  * Resolves how many slowest tests to report from the `VELOCIOUS_SLOW_TEST_COUNT`
@@ -389,6 +389,24 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
389
389
  return broadcastParams?.model === this._modelName()
390
390
  }
391
391
 
392
+ /**
393
+ * Drops the server-only destroy-authorization snapshot before replay
394
+ * persistence. The snapshot is what makes replayed destroy events
395
+ * require a client resync, and the pre-delete row it captures must
396
+ * never be stored.
397
+ * @param {Record<string, import("./query.js").FrontendModelTransportValue> | null | undefined} broadcastParams - Params from `broadcastToChannel`.
398
+ * @returns {Record<string, import("./query.js").FrontendModelTransportValue> | null} - Persistable routing params.
399
+ */
400
+ static replayableBroadcastParams(broadcastParams) {
401
+ if (!broadcastParams) return null
402
+
403
+ const replayableParams = {...broadcastParams}
404
+
405
+ delete replayableParams.destroyAuthorizationRecord
406
+
407
+ return replayableParams
408
+ }
409
+
392
410
  /**
393
411
  * Runs debug snapshot.
394
412
  * @returns {Record<string, ReturnType<typeof JSON.parse>>} Debug-safe subscription details.
@@ -1843,8 +1843,13 @@ export default class VelociousHttpServerClientWebsocketSession {
1843
1843
 
1844
1844
  /**
1845
1845
  * Replays missed events from the persistent event-log store for a
1846
- * channel subscription that provided `lastEventId`. Sends each
1847
- * missed event as a `channel-message` with `replayed: true`.
1846
+ * channel subscription that provided `lastEventId`. Delivery is
1847
+ * stream-scoped: each persisted event's broadcast params are
1848
+ * re-applied through the subscription's `matches()` — the same routing
1849
+ * decision live delivery uses — so the subscription only replays events
1850
+ * from its own stream of the channel. A checkpoint that belongs to a
1851
+ * different stream (or is no longer retained) yields
1852
+ * `channel-replay-gap` instead of cross-stream replay.
1848
1853
  * @param {object} args - Options.
1849
1854
  * @param {string} args.channelType - Channel type name (event-log key).
1850
1855
  * @param {string} args.lastEventId - Client's last-seen event id.
@@ -1858,7 +1863,7 @@ export default class VelociousHttpServerClientWebsocketSession {
1858
1863
 
1859
1864
  const checkpoint = await store.getEventById({channel: channelType, id: lastEventId})
1860
1865
 
1861
- if (!checkpoint) {
1866
+ if (!checkpoint || !this._replayEventMatchesSubscription({channelType, event: checkpoint, subscription})) {
1862
1867
  this.sendJson({
1863
1868
  type: "channel-replay-gap",
1864
1869
  subscriptionId: subscription.subscriptionId,
@@ -1880,6 +1885,8 @@ export default class VelociousHttpServerClientWebsocketSession {
1880
1885
  for (const event of events) {
1881
1886
  if (subscription.isClosed()) break
1882
1887
 
1888
+ if (!this._replayEventMatchesSubscription({channelType, event, subscription})) continue
1889
+
1883
1890
  if (await subscription._requiresReplayGap(event.payload)) {
1884
1891
  this.sendJson({
1885
1892
  type: "channel-replay-gap",
@@ -1891,11 +1898,41 @@ export default class VelociousHttpServerClientWebsocketSession {
1891
1898
 
1892
1899
  await subscription.deliverBroadcast(
1893
1900
  /** @type {import("../websocket-channel.js").WebsocketJsonValue} */ (event.payload),
1894
- {eventId: event.id}
1901
+ {
1902
+ ...(event.params !== null ? {broadcastParams: event.params} : {}),
1903
+ eventId: event.id
1904
+ }
1895
1905
  )
1896
1906
  }
1897
1907
  }
1898
1908
 
1909
+ /**
1910
+ * Whether a persisted replay event belongs to this subscription's
1911
+ * stream. A `matches()` failure means the stream membership cannot be
1912
+ * proven, so the event is treated as not matching — the same isolation
1913
+ * live delivery applies to a broken `matches()`.
1914
+ * @param {object} args - Options.
1915
+ * @param {string} args.channelType - Channel type name.
1916
+ * @param {{params: Record<string, ReturnType<typeof JSON.parse>> | null}} args.event - Persisted replay event.
1917
+ * @param {import("../websocket-channel.js").default} args.subscription - Live subscription.
1918
+ * @returns {boolean} - Whether the event belongs to the subscription's stream.
1919
+ */
1920
+ _replayEventMatchesSubscription({channelType, event, subscription}) {
1921
+ try {
1922
+ return Boolean(subscription.matches(event.params || {}))
1923
+ } catch (caughtError) {
1924
+ const error = this._reportUnexpectedDispatchError(caughtError, {
1925
+ channelType,
1926
+ stage: "websocket-channel-replay",
1927
+ subscriptionId: subscription.subscriptionId
1928
+ })
1929
+
1930
+ this.logger.error(() => [`Websocket replay subscription ${subscription.subscriptionId} matches() threw`, error])
1931
+
1932
+ return false
1933
+ }
1934
+ }
1935
+
1899
1936
  /**
1900
1937
  * Handles `{type: "channel-unsubscribe"}` from the client — calls
1901
1938
  * `unsubscribed()` and sends `channel-unsubscribed`.
@@ -94,6 +94,20 @@ export default class VelociousWebsocketChannel {
94
94
  */
95
95
  matches(..._broadcastArgs) { return true }
96
96
 
97
+ /**
98
+ * Returns the broadcast params that may be persisted for replay.
99
+ * Persisted params are re-applied through `matches(broadcastParams)`
100
+ * when replaying missed events, so they must be JSON-serializable and
101
+ * safe to store. Override when `broadcastParams` carries server-only
102
+ * values that must never reach the event log (e.g. authorization
103
+ * snapshots).
104
+ * @param {WebsocketParams | null | undefined} broadcastParams - Params passed to `broadcastToChannel`.
105
+ * @returns {WebsocketParams | null} - Params to persist, or null to store none.
106
+ */
107
+ static replayableBroadcastParams(broadcastParams) {
108
+ return broadcastParams ?? null
109
+ }
110
+
97
111
  /**
98
112
  * Whether replaying a persisted broadcast would require a client resync.
99
113
  * Subclasses override this when replay storage deliberately omits metadata