velocious 1.0.621 → 1.0.623

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 (77) hide show
  1. package/README.md +7 -3
  2. package/build/database/drivers/base.js +20 -3
  3. package/build/database/drivers/mssql/index.js +42 -36
  4. package/build/environment-handlers/base.js +9 -0
  5. package/build/environment-handlers/node.js +16 -0
  6. package/build/frontend-model-controller.js +20 -10
  7. package/build/frontend-models/base.js +90 -25
  8. package/build/frontend-models/remote-request-context.js +29 -0
  9. package/build/http-client/websocket-client.js +32 -0
  10. package/build/http-server/client/websocket-session.js +4 -1
  11. package/build/remote-request-context.js +97 -0
  12. package/build/src/database/drivers/base.d.ts +8 -0
  13. package/build/src/database/drivers/base.d.ts.map +1 -1
  14. package/build/src/database/drivers/base.js +16 -4
  15. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  16. package/build/src/database/drivers/mssql/index.js +47 -41
  17. package/build/src/environment-handlers/base.d.ts +8 -0
  18. package/build/src/environment-handlers/base.d.ts.map +1 -1
  19. package/build/src/environment-handlers/base.js +9 -1
  20. package/build/src/environment-handlers/node.d.ts +8 -0
  21. package/build/src/environment-handlers/node.d.ts.map +1 -1
  22. package/build/src/environment-handlers/node.js +15 -1
  23. package/build/src/frontend-model-controller.d.ts +3 -1
  24. package/build/src/frontend-model-controller.d.ts.map +1 -1
  25. package/build/src/frontend-model-controller.js +11 -7
  26. package/build/src/frontend-models/base.d.ts +4 -0
  27. package/build/src/frontend-models/base.d.ts.map +1 -1
  28. package/build/src/frontend-models/base.js +78 -25
  29. package/build/src/frontend-models/remote-request-context.d.ts +15 -0
  30. package/build/src/frontend-models/remote-request-context.d.ts.map +1 -0
  31. package/build/src/frontend-models/remote-request-context.js +26 -0
  32. package/build/src/http-client/websocket-client.d.ts +8 -0
  33. package/build/src/http-client/websocket-client.d.ts.map +1 -1
  34. package/build/src/http-client/websocket-client.js +31 -1
  35. package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
  36. package/build/src/http-server/client/websocket-session.js +4 -2
  37. package/build/src/remote-request-context.d.ts +34 -0
  38. package/build/src/remote-request-context.d.ts.map +1 -0
  39. package/build/src/remote-request-context.js +83 -0
  40. package/build/src/sync/sync-client-types.d.ts +10 -0
  41. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  42. package/build/src/sync/sync-client-types.js +3 -1
  43. package/build/src/sync/sync-client.d.ts.map +1 -1
  44. package/build/src/sync/sync-client.js +31 -7
  45. package/build/src/sync/sync-envelope-replay-service.d.ts +3 -2
  46. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  47. package/build/src/sync/sync-envelope-replay-service.js +18 -11
  48. package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
  49. package/build/src/sync/sync-realtime-bridge.js +8 -2
  50. package/build/src/testing/shared-transaction-connection-coordinator.d.ts +9 -0
  51. package/build/src/testing/shared-transaction-connection-coordinator.d.ts.map +1 -1
  52. package/build/src/testing/shared-transaction-connection-coordinator.js +15 -1
  53. package/build/src/testing/test-runner.js +2 -2
  54. package/build/sync/sync-client-types.js +2 -0
  55. package/build/sync/sync-client.js +31 -6
  56. package/build/sync/sync-envelope-replay-service.js +17 -10
  57. package/build/sync/sync-realtime-bridge.js +7 -1
  58. package/build/testing/shared-transaction-connection-coordinator.js +16 -0
  59. package/build/testing/test-runner.js +1 -1
  60. package/build/tsconfig.tsbuildinfo +1 -1
  61. package/package.json +1 -1
  62. package/src/database/drivers/base.js +20 -3
  63. package/src/database/drivers/mssql/index.js +42 -36
  64. package/src/environment-handlers/base.js +9 -0
  65. package/src/environment-handlers/node.js +16 -0
  66. package/src/frontend-model-controller.js +20 -10
  67. package/src/frontend-models/base.js +90 -25
  68. package/src/frontend-models/remote-request-context.js +29 -0
  69. package/src/http-client/websocket-client.js +32 -0
  70. package/src/http-server/client/websocket-session.js +4 -1
  71. package/src/remote-request-context.js +97 -0
  72. package/src/sync/sync-client-types.js +2 -0
  73. package/src/sync/sync-client.js +31 -6
  74. package/src/sync/sync-envelope-replay-service.js +17 -10
  75. package/src/sync/sync-realtime-bridge.js +7 -1
  76. package/src/testing/shared-transaction-connection-coordinator.js +16 -0
  77. package/src/testing/test-runner.js +1 -1
package/README.md CHANGED
@@ -13,11 +13,11 @@
13
13
  * External packages (engines) that contribute data models, frontend-model resources and migrations to a consuming app (see [docs/packages.md](docs/packages.md))
14
14
  * Optional Rampway-owned durable deployment control plane mounted through the standard routes DSL on Velocious 1.0.577 or newer (see [docs/rampway-integration.md](docs/rampway-integration.md))
15
15
  * Controllers and views for HTTP endpoints
16
- * Frontend-model transport for creating, updating, querying, and subscribing to query-filtered lifecycle events over HTTP/WebSocket, with structured per-attribute validation error responses and one-budget WebSocket startup controls (see [docs/frontend-models.md](docs/frontend-models.md) and [docs/websocket-channels.md](docs/websocket-channels.md))
16
+ * Frontend-model transport for creating, updating, querying, and subscribing to query-filtered lifecycle events over HTTP/WebSocket, with structured per-attribute validation error responses, immutable per-operation remote request context, and one-budget WebSocket startup controls (see [docs/frontend-models.md](docs/frontend-models.md), [docs/remote-request-context.md](docs/remote-request-context.md), and [docs/websocket-channels.md](docs/websocket-channels.md))
17
17
  * Client-side offline sync mutation logs and frontend-model optimistic queueing primitives (see the [shared-resource sync developer guide](docs/shared-resource-sync-guide.md) and [offline sync architecture](docs/offline-sync.md))
18
- * Declarative client sync scopes with per-scope cursors, automatic mutation tracking, opt-in durable base-version conflict replay, realtime delivery, and immutable-handle project clients whose queues, scopes, cursors, receipts, conflicts, pulls, and reconnect catch-up stay in one physical tenant database (see [docs/sync-client.md](docs/sync-client.md) and [docs/offline-sync.md](docs/offline-sync.md))
18
+ * Declarative client sync scopes with per-scope cursors, automatic mutation tracking, opt-in durable base-version conflict replay, realtime delivery, and immutable-handle project clients whose local database state plus remote pull/replay/realtime request context stay tenant-bound through reconnect (see [docs/sync-client.md](docs/sync-client.md), [docs/remote-request-context.md](docs/remote-request-context.md), and [docs/offline-sync.md](docs/offline-sync.md))
19
19
  * Reactive `useLiveQuery(Model.where(...))` queries for default databases plus immutable-handle tenant live-query sources whose committed events and refreshes stay on the captured physical tenant (see [docs/live-queries.md](docs/live-queries.md))
20
- * Server-side sync envelope replay orchestration for app-owned sync receivers, including allowlisted authoritative values for conflict resolution (see [docs/sync-envelope-replay-service.md](docs/sync-envelope-replay-service.md))
20
+ * Server-side sync envelope replay orchestration for app-owned sync receivers, including allowlisted authoritative conflict snapshots that retain submitted aliases in conflict metadata while keying `serverModel` by canonical model attributes (see [docs/sync-envelope-replay-service.md](docs/sync-envelope-replay-service.md))
21
21
  * Self-sustaining sync feeds: upstream imports triggered by the changes pull itself, with framework-owned coalescing and throttling (see [docs/sync-upstream-imports.md](docs/sync-upstream-imports.md))
22
22
  * AwesomeTasks-shaped offline sync proof using routed resources, domain commands, signed offline grants, and peer-forwarded mutations (see the [developer guide](docs/shared-resource-sync-guide.md) and [proof](docs/awesome-tasks-offline-sync-proof.md))
23
23
  * SQLite web persistence that automatically prefers OPFS, then IndexedDB, and migrates legacy persisted bytes when possible (see [docs/sqlite-web-persistence.md](docs/sqlite-web-persistence.md))
@@ -797,6 +797,7 @@ When your frontend app calls a backend on another host/port (or under a path pre
797
797
  import FrontendModelBase from "velocious/build/src/frontend-models/base.js"
798
798
 
799
799
  FrontendModelBase.configureTransport({
800
+ requestContext: () => ({projectId: currentProject.id, routingEpoch: currentProject.routingEpoch}),
800
801
  url: "http://127.0.0.1:4501/frontend-models",
801
802
  timeZone: () => Intl.DateTimeFormat().resolvedOptions().timeZone
802
803
  })
@@ -805,6 +806,7 @@ FrontendModelBase.configureTransport({
805
806
  Available transport options:
806
807
 
807
808
  - `url` (can also be a relative path like `"/frontend-models"` on web)
809
+ - `requestContext` (a scalar plain object or synchronous function returning one) captures immutable remote tenant-routing params independently for each CRUD/custom command and event subscription. See [docs/remote-request-context.md](docs/remote-request-context.md).
808
810
  - `timeZone` (an IANA timezone string or a function returning one). Browser clients auto-detect this when it is not configured. Frontend-model datetime strings without an explicit timezone are interpreted in this request timezone and stored/queried as UTC instants.
809
811
  - `timeout` (milliseconds or a function returning milliseconds) bounds each request, while `signal` (an `AbortSignal` or a function returning one) supports caller cancellation. See [docs/frontend-models.md](docs/frontend-models.md#core-transport) for timeout and cancellation behavior.
810
812
 
@@ -2016,6 +2018,8 @@ await client.close()
2016
2018
 
2017
2019
  For long-lived Node clients, the constructor also accepts opt-in liveness options (all default off, so browser/Expo usage is unchanged): `webSocketImplementation` (inject Node's `ws`, since the global/undici WebSocket exposes neither protocol ping nor an unref-able socket), `heartbeatIntervalMs` (a ping heartbeat that drops a socket whose peer stops ponging, so a client notices a vanished server), and `unref` (unref the underlying socket so an idle connection can't keep the process alive on its own). See [docs/websocket-channels.md](docs/websocket-channels.md).
2018
2020
 
2021
+ `await client.close()` is a final graceful shutdown that releases resumable server-session state; unexpected transport drops remain resumable. See [the WebSocket channel lifecycle guarantees](docs/websocket-channels.md#lifecycle-guarantees-phase-1b).
2022
+
2019
2023
  ## Subscribe to events
2020
2024
 
2021
2025
  ```js
@@ -171,7 +171,7 @@ import TableColumn from "../table-data/table-column.js"
171
171
  import TableForeignKey from "../table-data/table-foreign-key.js"
172
172
  import wait from "awaitery/build/wait.js"
173
173
  import { optionalPositiveInteger } from "typanic"
174
- import { coordinateSharedTransactionConnection } from "../../testing/shared-transaction-connection-coordinator.js"
174
+ import {coordinateSharedTransactionConnection, runWithoutSharedTransactionCoordinatorOwner} from "../../testing/shared-transaction-connection-coordinator.js"
175
175
  import { currentTestProfileContext } from "../../testing/test-profile-context.js"
176
176
  import sha256Hex from "../../utils/sha256-hex.js"
177
177
 
@@ -341,9 +341,22 @@ export default class VelociousDatabaseDriversBase {
341
341
  this._transactionCompletionPromise = Promise.resolve()
342
342
  this._resolveTransactionCompletion = undefined
343
343
  this._transactionsActionsMutex = new Mutex()
344
+ this._physicalConnectionMutex = new Mutex()
344
345
  this._schemaCache = new Map()
345
346
  }
346
347
 
348
+ /**
349
+ * Serializes access to one physical database session.
350
+ * @template T
351
+ * @param {() => Promise<T>} callback - Physical driver operation.
352
+ * @returns {Promise<T>} - Operation result.
353
+ */
354
+ async _runPhysicalConnectionRequest(callback) {
355
+ return await this._physicalConnectionMutex.sync(async () => {
356
+ return await runWithoutSharedTransactionCoordinatorOwner(this, callback)
357
+ })
358
+ }
359
+
347
360
  /**
348
361
  * Cleans driver-specific session state before this logical connection is reusable.
349
362
  * Drivers whose physical sessions cannot be safely reset should dispose them here.
@@ -2014,7 +2027,9 @@ export default class VelociousDatabaseDriversBase {
2014
2027
  let failed = true
2015
2028
 
2016
2029
  try {
2017
- const affectedRows = await this._affectedRowsActual(sql)
2030
+ const affectedRows = await this._runPhysicalConnectionRequest(
2031
+ async () => await this._affectedRowsActual(sql)
2032
+ )
2018
2033
 
2019
2034
  failed = false
2020
2035
  return affectedRows
@@ -2096,7 +2111,9 @@ export default class VelociousDatabaseDriversBase {
2096
2111
  let failed = true
2097
2112
 
2098
2113
  try {
2099
- const result = await this._queryActual(sql, options)
2114
+ const result = await this._runPhysicalConnectionRequest(
2115
+ async () => await this._queryActual(sql, options)
2116
+ )
2100
2117
 
2101
2118
  failed = false
2102
2119
  return result
@@ -558,52 +558,58 @@ export default class VelociousDatabaseDriversMssql extends Base{
558
558
  }
559
559
 
560
560
  async _startTransactionAction() {
561
- if (this._currentTransaction) throw new Error("A transaction is already running")
562
- if (!this.connection) await this.connect()
561
+ await this._runPhysicalConnectionRequest(async () => {
562
+ if (this._currentTransaction) throw new Error("A transaction is already running")
563
+ if (!this.connection) await this.connect()
563
564
 
564
- this._currentTransaction = new mssql.Transaction(this.connection)
565
+ this._currentTransaction = new mssql.Transaction(this.connection)
565
566
 
566
- try {
567
- await this._currentTransaction.begin()
568
- } catch (error) {
569
- this._currentTransaction = null
570
- throw error
571
- }
567
+ try {
568
+ await this._currentTransaction.begin()
569
+ } catch (error) {
570
+ this._currentTransaction = null
571
+ throw error
572
+ }
573
+ })
572
574
  }
573
575
 
574
576
  async _commitTransactionAction() {
575
- if (!this._currentTransaction) throw new Error("A transaction isn't running")
577
+ await this._runPhysicalConnectionRequest(async () => {
578
+ if (!this._currentTransaction) throw new Error("A transaction isn't running")
576
579
 
577
- await this._currentTransaction.commit()
578
- this._currentTransaction = null
580
+ await this._currentTransaction.commit()
581
+ this._currentTransaction = null
582
+ })
579
583
  }
580
584
 
581
585
  async _rollbackTransactionAction() {
582
- if (!this._currentTransaction) {
583
- this.logger.debug("A transaction isn't running - ignoring because that can happen if something else has failed in the db")
584
- return
585
- }
586
+ await this._runPhysicalConnectionRequest(async () => {
587
+ if (!this._currentTransaction) {
588
+ this.logger.debug("A transaction isn't running - ignoring because that can happen if something else has failed in the db")
589
+ return
590
+ }
586
591
 
587
- try {
588
- await this._currentTransaction.rollback()
589
- } catch (transactionRollbackError) {
590
- // When SQL Server has already aborted the transaction (e.g., a
591
- // stale concurrent request triggered XACT_ABORT), the
592
- // mssql.Transaction.rollback() call fails because the
593
- // Transaction object is dead. Issue a raw ROLLBACK on the
594
- // underlying connection to clear SQL Server's session-level
595
- // aborted-transaction state so the connection is usable for the
596
- // next BEGIN TRANSACTION.
597
- this.logger.warn("Transaction.rollback() failed, clearing session state with raw ROLLBACK", {
598
- error: transactionRollbackError instanceof Error ? transactionRollbackError.message : transactionRollbackError
599
- })
600
-
601
- const request = new mssql.Request(this.connection)
602
-
603
- await request.query("IF @@TRANCOUNT > 0 ROLLBACK")
604
- } finally {
605
- this._currentTransaction = null
606
- }
592
+ try {
593
+ await this._currentTransaction.rollback()
594
+ } catch (transactionRollbackError) {
595
+ // When SQL Server has already aborted the transaction (e.g., a
596
+ // stale concurrent request triggered XACT_ABORT), the
597
+ // mssql.Transaction.rollback() call fails because the
598
+ // Transaction object is dead. Issue a raw ROLLBACK on the
599
+ // underlying connection to clear SQL Server's session-level
600
+ // aborted-transaction state so the connection is usable for the
601
+ // next BEGIN TRANSACTION.
602
+ this.logger.warn("Transaction.rollback() failed, clearing session state with raw ROLLBACK", {
603
+ error: transactionRollbackError instanceof Error ? transactionRollbackError.message : transactionRollbackError
604
+ })
605
+
606
+ const request = new mssql.Request(this.connection)
607
+
608
+ await request.query("IF @@TRANCOUNT > 0 ROLLBACK")
609
+ } finally {
610
+ this._currentTransaction = null
611
+ }
612
+ })
607
613
  }
608
614
 
609
615
  /**
@@ -54,6 +54,15 @@ export default class VelociousEnvironmentHandlerBase {
54
54
  */
55
55
  runWithSharedTransactionCoordinatorOwner(_connection, _owner, callback) { return callback() }
56
56
 
57
+ /**
58
+ * Runs work without inherited shared-transaction ownership for one connection.
59
+ * @template T
60
+ * @param {import("../database/drivers/base.js").default} _connection - Physical connection whose owner is cleared.
61
+ * @param {() => T} callback - Physical connection work.
62
+ * @returns {T} - Callback result.
63
+ */
64
+ runWithoutSharedTransactionCoordinatorOwner(_connection, callback) { return callback() }
65
+
57
66
  /**
58
67
  * Runs work without inherited shared-transaction coordinator ownership.
59
68
  * @template T
@@ -154,6 +154,22 @@ export default class VelociousEnvironmentHandlerNode extends Base{
154
154
  return this._sharedTransactionCoordinatorAsyncLocalStorage.run(owners, callback)
155
155
  }
156
156
 
157
+ /**
158
+ * Runs work without inherited shared-transaction ownership for one connection.
159
+ * @template T
160
+ * @param {import("../database/drivers/base.js").default} connection - Physical connection whose owner is cleared.
161
+ * @param {() => T} callback - Physical connection work.
162
+ * @returns {T} - Callback result.
163
+ */
164
+ runWithoutSharedTransactionCoordinatorOwner(connection, callback) {
165
+ if (!this._sharedTransactionCoordinatorAsyncLocalStorage) return callback()
166
+
167
+ const owners = new Map(this._sharedTransactionCoordinatorAsyncLocalStorage.getStore())
168
+
169
+ owners.delete(connection)
170
+ return this._sharedTransactionCoordinatorAsyncLocalStorage.run(owners, callback)
171
+ }
172
+
157
173
  /**
158
174
  * Runs work without inherited shared-transaction coordinator ownership.
159
175
  * @template T
@@ -16,6 +16,7 @@ import {requestDetails} from "./error-reporting/request-details.js"
16
16
  import RoutesResolver from "./routes/resolver.js"
17
17
  import {ValidationError} from "./database/record/index.js"
18
18
  import RecordNotFoundError from "./database/record/record-not-found-error.js"
19
+ import {captureFrontendModelRemoteRequestContext, mergeFrontendModelRemoteRequestContext} from "./frontend-models/remote-request-context.js"
19
20
  import { normalizeDateStringForWrite } from "./database/datetime-storage.js"
20
21
  import VelociousError from "./velocious-error.js"
21
22
  import isDate from "./utils/is-date.js"
@@ -4360,13 +4361,17 @@ export default class FrontendModelController extends Controller {
4360
4361
  }
4361
4362
 
4362
4363
  try {
4364
+ const requestContext = captureFrontendModelRemoteRequestContext(requestEntry?.requestContext)
4363
4365
  let responsePayload
4364
4366
 
4365
4367
  if (isBuiltInCommand) {
4366
- const commandParams = {
4367
- ...(payload && typeof payload === "object" ? payload : {}),
4368
- model
4369
- }
4368
+ const commandParams = mergeFrontendModelRemoteRequestContext(
4369
+ requestContext,
4370
+ {
4371
+ ...(payload && typeof payload === "object" ? payload : {}),
4372
+ model
4373
+ }
4374
+ )
4370
4375
 
4371
4376
  responsePayload = await this.withFrontendModelParams(commandParams, async () => {
4372
4377
  return await this.withFrontendModelRequestContext(commandParams, this.response(), async () => {
@@ -4376,7 +4381,8 @@ export default class FrontendModelController extends Controller {
4376
4381
  } else {
4377
4382
  responsePayload = await this.frontendApiCustomCommandPayload({
4378
4383
  customPath,
4379
- payload
4384
+ payload,
4385
+ requestContext
4380
4386
  })
4381
4387
  }
4382
4388
 
@@ -4415,9 +4421,10 @@ export default class FrontendModelController extends Controller {
4415
4421
  * @param {object} args - Arguments.
4416
4422
  * @param {string} args.customPath - Custom backend route path.
4417
4423
  * @param {ReturnType<typeof JSON.parse>} args.payload - Request payload.
4424
+ * @param {import("./remote-request-context.js").RemoteRequestContext} args.requestContext - Captured remote request context.
4418
4425
  * @returns {Promise<Record<string, ReturnType<typeof JSON.parse>>>} - Parsed JSON response payload.
4419
4426
  */
4420
- async frontendApiCustomCommandPayload({customPath, payload}) {
4427
+ async frontendApiCustomCommandPayload({customPath, payload, requestContext}) {
4421
4428
  const configuration = this.getConfiguration()
4422
4429
  const response = new Response({configuration})
4423
4430
  const resolver = new RoutesResolver({
@@ -4449,10 +4456,13 @@ export default class FrontendModelController extends Controller {
4449
4456
  const viewPath = routeHookMatch?.viewPath || `${configuration.getDirectory()}/src/routes/${controller}`
4450
4457
  resolver.routeHookControllerClass = routeHookMatch?.controllerClass
4451
4458
  const controllerClass = await resolver.resolveControllerClass({controllerPath})
4452
- const controllerParams = {
4453
- ...((payload && typeof payload === "object") ? payload : {}),
4454
- ...resolver.params
4455
- }
4459
+ const controllerParams = mergeFrontendModelRemoteRequestContext(
4460
+ requestContext,
4461
+ {
4462
+ ...((payload && typeof payload === "object") ? payload : {}),
4463
+ ...resolver.params
4464
+ }
4465
+ )
4456
4466
  const controllerInstance = new controllerClass({
4457
4467
  action,
4458
4468
  configuration,
@@ -12,6 +12,8 @@ import {deserializeFrontendModelTransportValue, serializeFrontendModelTransportV
12
12
  import runWithTransportDeadline from "./transport-deadline.js"
13
13
  import {REQUEST_TIME_ZONE_HEADER, validateTimeZone} from "../time-zone.js"
14
14
  import VelociousWebsocketClient from "../http-client/websocket-client.js"
15
+ import {remoteRequestContextKey} from "../remote-request-context.js"
16
+ import {captureFrontendModelRemoteRequestContext, mergeFrontendModelRemoteRequestContext} from "./remote-request-context.js"
15
17
  import {bufferOutgoingEvent, clearBufferedOutgoingEvents, drainBufferedOutgoingEvents} from "./outgoing-event-buffer.js"
16
18
  import {defineModelScope} from "../utils/model-scope.js"
17
19
  import isPlainObject from "../utils/plain-object.js"
@@ -132,6 +134,7 @@ import {readPayloadAssociationCount, readPayloadComputedAbility, readPayloadQuer
132
134
  * @property {string | (() => string | undefined | null)} [websocketUrl] - Optional websocket URL. When set, Velocious creates and manages its own websocket client internally. Subscriptions use the websocket; CRUD uses HTTP and falls back gracefully. Example: `"ws://localhost:3006/websocket"`.
133
135
  * @property {{post: (path: string, body?: ReturnType<typeof JSON.parse>, options?: {headers?: Record<string, string>, signal?: AbortSignal}) => Promise<{json: () => ReturnType<typeof JSON.parse>}>, subscribe: (channel: string, options: {params?: Record<string, ReturnType<typeof JSON.parse>>}, callback: (payload: ReturnType<typeof JSON.parse>) => void) => (() => void), subscribeAndWait?: (channel: string, options: {params?: Record<string, ReturnType<typeof JSON.parse>>}, callback: (payload: ReturnType<typeof JSON.parse>) => void) => Promise<(() => void)>}} [websocketClient] - Optional websocket client for shared frontend-model API requests and subscriptions. Its `post` receives the bounded-deadline `signal` and should forward it into the underlying transport so the deadline can abort the live request and its response-body read.
134
136
  * @property {Record<string, string> | (() => Record<string, string>)} [requestHeaders] - Extra HTTP/WS headers to attach to every frontend-model API request. Pass a function to compute them at request time (for example to include the current locale).
137
+ * @property {import("../remote-request-context.js").RemoteRequestContext | (() => import("../remote-request-context.js").RemoteRequestContext | undefined | null)} [requestContext] - Immutable scalar context captured independently when each operation or event subscription starts and sent for remote tenant/ability resolution.
135
138
  * @property {number | (() => number | undefined | null)} [timeout] - Bounded deadline in milliseconds covering connection, response headers, and response-body consumption for each frontend-model API request. On expiry the live fetch/adapter request is aborted (built on awaitery's `timeout`) and awaitery's `TimeoutError` is thrown, so callers can classify a timeout via `error instanceof TimeoutError`. Pass a function to resolve it per request. Falsy/absent means no deadline.
136
139
  * @property {AbortSignal | (() => AbortSignal | undefined | null)} [signal] - Optional caller/session AbortSignal composed with the deadline. Aborting it cancels the live request (for example on session shutdown or offline transition); the resulting abort error stays distinguishable from a timeout. Pass a function to resolve the current signal per request.
137
140
  * @property {{get: () => string | null | undefined | Promise<string | null | undefined>, set: (sessionId: string) => void | Promise<void>, clear: () => void | Promise<void>}} [sessionStore] - Optional sessionId persistence hook forwarded to the internal `VelociousWebsocketClient` so WS sessions can be resumed across page reloads / app restarts.
@@ -157,8 +160,9 @@ const QUERY_DATA_KEY = "__queryData"
157
160
  const ABILITIES_KEY = "__abilities"
158
161
  /**
159
162
  * Pending shared frontend model requests.
160
- * @type {Array<{commandName?: string, commandType: FrontendModelRequestCommandType, customPath?: string, modelClass: FrontendModelClass, payload: Record<string, ReturnType<typeof JSON.parse>>, requestId: string, resolve: (response: Record<string, ReturnType<typeof JSON.parse>>) => void, reject: (error: ReturnType<typeof JSON.parse>) => void, resourcePath?: string | null}>} */
163
+ * @type {Array<{commandName?: string, commandType: FrontendModelRequestCommandType, customPath?: string, modelClass: FrontendModelClass, payload: Record<string, ReturnType<typeof JSON.parse>>, requestContext: import("../remote-request-context.js").RemoteRequestContext, requestId: string, resolve: (response: Record<string, ReturnType<typeof JSON.parse>>) => void, reject: (error: ReturnType<typeof JSON.parse>) => void, resourcePath?: string | null}>} */
161
164
  let pendingSharedFrontendModelRequests = []
165
+
162
166
  let sharedFrontendModelRequestId = 0
163
167
  let sharedFrontendModelFlushScheduled = false
164
168
  let activeFrontendModelTransportRequestCount = 0
@@ -1450,9 +1454,11 @@ class FrontendModelEventSubscription {
1450
1454
  /**
1451
1455
  * Runs constructor.
1452
1456
  * @param {FrontendModelClass} ModelClass - Frontend model class for this subscription bucket.
1457
+ * @param {import("../remote-request-context.js").RemoteRequestContext} requestContext - Captured subscription context.
1453
1458
  */
1454
- constructor(ModelClass) {
1459
+ constructor(ModelClass, requestContext) {
1455
1460
  this.ModelClass = ModelClass
1461
+ this.requestContext = requestContext
1456
1462
  /**
1457
1463
  * Narrows the runtime value to the documented type.
1458
1464
  * @type {Set<FrontendModelModelEventCallbackEntry>} */
@@ -1530,11 +1536,14 @@ class FrontendModelEventSubscription {
1530
1536
  }
1531
1537
  : {}
1532
1538
 
1533
- return {
1534
- model: this.ModelClass.getModelName(),
1535
- ...eventFilterParams,
1536
- ...projectionPayload
1537
- }
1539
+ return mergeFrontendModelRemoteRequestContext(
1540
+ this.requestContext,
1541
+ {
1542
+ model: this.ModelClass.getModelName(),
1543
+ ...eventFilterParams,
1544
+ ...projectionPayload
1545
+ }
1546
+ )
1538
1547
  }
1539
1548
 
1540
1549
  /**
@@ -1681,41 +1690,79 @@ class FrontendModelEventSubscription {
1681
1690
  || this.instanceListeners.size > 0
1682
1691
 
1683
1692
  if (hasAnyListener) return
1684
- if (!this.channelHandle) return
1685
1693
 
1686
- try {
1687
- this.channelHandle.close()
1688
- } catch (error) {
1689
- console.error(error)
1694
+ if (this.channelHandle) {
1695
+ try {
1696
+ this.channelHandle.close()
1697
+ } catch (error) {
1698
+ console.error(error)
1699
+ }
1690
1700
  }
1691
1701
 
1692
1702
  this.channelHandle = null
1693
1703
  this.readyPromise = null
1694
1704
  this.subscriptionParamsKey = null
1705
+ releaseFrontendModelEventSubscription(this)
1695
1706
  }
1696
1707
  }
1697
1708
 
1698
1709
  /**
1699
1710
  * Frontend model event subscriptions.
1700
- * @type {WeakMap<FrontendModelClass, FrontendModelEventSubscription>} */
1711
+ * @type {WeakMap<FrontendModelClass, Map<string, FrontendModelEventSubscription>>} */
1701
1712
  const frontendModelEventSubscriptions = new WeakMap()
1702
1713
 
1703
1714
  /**
1704
1715
  * Runs ensure frontend model event subscription.
1705
1716
  * @param {FrontendModelClass} ModelClass - Model class.
1717
+ * @param {import("../remote-request-context.js").RemoteRequestContext} requestContext - Captured subscription context.
1706
1718
  * @returns {FrontendModelEventSubscription} - Per-class subscription helper.
1707
1719
  */
1708
- function ensureFrontendModelEventSubscription(ModelClass) {
1709
- let sub = frontendModelEventSubscriptions.get(ModelClass)
1720
+ function ensureFrontendModelEventSubscription(ModelClass, requestContext) {
1721
+ let subscriptions = frontendModelEventSubscriptions.get(ModelClass)
1722
+
1723
+ if (!subscriptions) {
1724
+ subscriptions = new Map()
1725
+ frontendModelEventSubscriptions.set(ModelClass, subscriptions)
1726
+ }
1727
+
1728
+ const contextKey = remoteRequestContextKey(requestContext)
1729
+ let sub = subscriptions.get(contextKey)
1710
1730
 
1711
1731
  if (!sub) {
1712
- sub = new FrontendModelEventSubscription(ModelClass)
1713
- frontendModelEventSubscriptions.set(ModelClass, sub)
1732
+ sub = new FrontendModelEventSubscription(ModelClass, requestContext)
1733
+ subscriptions.set(contextKey, sub)
1714
1734
  }
1715
1735
 
1716
1736
  return sub
1717
1737
  }
1718
1738
 
1739
+ /**
1740
+ * Removes an empty context bucket so switching through many tenants does not retain every snapshot.
1741
+ * @param {FrontendModelEventSubscription} subscription - Empty subscription bucket.
1742
+ * @returns {void}
1743
+ */
1744
+ function releaseFrontendModelEventSubscription(subscription) {
1745
+ const subscriptions = frontendModelEventSubscriptions.get(subscription.ModelClass)
1746
+ const contextKey = remoteRequestContextKey(subscription.requestContext)
1747
+
1748
+ if (subscriptions?.get(contextKey) !== subscription) return
1749
+
1750
+ subscriptions.delete(contextKey)
1751
+ if (subscriptions.size === 0) frontendModelEventSubscriptions.delete(subscription.ModelClass)
1752
+ }
1753
+
1754
+ /**
1755
+ * Captures the current frontend-model transport context for one operation.
1756
+ * @returns {import("../remote-request-context.js").RemoteRequestContext} Frozen context snapshot.
1757
+ */
1758
+ function frontendModelRequestContext() {
1759
+ const configuredContext = typeof frontendModelTransportConfig.requestContext === "function"
1760
+ ? frontendModelTransportConfig.requestContext()
1761
+ : frontendModelTransportConfig.requestContext
1762
+
1763
+ return captureFrontendModelRemoteRequestContext(configuredContext)
1764
+ }
1765
+
1719
1766
  /**
1720
1767
  * Runs ensure frontend model instance listener.
1721
1768
  * @param {FrontendModelEventSubscription} sub - Event subscription bucket.
@@ -2007,6 +2054,7 @@ async function flushPendingSharedFrontendModelRequests() {
2007
2054
  customPath: request.customPath,
2008
2055
  model: request.modelClass.getModelName(),
2009
2056
  payload: request.payload,
2057
+ ...(Object.keys(request.requestContext).length > 0 ? {requestContext: request.requestContext} : {}),
2010
2058
  requestId: request.requestId
2011
2059
  }
2012
2060
  }
@@ -2015,6 +2063,7 @@ async function flushPendingSharedFrontendModelRequests() {
2015
2063
  commandType: request.commandType,
2016
2064
  model: request.modelClass.getModelName(),
2017
2065
  payload: request.payload,
2066
+ ...(Object.keys(request.requestContext).length > 0 ? {requestContext: request.requestContext} : {}),
2018
2067
  requestId: request.requestId
2019
2068
  }
2020
2069
  })
@@ -3016,6 +3065,10 @@ export default class FrontendModelBase {
3016
3065
  frontendModelTransportConfig.requestHeaders = config.requestHeaders
3017
3066
  }
3018
3067
 
3068
+ if (Object.prototype.hasOwnProperty.call(config, "requestContext")) {
3069
+ frontendModelTransportConfig.requestContext = config.requestContext
3070
+ }
3071
+
3019
3072
  if (Object.prototype.hasOwnProperty.call(config, "timeout")) {
3020
3073
  frontendModelTransportConfig.timeout = config.timeout
3021
3074
  }
@@ -3286,9 +3339,14 @@ export default class FrontendModelBase {
3286
3339
  throw new Error("subscribeWebsocketChannel requires configureTransport({websocketUrl})")
3287
3340
  }
3288
3341
 
3289
- const {signal, timeoutMs, ...channelOptions} = options
3342
+ const {params, signal, timeoutMs, ...channelOptions} = options
3343
+ const requestContext = frontendModelRequestContext()
3344
+ const scopedParams = mergeFrontendModelRemoteRequestContext(requestContext, params === undefined ? {} : params)
3290
3345
  const startupControls = frontendModelWebsocketStartupControls({signal, timeoutMs})
3291
- const handle = client.subscribeChannel(channelType, {...channelOptions, ...startupControls})
3346
+ const scopedParamsOption = params === undefined && Object.keys(requestContext).length === 0
3347
+ ? {}
3348
+ : {params: scopedParams}
3349
+ const handle = client.subscribeChannel(channelType, {...channelOptions, ...scopedParamsOption, ...startupControls})
3292
3350
 
3293
3351
  if (typeof client.connect === "function") {
3294
3352
  void client.connect(startupControls).catch(() => handle.close())
@@ -3649,7 +3707,7 @@ export default class FrontendModelBase {
3649
3707
  * @returns {Promise<() => void>} - Unsubscribe callback.
3650
3708
  */
3651
3709
  static async onCreate(callback, options = {}) {
3652
- const sub = ensureFrontendModelEventSubscription(this)
3710
+ const sub = ensureFrontendModelEventSubscription(this, frontendModelRequestContext())
3653
3711
  const entry = {callback, ...frontendModelEventOptionsPayload(this, options)}
3654
3712
 
3655
3713
  sub.classCreateCallbacks.add(entry)
@@ -3669,7 +3727,7 @@ export default class FrontendModelBase {
3669
3727
  * @returns {Promise<() => void>} - Unsubscribe callback.
3670
3728
  */
3671
3729
  static async onUpdate(callback, options = {}) {
3672
- const sub = ensureFrontendModelEventSubscription(this)
3730
+ const sub = ensureFrontendModelEventSubscription(this, frontendModelRequestContext())
3673
3731
  const entry = {callback, ...frontendModelEventOptionsPayload(this, options)}
3674
3732
 
3675
3733
  sub.classUpdateCallbacks.add(entry)
@@ -3691,7 +3749,7 @@ export default class FrontendModelBase {
3691
3749
  static async onDestroy(callback, options = {}) {
3692
3750
  assertNoDestroyEventFilter(this, options)
3693
3751
 
3694
- const sub = ensureFrontendModelEventSubscription(this)
3752
+ const sub = ensureFrontendModelEventSubscription(this, frontendModelRequestContext())
3695
3753
  const entry = {callback}
3696
3754
 
3697
3755
  sub.classDestroyCallbacks.add(entry)
@@ -3715,7 +3773,7 @@ export default class FrontendModelBase {
3715
3773
  async onUpdate(callback, options = {}) {
3716
3774
  const self = /** @type {ReturnType<typeof JSON.parse>} */ (this)
3717
3775
  const ModelClass = frontendModelClassFor(this)
3718
- const sub = ensureFrontendModelEventSubscription(ModelClass)
3776
+ const sub = ensureFrontendModelEventSubscription(ModelClass, frontendModelRequestContext())
3719
3777
  const id = String(self.id())
3720
3778
  const entry = {callback, ...frontendModelEventOptionsPayload(ModelClass, options)}
3721
3779
  const listener = ensureFrontendModelInstanceListener(sub, id, this)
@@ -3748,7 +3806,7 @@ export default class FrontendModelBase {
3748
3806
 
3749
3807
  assertNoDestroyEventFilter(ModelClass, options)
3750
3808
 
3751
- const sub = ensureFrontendModelEventSubscription(ModelClass)
3809
+ const sub = ensureFrontendModelEventSubscription(ModelClass, frontendModelRequestContext())
3752
3810
  const id = String(self.id())
3753
3811
  const entry = {callback}
3754
3812
  const listener = ensureFrontendModelInstanceListener(sub, id, this)
@@ -4596,6 +4654,8 @@ export default class FrontendModelBase {
4596
4654
  const commandName = this.commandName(commandType)
4597
4655
  const timeZone = frontendModelTransportTimeZone()
4598
4656
  const serializedPayload = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (serializeFrontendModelTransportValue(payload, {timeZone}))
4657
+ const requestContext = frontendModelRequestContext()
4658
+ const requestPayload = mergeFrontendModelRemoteRequestContext(requestContext, serializedPayload)
4599
4659
  const resourcePath = this.resourcePath()
4600
4660
  const containsAttachmentUpload = frontendModelPayloadContainsAttachmentUpload(serializedPayload)
4601
4661
  const useSharedTransport = !containsAttachmentUpload
@@ -4608,6 +4668,7 @@ export default class FrontendModelBase {
4608
4668
  commandType,
4609
4669
  modelClass: this,
4610
4670
  payload: serializedPayload,
4671
+ requestContext,
4611
4672
  reject,
4612
4673
  requestId: `${++sharedFrontendModelRequestId}`,
4613
4674
  resolve,
@@ -4635,7 +4696,7 @@ export default class FrontendModelBase {
4635
4696
  },
4636
4697
  async (signal) => {
4637
4698
  const directResponse = await fetch(url, {
4638
- body: JSON.stringify(serializedPayload),
4699
+ body: JSON.stringify(requestPayload),
4639
4700
  credentials: "include",
4640
4701
  headers: frontendModelRequestHeaders(timeZone),
4641
4702
  method: "POST",
@@ -4675,6 +4736,9 @@ export default class FrontendModelBase {
4675
4736
  const {commandName, commandType, memberId = null, payload, resourcePath} = args
4676
4737
  const timeZone = frontendModelTransportTimeZone()
4677
4738
  const serializedPayload = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (serializeFrontendModelTransportValue(payload, {timeZone}))
4739
+ const requestContext = frontendModelRequestContext()
4740
+
4741
+ mergeFrontendModelRemoteRequestContext(requestContext, serializedPayload)
4678
4742
  const customPath = frontendModelCustomCommandPath({
4679
4743
  commandName,
4680
4744
  memberId,
@@ -4688,6 +4752,7 @@ export default class FrontendModelBase {
4688
4752
  customPath,
4689
4753
  modelClass: this,
4690
4754
  payload: serializedPayload,
4755
+ requestContext,
4691
4756
  reject,
4692
4757
  requestId: `${++sharedFrontendModelRequestId}`,
4693
4758
  resolve
@@ -0,0 +1,29 @@
1
+ // @ts-check
2
+
3
+ import {captureRemoteRequestContext, mergeRemoteRequestContext} from "../remote-request-context.js"
4
+
5
+ const RESERVED_KEYS = ["commandType", "customPath", "model", "payload", "requestContext", "requestId", "requests"]
6
+ const REQUEST_CONTEXT_LABEL = "Frontend model request context"
7
+
8
+ /**
9
+ * Captures one frontend-model operation's immutable remote request context.
10
+ * @param {ReturnType<typeof JSON.parse> | undefined} value - Configured or untrusted context value.
11
+ * @returns {import("../remote-request-context.js").RemoteRequestContext} Frozen context snapshot.
12
+ */
13
+ export function captureFrontendModelRemoteRequestContext(value) {
14
+ return captureRemoteRequestContext(value, {
15
+ label: REQUEST_CONTEXT_LABEL,
16
+ reservedKeys: RESERVED_KEYS
17
+ })
18
+ }
19
+
20
+ /**
21
+ * Merges captured context into frontend-model command or subscription params.
22
+ * @template {Record<string, ReturnType<typeof JSON.parse>>} TParams
23
+ * @param {import("../remote-request-context.js").RemoteRequestContext} context - Captured context.
24
+ * @param {TParams} params - Framework-owned params.
25
+ * @returns {TParams & import("../remote-request-context.js").RemoteRequestContext} Merged params.
26
+ */
27
+ export function mergeFrontendModelRemoteRequestContext(context, params) {
28
+ return mergeRemoteRequestContext({context, label: REQUEST_CONTEXT_LABEL, params})
29
+ }