velocious 1.0.469 → 1.0.471

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 (74) hide show
  1. package/README.md +4 -2
  2. package/build/configuration-types.js +1 -0
  3. package/build/configuration.js +27 -1
  4. package/build/controller.js +13 -1
  5. package/build/database/datetime-storage.js +49 -5
  6. package/build/database/record/index.js +14 -4
  7. package/build/environment-handlers/base.js +46 -0
  8. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +487 -40
  9. package/build/environment-handlers/node.js +84 -8
  10. package/build/frontend-model-controller.js +7 -7
  11. package/build/frontend-models/base.js +75 -9
  12. package/build/frontend-models/transport-serialization.js +41 -8
  13. package/build/frontend-models/websocket-channel.js +18 -1
  14. package/build/frontend-models/websocket-publishers.js +12 -1
  15. package/build/http-server/client/request-runner.js +13 -1
  16. package/build/src/configuration-types.d.ts +5 -0
  17. package/build/src/configuration-types.d.ts.map +1 -1
  18. package/build/src/configuration-types.js +2 -1
  19. package/build/src/configuration.d.ts +14 -1
  20. package/build/src/configuration.d.ts.map +1 -1
  21. package/build/src/configuration.js +25 -2
  22. package/build/src/controller.d.ts +5 -0
  23. package/build/src/controller.d.ts.map +1 -1
  24. package/build/src/controller.js +12 -2
  25. package/build/src/database/datetime-storage.d.ts +12 -4
  26. package/build/src/database/datetime-storage.d.ts.map +1 -1
  27. package/build/src/database/datetime-storage.js +48 -6
  28. package/build/src/database/record/index.d.ts +5 -0
  29. package/build/src/database/record/index.d.ts.map +1 -1
  30. package/build/src/database/record/index.js +13 -5
  31. package/build/src/environment-handlers/base.d.ts +19 -0
  32. package/build/src/environment-handlers/base.d.ts.map +1 -1
  33. package/build/src/environment-handlers/base.js +41 -1
  34. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +253 -40
  35. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  36. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +425 -41
  37. package/build/src/environment-handlers/node.d.ts +1 -0
  38. package/build/src/environment-handlers/node.d.ts.map +1 -1
  39. package/build/src/environment-handlers/node.js +76 -9
  40. package/build/src/frontend-model-controller.js +8 -8
  41. package/build/src/frontend-models/base.d.ts +4 -0
  42. package/build/src/frontend-models/base.d.ts.map +1 -1
  43. package/build/src/frontend-models/base.js +64 -9
  44. package/build/src/frontend-models/transport-serialization.d.ts +30 -1
  45. package/build/src/frontend-models/transport-serialization.d.ts.map +1 -1
  46. package/build/src/frontend-models/transport-serialization.js +34 -9
  47. package/build/src/frontend-models/websocket-channel.d.ts.map +1 -1
  48. package/build/src/frontend-models/websocket-channel.js +16 -2
  49. package/build/src/frontend-models/websocket-publishers.d.ts.map +1 -1
  50. package/build/src/frontend-models/websocket-publishers.js +12 -2
  51. package/build/src/http-server/client/request-runner.d.ts.map +1 -1
  52. package/build/src/http-server/client/request-runner.js +13 -2
  53. package/build/src/time-zone.d.ts +16 -0
  54. package/build/src/time-zone.d.ts.map +1 -0
  55. package/build/src/time-zone.js +56 -0
  56. package/build/time-zone.js +65 -0
  57. package/build/tsconfig.tsbuildinfo +1 -1
  58. package/package.json +2 -1
  59. package/scripts/test-browser.js +1 -0
  60. package/src/configuration-types.js +1 -0
  61. package/src/configuration.js +27 -1
  62. package/src/controller.js +13 -1
  63. package/src/database/datetime-storage.js +49 -5
  64. package/src/database/record/index.js +14 -4
  65. package/src/environment-handlers/base.js +46 -0
  66. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +487 -40
  67. package/src/environment-handlers/node.js +84 -8
  68. package/src/frontend-model-controller.js +7 -7
  69. package/src/frontend-models/base.js +75 -9
  70. package/src/frontend-models/transport-serialization.js +41 -8
  71. package/src/frontend-models/websocket-channel.js +18 -1
  72. package/src/frontend-models/websocket-publishers.js +12 -1
  73. package/src/http-server/client/request-runner.js +13 -1
  74. package/src/time-zone.js +65 -0
@@ -32,10 +32,11 @@ import path from "path"
32
32
  import {AsyncLocalStorage as NodeAsyncLocalStorage} from "node:async_hooks"
33
33
  import {timingSafeEqual} from "node:crypto"
34
34
  import toImportSpecifier from "../utils/to-import-specifier.js"
35
+ import {validateTimeZone} from "../time-zone.js"
35
36
 
36
37
  /**
37
38
  * Defines this typedef.
38
- * @typedef {{ability?: import("../authorization/ability.js").default, offsetMinutes: number, requestTiming?: import("../http-server/client/request-timing.js").default, tenant?: ?}} TimezoneStore */
39
+ * @typedef {{ability?: import("../authorization/ability.js").default, offsetMinutes: number, requestTiming?: import("../http-server/client/request-timing.js").default, tenant?: ?, timeZone?: string}} TimezoneStore */
39
40
 
40
41
  /**
41
42
  * Runs path within allowed prefixes.
@@ -217,7 +218,30 @@ export default class VelociousEnvironmentHandlerNode extends Base{
217
218
  ability: existingStore?.ability,
218
219
  offsetMinutes,
219
220
  requestTiming: existingStore?.requestTiming,
220
- tenant: existingStore?.tenant
221
+ tenant: existingStore?.tenant,
222
+ timeZone: existingStore?.timeZone ?? this.getTimeZone(this.getConfiguration())
223
+ }, callback)
224
+ }
225
+
226
+ /**
227
+ * Runs run with timezone.
228
+ * @param {string} timeZone - IANA timezone identifier.
229
+ * @param {() => Promise<?>} callback - Callback to run.
230
+ * @returns {Promise<?>} - Result of the callback.
231
+ */
232
+ async runWithTimezone(timeZone, callback) {
233
+ if (!this._timezoneAsyncLocalStorage) {
234
+ return await super.runWithTimezone(timeZone, callback)
235
+ }
236
+
237
+ const existingStore = this._timezoneAsyncLocalStorage.getStore()
238
+
239
+ return await this._timezoneAsyncLocalStorage.run({
240
+ ability: existingStore?.ability,
241
+ offsetMinutes: existingStore?.offsetMinutes ?? this.getTimezoneOffsetMinutes(this.getConfiguration()),
242
+ requestTiming: existingStore?.requestTiming,
243
+ tenant: existingStore?.tenant,
244
+ timeZone: validateTimeZone(timeZone, "timeZone")
221
245
  }, callback)
222
246
  }
223
247
 
@@ -240,7 +264,37 @@ export default class VelociousEnvironmentHandlerNode extends Base{
240
264
  ability: existingStore?.ability,
241
265
  offsetMinutes,
242
266
  requestTiming: existingStore?.requestTiming,
243
- tenant: existingStore?.tenant
267
+ tenant: existingStore?.tenant,
268
+ timeZone: existingStore?.timeZone ?? this.getTimeZone(this.getConfiguration())
269
+ })
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Runs set timezone.
275
+ * @param {string} timeZone - IANA timezone identifier.
276
+ * @returns {void} - No return value.
277
+ */
278
+ setTimezone(timeZone) {
279
+ if (!this._timezoneAsyncLocalStorage) {
280
+ super.setTimezone(timeZone)
281
+ return
282
+ }
283
+
284
+ const normalizedTimeZone = validateTimeZone(timeZone, "timeZone")
285
+ const store = this._timezoneAsyncLocalStorage.getStore()
286
+
287
+ if (store) {
288
+ store.timeZone = normalizedTimeZone
289
+ } else {
290
+ const existingStore = this._timezoneAsyncLocalStorage.getStore()
291
+
292
+ this._timezoneAsyncLocalStorage.enterWith({
293
+ ability: existingStore?.ability,
294
+ offsetMinutes: existingStore?.offsetMinutes ?? this.getTimezoneOffsetMinutes(this.getConfiguration()),
295
+ requestTiming: existingStore?.requestTiming,
296
+ tenant: existingStore?.tenant,
297
+ timeZone: normalizedTimeZone
244
298
  })
245
299
  }
246
300
  }
@@ -262,6 +316,23 @@ export default class VelociousEnvironmentHandlerNode extends Base{
262
316
  return super.getTimezoneOffsetMinutes(configuration)
263
317
  }
264
318
 
319
+ /**
320
+ * Runs get timezone.
321
+ * @param {import("../configuration.js").default | undefined} configuration - Configuration instance.
322
+ * @returns {string | undefined} - Timezone identifier.
323
+ */
324
+ getTimeZone(configuration) {
325
+ if (this._timezoneAsyncLocalStorage) {
326
+ const store = this._timezoneAsyncLocalStorage.getStore()
327
+
328
+ if (store && typeof store.timeZone === "string") {
329
+ return store.timeZone
330
+ }
331
+ }
332
+
333
+ return super.getTimeZone(configuration)
334
+ }
335
+
265
336
  /**
266
337
  * Runs run with ability.
267
338
  * @param {import("../authorization/ability.js").default | undefined} ability - Ability to set for callback scope.
@@ -279,7 +350,8 @@ export default class VelociousEnvironmentHandlerNode extends Base{
279
350
  ability,
280
351
  offsetMinutes: existingStore?.offsetMinutes ?? this.getTimezoneOffsetMinutes(this.getConfiguration()),
281
352
  requestTiming: existingStore?.requestTiming,
282
- tenant: existingStore?.tenant
353
+ tenant: existingStore?.tenant,
354
+ timeZone: existingStore?.timeZone ?? this.getTimeZone(this.getConfiguration())
283
355
  }, callback)
284
356
  }
285
357
 
@@ -303,7 +375,8 @@ export default class VelociousEnvironmentHandlerNode extends Base{
303
375
  ability,
304
376
  offsetMinutes: this.getTimezoneOffsetMinutes(this.getConfiguration()),
305
377
  requestTiming: undefined,
306
- tenant: undefined
378
+ tenant: undefined,
379
+ timeZone: this.getTimeZone(this.getConfiguration())
307
380
  })
308
381
  }
309
382
  }
@@ -337,7 +410,8 @@ export default class VelociousEnvironmentHandlerNode extends Base{
337
410
  ability: existingStore?.ability,
338
411
  offsetMinutes: existingStore?.offsetMinutes ?? this.getTimezoneOffsetMinutes(this.getConfiguration()),
339
412
  requestTiming,
340
- tenant: existingStore?.tenant
413
+ tenant: existingStore?.tenant,
414
+ timeZone: existingStore?.timeZone ?? this.getTimeZone(this.getConfiguration())
341
415
  }, callback)
342
416
  }
343
417
 
@@ -370,7 +444,8 @@ export default class VelociousEnvironmentHandlerNode extends Base{
370
444
  ability: existingStore?.ability,
371
445
  offsetMinutes: existingStore?.offsetMinutes ?? this.getTimezoneOffsetMinutes(this.getConfiguration()),
372
446
  requestTiming: existingStore?.requestTiming,
373
- tenant
447
+ tenant,
448
+ timeZone: existingStore?.timeZone ?? this.getTimeZone(this.getConfiguration())
374
449
  }, callback)
375
450
  }
376
451
 
@@ -394,7 +469,8 @@ export default class VelociousEnvironmentHandlerNode extends Base{
394
469
  ability: undefined,
395
470
  offsetMinutes: this.getTimezoneOffsetMinutes(this.getConfiguration()),
396
471
  requestTiming: undefined,
397
- tenant
472
+ tenant,
473
+ timeZone: this.getTimeZone(this.getConfiguration())
398
474
  })
399
475
  }
400
476
  }
@@ -2315,7 +2315,7 @@ export default class FrontendModelController extends Controller {
2315
2315
  const isDateTimeColumn = typeof columnType === "string" && ["date", "datetime", "timestamp"].some((type) => columnType.includes(type))
2316
2316
 
2317
2317
  if (isDateTimeColumn) {
2318
- const parsedDate = normalizeDateStringForWrite(value)
2318
+ const parsedDate = normalizeDateStringForWrite(value, {timeZone: this.getConfiguration().getEnvironmentHandler().getTimeZone(this.getConfiguration())})
2319
2319
 
2320
2320
  if (isDate(parsedDate)) {
2321
2321
  return parsedDate
@@ -3042,7 +3042,7 @@ export default class FrontendModelController extends Controller {
3042
3042
  json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue({
3043
3043
  errorMessage: frontendModelClientSafeErrorMessage,
3044
3044
  status: "error"
3045
- }))
3045
+ }, this.transportSerializationOptions()))
3046
3046
  })
3047
3047
  }
3048
3048
 
@@ -3203,7 +3203,7 @@ export default class FrontendModelController extends Controller {
3203
3203
  if (!responsePayload) return
3204
3204
 
3205
3205
  await this.render({
3206
- json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(responsePayload))
3206
+ json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(responsePayload, this.transportSerializationOptions()))
3207
3207
  })
3208
3208
  } catch (error) {
3209
3209
  const errorContext = this.frontendModelEndpointErrorContext({action, commandType: action, error})
@@ -3211,7 +3211,7 @@ export default class FrontendModelController extends Controller {
3211
3211
  await this.frontendModelLogEndpointError({action, commandType: action, error, model: errorContext.model})
3212
3212
 
3213
3213
  await this.render({
3214
- json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(await this.frontendModelClientErrorPayloadForError(error, errorContext)))
3214
+ json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(await this.frontendModelClientErrorPayloadForError(error, errorContext), this.transportSerializationOptions()))
3215
3215
  })
3216
3216
  }
3217
3217
  }
@@ -3529,7 +3529,7 @@ export default class FrontendModelController extends Controller {
3529
3529
  json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue({
3530
3530
  responses,
3531
3531
  status: "success"
3532
- }))
3532
+ }, this.transportSerializationOptions()))
3533
3533
  })
3534
3534
  }
3535
3535
 
@@ -3736,7 +3736,7 @@ export default class FrontendModelController extends Controller {
3736
3736
  const responsePayload = await this.frontendModelCustomCommandPayload()
3737
3737
 
3738
3738
  await this.render({
3739
- json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(responsePayload))
3739
+ json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(responsePayload, this.transportSerializationOptions()))
3740
3740
  })
3741
3741
  } catch (error) {
3742
3742
  const errorContext = this.frontendModelEndpointErrorContext({action: "frontendCustomCommand", commandType: "custom-command", error})
@@ -3744,7 +3744,7 @@ export default class FrontendModelController extends Controller {
3744
3744
  await this.frontendModelLogEndpointError({action: errorContext.action, commandType: errorContext.commandType, error, model: errorContext.model})
3745
3745
 
3746
3746
  await this.render({
3747
- json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(await this.frontendModelClientErrorPayloadForError(error, errorContext)))
3747
+ json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(await this.frontendModelClientErrorPayloadForError(error, errorContext), this.transportSerializationOptions()))
3748
3748
  })
3749
3749
  }
3750
3750
  }
@@ -5,10 +5,11 @@ import timeout from "awaitery/build/timeout.js"
5
5
  import wait from "awaitery/build/wait.js"
6
6
  import FrontendModelQuery, {frontendModelEventOptionsPayload} from "./query.js"
7
7
  import FrontendModelPreloader from "./preloader.js"
8
- import { normalizeDateStringForWrite } from "../database/datetime-storage.js"
8
+ import {normalizeDateStringForWrite} from "../database/datetime-storage.js"
9
9
  import {registerFrontendModel, resolveFrontendModelClass} from "./model-registry.js"
10
10
  import {validateFrontendModelResourceCommandName, validateFrontendModelResourcePath} from "./resource-config-validation.js"
11
11
  import {deserializeFrontendModelTransportValue, serializeFrontendModelTransportValue} from "./transport-serialization.js"
12
+ import {REQUEST_TIME_ZONE_HEADER, validateTimeZone} from "../time-zone.js"
12
13
  import VelociousWebsocketClient from "../http-client/websocket-client.js"
13
14
  import {bufferOutgoingEvent, drainBufferedOutgoingEvents} from "./outgoing-event-buffer.js"
14
15
  import {defineModelScope} from "../utils/model-scope.js"
@@ -102,6 +103,7 @@ import {readPayloadAssociationCount, readPayloadComputedAbility, readPayloadQuer
102
103
  * @property {{post: (path: string, body?: ?, options?: {headers?: Record<string, string>}) => Promise<{json: () => ?}>, subscribe: (channel: string, options: {params?: Record<string, ?>}, callback: (payload: ?) => void) => (() => void), subscribeAndWait?: (channel: string, options: {params?: Record<string, ?>}, callback: (payload: ?) => void) => Promise<(() => void)>}} [websocketClient] - Optional websocket client for shared frontend-model API requests and subscriptions.
103
104
  * @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).
104
105
  * @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.
106
+ * @property {string | (() => string | null | undefined)} [timeZone] - IANA timezone sent with every frontend-model API request for timezone-less datetime parsing.
105
107
  */
106
108
  /**
107
109
  * FrontendModelIdleWaitArgs type.
@@ -1590,16 +1592,69 @@ function frontendModelTransportPath(url) {
1590
1592
  }
1591
1593
  }
1592
1594
 
1595
+ /**
1596
+ * Resolves the browser runtime timezone when available.
1597
+ * @returns {string | undefined} - Browser runtime timezone when available.
1598
+ */
1599
+ function defaultFrontendModelTimeZone() {
1600
+ if (typeof window === "undefined") return undefined
1601
+
1602
+ const intl = globalThis.Intl
1603
+
1604
+ if (!intl) {
1605
+ throw new Error("Expected Intl to be available for browser timezone detection")
1606
+ }
1607
+
1608
+ if (typeof intl.DateTimeFormat !== "function") {
1609
+ throw new Error("Expected Intl.DateTimeFormat to be available as a function")
1610
+ }
1611
+
1612
+ const timeZone = intl.DateTimeFormat().resolvedOptions().timeZone
1613
+
1614
+ if (typeof timeZone !== "string" || timeZone.trim().length < 1) {
1615
+ throw new Error("Expected Intl.DateTimeFormat to resolve a browser timezone string")
1616
+ }
1617
+
1618
+ return validateTimeZone(timeZone, "browser timeZone")
1619
+ }
1620
+
1621
+ /**
1622
+ * Resolves the configured frontend-model request timezone.
1623
+ * @returns {string | undefined} - Configured frontend-model timezone.
1624
+ */
1625
+ function frontendModelTransportTimeZone() {
1626
+ if (!Object.prototype.hasOwnProperty.call(frontendModelTransportConfig, "timeZone")) {
1627
+ return defaultFrontendModelTimeZone()
1628
+ }
1629
+
1630
+ const timeZone = typeof frontendModelTransportConfig.timeZone === "function"
1631
+ ? frontendModelTransportConfig.timeZone()
1632
+ : frontendModelTransportConfig.timeZone
1633
+
1634
+ if (timeZone === undefined || timeZone === null) {
1635
+ throw new Error("Frontend model transport timeZone did not resolve to a timezone string")
1636
+ }
1637
+
1638
+ return validateTimeZone(timeZone, "frontend model transport timeZone")
1639
+ }
1640
+
1593
1641
  /**
1594
1642
  * Runs frontend model request headers.
1643
+ * @param {string | undefined} [timeZone] - Pre-resolved timezone for this request.
1595
1644
  * @returns {Record<string, string>} - Headers for frontend-model HTTP requests.
1596
1645
  */
1597
- function frontendModelRequestHeaders() {
1646
+ function frontendModelRequestHeaders(timeZone = frontendModelTransportTimeZone()) {
1598
1647
  const dynamicHeaders = typeof frontendModelTransportConfig.requestHeaders === "function"
1599
1648
  ? (frontendModelTransportConfig.requestHeaders() || {})
1600
1649
  : (frontendModelTransportConfig.requestHeaders || {})
1650
+ /** @type {Record<string, string>} */
1651
+ const headers = {"Content-Type": "application/json", ...dynamicHeaders}
1652
+
1653
+ if (timeZone) {
1654
+ headers[REQUEST_TIME_ZONE_HEADER] = timeZone
1655
+ }
1601
1656
 
1602
- return {"Content-Type": "application/json", ...dynamicHeaders}
1657
+ return headers
1603
1658
  }
1604
1659
 
1605
1660
  /**
@@ -1608,10 +1663,11 @@ function frontendModelRequestHeaders() {
1608
1663
  * @returns {Promise<Record<string, ?>>} - Decoded shared frontend-model API response.
1609
1664
  */
1610
1665
  async function performSharedFrontendModelApiRequest(requestPayload) {
1611
- const serializedRequestPayload = serializeFrontendModelTransportValue(requestPayload)
1666
+ const timeZone = frontendModelTransportTimeZone()
1667
+ const serializedRequestPayload = serializeFrontendModelTransportValue(requestPayload, {timeZone})
1612
1668
  const websocketClient = frontendModelTransportConfig.websocketClient
1613
1669
  const url = frontendModelApiUrl()
1614
- const mergedHeaders = frontendModelRequestHeaders()
1670
+ const mergedHeaders = frontendModelRequestHeaders(timeZone)
1615
1671
 
1616
1672
  if (websocketClient) {
1617
1673
  const response = await websocketClient.post(frontendModelTransportPath(url), serializedRequestPayload, {
@@ -2710,6 +2766,14 @@ export default class FrontendModelBase {
2710
2766
  frontendModelTransportConfig.requestHeaders = config.requestHeaders
2711
2767
  }
2712
2768
 
2769
+ if (Object.prototype.hasOwnProperty.call(config, "timeZone")) {
2770
+ if (config.timeZone === undefined) {
2771
+ delete frontendModelTransportConfig.timeZone
2772
+ } else {
2773
+ frontendModelTransportConfig.timeZone = config.timeZone
2774
+ }
2775
+ }
2776
+
2713
2777
  if (Object.prototype.hasOwnProperty.call(config, "sessionStore")) {
2714
2778
  frontendModelTransportConfig.sessionStore = config.sessionStore
2715
2779
  // Reset cached internal client so the new sessionStore is picked up.
@@ -3706,7 +3770,7 @@ export default class FrontendModelBase {
3706
3770
  */
3707
3771
  static findByPrimitiveValuesMatch(actualValue, expectedValue) {
3708
3772
  if (actualValue instanceof Date && typeof expectedValue === "string") {
3709
- const normalizedExpectedValue = normalizeDateStringForWrite(expectedValue)
3773
+ const normalizedExpectedValue = normalizeDateStringForWrite(expectedValue, {timeZone: frontendModelTransportTimeZone()})
3710
3774
 
3711
3775
  if (normalizedExpectedValue instanceof Date) {
3712
3776
  return actualValue.toISOString() === normalizedExpectedValue.toISOString()
@@ -4183,7 +4247,8 @@ export default class FrontendModelBase {
4183
4247
  */
4184
4248
  static async executeCommand(commandType, payload) {
4185
4249
  const commandName = this.commandName(commandType)
4186
- const serializedPayload = /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(payload))
4250
+ const timeZone = frontendModelTransportTimeZone()
4251
+ const serializedPayload = /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(payload, {timeZone}))
4187
4252
  const resourcePath = this.resourcePath()
4188
4253
  const containsAttachmentUpload = frontendModelPayloadContainsAttachmentUpload(serializedPayload)
4189
4254
  const useSharedTransport = !containsAttachmentUpload
@@ -4219,7 +4284,7 @@ export default class FrontendModelBase {
4219
4284
  const directResponse = await fetch(url, {
4220
4285
  body: JSON.stringify(serializedPayload),
4221
4286
  credentials: "include",
4222
- headers: frontendModelRequestHeaders(),
4287
+ headers: frontendModelRequestHeaders(timeZone),
4223
4288
  method: "POST"
4224
4289
  })
4225
4290
 
@@ -4257,7 +4322,8 @@ export default class FrontendModelBase {
4257
4322
  * @returns {Promise<Record<string, FrontendModelAttributeValue>>} - Decoded response payload.
4258
4323
  */
4259
4324
  static async executeCustomCommand({commandName, commandType, memberId = null, payload, resourcePath}) {
4260
- const serializedPayload = /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(payload))
4325
+ const timeZone = frontendModelTransportTimeZone()
4326
+ const serializedPayload = /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(payload, {timeZone}))
4261
4327
  const customPath = frontendModelCustomCommandPath({
4262
4328
  commandName,
4263
4329
  memberId,
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {resolveFrontendModelClass} from "./model-registry.js"
4
4
  import isPlainObject from "../utils/plain-object.js"
5
+ import {formatDateInTimeZone, validateTimeZone} from "../time-zone.js"
5
6
 
6
7
  const TYPE_KEY = "__velocious_type"
7
8
  const TYPE_DATE = "date"
@@ -14,6 +15,18 @@ const NUMBER_POSITIVE_INFINITY = "Infinity"
14
15
  const NUMBER_NEGATIVE_INFINITY = "-Infinity"
15
16
  const PRELOADED_RELATIONSHIPS_KEY = "__preloadedRelationships"
16
17
 
18
+ /**
19
+ * Frontend model transport serialization options.
20
+ * @typedef {object} FrontendModelTransportSerializationOptions
21
+ * @property {string | undefined} [timeZone] - IANA timezone used when serializing Date instants.
22
+ */
23
+
24
+ /**
25
+ * Normalized frontend model transport serialization options.
26
+ * @typedef {object} NormalizedFrontendModelTransportSerializationOptions
27
+ * @property {string | undefined} timeZone - Validated IANA timezone used when serializing Date instants.
28
+ */
29
+
17
30
  /**
18
31
  * Assign a key to a plain object without triggering the `__proto__` setter.
19
32
  * Uses `Object.defineProperty` so that keys like `__proto__` are stored as
@@ -150,9 +163,10 @@ export function isBackendModelInstance(value) {
150
163
  * Runs serialize frontend model transport value internal.
151
164
  * @param {?} value - Value to serialize.
152
165
  * @param {WeakSet<object>} seenModels - Models already visited in the current recursion path.
166
+ * @param {NormalizedFrontendModelTransportSerializationOptions} options - Serialization options.
153
167
  * @returns {?} - Serialized value with transport markers.
154
168
  */
155
- function serializeFrontendModelTransportValueInternal(value, seenModels) {
169
+ function serializeFrontendModelTransportValueInternal(value, seenModels, options) {
156
170
  if (value === undefined) {
157
171
  return {[TYPE_KEY]: TYPE_UNDEFINED}
158
172
  }
@@ -160,7 +174,7 @@ function serializeFrontendModelTransportValueInternal(value, seenModels) {
160
174
  if (value instanceof Date) {
161
175
  return {
162
176
  [TYPE_KEY]: TYPE_DATE,
163
- value: value.toISOString()
177
+ value: options.timeZone ? formatDateInTimeZone(value, options.timeZone) : value.toISOString()
164
178
  }
165
179
  }
166
180
 
@@ -183,7 +197,7 @@ function serializeFrontendModelTransportValueInternal(value, seenModels) {
183
197
  }
184
198
 
185
199
  if (Array.isArray(value)) {
186
- return value.map((entry) => serializeFrontendModelTransportValueInternal(entry, seenModels))
200
+ return value.map((entry) => serializeFrontendModelTransportValueInternal(entry, seenModels, options))
187
201
  }
188
202
 
189
203
  if (isBackendModelInstance(value)) {
@@ -195,7 +209,7 @@ function serializeFrontendModelTransportValueInternal(value, seenModels) {
195
209
  * @type {Record<string, ?>} */
196
210
  const serializedModel = {
197
211
  [TYPE_KEY]: TYPE_FRONTEND_MODEL,
198
- attributes: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValueInternal(modelAttributes, seenModels)),
212
+ attributes: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValueInternal(modelAttributes, seenModels, options)),
199
213
  modelName
200
214
  }
201
215
 
@@ -220,7 +234,8 @@ function serializeFrontendModelTransportValueInternal(value, seenModels) {
220
234
 
221
235
  assignSafeProperty(preloadedRelationships, relationshipName, serializeFrontendModelTransportValueInternal(
222
236
  loadedRelationship == undefined ? null : loadedRelationship,
223
- seenModels
237
+ seenModels,
238
+ options
224
239
  ))
225
240
  }
226
241
 
@@ -240,7 +255,7 @@ function serializeFrontendModelTransportValueInternal(value, seenModels) {
240
255
  const serialized = {}
241
256
 
242
257
  for (const [key, nestedValue] of Object.entries(value)) {
243
- assignSafeProperty(serialized, key, serializeFrontendModelTransportValueInternal(nestedValue, seenModels))
258
+ assignSafeProperty(serialized, key, serializeFrontendModelTransportValueInternal(nestedValue, seenModels, options))
244
259
  }
245
260
 
246
261
  return serialized
@@ -249,6 +264,19 @@ function serializeFrontendModelTransportValueInternal(value, seenModels) {
249
264
  return value
250
265
  }
251
266
 
267
+ /**
268
+ * Normalizes serializer options once per top-level serialization.
269
+ * @param {FrontendModelTransportSerializationOptions} options - Serialization options.
270
+ * @returns {NormalizedFrontendModelTransportSerializationOptions} - Normalized options.
271
+ */
272
+ function normalizeFrontendModelTransportSerializationOptions(options) {
273
+ return {
274
+ timeZone: options.timeZone === undefined
275
+ ? undefined
276
+ : validateTimeZone(options.timeZone, "transport serialization timeZone")
277
+ }
278
+ }
279
+
252
280
  /**
253
281
  * Runs deserialize frontend model marker.
254
282
  * @param {{attributes: Record<string, ?>, modelName: string, preloadedRelationships?: Record<string, ?>}} marker - Encoded frontend-model marker.
@@ -292,10 +320,15 @@ function deserializeFrontendModelMarker(marker) {
292
320
  /**
293
321
  * Runs the serializeFrontendModelTransportValue helper.
294
322
  * @param {?} value - Value to serialize.
323
+ * @param {FrontendModelTransportSerializationOptions} [options] - Serialization options.
295
324
  * @returns {?} - Serialized value with transport markers.
296
325
  */
297
- export function serializeFrontendModelTransportValue(value) {
298
- return serializeFrontendModelTransportValueInternal(value, new WeakSet())
326
+ export function serializeFrontendModelTransportValue(value, options = {}) {
327
+ return serializeFrontendModelTransportValueInternal(
328
+ value,
329
+ new WeakSet(),
330
+ normalizeFrontendModelTransportSerializationOptions(options)
331
+ )
299
332
  }
300
333
 
301
334
  /**
@@ -10,6 +10,17 @@ const EVENT_FILTER_KEYS = new Set(["joins", "key", "searches", "where"])
10
10
  // to avoid the configuration → logger → websocket-publishers import cycle.
11
11
  const FRONTEND_MODELS_CHANNEL_NAME = "frontend-models"
12
12
 
13
+ /**
14
+ * Runs transport serialization options for a configuration.
15
+ * @param {import("../configuration.js").default} configuration - Configuration instance.
16
+ * @returns {import("./transport-serialization.js").FrontendModelTransportSerializationOptions} - Serialization options.
17
+ */
18
+ function transportSerializationOptionsForConfiguration(configuration) {
19
+ return {
20
+ timeZone: configuration.getEnvironmentHandler().getTimeZone(configuration)
21
+ }
22
+ }
23
+
13
24
  /**
14
25
  * Defines this typedef.
15
26
  * @typedef {{action?: string, id?: string | number, matchedEventFilterKeys?: string[], record?: import("./query.js").FrontendModelTransportValue, [key: string]: import("./query.js").FrontendModelTransportValue | string[] | undefined}} FrontendModelLifecycleBroadcastBody
@@ -164,9 +175,15 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
164
175
  return
165
176
  }
166
177
 
178
+ const configuration = this.session.configuration
179
+
180
+ if (!configuration) {
181
+ throw new Error("Frontend model websocket channel has no configuration for transport serialization")
182
+ }
183
+
167
184
  deliverBody = {
168
185
  ...deliverBody,
169
- record: /** @type {import("./query.js").FrontendModelTransportValue} */ (serializeFrontendModelTransportValue(projectedRecord))
186
+ record: /** @type {import("./query.js").FrontendModelTransportValue} */ (serializeFrontendModelTransportValue(projectedRecord, transportSerializationOptionsForConfiguration(configuration)))
170
187
  }
171
188
  }
172
189
 
@@ -11,6 +11,17 @@ const channelClassRegisteredConfigurations = new WeakSet()
11
11
  /** Shared channel name for all frontend-model lifecycle subscriptions. */
12
12
  export const FRONTEND_MODELS_CHANNEL_NAME = "frontend-models"
13
13
 
14
+ /**
15
+ * Runs transport serialization options for a configuration.
16
+ * @param {import("../configuration.js").default} configuration - Configuration instance.
17
+ * @returns {import("./transport-serialization.js").FrontendModelTransportSerializationOptions} - Serialization options.
18
+ */
19
+ function transportSerializationOptionsForConfiguration(configuration) {
20
+ return {
21
+ timeZone: configuration.getEnvironmentHandler().getTimeZone(configuration)
22
+ }
23
+ }
24
+
14
25
  /**
15
26
  * Runs the frontendModelBroadcastChannelName helper.
16
27
  * @param {string} modelName - Model class name.
@@ -169,7 +180,7 @@ function broadcastFrontendModelEvent(configuration, modelName, event) {
169
180
  action: event.action,
170
181
  id: event.id,
171
182
  model: modelName,
172
- ...(event.record ? {record: serializeFrontendModelTransportValue(event.record)} : {})
183
+ ...(event.record ? {record: serializeFrontendModelTransportValue(event.record, transportSerializationOptionsForConfiguration(configuration))} : {})
173
184
  }
174
185
 
175
186
  configuration.broadcastToChannel(FRONTEND_MODELS_CHANNEL_NAME, {model: modelName}, body)
@@ -7,6 +7,7 @@ import Logger from "../../logger.js"
7
7
  import RequestTiming from "./request-timing.js"
8
8
  import Response from "./response.js"
9
9
  import RoutesResolver from "../../routes/resolver.js"
10
+ import {REQUEST_TIME_ZONE_HEADER} from "../../time-zone.js"
10
11
 
11
12
  /**
12
13
  * Runs stack frame line.
@@ -223,9 +224,10 @@ export default class VelociousHttpServerClientRequestRunner {
223
224
 
224
225
  setRequestTimeoutSeconds(configuration.getRequestTimeoutMs?.())
225
226
 
227
+ /** @type {Promise<void> | undefined} */
226
228
  let resolvePromise
227
229
 
228
- try {
230
+ const runResolvedRequest = async () => {
229
231
  resolvePromise = routesResolver.resolve()
230
232
  // Keep Promise.race here to allow dynamic timeout updates.
231
233
  await Promise.race([resolvePromise, timeoutPromise])
@@ -236,6 +238,16 @@ export default class VelociousHttpServerClientRequestRunner {
236
238
  hasFilePath: Boolean(response.getFilePath()),
237
239
  bodyType: responseBodyTypeForLog(response)
238
240
  }])
241
+ }
242
+
243
+ try {
244
+ const requestTimeZone = request.header(REQUEST_TIME_ZONE_HEADER)
245
+
246
+ if (requestTimeZone !== undefined && requestTimeZone !== null) {
247
+ await configuration.runWithTimezone(requestTimeZone, runResolvedRequest)
248
+ } else {
249
+ await runResolvedRequest()
250
+ }
239
251
  } catch (error) {
240
252
  if (timedOut && resolvePromise) {
241
253
  void resolvePromise.catch((resolveError) => {
@@ -415,6 +415,7 @@
415
415
  * @property {TenantDatabaseResolverType} [tenantDatabaseResolver] - Resolver for deriving tenant-specific database config overrides.
416
416
  * @property {Record<string, TenantDatabaseProviderType>} [tenantDatabaseProviders] - Tenant database lifecycle providers keyed by database identifier.
417
417
  * @property {string} [testing] - Path to the testing configuration file.
418
+ * @property {string | (() => string | undefined)} [timeZone] - Default timezone for timezone-less datetime strings.
418
419
  * @property {number | (() => number)} [timezoneOffsetMinutes] - Default timezone offset in minutes.
419
420
  * @property {string | string[]} [trustedProxies] - Trusted reverse proxy address ranges used to resolve request remote addresses from forwarding headers.
420
421
  * @property {number | (() => number)} [requestTimeoutMs] - Timeout in seconds for completing a HTTP request.
@@ -1538,6 +1539,10 @@ export type ConfigurationArgsType = {
1538
1539
  * - Path to the testing configuration file.
1539
1540
  */
1540
1541
  testing?: string | undefined;
1542
+ /**
1543
+ * - Default timezone for timezone-less datetime strings.
1544
+ */
1545
+ timeZone?: string | (() => string | undefined) | undefined;
1541
1546
  /**
1542
1547
  * - Default timezone offset in minutes.
1543
1548
  */