velocious 1.0.621 → 1.0.622

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 (72) hide show
  1. package/README.md +6 -2
  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-realtime-bridge.d.ts.map +1 -1
  46. package/build/src/sync/sync-realtime-bridge.js +8 -2
  47. package/build/src/testing/shared-transaction-connection-coordinator.d.ts +9 -0
  48. package/build/src/testing/shared-transaction-connection-coordinator.d.ts.map +1 -1
  49. package/build/src/testing/shared-transaction-connection-coordinator.js +15 -1
  50. package/build/src/testing/test-runner.js +2 -2
  51. package/build/sync/sync-client-types.js +2 -0
  52. package/build/sync/sync-client.js +31 -6
  53. package/build/sync/sync-realtime-bridge.js +7 -1
  54. package/build/testing/shared-transaction-connection-coordinator.js +16 -0
  55. package/build/testing/test-runner.js +1 -1
  56. package/build/tsconfig.tsbuildinfo +1 -1
  57. package/package.json +1 -1
  58. package/src/database/drivers/base.js +20 -3
  59. package/src/database/drivers/mssql/index.js +42 -36
  60. package/src/environment-handlers/base.js +9 -0
  61. package/src/environment-handlers/node.js +16 -0
  62. package/src/frontend-model-controller.js +20 -10
  63. package/src/frontend-models/base.js +90 -25
  64. package/src/frontend-models/remote-request-context.js +29 -0
  65. package/src/http-client/websocket-client.js +32 -0
  66. package/src/http-server/client/websocket-session.js +4 -1
  67. package/src/remote-request-context.js +97 -0
  68. package/src/sync/sync-client-types.js +2 -0
  69. package/src/sync/sync-client.js +31 -6
  70. package/src/sync/sync-realtime-bridge.js +7 -1
  71. package/src/testing/shared-transaction-connection-coordinator.js +16 -0
  72. package/src/testing/test-runner.js +1 -1
@@ -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
+ }
@@ -26,6 +26,8 @@ export default class VelociousWebsocketClient extends SnapReqWebSocketClient {
26
26
  this.reconnectGeneration = 0
27
27
  /** @type {Set<Promise<void>>} */
28
28
  this.runningReconnectTasks = new Set()
29
+ /** @type {Promise<void> | null} */
30
+ this.gracefulClosePromise = null
29
31
  }
30
32
 
31
33
  /**
@@ -55,6 +57,36 @@ export default class VelociousWebsocketClient extends SnapReqWebSocketClient {
55
57
  }
56
58
  }
57
59
 
60
+ /**
61
+ * Closes the WebSocket as a normal shutdown so the server permanently
62
+ * releases resumable session state.
63
+ * @returns {Promise<void>} - Resolves once closed.
64
+ */
65
+ async close() {
66
+ if (this.gracefulClosePromise) return await this.gracefulClosePromise
67
+
68
+ this.autoReconnect = false
69
+ const socket = this.socket
70
+ const closePromise = (async () => {
71
+ if (socket && socket.readyState === socket.OPEN) {
72
+ await new Promise((resolve) => {
73
+ socket.addEventListener("close", () => resolve(undefined), {once: true})
74
+ socket.close(1000)
75
+ })
76
+ }
77
+
78
+ await super.close()
79
+ })()
80
+
81
+ this.gracefulClosePromise = closePromise
82
+
83
+ try {
84
+ await closePromise
85
+ } finally {
86
+ if (this.gracefulClosePromise === closePromise) this.gracefulClosePromise = null
87
+ }
88
+ }
89
+
58
90
  /**
59
91
  * Stops reconnect, drains work that already passed SnapReq's reconnect guard,
60
92
  * and clears state changed by a stale attempt while it settled.
@@ -39,6 +39,7 @@ const WEBSOCKET_OPCODE_CLOSE = 0x8
39
39
  const WEBSOCKET_OPCODE_PING = 0x9
40
40
  const WEBSOCKET_OPCODE_PONG = 0xA
41
41
 
42
+ const WEBSOCKET_CLOSE_NORMAL = 1000
42
43
  const WEBSOCKET_CLOSE_POLICY_VIOLATION = 1008
43
44
  const WEBSOCKET_INBOUND_BACKLOG_CLOSE_REASON = "Inbound message backlog exceeded"
44
45
  const WEBSOCKET_MAX_CLOSE_REASON_BYTES = 123
@@ -785,8 +786,10 @@ export default class VelociousHttpServerClientWebsocketSession {
785
786
  }
786
787
 
787
788
  if (opcode === WEBSOCKET_OPCODE_CLOSE) {
789
+ const allowResume = payload.length < 2 || payload.readUInt16BE(0) !== WEBSOCKET_CLOSE_NORMAL
790
+
788
791
  this.sendGoodbye(this.client)
789
- this._handleClose()
792
+ this._handleClose({allowResume})
790
793
  continue
791
794
  }
792
795
 
@@ -0,0 +1,97 @@
1
+ // @ts-check
2
+
3
+ import VelociousError from "./velocious-error.js"
4
+ import isPlainObject from "./utils/plain-object.js"
5
+
6
+ /** @typedef {Readonly<Record<string, string | number | boolean>>} RemoteRequestContext */
7
+
8
+ const UNSAFE_CONTEXT_KEYS = new Set(["__proto__", "constructor", "prototype"])
9
+
10
+ /**
11
+ * Captures and validates immutable scalar context for one remote operation.
12
+ * @param {ReturnType<typeof JSON.parse> | undefined} value - Configured context value.
13
+ * @param {object} [args] - Validation options.
14
+ * @param {string} [args.label] - Context label used in errors.
15
+ * @param {Iterable<string>} [args.reservedKeys] - Framework-owned keys unavailable to context.
16
+ * @returns {RemoteRequestContext} Frozen context snapshot.
17
+ */
18
+ export function captureRemoteRequestContext(value, {label = "Remote request context", reservedKeys = []} = {}) {
19
+ if (value === undefined || value === null) return Object.freeze({})
20
+
21
+ if (!isPlainObject(value)) {
22
+ throw remoteRequestContextError(`${label} must be a plain object of scalar values`)
23
+ }
24
+
25
+ const reservedKeySet = new Set(reservedKeys)
26
+ /** @type {Record<string, string | number | boolean>} */
27
+ const context = {}
28
+
29
+ for (const key of Object.keys(value).sort()) {
30
+ if (!key.trim()) throw remoteRequestContextError(`${label} keys must be non-blank strings`)
31
+ if (UNSAFE_CONTEXT_KEYS.has(key) || reservedKeySet.has(key)) {
32
+ throw remoteRequestContextError(`${label} key ${JSON.stringify(key)} is reserved by the framework`)
33
+ }
34
+
35
+ const contextValue = value[key]
36
+
37
+ if (!remoteRequestContextScalar(contextValue)) {
38
+ throw remoteRequestContextError(`${label} key ${JSON.stringify(key)} must contain a string, finite number, or boolean scalar`)
39
+ }
40
+
41
+ context[key] = contextValue
42
+ }
43
+
44
+ return Object.freeze(context)
45
+ }
46
+
47
+ /**
48
+ * Merges captured context into framework request params without ambiguity.
49
+ * @template {Record<string, ReturnType<typeof JSON.parse>>} TParams
50
+ * @param {object} args - Merge arguments.
51
+ * @param {RemoteRequestContext} args.context - Captured context.
52
+ * @param {string} [args.label] - Context label used in errors.
53
+ * @param {TParams} args.params - Framework-owned request params.
54
+ * @returns {TParams & RemoteRequestContext} Merged params, or the original params when unscoped.
55
+ */
56
+ export function mergeRemoteRequestContext({context, label = "Remote request context", params}) {
57
+ const contextKeys = Object.keys(context)
58
+
59
+ if (contextKeys.length === 0) return params
60
+
61
+ for (const key of contextKeys) {
62
+ if (Object.hasOwn(params, key)) {
63
+ throw remoteRequestContextError(`${label} key ${JSON.stringify(key)} is reserved by the request payload`)
64
+ }
65
+ }
66
+
67
+ return {...params, ...context}
68
+ }
69
+
70
+ /**
71
+ * Returns a stable identity for an immutable captured context.
72
+ * @param {RemoteRequestContext} context - Captured context.
73
+ * @returns {string} Stable serialized key.
74
+ */
75
+ export function remoteRequestContextKey(context) {
76
+ return JSON.stringify(context)
77
+ }
78
+
79
+ /**
80
+ * Checks whether a value is a supported request-context scalar.
81
+ * @param {ReturnType<typeof JSON.parse>} value - Candidate scalar.
82
+ * @returns {value is string | number | boolean} Whether the value is supported.
83
+ */
84
+ function remoteRequestContextScalar(value) {
85
+ if (["string", "boolean"].includes(typeof value)) return true
86
+
87
+ return typeof value === "number" && Number.isFinite(value)
88
+ }
89
+
90
+ /**
91
+ * Builds a client-safe request-context validation error.
92
+ * @param {string} message - Safe validation message.
93
+ * @returns {VelociousError} Validation error.
94
+ */
95
+ function remoteRequestContextError(message) {
96
+ return VelociousError.safe(message, {code: "remote-request-context-invalid", errorType: "validation_error"})
97
+ }
@@ -90,6 +90,7 @@
90
90
  * @typedef {object} SyncClientOptions
91
91
  * @property {import("../configuration.js").default} [configuration] - Configuration owning the registered models, the `sync.client` block, and the scope-store database. Defaults to the current configuration.
92
92
  * @property {(args: {scope: SerializedSyncScope}) => string | null | Promise<string | null>} [legacyCursor] - Seeds a newly declared scope's cursor (e.g. from a pre-scope cursor store) so devices don't re-pull everything.
93
+ * @property {import("../remote-request-context.js").RemoteRequestContext} [requestContext] - Immutable scalar context captured for this client and sent with every pull, replay, and realtime subscription.
93
94
  * @property {import("./sync-scope-store.js").default} [scopeStore] - Scope store override (tests).
94
95
  * @property {ReturnType<typeof JSON.parse>} [syncModel] - Pending-sync model override. Defaults to the registered "Sync" model.
95
96
  * @property {string} [databaseIdentifier] - Tenant database identifier; required with tenantHandle.
@@ -109,6 +110,7 @@
109
110
  * @property {(payload: import("./sync-api-client-types.js").SyncChangesRequest & {scope: SerializedSyncScope}) => Promise<import("./sync-api-client-types.js").SyncChangesResponse>} postChanges - Posts one changes request.
110
111
  * @property {(payload: {authenticationToken: string, syncs: Array<Record<string, ReturnType<typeof JSON.parse>>>}) => Promise<import("./sync-api-client-types.js").SyncReplayResponse>} postReplay - Posts one replay request.
111
112
  * @property {import("../configuration-types.js").VelociousSyncClientRealtimeConfiguration} [realtime] - Realtime push configuration consumed by `subscribeRealtime(...)`.
113
+ * @property {import("../remote-request-context.js").RemoteRequestContext} requestContext - Immutable scalar context captured for this client.
112
114
  * @property {Record<string, SyncClientResourceConfig>} resources - Derived resource policies keyed by resource/model name.
113
115
  * @property {ReturnType<typeof JSON.parse>} syncModel - Local pending-sync model class.
114
116
  * @property {string} [databaseIdentifier] - Tenant-scoped database identifier.
@@ -3,6 +3,7 @@
3
3
  import Configuration from "../configuration.js"
4
4
  import {isBooleanColumnType} from "../database/column-types.js"
5
5
  import Logger from "../logger.js"
6
+ import {captureRemoteRequestContext, mergeRemoteRequestContext} from "../remote-request-context.js"
6
7
  import restArgsError from "../utils/rest-args-error.js"
7
8
  import VelociousWebsocketClient from "../http-client/websocket-client.js"
8
9
 
@@ -28,6 +29,20 @@ const DEFAULT_TRACKED_OPERATIONS = ["create", "update"]
28
29
  /** Attribute names treated as client-local sync bookkeeping when deriving localOnlyAttributes. */
29
30
  const LOCAL_BOOKKEEPING_ATTRIBUTE_NAMES = ["createdAt", "updatedAt", "lastSyncChangeAt"]
30
31
 
32
+ const SYNC_REQUEST_RESERVED_KEYS = [
33
+ "afterId",
34
+ "afterServerSequence",
35
+ "afterUpdatedAt",
36
+ "authenticationToken",
37
+ "limit",
38
+ "scope",
39
+ "syncs",
40
+ "upstreamRefresh",
41
+ "upToId",
42
+ "upToServerSequence",
43
+ "upToUpdatedAt"
44
+ ]
45
+
31
46
  /** @type {WeakMap<Configuration, SyncClient>} */
32
47
  const syncClientsByConfiguration = new WeakMap()
33
48
 
@@ -56,11 +71,15 @@ export default class SyncClient {
56
71
  * @param {import("./sync-client-types.js").SyncClientOptions} [options] - Optional overrides.
57
72
  */
58
73
  constructor(options = {}) {
59
- const {configuration = Configuration.current(), databaseIdentifier, legacyCursor, scopeStore, syncModel, tenantHandle, ...restOptions} = options
74
+ const {configuration = Configuration.current(), databaseIdentifier, legacyCursor, requestContext, scopeStore, syncModel, tenantHandle, ...restOptions} = options
60
75
 
61
76
  restArgsError(restOptions)
62
77
 
63
78
  const clientConfiguration = configuration.getSyncConfiguration().client
79
+ const capturedRequestContext = captureRemoteRequestContext(requestContext, {
80
+ label: "Sync client request context",
81
+ reservedKeys: SYNC_REQUEST_RESERVED_KEYS
82
+ })
64
83
 
65
84
  if (!clientConfiguration) {
66
85
  throw new Error("SyncClient requires a sync.client configuration block: new Configuration({sync: {client: {authenticationToken, transport}}})")
@@ -121,9 +140,10 @@ export default class SyncClient {
121
140
  isOnline: clientConfiguration.isOnline,
122
141
  legacyCursor,
123
142
  onError: clientConfiguration.onError,
124
- postChanges: transportPoster({path: `${clientConfiguration.mountPath}/changes`, transport: clientConfiguration.transport}),
125
- postReplay: transportPoster({path: `${clientConfiguration.mountPath}/replay`, transport: clientConfiguration.transport}),
143
+ postChanges: transportPoster({path: `${clientConfiguration.mountPath}/changes`, requestContext: capturedRequestContext, transport: clientConfiguration.transport}),
144
+ postReplay: transportPoster({path: `${clientConfiguration.mountPath}/replay`, requestContext: capturedRequestContext, transport: clientConfiguration.transport}),
126
145
  realtime: clientConfiguration.realtime,
146
+ requestContext: capturedRequestContext,
127
147
  resources,
128
148
  syncModel: resolvedSyncModel,
129
149
  tenantHandle,
@@ -1267,12 +1287,17 @@ function normalizedTrack(track) {
1267
1287
 
1268
1288
  /**
1269
1289
  * Builds a framework-owned sync endpoint POSTer over the configured transport.
1270
- * @param {{path: string, transport: import("../configuration-types.js").VelociousSyncClientTransport}} args - Poster args.
1290
+ * @param {{path: string, requestContext: import("../remote-request-context.js").RemoteRequestContext, transport: import("../configuration-types.js").VelociousSyncClientTransport}} args - Poster args.
1271
1291
  * @returns {(payload: Record<string, ReturnType<typeof JSON.parse>>) => Promise<ReturnType<typeof JSON.parse>>} Sync endpoint POSTer.
1272
1292
  */
1273
- function transportPoster({path, transport}) {
1293
+ function transportPoster({path, requestContext, transport}) {
1274
1294
  return async (payload) => {
1275
- const response = await transport.post(path, payload)
1295
+ const requestPayload = mergeRemoteRequestContext({
1296
+ context: requestContext,
1297
+ label: "Sync client request context",
1298
+ params: payload
1299
+ })
1300
+ const response = await transport.post(path, requestPayload)
1276
1301
 
1277
1302
  if (!response || typeof response.json !== "function") {
1278
1303
  throw new Error(`sync.client transport.post must resolve to a response with a json() method for ${path} (like the frontend-model websocket client)`)