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
@@ -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
@@ -11,6 +11,7 @@ import Logger from "../logger.js"
11
11
  * @property {Date | string} created_at - Creation time.
12
12
  * @property {string} id - Event id.
13
13
  * @property {string} payload_json - Serialized payload.
14
+ * @property {string | null} params_json - Serialized broadcast params, or null when the publish carried none.
14
15
  * @property {number | string} sequence - Sequence number.
15
16
  */
16
17
  /**
@@ -18,6 +19,16 @@ import Logger from "../logger.js"
18
19
  * @typedef {object} WebsocketReplayChannelRow
19
20
  * @property {string} channel - Channel name.
20
21
  */
22
+ /**
23
+ * Normalized persisted websocket event.
24
+ * @typedef {object} WebsocketPersistedEvent
25
+ * @property {string} channel - Channel name.
26
+ * @property {string} createdAt - ISO creation time.
27
+ * @property {string} id - Event id.
28
+ * @property {Record<string, ReturnType<typeof JSON.parse>> | null} params - Persisted broadcast params, or null when the publish carried none.
29
+ * @property {ReturnType<typeof JSON.parse>} payload - Event payload.
30
+ * @property {number} sequence - Sequence number.
31
+ */
21
32
  const EVENTS_TABLE = "websocket_channel_events"
22
33
  const REPLAY_CHANNELS_TABLE = "websocket_replay_channels"
23
34
  const DEFAULT_RETENTION_MS = 10 * 60 * 1000
@@ -100,22 +111,40 @@ export default class VelociousHttpServerWebsocketEventLogStore {
100
111
 
101
112
  /**
102
113
  * Runs schema present.
103
- * @returns {Promise<boolean>} - Whether both event-log tables physically exist.
114
+ * @returns {Promise<boolean>} - Whether both event-log tables exist and the events table carries every required column.
104
115
  */
105
116
  async _schemaPresent() {
106
117
  return await this._withDb(async (db) =>
107
- await db.tableExists(EVENTS_TABLE) && await db.tableExists(REPLAY_CHANNELS_TABLE)
118
+ (await db.tableExists(EVENTS_TABLE) && await db.tableExists(REPLAY_CHANNELS_TABLE))
119
+ && (await this._columnPresent(db, EVENTS_TABLE, "params_json"))
108
120
  )
109
121
  }
110
122
 
123
+ /**
124
+ * Runs column present.
125
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
126
+ * @param {string} tableName - Table name.
127
+ * @param {string} columnName - Column name.
128
+ * @returns {Promise<boolean>} - Whether the column exists on the table.
129
+ */
130
+ async _columnPresent(db, tableName, columnName) {
131
+ const tables = await db.getTables()
132
+ const table = tables.find((candidate) => candidate.getName() == tableName)
133
+
134
+ if (!table) return false
135
+
136
+ return (await table.getColumnByName(columnName)) !== undefined
137
+ }
138
+
111
139
  /**
112
140
  * Runs append event.
113
141
  * @param {object} args - Options.
114
142
  * @param {string} args.channel - Channel name.
115
143
  * @param {ReturnType<typeof JSON.parse>} args.payload - Event payload.
116
- * @returns {Promise<{channel: string, createdAt: string, id: string, payload: ReturnType<typeof JSON.parse>}>} - Persisted event row.
144
+ * @param {Record<string, ReturnType<typeof JSON.parse>> | null} [args.params] - Broadcast params to persist for stream-scoped replay, or null when the publish carried none.
145
+ * @returns {Promise<WebsocketPersistedEvent>} - Persisted event row.
117
146
  */
118
- async appendEvent({channel, payload}) {
147
+ async appendEvent({channel, params = null, payload}) {
119
148
  await this.ensureReady()
120
149
 
121
150
  const id = randomUUID()
@@ -128,10 +157,11 @@ export default class VelociousHttpServerWebsocketEventLogStore {
128
157
  channel,
129
158
  created_at: createdAt,
130
159
  id,
160
+ params_json: params === null ? null : JSON.stringify(params),
131
161
  payload_json: JSON.stringify(payload)
132
162
  }
133
163
  })
134
- return {channel, createdAt: createdAt.toISOString(), id, payload}
164
+ return {channel, createdAt: createdAt.toISOString(), id, params, payload}
135
165
  })
136
166
  }
137
167
 
@@ -139,8 +169,13 @@ export default class VelociousHttpServerWebsocketEventLogStore {
139
169
  * Runs mark channel interested.
140
170
  * @param {string} channel - Channel name.
141
171
  * @returns {Promise<void>} - Resolves when the channel interest was persisted.
172
+ * @throws {Error} When the channel is registered live-only, which forbids replay persistence.
142
173
  */
143
174
  async markChannelInterested(channel) {
175
+ if (this.configuration.isWebsocketChannelLiveOnly(channel)) {
176
+ throw new Error(`Websocket channel "${channel}" is registered live-only and cannot be marked interested in replay persistence`)
177
+ }
178
+
144
179
  await this.ensureReady()
145
180
 
146
181
  const interestedUntil = new Date(Date.now() + this.retentionMs)
@@ -158,6 +193,12 @@ export default class VelociousHttpServerWebsocketEventLogStore {
158
193
  * @returns {Promise<boolean>} - Whether the channel should be persisted for replay.
159
194
  */
160
195
  async shouldPersistChannel(channel) {
196
+ // A channel re-registered live-only can still hold cached or durable
197
+ // interest state from before the re-registration; the live-only
198
+ // contract must hold at the persistence decision, not only at
199
+ // interest marking.
200
+ if (this.configuration.isWebsocketChannelLiveOnly(channel)) return false
201
+
161
202
  if (this._channelInterestCached(channel)) return true
162
203
  if (this._interestedChannels.size === 0) return false
163
204
 
@@ -197,7 +238,7 @@ export default class VelociousHttpServerWebsocketEventLogStore {
197
238
  * @param {object} args - Options.
198
239
  * @param {string} args.channel - Channel name.
199
240
  * @param {string} args.id - Event id.
200
- * @returns {Promise<{channel: string, createdAt: string, id: string, payload: ReturnType<typeof JSON.parse>, sequence: number} | null>} - Event row or null.
241
+ * @returns {Promise<WebsocketPersistedEvent | null>} - Event row or null.
201
242
  */
202
243
  async getEventById({channel, id}) {
203
244
  await this.ensureReady()
@@ -237,7 +278,7 @@ export default class VelociousHttpServerWebsocketEventLogStore {
237
278
  * @param {string} args.channel - Channel name.
238
279
  * @param {number} args.sequence - Lower bound sequence.
239
280
  * @param {number | null | undefined} [args.upToSequence] - Inclusive ceiling sequence.
240
- * @returns {Promise<Array<{channel: string, createdAt: string, id: string, payload: ReturnType<typeof JSON.parse>, sequence: number}>>} - Ordered events.
281
+ * @returns {Promise<WebsocketPersistedEvent[]>} - Ordered events.
241
282
  */
242
283
  async getEventsAfter({channel, sequence, upToSequence}) {
243
284
  await this.ensureReady()
@@ -312,24 +353,55 @@ export default class VelociousHttpServerWebsocketEventLogStore {
312
353
  * @returns {Promise<void>} - Resolves when complete.
313
354
  */
314
355
  async _ensureEventsTable(db) {
315
- this.logger.info("Applying websocket event-log schema")
316
-
317
356
  if (await db.tableExists(EVENTS_TABLE)) {
318
- this.logger.info("Websocket event-log table already exists - skipping create")
357
+ await this._ensureEventsTableColumns(db)
319
358
  return
320
359
  }
321
360
 
361
+ this.logger.info("Applying websocket event-log schema")
362
+
322
363
  const eventTable = new TableData(EVENTS_TABLE, {ifNotExists: true})
323
364
 
324
365
  eventTable.integer("sequence", {autoIncrement: true, null: false, primaryKey: true})
325
366
  eventTable.string("id", {index: true, null: false})
326
367
  eventTable.string("channel", {index: true, null: false})
368
+ eventTable.text("params_json", {null: true})
327
369
  eventTable.text("payload_json", {null: false})
328
370
  eventTable.datetime("created_at", {index: true, null: false})
329
371
 
330
372
  await db.createTable(eventTable)
331
373
  }
332
374
 
375
+ /**
376
+ * Adds columns the current schema requires to an events table created by an
377
+ * older framework version, so upgrades keep working without a manual ALTER.
378
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
379
+ * @returns {Promise<void>} - Resolves when the events table schema is current.
380
+ */
381
+ async _ensureEventsTableColumns(db) {
382
+ if (await this._columnPresent(db, EVENTS_TABLE, "params_json")) return
383
+
384
+ this.logger.info("Adding params_json column to websocket event-log table")
385
+
386
+ const tableData = new TableData(EVENTS_TABLE)
387
+
388
+ tableData.addColumn("params_json", {isNewColumn: true, null: true, type: "text"})
389
+
390
+ try {
391
+ for (const sql of await db.alterTableSQLs(tableData)) {
392
+ await db.query(sql)
393
+ }
394
+ } catch (error) {
395
+ // A concurrent process can add the column between the presence check
396
+ // and the ALTER (multi-worker or rolling upgrade); the
397
+ // duplicate-column failure is then the expected outcome, not a real
398
+ // error. Anything else re-checks as absent and rethrows.
399
+ if (await this._columnPresent(db, EVENTS_TABLE, "params_json")) return
400
+
401
+ throw error
402
+ }
403
+ }
404
+
333
405
  /**
334
406
  * Runs ensure replay channels table.
335
407
  * @param {import("../database/drivers/base.js").default} db - Database connection.
@@ -352,7 +424,7 @@ export default class VelociousHttpServerWebsocketEventLogStore {
352
424
  * @param {string} args.channel - Channel name.
353
425
  * @param {import("../database/drivers/base.js").default} args.db - Database connection.
354
426
  * @param {string} args.id - Event id.
355
- * @returns {Promise<{channel: string, createdAt: string, id: string, payload: ReturnType<typeof JSON.parse>, sequence: number} | null>} - Event row or null.
427
+ * @returns {Promise<WebsocketPersistedEvent | null>} - Event row or null.
356
428
  */
357
429
  async _getEventById({channel, db, id}) {
358
430
  const rows = /** @type {WebsocketEventRow[]} */ (await db
@@ -370,7 +442,7 @@ export default class VelociousHttpServerWebsocketEventLogStore {
370
442
  /**
371
443
  * Runs normalize event row.
372
444
  * @param {WebsocketEventRow} row - Raw row.
373
- * @returns {{channel: string, createdAt: string, id: string, payload: ReturnType<typeof JSON.parse>, sequence: number}} - Normalized row.
445
+ * @returns {WebsocketPersistedEvent} - Normalized row.
374
446
  */
375
447
  _normalizeEventRow(row) {
376
448
  const createdAtValue = row.created_at
@@ -379,6 +451,7 @@ export default class VelociousHttpServerWebsocketEventLogStore {
379
451
  channel: row.channel,
380
452
  createdAt: createdAtValue instanceof Date ? createdAtValue.toISOString() : new Date(createdAtValue).toISOString(),
381
453
  id: row.id,
454
+ params: row.params_json === null || row.params_json === undefined ? null : JSON.parse(row.params_json),
382
455
  payload: JSON.parse(row.payload_json),
383
456
  sequence: Number(row.sequence)
384
457
  }
@@ -116,7 +116,7 @@ export class VelociousHttpServerWebsocketEventsHost {
116
116
  // Other channels chain onto their own tails and are not delayed.
117
117
  this._queuePublish({
118
118
  callback: async () => {
119
- const persistedEvent = await this._persistV2EventIfNeeded({body, channel, configuration})
119
+ const persistedEvent = await this._persistV2EventIfNeeded({body, broadcastParams, channel, configuration})
120
120
  const dispatchedTargets = new Set()
121
121
 
122
122
  for (const handler of this.broadcastHandlersByConfiguration.get(configuration) || []) {
@@ -193,12 +193,13 @@ export class VelociousHttpServerWebsocketEventsHost {
193
193
  * Runs persist v2 event if needed.
194
194
  * @param {object} args - Options.
195
195
  * @param {ReturnType<typeof JSON.parse>} args.body - Event body.
196
+ * @param {Record<string, ReturnType<typeof JSON.parse>>} args.broadcastParams - Routing filter params.
196
197
  * @param {string} args.channel - Channel name.
197
198
  * @param {import("../configuration.js").default} args.configuration - Originating configuration.
198
199
  * @returns {Promise<{createdAt: string, id: string} | null>} - Persisted event metadata when storage is enabled.
199
200
  */
200
- async _persistV2EventIfNeeded({body, channel, configuration}) {
201
- return await this._persistChannelEventIfNeeded({channel, payload: body, configuration})
201
+ async _persistV2EventIfNeeded({body, broadcastParams, channel, configuration}) {
202
+ return await this._persistChannelEventIfNeeded({broadcastParams, channel, configuration, payload: body})
202
203
  }
203
204
 
204
205
  /**
@@ -213,14 +214,18 @@ export class VelociousHttpServerWebsocketEventsHost {
213
214
  }
214
215
 
215
216
  /**
216
- * Runs persist channel event if needed.
217
+ * Runs persist channel event if needed. Persists the broadcast's routing
218
+ * params (sanitized through the channel class's
219
+ * `replayableBroadcastParams`) so replay can re-apply `matches()` per
220
+ * stream instead of delivering the whole channel's log.
217
221
  * @param {object} args - Options object.
222
+ * @param {Record<string, ReturnType<typeof JSON.parse>> | null} [args.broadcastParams] - Broadcast params to persist for stream-scoped replay.
218
223
  * @param {string} args.channel - Channel name.
219
224
  * @param {ReturnType<typeof JSON.parse>} args.payload - Payload data.
220
225
  * @param {import("../configuration.js").default} [args.configuration] - Configuration owning the event store.
221
226
  * @returns {Promise<{createdAt: string, id: string} | null>} - Persisted event metadata.
222
227
  */
223
- async _persistChannelEventIfNeeded({channel, payload, configuration}) {
228
+ async _persistChannelEventIfNeeded({broadcastParams = null, channel, configuration, payload}) {
224
229
  const handler = this.handlers.values().next().value
225
230
  const eventConfiguration = configuration || handler?.configuration
226
231
 
@@ -231,7 +236,10 @@ export class VelociousHttpServerWebsocketEventsHost {
231
236
 
232
237
  if (!shouldPersist) return null
233
238
 
234
- const persistedEvent = await websocketEventLogStore.appendEvent({channel, payload})
239
+ const ChannelClass = eventConfiguration.getWebsocketChannelClass(channel)
240
+ const params = ChannelClass ? ChannelClass.replayableBroadcastParams(broadcastParams) : broadcastParams
241
+
242
+ const persistedEvent = await websocketEventLogStore.appendEvent({channel, params, payload})
235
243
 
236
244
  return {
237
245
  createdAt: persistedEvent.createdAt,