velocious 1.0.468 → 1.0.470

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.
package/README.md CHANGED
@@ -378,7 +378,7 @@ export default new Configuration({
378
378
  `frontendModels` entries must be `FrontendModelBaseResource` subclasses. Built-in CRUD/find/index/serialize behavior lives in the base class, and app resources override only the pieces they actually need.
379
379
  Resource-level index customization should prefer `indexQuery()` or the pagination/search/sort hooks over replacing `records()`, so built-in pluck and aggregate count support can keep using the same query. See [`docs/frontend-model-resources.md`](docs/frontend-model-resources.md) for the resource extension points.
380
380
 
381
- Custom class- and instance-level commands are declared via `collectionCommands` / `memberCommands`. Each entry is a plain camelCase method name, or a `{name, args?, returnType?}` object that types the command's arguments and response — e.g. `memberCommands: ["suspend", {name: "refresh", args: [{name: "age", type: "number"}], returnType: "string"}]`. See [`docs/frontend-model-resources.md#custom-commands`](docs/frontend-model-resources.md#custom-commands).
381
+ Custom class- and instance-level commands are declared via `collectionCommands` / `memberCommands`. Each entry is a plain camelCase method name, or a `{name, args?, returnType?}` object that types the command's arguments and response — e.g. `memberCommands: ["suspend", {name: "refresh", args: [{name: "age", type: "number"}], returnType: "string"}]`. A command whose args are a single object literal with only optional fields generates an omittable parameter (`record.command()` works without passing `{}`); any required field keeps the argument mandatory. See [`docs/frontend-model-resources.md#custom-commands`](docs/frontend-model-resources.md#custom-commands).
382
382
 
383
383
  Resources expose the full CRUD ability set (`create`, `destroy`, `read`, `update`) by default. To restrict the API surface — for example to a read-only resource — declare an explicit subset:
384
384
 
@@ -591,16 +591,18 @@ const configuration = new Configuration({
591
591
 
592
592
  This opt-in is ignored in `production`; production frontend-model responses never include internal exception details.
593
593
 
594
- Backends can append client-safe metadata to frontend-model error responses with `configuration.addClientErrorPayloadReporter(...)`. Reporters receive the caught `error`, the current `request`, and a small `context` object, and should only return fields that are safe for clients to see. Frontend-model endpoint failures include `context.frontendModelEndpoint`, `action`, `commandType`, `model`, `requestId`, and `expectedError`. This is useful for attaching an error-reporting URL while keeping the normal production error message generic:
594
+ Backends can append client-safe metadata to frontend-model error responses with `configuration.addClientErrorPayloadReporter(...)`. Reporters receive the caught `error`, the current `request`, a safe `requestDetails` snapshot, and a small `context` object, and should only return fields that are safe for clients to see. Frontend-model endpoint failures include `context.frontendModelEndpoint`, `action`, `commandType`, `model`, `requestId`, and `expectedError`. This is useful for attaching an error-reporting URL while keeping the normal production error message generic:
595
595
 
596
596
  ```js
597
- configuration.addClientErrorPayloadReporter(async ({error, request, context}) => {
598
- const report = await reportErrorToService({error, request, context})
597
+ configuration.addClientErrorPayloadReporter(async ({error, requestDetails, context}) => {
598
+ const report = await reportErrorToService({error, requestDetails, context})
599
599
 
600
600
  return {bugReportUrl: report.url}
601
601
  })
602
602
  ```
603
603
 
604
+ `requestDetails` includes `httpMethod`, `path`, and a parsed `body` snapshot when available. The body snapshot redacts common secret keys, truncates large strings and arrays, summarizes uploaded files and buffers without bytes, and compacts oversized frontend-model batches while preserving `requestId`, `model`, `commandType` / `customPath`, and payload shape.
605
+
604
606
  For sqlite web databases, Velocious defaults to `https://sql.js.org/dist/<file>` for `sql.js` wasm loading. You can override wasm resolution per database config with `locateFile`:
605
607
 
606
608
  ```js
@@ -212,6 +212,13 @@
212
212
  * @typedef {Record<string, import("./frontend-models/query.js").FrontendModelTransportValue>} ClientErrorPayloadReporterPayload
213
213
  */
214
214
 
215
+ /**
216
+ * @typedef {object} ErrorRequestDetails
217
+ * @property {?} [body] - Sanitized parsed request body, when available.
218
+ * @property {string} httpMethod - Request HTTP method.
219
+ * @property {string} path - Request path.
220
+ */
221
+
215
222
  /**
216
223
  * @typedef {object} ClientErrorPayloadContext
217
224
  * @property {string} controller - Controller class name.
@@ -227,7 +234,8 @@
227
234
  * @typedef {function({
228
235
  * context: ClientErrorPayloadContext,
229
236
  * error: Error,
230
- * request: import("./http-server/client/request.js").default | import("./http-server/client/websocket-request.js").default | undefined
237
+ * request: import("./http-server/client/request.js").default | import("./http-server/client/websocket-request.js").default | undefined,
238
+ * requestDetails: ErrorRequestDetails | null
231
239
  * }): Promise<ClientErrorPayloadReporterPayload | void> | ClientErrorPayloadReporterPayload | void} ClientErrorPayloadReporterType
232
240
  */
233
241
 
@@ -18,6 +18,7 @@ import Ability from "./authorization/ability.js"
18
18
  import EventEmitter from "./utils/event-emitter.js"
19
19
  import VelociousWebsocketChannelSubscribers from "./http-server/websocket-channel-subscribers.js"
20
20
  import {CurrentConfigurationNotSetError, currentConfiguration, setCurrentConfiguration} from "./current-configuration.js"
21
+ import {requestDetails} from "./error-reporting/request-details.js"
21
22
  import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcesForBackendProject} from "./frontend-models/resource-definition.js"
22
23
  import PluginRoutes from "./routes/plugin-routes.js"
23
24
  import restArgsError from "./utils/rest-args-error.js"
@@ -2411,9 +2412,13 @@ export default class VelociousConfiguration {
2411
2412
  async clientErrorPayloadForError(args) {
2412
2413
  /** @type {import("./configuration-types.js").ClientErrorPayloadReporterPayload} */
2413
2414
  const payload = {}
2415
+ const details = requestDetails(args.request)
2414
2416
 
2415
2417
  for (const reporter of this._clientErrorPayloadReporters) {
2416
- const reporterPayload = await reporter(args)
2418
+ const reporterPayload = await reporter({
2419
+ ...args,
2420
+ requestDetails: details
2421
+ })
2417
2422
 
2418
2423
  if (reporterPayload && typeof reporterPayload === "object") {
2419
2424
  Object.assign(payload, reporterPayload)
@@ -1474,10 +1474,15 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1474
1474
 
1475
1475
  if (metadata.args.length > 0) {
1476
1476
  const parameterNames = metadata.args.map((arg) => arg.name)
1477
+ // A single args object whose every field is optional accepts `{}`, so default
1478
+ // the parameter and mark it optional — callers can then omit it entirely
1479
+ // (`record.command()` instead of `record.command({})`). Required-field args keep
1480
+ // the mandatory parameter (a `{}` default wouldn't satisfy their type).
1481
+ const defaultsToEmptyObject = metadata.args.length === 1 && this.argTypeAcceptsEmptyObject(metadata.args[0].type)
1477
1482
 
1478
1483
  return {
1479
- paramDocs: metadata.args.map((arg) => ` * @param {${arg.type}} ${arg.name} - Command argument.\n`).join(""),
1480
- parameters: parameterNames.join(", "),
1484
+ paramDocs: metadata.args.map((arg) => ` * @param {${arg.type}} ${defaultsToEmptyObject ? `[${arg.name}]` : arg.name} - Command argument.\n`).join(""),
1485
+ parameters: defaultsToEmptyObject ? `${parameterNames[0]} = {}` : parameterNames.join(", "),
1481
1486
  payloadArguments: `[${parameterNames.join(", ")}]`,
1482
1487
  returnType
1483
1488
  }
@@ -1491,6 +1496,124 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1491
1496
  }
1492
1497
  }
1493
1498
 
1499
+ /**
1500
+ * Whether a single command-args JSDoc type is known to accept an empty object `{}`:
1501
+ * a single balanced object literal whose top-level members are all optional (`name?:`)
1502
+ * or index signatures (`[k: ...]:`). Anything else returns false so the parameter stays
1503
+ * required — including a required member, a non-object-literal (a positional `number`,
1504
+ * a `Record<...>` / `Partial<...>` whose key/wrapper may still require data), and any
1505
+ * intersection/union (e.g. `{a?: x} & {b: string}`), where `{}` is not assignable.
1506
+ * @param {string} type - The arg's JSDoc type string.
1507
+ * @returns {boolean} - Whether the generated parameter can default to `{}`.
1508
+ */
1509
+ argTypeAcceptsEmptyObject(type) {
1510
+ const trimmedType = type.trim()
1511
+
1512
+ // Must be a single balanced object literal: starts with `{`, ends with `}`, and the
1513
+ // opening brace closes only at the final character. This rejects intersections/unions
1514
+ // like `{a?: x} & {b: string}` that merely happen to start `{` and end `}`.
1515
+ if (!(trimmedType.startsWith("{") && trimmedType.endsWith("}"))) return false
1516
+ if (!this.isSingleBalancedObjectLiteral(trimmedType)) return false
1517
+
1518
+ const inner = trimmedType.slice(1, -1)
1519
+
1520
+ for (const member of this.splitTopLevelTypeMembers(inner)) {
1521
+ const colonIndex = this.topLevelColonIndex(member)
1522
+
1523
+ // No top-level colon: a call/construct/mapped signature or malformed member —
1524
+ // can't confirm it's optional, so treat the type as not empty-defaultable.
1525
+ if (colonIndex < 0) return false
1526
+
1527
+ const key = member.slice(0, colonIndex).trim()
1528
+
1529
+ // Index signatures (`[k: string]`) don't require a value; optional props end in `?`.
1530
+ // Anything else is a required property, so `{}` would not satisfy the type.
1531
+ if (!key.startsWith("[") && !key.endsWith("?")) return false
1532
+ }
1533
+
1534
+ return true
1535
+ }
1536
+
1537
+ /**
1538
+ * Splits the inner body of an object-literal type into its top-level members,
1539
+ * respecting nested `{}` / `[]` / `<>` / `()` so field types like `string[] | null`
1540
+ * or `{a: b}` aren't split mid-type. Members are separated by `,` or `;`.
1541
+ * @param {string} inner - Object-literal body (without the outer braces).
1542
+ * @returns {string[]} - Trimmed non-empty top-level members.
1543
+ */
1544
+ splitTopLevelTypeMembers(inner) {
1545
+ const members = []
1546
+ let depth = 0
1547
+ let start = 0
1548
+
1549
+ for (let index = 0; index < inner.length; index += 1) {
1550
+ const character = inner[index]
1551
+
1552
+ if (character === "{" || character === "[" || character === "<" || character === "(") {
1553
+ depth += 1
1554
+ } else if (character === "}" || character === "]" || character === ">" || character === ")") {
1555
+ depth -= 1
1556
+ } else if ((character === "," || character === ";") && depth === 0) {
1557
+ members.push(inner.slice(start, index))
1558
+ start = index + 1
1559
+ }
1560
+ }
1561
+
1562
+ members.push(inner.slice(start))
1563
+
1564
+ return members.map((member) => member.trim()).filter((member) => member.length > 0)
1565
+ }
1566
+
1567
+ /**
1568
+ * Index of the first top-level `:` in an object-literal member, ignoring colons
1569
+ * nested inside `{}` / `[]` / `<>` / `()` (e.g. an index signature `[k: string]`).
1570
+ * @param {string} member - A single object-literal member.
1571
+ * @returns {number} - The colon index, or -1 when none is found at the top level.
1572
+ */
1573
+ topLevelColonIndex(member) {
1574
+ let depth = 0
1575
+
1576
+ for (let index = 0; index < member.length; index += 1) {
1577
+ const character = member[index]
1578
+
1579
+ if (character === "{" || character === "[" || character === "<" || character === "(") {
1580
+ depth += 1
1581
+ } else if (character === "}" || character === "]" || character === ">" || character === ")") {
1582
+ depth -= 1
1583
+ } else if (character === ":" && depth === 0) {
1584
+ return index
1585
+ }
1586
+ }
1587
+
1588
+ return -1
1589
+ }
1590
+
1591
+ /**
1592
+ * Whether the type is a single balanced object literal — its leading `{` closes only
1593
+ * at the final character. Rejects top-level intersections/unions like `{a?: x} & {b: y}`
1594
+ * or `{a?: x} | string` whose brace depth returns to 0 before the end.
1595
+ * @param {string} type - A trimmed type string that starts with `{` and ends with `}`.
1596
+ * @returns {boolean} - Whether the braces wrap the whole type.
1597
+ */
1598
+ isSingleBalancedObjectLiteral(type) {
1599
+ let depth = 0
1600
+
1601
+ for (let index = 0; index < type.length; index += 1) {
1602
+ const character = type[index]
1603
+
1604
+ if (character === "{" || character === "[" || character === "<" || character === "(") {
1605
+ depth += 1
1606
+ } else if (character === "}" || character === "]" || character === ">" || character === ")") {
1607
+ depth -= 1
1608
+
1609
+ // The opening brace balanced before the end, so something follows the literal.
1610
+ if (depth === 0 && index < type.length - 1) return false
1611
+ }
1612
+ }
1613
+
1614
+ return depth === 0
1615
+ }
1616
+
1494
1617
  /**
1495
1618
  * Enriches custom-command metadata by deriving a command's typed args and return
1496
1619
  * type from the backend resource method's `@param`/`@returns` JSDoc when they are
@@ -1852,7 +1975,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1852
1975
  throw new Error(`Could not parse JSDoc return type from: ${jsDocText}`)
1853
1976
  }
1854
1977
 
1855
- const returnType = jsDocText.slice(typeOpenIndex + 1, typeCloseIndex).trim()
1978
+ const returnType = this.normalizeJsDocType(jsDocText.slice(typeOpenIndex + 1, typeCloseIndex))
1856
1979
 
1857
1980
  if (returnType.length < 1) {
1858
1981
  throw new Error(`Expected non-empty JSDoc return type in: ${jsDocText}`)
@@ -1861,6 +1984,18 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1861
1984
  return returnType
1862
1985
  }
1863
1986
 
1987
+ /**
1988
+ * Collapses a JSDoc type spanning multiple comment lines into a single line so it can
1989
+ * be emitted into an inline type-assertion cast. A multiline backend return type keeps
1990
+ * its leading continuation asterisks in the captured substring, which are invalid inside
1991
+ * an inline cast and make TypeScript read the asserted type as `undefined`.
1992
+ * @param {string} jsDocType - Raw captured JSDoc type, possibly multiline.
1993
+ * @returns {string} - Single-line JSDoc type.
1994
+ */
1995
+ normalizeJsDocType(jsDocType) {
1996
+ return jsDocType.replace(/\s*\n\s*\*?[ \t]*/g, " ").trim()
1997
+ }
1998
+
1864
1999
  /**
1865
2000
  * Runs js doc parameters.
1866
2001
  * @param {string} jsDocText - JSDoc text inside comment markers.
@@ -1879,7 +2014,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1879
2014
  throw new Error(`Could not parse JSDoc parameter type from: ${jsDocText}`)
1880
2015
  }
1881
2016
 
1882
- const type = jsDocText.slice(typeOpenIndex + 1, typeCloseIndex).trim()
2017
+ const type = this.normalizeJsDocType(jsDocText.slice(typeOpenIndex + 1, typeCloseIndex))
1883
2018
 
1884
2019
  if (type.length < 1) {
1885
2020
  throw new Error(`Expected non-empty JSDoc parameter type in: ${jsDocText}`)
@@ -0,0 +1,308 @@
1
+ // @ts-check
2
+
3
+ import UploadedFile from "../http-server/client/uploaded-file/uploaded-file.js"
4
+
5
+ const REQUEST_DETAILS_BODY_MAX_SERIALIZED_LENGTH = 12000
6
+ const REQUEST_DETAILS_STRING_MAX_LENGTH = 1000
7
+ const REQUEST_DETAILS_ARRAY_MAX_ITEMS = 50
8
+ const REDACTED_REQUEST_DETAILS_VALUE = "[redacted]"
9
+
10
+ /**
11
+ * Extracts request metadata without retaining the request object.
12
+ * @param {import("../http-server/client/request.js").default | import("../http-server/client/websocket-request.js").default | undefined} request - Request object, when present.
13
+ * @returns {import("../configuration-types.js").ErrorRequestDetails | null} - Request metadata.
14
+ */
15
+ export function requestDetails(request) {
16
+ if (!request) return null
17
+
18
+ let details
19
+
20
+ try {
21
+ details = {
22
+ httpMethod: request.httpMethod(),
23
+ path: request.path()
24
+ }
25
+ } catch {
26
+ return null
27
+ }
28
+
29
+ const body = requestBodySnapshot(request)
30
+
31
+ if (body !== undefined) {
32
+ return {...details, body}
33
+ }
34
+
35
+ return details
36
+ }
37
+
38
+ /**
39
+ * Snapshots parsed request params for error reports.
40
+ * @param {import("../http-server/client/request.js").default | import("../http-server/client/websocket-request.js").default} request - Request object.
41
+ * @returns {? | undefined} - Sanitized body snapshot.
42
+ */
43
+ function requestBodySnapshot(request) {
44
+ if (typeof request.params !== "function") return undefined
45
+
46
+ try {
47
+ return requestDetailsBodySnapshot(request.params())
48
+ } catch (error) {
49
+ return {
50
+ __unavailable: true,
51
+ error: error instanceof Error ? error.message : String(error)
52
+ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Builds a bounded, redacted body snapshot for error reports.
58
+ * @param {?} body - Parsed request body.
59
+ * @returns {?} - Sanitized body snapshot.
60
+ */
61
+ function requestDetailsBodySnapshot(body) {
62
+ const originalLength = serializedLength(body)
63
+ const sanitized = sanitizeRequestDetailsValue(body)
64
+ const sanitizedLength = serializedLength(sanitized)
65
+
66
+ if (originalLength <= REQUEST_DETAILS_BODY_MAX_SERIALIZED_LENGTH && sanitizedLength <= REQUEST_DETAILS_BODY_MAX_SERIALIZED_LENGTH) {
67
+ return sanitized
68
+ }
69
+
70
+ const compactFrontendModelBody = compactFrontendModelRequestBody(sanitized, originalLength)
71
+
72
+ if (serializedLength(compactFrontendModelBody) <= REQUEST_DETAILS_BODY_MAX_SERIALIZED_LENGTH) {
73
+ return compactFrontendModelBody
74
+ }
75
+
76
+ return compactRequestDetailsBody(sanitized, originalLength)
77
+ }
78
+
79
+ /**
80
+ * Sanitizes a value recursively for request details.
81
+ * @param {?} value - Value to sanitize.
82
+ * @param {string} [key] - Current object key.
83
+ * @param {WeakSet<object>} [seen] - Seen object references.
84
+ * @returns {?} - Sanitized value.
85
+ */
86
+ function sanitizeRequestDetailsValue(value, key = "", seen = new WeakSet()) {
87
+ if (sensitiveRequestDetailsKey(key)) return REDACTED_REQUEST_DETAILS_VALUE
88
+ if (primitiveRequestDetailsValue(value)) return value
89
+ if (bufferValue(value)) return bufferRequestDetailsSummary(value)
90
+ if (value instanceof UploadedFile) return uploadedFileRequestDetailsSummary(value)
91
+ if (typeof value === "string") return sanitizeRequestDetailsString(value)
92
+ if (Array.isArray(value)) return sanitizeRequestDetailsArray(value, seen)
93
+ if (typeof value === "object") return sanitizeRequestDetailsObject(value, seen)
94
+
95
+ return String(value)
96
+ }
97
+
98
+ /**
99
+ * Checks whether a value can be kept as-is.
100
+ * @param {?} value - Value.
101
+ * @returns {boolean} - Whether the value is primitive and safe.
102
+ */
103
+ function primitiveRequestDetailsValue(value) {
104
+ return value == null || typeof value === "boolean" || typeof value === "number"
105
+ }
106
+
107
+ /**
108
+ * Sanitizes a string value.
109
+ * @param {string} value - String value.
110
+ * @returns {string} - Sanitized string.
111
+ */
112
+ function sanitizeRequestDetailsString(value) {
113
+ if (value.length <= REQUEST_DETAILS_STRING_MAX_LENGTH) return value
114
+
115
+ return `${value.slice(0, REQUEST_DETAILS_STRING_MAX_LENGTH)}... [truncated ${value.length - REQUEST_DETAILS_STRING_MAX_LENGTH} chars]`
116
+ }
117
+
118
+ /**
119
+ * Sanitizes an array value.
120
+ * @param {Array<?>} value - Array value.
121
+ * @param {WeakSet<object>} seen - Seen object references.
122
+ * @returns {Array<?>} - Sanitized array.
123
+ */
124
+ function sanitizeRequestDetailsArray(value, seen) {
125
+ const entries = value
126
+ .slice(0, REQUEST_DETAILS_ARRAY_MAX_ITEMS)
127
+ .map((entry) => sanitizeRequestDetailsValue(entry, "", seen))
128
+
129
+ if (value.length > REQUEST_DETAILS_ARRAY_MAX_ITEMS) {
130
+ entries.push({__omittedItems: value.length - REQUEST_DETAILS_ARRAY_MAX_ITEMS})
131
+ }
132
+
133
+ return entries
134
+ }
135
+
136
+ /**
137
+ * Sanitizes a plain object-like value.
138
+ * @param {object} value - Object value.
139
+ * @param {WeakSet<object>} seen - Seen object references.
140
+ * @returns {Record<string, ?> | string} - Sanitized object or circular marker.
141
+ */
142
+ function sanitizeRequestDetailsObject(value, seen) {
143
+ if (seen.has(value)) return "[circular]"
144
+
145
+ seen.add(value)
146
+
147
+ /** @type {Record<string, ?>} */
148
+ const sanitized = {}
149
+
150
+ for (const [entryKey, entryValue] of Object.entries(value)) {
151
+ sanitized[entryKey] = sanitizeRequestDetailsValue(entryValue, entryKey, seen)
152
+ }
153
+
154
+ seen.delete(value)
155
+
156
+ return sanitized
157
+ }
158
+
159
+ /**
160
+ * Checks whether a value is a Node.js Buffer.
161
+ * @param {?} value - Value.
162
+ * @returns {value is Buffer} - Whether value is a Buffer.
163
+ */
164
+ function bufferValue(value) {
165
+ return typeof Buffer !== "undefined" && typeof Buffer.isBuffer === "function" && Buffer.isBuffer(value)
166
+ }
167
+
168
+ /**
169
+ * Summarizes raw binary data without serializing bytes.
170
+ * @param {Buffer} buffer - Uploaded or request buffer.
171
+ * @returns {Record<string, ?>} - Request-safe buffer summary.
172
+ */
173
+ function bufferRequestDetailsSummary(buffer) {
174
+ return {
175
+ __redacted: true,
176
+ __type: "Buffer",
177
+ byteLength: buffer.byteLength
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Summarizes uploaded files without serializing file bytes or temp paths.
183
+ * @param {UploadedFile} uploadedFile - Uploaded file object.
184
+ * @returns {Record<string, ?>} - Request-safe upload summary.
185
+ */
186
+ function uploadedFileRequestDetailsSummary(uploadedFile) {
187
+ return {
188
+ __redacted: true,
189
+ __type: "UploadedFile",
190
+ contentType: uploadedFile.contentType() ?? null,
191
+ fieldName: uploadedFile.fieldName(),
192
+ filename: uploadedFile.filename(),
193
+ size: uploadedFile.size()
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Checks whether a key should be redacted from request details.
199
+ * @param {string} key - Object key.
200
+ * @returns {boolean} - Whether the value is sensitive.
201
+ */
202
+ function sensitiveRequestDetailsKey(key) {
203
+ return /authorization|contentBase64|password|secret|sessionToken|token/i.test(key)
204
+ }
205
+
206
+ /**
207
+ * Compacts a frontend-model request envelope while preserving debugging shape.
208
+ * @param {?} value - Sanitized body value.
209
+ * @param {number} originalSerializedLength - Byte count before redaction or compaction.
210
+ * @returns {?} - Compacted body value.
211
+ */
212
+ function compactFrontendModelRequestBody(value, originalSerializedLength) {
213
+ if (!value || typeof value !== "object" || !Array.isArray(/** @type {Record<string, ?>} */ (value).requests)) {
214
+ return compactRequestDetailsBody(value, originalSerializedLength)
215
+ }
216
+
217
+ return {
218
+ __truncated: true,
219
+ originalSerializedLength,
220
+ requests: /** @type {Array<?>} */ (/** @type {Record<string, ?>} */ (value).requests).map((request) => {
221
+ return compactFrontendModelRequest(request)
222
+ })
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Compacts one frontend-model request entry.
228
+ * @param {?} value - Candidate request entry from the transport envelope.
229
+ * @returns {?} - Compacted request.
230
+ */
231
+ function compactFrontendModelRequest(value) {
232
+ if (!value || typeof value !== "object") return value
233
+
234
+ const request = /** @type {Record<string, ?>} */ (value)
235
+ /** @type {Record<string, ?>} */
236
+ const compact = {}
237
+
238
+ for (const key of ["requestId", "model", "commandType", "customPath"]) {
239
+ if (request[key] !== undefined) compact[key] = request[key]
240
+ }
241
+
242
+ if (request.payload !== undefined) {
243
+ compact.payload = compactFrontendModelPayload(request.payload)
244
+ }
245
+
246
+ return compact
247
+ }
248
+
249
+ /**
250
+ * Compacts a frontend-model payload.
251
+ * @param {?} value - Candidate payload from a request entry.
252
+ * @returns {?} - Compacted payload.
253
+ */
254
+ function compactFrontendModelPayload(value) {
255
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value
256
+
257
+ const payload = /** @type {Record<string, ?>} */ (value)
258
+ /** @type {Record<string, ?>} */
259
+ const compact = {}
260
+
261
+ for (const [key, entryValue] of Object.entries(payload)) {
262
+ compact[key] = compactFrontendModelPayloadEntry({key, value: entryValue})
263
+ }
264
+
265
+ return compact
266
+ }
267
+
268
+ /**
269
+ * Compacts one frontend-model payload entry.
270
+ * @param {{key: string, value: ?}} args - Payload entry.
271
+ * @returns {?} - Compacted value.
272
+ */
273
+ function compactFrontendModelPayloadEntry({key, value}) {
274
+ if (key !== "attributes" || !value || typeof value !== "object" || Array.isArray(value)) {
275
+ return value
276
+ }
277
+
278
+ return {__keys: Object.keys(/** @type {Record<string, ?>} */ (value))}
279
+ }
280
+
281
+ /**
282
+ * Builds a last-resort compact body summary.
283
+ * @param {?} value - Sanitized body.
284
+ * @param {number} originalSerializedLength - Byte count before redaction or compaction.
285
+ * @returns {Record<string, ?>} - Compact summary.
286
+ */
287
+ function compactRequestDetailsBody(value, originalSerializedLength) {
288
+ return {
289
+ __truncated: true,
290
+ originalSerializedLength,
291
+ topLevelKeys: value && typeof value === "object" && !Array.isArray(value)
292
+ ? Object.keys(/** @type {Record<string, ?>} */ (value))
293
+ : []
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Measures JSON serialized length.
299
+ * @param {?} value - Value to measure.
300
+ * @returns {number} - Serialized length, or Infinity when not serializable.
301
+ */
302
+ function serializedLength(value) {
303
+ try {
304
+ return JSON.stringify(value).length
305
+ } catch {
306
+ return Infinity
307
+ }
308
+ }
@@ -8,6 +8,7 @@ import {frontendModelResourcesWithBuiltInsForBackendProject} from "./frontend-mo
8
8
  import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcePath, frontendModelResourcesForBackendProject} from "./frontend-models/resource-definition.js"
9
9
  import {FrontendModelQueryError, normalizeGroup as normalizeQueryGroup, normalizeJoins as normalizeQueryJoins, normalizePluck as normalizeQueryPluck, normalizePreload as normalizeQueryPreload, normalizeSearchOperator as normalizeQuerySearchOperator, normalizeSort as normalizeQuerySort} from "./frontend-models/query.js"
10
10
  import {assignSafeProperty, deserializeFrontendModelTransportValue, isBackendModelInstance, serializeFrontendModelTransportValue} from "./frontend-models/transport-serialization.js"
11
+ import {requestDetails} from "./error-reporting/request-details.js"
11
12
  import RoutesResolver from "./routes/resolver.js"
12
13
  import {ValidationError} from "./database/record/index.js"
13
14
  import { normalizeDateStringForWrite } from "./database/datetime-storage.js"
@@ -3183,7 +3184,8 @@ export default class FrontendModelController extends Controller {
3183
3184
  const errorPayload = {
3184
3185
  context: errorContext,
3185
3186
  error: error instanceof Error ? error : new Error(String(error)),
3186
- request: this.getRequest()
3187
+ request: this.getRequest(),
3188
+ requestDetails: requestDetails(this.getRequest())
3187
3189
  }
3188
3190
 
3189
3191
  this.getConfiguration().getErrorEvents().emit("framework-error", errorPayload)
@@ -186,6 +186,12 @@
186
186
  /**
187
187
  * @typedef {Record<string, import("./frontend-models/query.js").FrontendModelTransportValue>} ClientErrorPayloadReporterPayload
188
188
  */
189
+ /**
190
+ * @typedef {object} ErrorRequestDetails
191
+ * @property {?} [body] - Sanitized parsed request body, when available.
192
+ * @property {string} httpMethod - Request HTTP method.
193
+ * @property {string} path - Request path.
194
+ */
189
195
  /**
190
196
  * @typedef {object} ClientErrorPayloadContext
191
197
  * @property {string} controller - Controller class name.
@@ -200,7 +206,8 @@
200
206
  * @typedef {function({
201
207
  * context: ClientErrorPayloadContext,
202
208
  * error: Error,
203
- * request: import("./http-server/client/request.js").default | import("./http-server/client/websocket-request.js").default | undefined
209
+ * request: import("./http-server/client/request.js").default | import("./http-server/client/websocket-request.js").default | undefined,
210
+ * requestDetails: ErrorRequestDetails | null
204
211
  * }): Promise<ClientErrorPayloadReporterPayload | void> | ClientErrorPayloadReporterPayload | void} ClientErrorPayloadReporterType
205
212
  */
206
213
  /**
@@ -878,6 +885,20 @@ export type ScheduledBackgroundJobConfiguration = {
878
885
  };
879
886
  export type VelociousParams = Record<string, string>;
880
887
  export type ClientErrorPayloadReporterPayload = Record<string, import("./frontend-models/query.js").FrontendModelTransportValue>;
888
+ export type ErrorRequestDetails = {
889
+ /**
890
+ * - Sanitized parsed request body, when available.
891
+ */
892
+ body?: unknown;
893
+ /**
894
+ * - Request HTTP method.
895
+ */
896
+ httpMethod: string;
897
+ /**
898
+ * - Request path.
899
+ */
900
+ path: string;
901
+ };
881
902
  export type ClientErrorPayloadContext = {
882
903
  /**
883
904
  * - Controller class name.
@@ -912,6 +933,7 @@ export type ClientErrorPayloadReporterType = (arg0: {
912
933
  context: ClientErrorPayloadContext;
913
934
  error: Error;
914
935
  request: import("./http-server/client/request.js").default | import("./http-server/client/websocket-request.js").default | undefined;
936
+ requestDetails: ErrorRequestDetails | null;
915
937
  }) => Promise<ClientErrorPayloadReporterPayload | void> | ClientErrorPayloadReporterPayload | void;
916
938
  export type VelociousLooseObject = Record<string, unknown> & {
917
939
  configuration?: import("./configuration.js").default;