json-rest-api 1.0.28 → 1.0.29

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/docs/API.md CHANGED
@@ -2549,6 +2549,8 @@ The library uses standard JSON:API error format:
2549
2549
 
2550
2550
  ### Custom Error Handling
2551
2551
 
2552
+ Typed API errors thrown by getters, setters, computed fields, file detectors, or upload adapters retain their original code and details. For example, throwing `new RestApiResourceError('Upload forbidden', { subtype: 'forbidden' })` from an upload adapter produces a 403 response and still runs temporary-file cleanup.
2553
+
2552
2554
  ```javascript
2553
2555
  // In hooks
2554
2556
  api.resource('articles').hook('beforeDataPost', async (context) => {
@@ -96,6 +96,8 @@ await api.resources.post_tags.createKnexTable();
96
96
 
97
97
  **Basic Pagination**
98
98
 
99
+ When `sortableFields` is omitted or empty, visible stored schema fields and relationship aliases can be sorted. Query fields marked `sortable: true` are added to that set. Unknown fields and ordinary computed fields are rejected with 422. If a sort field is repeated, its first occurrence determines the direction.
100
+
99
101
  The API supports two types of pagination:
100
102
 
101
103
  1. **Offset-based pagination** using `page[number]` and `page[size]` parameters
@@ -671,6 +673,13 @@ Next multiField page: {
671
673
 
672
674
  **Cursor Pagination Notes:**
673
675
  - Cursors are simple strings containing the sort field values (format: `field:value,field2:value2`)
676
+ - Sparse fieldsets may omit sort fields from the response; the Knex query still fetches those values internally to construct a complete cursor.
677
+ - A cursor missing any active sort field is rejected with a validation error. Keep the same sort order when following a cursor.
678
+ - Cursor values are validated against the sort field types. Invalid dates, numbers, or duplicate cursor fields produce HTTP 422.
679
+ - `page[size]` and `page[number]` must be positive integers. Choose one of `page[number]`, `page[after]`, or `page[before]`; these cannot be combined.
680
+ - Null sort values appear last in either sort direction. Generated cursors distinguish null from the literal string `"null"`; treat cursor strings as opaque values.
681
+ - `page[before]` returns the adjacent preceding records in the requested sort order. Use a cursor for the first record of the current page to step back. When a backward response has more preceding records, its `links.next` and `meta.pagination.cursor.next` continue in the `before` direction.
682
+ - Requested page sizes above `queryMaxLimit` are capped consistently for returned records, pagination metadata, and links.
674
683
  - The API automatically generates appropriate WHERE clauses based on sort direction
675
684
  - Cursor pagination is more efficient for large datasets as it doesn't need to count all records
676
685
  - Works seamlessly with any sort field, not just timestamps
@@ -132,7 +132,7 @@ await api.addResource('users', {
132
132
  });
133
133
  ```
134
134
 
135
- If a setter throws or rejects, the write fails with a validation error and the transaction rolls back instead of storing the untransformed value.
135
+ If a setter throws or rejects, the write fails and the transaction rolls back. Typed API errors retain their code and details; other setter failures become validation errors. Getters and computed fields also propagate typed API errors, including `RestApiTemporalDataError`, so invalid output cannot be silently accepted.
136
136
 
137
137
  ### Getters - Transform After Retrieval
138
138
 
@@ -86,6 +86,20 @@ JavaScript `Date` objects, SQL datetime strings, Unix timestamps, locale text, a
86
86
  **Storage Format:**
87
87
  Schema validation preserves valid JSON temporal strings exactly. The built-in storage adapters convert `date` and `dateTime` strings to database-native `Date` values at the database boundary, while `time` remains a string. A custom field-level `storage.serialize` function receives the validated string and owns any custom database conversion.
88
88
 
89
+ The built-in `dateTime` conversion supports millisecond precision. A value such
90
+ as `2026-09-01T10:20:30.123456Z` fails with HTTP 422 before the write, because
91
+ converting it to `Date` would lose information. Trailing zeroes beyond three
92
+ digits are accepted (`.123000Z` represents exactly `.123Z`). To retain finer
93
+ precision in table-backed storage, use a custom `storage.serialize` function
94
+ and a database column and driver that preserve it. The same serializer is used
95
+ for filters and cursor comparisons, with `operation: 'filter'` and `context: null`.
96
+
97
+ Response normalization preserves fractional digits returned as strings by
98
+ storage, up to the field's declared precision, including during conversion of
99
+ timezone offsets to UTC. It also converts valid epoch strings and `bigint`
100
+ values from database drivers to JSON numbers, rejecting invalid or out-of-range
101
+ epoch data.
102
+
89
103
  ## Output Normalization
90
104
 
91
105
  ### Database to API Response
@@ -115,8 +129,9 @@ All date/time values are normalized when returned from the API:
115
129
  3. **UTC Assumption**: MySQL DATETIME values (which lack timezone info) are assumed to be UTC
116
130
 
117
131
  Normalization runs once after the database read and again at the final response
118
- boundary. The final pass covers getters, computed fields, and finishing hooks
119
- that produce native `Date` values. A malformed non-null temporal value is not
132
+ boundary. The final pass covers getters, computed fields, query projections,
133
+ and finishing hooks that produce native `Date` values, including nested
134
+ relationships in simplified write responses. A malformed non-null temporal value is not
120
135
  reported as `null`; it fails with `REST_API_TEMPORAL_DATA_INVALID` and HTTP 500
121
136
  so stored-data or enrichment defects remain visible.
122
137
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "json-rest-api",
3
- "version": "1.0.28",
3
+ "version": "1.0.29",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -33,6 +33,7 @@
33
33
  */
34
34
 
35
35
  import { RestApiValidationError } from '../../lib/rest-api-errors.js'
36
+ import { isRestApiError } from '../../lib/error-context.js'
36
37
 
37
38
  export const FileHandlingPlugin = {
38
39
  name: 'file-handling',
@@ -165,6 +166,7 @@ export const FileHandlingPlugin = {
165
166
  break
166
167
  }
167
168
  } catch (error) {
169
+ if (isRestApiError(error)) throw error
168
170
  log.warn(`Detector '${detector.name}' failed:`, error)
169
171
  }
170
172
  }
@@ -269,6 +271,7 @@ export const FileHandlingPlugin = {
269
271
  })
270
272
  log.debug(`Uploaded file for field '${fieldConfig.field}' to: ${storedUrl}`)
271
273
  } catch (error) {
274
+ if (isRestApiError(error)) throw error
272
275
  throw new RestApiValidationError(
273
276
  `Failed to upload file for field '${fieldConfig.field}': ${error.message}`,
274
277
  {
@@ -158,7 +158,7 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
158
158
 
159
159
  if (pageCount !== undefined) {
160
160
  links.first = buildJsonApiLink(baseUrl, queryParams, { number: 1, size: pageSize })
161
- links.last = buildJsonApiLink(baseUrl, queryParams, { number: pageCount, size: pageSize })
161
+ links.last = buildJsonApiLink(baseUrl, queryParams, { number: Math.max(1, pageCount), size: pageSize })
162
162
 
163
163
  if (page > 1) {
164
164
  links.prev = buildJsonApiLink(baseUrl, queryParams, { number: page - 1, size: pageSize })
@@ -234,13 +234,17 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
234
234
  * 4. Cursor used in 'next' link for fetching subsequent pages
235
235
  * 5. Enables efficient "WHERE (field1, field2) > (val1, val2)" queries
236
236
  */
237
- export const createCursor = (record, sortFields = ['id'], { schemaInfo = null } = {}) => {
237
+ export const createCursor = (record, sortFields = ['id'], { schemaInfo = null, definitions = {} } = {}) => {
238
238
  const parts = []
239
239
  sortFields.forEach(field => {
240
240
  let value = schemaInfo
241
241
  ? getFieldValue(record, schemaInfo, field)
242
242
  : record[field]
243
- const definition = schemaInfo?.schemaStructure?.[field]
243
+ const definition = definitions[field] || schemaInfo?.schemaStructure?.[field]
244
+ if (value === null) {
245
+ parts.push(`${field}:~null`)
246
+ return
247
+ }
244
248
  if (['date', 'dateTime', 'time'].includes(definition?.type) && value != null) {
245
249
  value = normalizeDateValue(value, definition.type, {
246
250
  temporalPrecision: definition.temporalPrecision,
@@ -251,7 +255,7 @@ export const createCursor = (record, sortFields = ['id'], { schemaInfo = null }
251
255
  }
252
256
  if (value !== undefined) {
253
257
  const stringValue = value instanceof Date ? value.toISOString() : String(value)
254
- parts.push(`${field}:${encodeURIComponent(stringValue)}`)
258
+ parts.push(`${field}:${encodeURIComponent(stringValue).replace(/^~/, '%7E')}`)
255
259
  }
256
260
  })
257
261
  return parts.join(',')
@@ -311,7 +315,7 @@ export const createCursor = (record, sortFields = ['id'], { schemaInfo = null }
311
315
  */
312
316
  export const parseCursor = (cursor) => {
313
317
  try {
314
- const data = {}
318
+ const data = Object.create(null)
315
319
  if (!cursor || cursor.trim() === '') {
316
320
  throw new Error('Empty cursor')
317
321
  }
@@ -331,7 +335,8 @@ export const parseCursor = (cursor) => {
331
335
  throw new Error('Invalid cursor format: empty field name')
332
336
  }
333
337
 
334
- data[field] = decodeURIComponent(encodedValue)
338
+ if (Object.hasOwn(data, field)) throw new Error(`Duplicate cursor field '${field}'`)
339
+ data[field] = encodedValue === '~null' ? null : decodeURIComponent(encodedValue)
335
340
  }
336
341
 
337
342
  return data
@@ -423,7 +428,7 @@ export const generateCursorPaginationLinks = (
423
428
  pageSize,
424
429
  hasMore,
425
430
  sortFields = ['id'],
426
- { schemaInfo = null } = {}
431
+ { schemaInfo = null, definitions = {} } = {}
427
432
  ) => {
428
433
  // Allow empty urlPrefix to generate relative links
429
434
  if (!records.length) return null
@@ -442,9 +447,10 @@ export const generateCursorPaginationLinks = (
442
447
  links.first = buildJsonApiLink(baseUrl, queryParams, { size: pageSize })
443
448
 
444
449
  if (hasMore && records.length > 0) {
445
- const lastRecord = records[records.length - 1]
446
- const nextCursor = createCursor(lastRecord, sortFields, { schemaInfo })
447
- links.next = buildJsonApiLink(baseUrl, queryParams, { size: pageSize, after: nextCursor })
450
+ const direction = queryParams.page?.before ? 'before' : 'after'
451
+ const boundary = direction === 'before' ? records[0] : records[records.length - 1]
452
+ const nextCursor = createCursor(boundary, sortFields, { schemaInfo, definitions })
453
+ links.next = buildJsonApiLink(baseUrl, queryParams, { size: pageSize, [direction]: nextCursor })
448
454
  }
449
455
 
450
456
  return links
@@ -507,16 +513,16 @@ export const generateCursorPaginationLinks = (
507
513
  * 3. Added to response.meta.pagination
508
514
  * 4. Clients can use cursor directly or use the next link
509
515
  */
510
- export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'], { schemaInfo = null } = {}) => {
516
+ export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'], { schemaInfo = null, definitions = {}, before = false } = {}) => {
511
517
  const meta = {
512
518
  pageSize,
513
519
  hasMore
514
520
  }
515
521
 
516
522
  if (hasMore && records.length > 0) {
517
- const lastRecord = records[records.length - 1]
523
+ const lastRecord = before ? records[0] : records[records.length - 1]
518
524
  meta.cursor = {
519
- next: createCursor(lastRecord, sortFields, { schemaInfo })
525
+ next: createCursor(lastRecord, sortFields, { schemaInfo, definitions })
520
526
  }
521
527
  }
522
528
 
@@ -1,9 +1,55 @@
1
1
  import { normalizeStableSort, parseSortEntry } from './sort-helpers.js'
2
+ import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
3
+ import { createSchema } from 'json-rest-schema'
4
+ import { normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
5
+
6
+ export const validateCursorValues = (descriptors, cursorValues, parameter) => {
7
+ const validated = Object.create(null)
8
+ for (const { field, definition = {}, isRelationship } of descriptors) {
9
+ if (!Object.hasOwn(cursorValues, field)) continue
10
+ const value = cursorValues[field]
11
+ // Public resource IDs may be opaque strings, even when the storage schema
12
+ // uses the numeric id type for ordinary attributes.
13
+ const type = field === 'id' || isRelationship || definition?.type === 'id'
14
+ ? 'string'
15
+ : definition?.type
16
+ if (!type) {
17
+ validated[field] = value
18
+ continue
19
+ }
20
+ const contract = createSchema({
21
+ value: {
22
+ type,
23
+ noTrim: true,
24
+ nullable: field !== 'id',
25
+ ...(definition?.temporalPrecision !== undefined
26
+ ? { temporalPrecision: definition.temporalPrecision }
27
+ : {})
28
+ }
29
+ })
30
+ const { validatedObject, errors } = contract.patch({ value })
31
+ if (Object.keys(errors).length > 0 || (field === 'id' && !String(value ?? '').trim())) {
32
+ const path = `page.${parameter}`
33
+ throw new RestApiValidationError(`Invalid cursor value for sort field '${field}'.`, {
34
+ fields: [path],
35
+ violations: [{ field: path, rule: 'invalid_cursor_value', message: `Invalid ${type} value for '${field}'.` }]
36
+ })
37
+ }
38
+ validated[field] = validatedObject.value
39
+ }
40
+ return validated
41
+ }
2
42
 
3
43
  export const getEffectiveSortableFields = (vars = {}) => {
4
- const baseSortableFields = Array.isArray(vars.sortableFields)
44
+ const schema = vars.schemaInfo?.schemaStructure || {}
45
+ const baseSortableFields = Array.isArray(vars.sortableFields) && vars.sortableFields.length > 0
5
46
  ? vars.sortableFields
6
- : []
47
+ : ['id', ...Object.keys(schema).filter((field) => {
48
+ const definition = schema[field]
49
+ return !definition.hidden && !definition.virtual && !definition.computed && !definition.belongsToPolymorphic
50
+ }), ...Object.entries(vars.schemaInfo?.searchSchemaStructure || {})
51
+ .filter(([, definition]) => definition.isRelationship && schema[definition.actualField]?.hidden !== true)
52
+ .map(([field]) => field)]
7
53
  const queryFieldSortableFields = Object.entries(vars.queryFields || {})
8
54
  .filter(([, fieldDef]) => fieldDef?.sortable === true)
9
55
  .map(([fieldName]) => fieldName)
@@ -29,15 +75,37 @@ export const buildEffectiveSortList = (sort, { defaultSort, idField = 'id' } = {
29
75
  return normalizeStableSort(sortList.length > 0 ? sortList : [idField], { idField })
30
76
  }
31
77
 
32
- export const applyQueryFieldOrder = (query, queryFieldRuntime, direction) => {
78
+ export const applyQueryFieldOrder = (query, queryFieldRuntime, direction, nulls = 'last') => {
79
+ query.orderByRaw(`(${queryFieldRuntime.sql}) IS NULL ${nulls === 'last' ? 'ASC' : 'DESC'}`, queryFieldRuntime.bindings)
33
80
  query.orderByRaw(`(${queryFieldRuntime.sql}) ${direction}`, queryFieldRuntime.bindings)
34
81
  }
35
82
 
36
83
  export const applyQueryFieldPredicate = (builder, queryFieldRuntime, operator, value) => {
37
- builder.whereRaw(`(${queryFieldRuntime.sql}) ${operator} ?`, [...queryFieldRuntime.bindings, value])
84
+ const definition = queryFieldRuntime.definition || {}
85
+ const normalizedValue = normalizeValueForDatabaseStorage(value, definition.type, {
86
+ temporalPrecision: definition.temporalPrecision
87
+ })
88
+ builder.whereRaw(`(${queryFieldRuntime.sql}) ${operator} ?`, [...queryFieldRuntime.bindings, normalizedValue])
38
89
  }
39
90
 
40
- export const applySortDescriptorPredicate = (builder, descriptor, operator, value, applyPlainPredicate) => {
91
+ export const applySortDescriptorPredicate = (builder, descriptor, operator, value, applyPlainPredicate, includeNull = false) => {
92
+ if (includeNull) {
93
+ return builder.where(function () {
94
+ applySortDescriptorPredicate(this, descriptor, operator, value, applyPlainPredicate)
95
+ if (descriptor.queryFieldRuntime) {
96
+ this.orWhereRaw(`(${descriptor.queryFieldRuntime.sql}) IS NULL`, descriptor.queryFieldRuntime.bindings)
97
+ } else {
98
+ this.orWhereNull(descriptor.column)
99
+ }
100
+ })
101
+ }
102
+ if (value === null) {
103
+ const isNotNull = operator === 'IS NOT NULL'
104
+ if (descriptor.queryFieldRuntime) {
105
+ return builder.whereRaw(`(${descriptor.queryFieldRuntime.sql}) IS ${isNotNull ? 'NOT ' : ''}NULL`, descriptor.queryFieldRuntime.bindings)
106
+ }
107
+ return isNotNull ? builder.whereNotNull(descriptor.column) : builder.whereNull(descriptor.column)
108
+ }
41
109
  if (descriptor?.queryFieldRuntime) {
42
110
  return applyQueryFieldPredicate(builder, descriptor.queryFieldRuntime, operator, value)
43
111
  }
@@ -62,19 +130,27 @@ export const buildCursorPredicateChains = (
62
130
 
63
131
  if (cursorValue === undefined) {
64
132
  onMissingValue?.(descriptor.field)
65
- return
133
+ throw new RestApiValidationError(`Cursor is missing a value for sort field '${descriptor.field}'.`, {
134
+ fields: [descriptor.field],
135
+ violations: [{
136
+ field: descriptor.field,
137
+ rule: 'missing_cursor_value',
138
+ message: 'Cursor must include every sort field.'
139
+ }]
140
+ })
66
141
  }
67
142
 
143
+ const operator = operatorSelector(descriptor.direction)
144
+ const forward = operator === (descriptor.direction.toUpperCase() === 'DESC' ? '<' : '>')
145
+ // Nulls sort last. There are no values after null at this sort position,
146
+ // but later tie breakers can still advance within the null group.
147
+ if (cursorValue === null && forward) return
148
+
68
149
  const chain = []
69
150
  for (let i = 0; i < index; i += 1) {
70
151
  const previousDescriptor = descriptors[i]
71
152
  const previousValue = cursorValues?.[previousDescriptor.field]
72
153
 
73
- if (previousValue === undefined) {
74
- onMissingValue?.(previousDescriptor.field)
75
- return
76
- }
77
-
78
154
  chain.push({
79
155
  descriptor: previousDescriptor,
80
156
  operator: '=',
@@ -84,8 +160,9 @@ export const buildCursorPredicateChains = (
84
160
 
85
161
  chain.push({
86
162
  descriptor,
87
- operator: operatorSelector(descriptor.direction),
88
- value: cursorValue
163
+ operator: cursorValue === null ? 'IS NOT NULL' : operator,
164
+ value: cursorValue,
165
+ includeNull: cursorValue !== null && forward
89
166
  })
90
167
 
91
168
  predicateChains.push(chain)
@@ -116,13 +193,14 @@ export const applyCursorPredicate = (
116
193
  query.where(function () {
117
194
  predicateChains.forEach((chain) => {
118
195
  this.orWhere(function () {
119
- chain.forEach(({ descriptor, operator, value }) => {
196
+ chain.forEach(({ descriptor, operator, value, includeNull }) => {
120
197
  applySortDescriptorPredicate(
121
198
  this,
122
199
  descriptor,
123
200
  operator,
124
201
  value,
125
- applyPlainPredicate
202
+ applyPlainPredicate,
203
+ includeNull
126
204
  )
127
205
  })
128
206
  })
@@ -1,6 +1,13 @@
1
1
  export function normalizeStableSort (sort = [], { idField = 'id' } = {}) {
2
+ const seenFields = new Set()
2
3
  const sortList = Array.isArray(sort)
3
- ? sort.filter((entry) => typeof entry === 'string' && entry.trim())
4
+ ? sort.filter((entry) => {
5
+ if (typeof entry !== 'string' || !entry.trim()) return false
6
+ const field = entry.startsWith('-') ? entry.slice(1) : entry
7
+ if (seenFields.has(field)) return false
8
+ seenFields.add(field)
9
+ return true
10
+ })
4
11
  : []
5
12
 
6
13
  if (sortList.length === 0) {
@@ -1,5 +1,5 @@
1
1
  import { createSchema } from 'json-rest-schema'
2
- import { RestApiTemporalDataError } from '../../../../lib/rest-api-errors.js'
2
+ import { RestApiTemporalDataError, RestApiValidationError } from '../../../../lib/rest-api-errors.js'
3
3
 
4
4
  const DATE_VALUE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
5
5
  const RFC3339_DATETIME_PATTERN = /^(\d{4}-\d{2}-\d{2})T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/
@@ -71,12 +71,12 @@ const parseEpochDatabaseValue = (value) => {
71
71
 
72
72
  const parseKnownDatabaseDate = (value, { fieldName, resourceType, type, source }) => {
73
73
  if (value instanceof Date) {
74
- if (!Number.isNaN(value.getTime())) return value
74
+ if (!Number.isNaN(value.getTime())) return value.toISOString()
75
75
  throwTemporalDataError({ fieldName, resourceType, type, source })
76
76
  }
77
77
 
78
78
  const epochDate = parseEpochDatabaseValue(value)
79
- if (epochDate) return epochDate
79
+ if (epochDate) return epochDate.toISOString()
80
80
 
81
81
  if (typeof value !== 'string') {
82
82
  throwTemporalDataError({ fieldName, resourceType, type, source })
@@ -106,7 +106,11 @@ const parseKnownDatabaseDate = (value, { fieldName, resourceType, type, source }
106
106
  if (Number.isNaN(parsed.getTime())) {
107
107
  throwTemporalDataError({ fieldName, resourceType, type, source })
108
108
  }
109
- return parsed
109
+ // Date handles the offset, but cannot retain digits beyond milliseconds.
110
+ const fraction = /\.(\d+)(?:Z|[+-]\d{2}:\d{2})$/.exec(dateTimeValue)?.[1]
111
+ return fraction
112
+ ? parsed.toISOString().replace(/\.\d{3}Z$/, `.${fraction}Z`)
113
+ : parsed.toISOString()
110
114
  }
111
115
 
112
116
  /**
@@ -146,8 +150,7 @@ export function normalizeDateValue (value, type, {
146
150
  })
147
151
  }
148
152
 
149
- const dateValue = parseKnownDatabaseDate(value, { fieldName, resourceType, type, source })
150
- const isoValue = dateValue.toISOString()
153
+ const isoValue = parseKnownDatabaseDate(value, { fieldName, resourceType, type, source })
151
154
  const normalized = type === 'date'
152
155
  ? isoValue.slice(0, 10)
153
156
  : limitFractionalSecondPrecision(isoValue, temporalPrecision)
@@ -202,6 +205,20 @@ export function normalizeValueForDatabaseStorage (value, type, {
202
205
  resourceType,
203
206
  source
204
207
  })
208
+ const fraction = type === 'dateTime'
209
+ ? /\.(\d+)(?:Z|[+-]\d{2}:\d{2})$/.exec(validatedValue)?.[1]
210
+ : null
211
+ if (fraction && /[1-9]/.test(fraction.slice(3))) {
212
+ const field = fieldName ? `data.attributes.${fieldName}` : 'value'
213
+ throw new RestApiValidationError('Built-in dateTime storage supports millisecond precision.', {
214
+ fields: [field],
215
+ violations: [{
216
+ field,
217
+ rule: 'storage_precision',
218
+ message: 'Submillisecond values require a custom storage serializer.'
219
+ }]
220
+ })
221
+ }
205
222
  const databaseValue = new Date(
206
223
  type === 'date' ? `${validatedValue}T00:00:00Z` : validatedValue
207
224
  )
@@ -323,6 +340,12 @@ export function normalizeAttributes (attributes, schemaStructure, {
323
340
  resourceType,
324
341
  source
325
342
  })
343
+ } else if (['epochMilliseconds', 'epochSeconds'].includes(fieldDef.type) && value != null) {
344
+ normalized[fieldName] = validateTemporalJsonValue(
345
+ typeof value === 'bigint' ? String(value) : value,
346
+ fieldDef.type,
347
+ { fieldName, resourceType, source }
348
+ )
326
349
  }
327
350
  }
328
351
 
@@ -420,11 +443,19 @@ export function normalizeAttributes (attributes, schemaStructure, {
420
443
  * 4. Processes included array the same way
421
444
  * 5. Returns complete response with normalized values
422
445
  */
423
- export function normalizeRecordAttributes (record, scopes, { source = 'database' } = {}) {
446
+ export function normalizeRecordAttributes (record, scopes, {
447
+ source = 'database',
448
+ simplified = false,
449
+ resourceType
450
+ } = {}) {
424
451
  if (!record || !scopes) {
425
452
  return record
426
453
  }
427
454
 
455
+ if (simplified) {
456
+ return normalizeSimplifiedRecord(record, resourceType, scopes, { source })
457
+ }
458
+
428
459
  const normalizeEntry = (entry) => {
429
460
  const schemaInfo = scopes[entry?.type]?.vars?.schemaInfo
430
461
  if (!schemaInfo || !entry?.attributes) return
@@ -433,7 +464,8 @@ export function normalizeRecordAttributes (record, scopes, { source = 'database'
433
464
  entry.attributes,
434
465
  {
435
466
  ...(schemaInfo.schemaStructure || {}),
436
- ...(schemaInfo.computed || {})
467
+ ...(schemaInfo.computed || {}),
468
+ ...(scopes[entry.type]?.vars?.queryFields || {})
437
469
  },
438
470
  {
439
471
  resourceType: entry.type,
@@ -464,3 +496,37 @@ export function normalizeRecordAttributes (record, scopes, { source = 'database'
464
496
 
465
497
  return record
466
498
  }
499
+
500
+ function normalizeSimplifiedRecord (record, resourceType, scopes, options, visited = new WeakMap()) {
501
+ if (!record || typeof record !== 'object') return record
502
+ if (visited.has(record)) return visited.get(record)
503
+ const schemaInfo = scopes[resourceType]?.vars?.schemaInfo
504
+ if (!schemaInfo) return record
505
+
506
+ const schema = { ...schemaInfo.schemaStructure, ...schemaInfo.computed, ...scopes[resourceType]?.vars?.queryFields }
507
+ const normalized = normalizeAttributes(record, schema, { ...options, resourceType })
508
+ visited.set(record, normalized)
509
+ const relationships = { ...schemaInfo.schemaRelationships }
510
+ for (const definition of Object.values(schema)) {
511
+ if (definition.as && (definition.belongsTo || definition.belongsToPolymorphic)) {
512
+ relationships[definition.as] = definition
513
+ }
514
+ }
515
+ for (const [name, definition] of Object.entries(relationships)) {
516
+ const normalizeRelated = (entry) => normalizeSimplifiedRecord(
517
+ entry,
518
+ definition.belongsToPolymorphic
519
+ ? entry?._type
520
+ : definition.belongsTo || definition.target || name,
521
+ scopes,
522
+ options,
523
+ visited
524
+ )
525
+ if (Object.hasOwn(normalized, name)) {
526
+ normalized[name] = Array.isArray(normalized[name])
527
+ ? normalized[name].map(normalizeRelated)
528
+ : normalizeRelated(normalized[name])
529
+ }
530
+ }
531
+ return normalized
532
+ }
@@ -3,6 +3,15 @@ import { createSelectTranslator } from '../storage/storage-adapter.js'
3
3
  import { buildQueryFieldRuntimes } from './query-field-helpers.js'
4
4
  import { RestApiFieldsetError } from '../../../../lib/rest-api-errors.js'
5
5
 
6
+ export const validateRequestedFieldsets = async (context, scopes) => {
7
+ for (const scopeName of Object.keys(context.queryParams?.fields || {})) {
8
+ if (!scopes[scopeName]?.vars?.schemaInfo) continue
9
+ await buildFieldSelection(scopes[scopeName], {
10
+ context: { scopeName, queryParams: { fields: context.queryParams.fields } }
11
+ })
12
+ }
13
+ }
14
+
6
15
  /**
7
16
  * Checks if a field exists only in code, not in the database
8
17
  *
@@ -212,7 +221,7 @@ export const buildFieldSelection = async (scope, deps) => {
212
221
  if (nonDatabaseFields.has(field)) return
213
222
 
214
223
  const fieldDef = schemaStructure[field]
215
- if (!fieldDef) {
224
+ if (!Object.hasOwn(schemaStructure, field) || !fieldDef) {
216
225
  throw new RestApiFieldsetError({
217
226
  field,
218
227
  resourceType: scopeName
@@ -325,6 +334,11 @@ export const buildFieldSelection = async (scope, deps) => {
325
334
 
326
335
  if (queryFieldNames.has(normalizedField)) {
327
336
  queryFieldsToSelect.add(normalizedField)
337
+ } else if (schemaStructure[normalizedField] &&
338
+ schemaStructure[normalizedField].hidden !== true &&
339
+ !nonDatabaseFields.has(normalizedField)) {
340
+ // Cursor generation needs sort values even when the response omits them.
341
+ fieldsToSelect.add(normalizedField)
328
342
  }
329
343
  }
330
344
 
@@ -76,12 +76,12 @@ function buildFieldMapJsonSchemaFragment () {
76
76
 
77
77
  function buildKnownPageSchema () {
78
78
  return createSchema({
79
- number: { type: 'number' },
80
- size: { type: 'number' },
81
- limit: { type: 'number' },
82
- offset: { type: 'number' },
83
- after: { type: 'string' },
84
- before: { type: 'string' },
79
+ number: { type: 'integer', min: 1 },
80
+ size: { type: 'integer', min: 1 },
81
+ limit: { type: 'integer', min: 1 },
82
+ offset: { type: 'integer', min: 0 },
83
+ after: { type: 'string', min: 1 },
84
+ before: { type: 'string', min: 1 },
85
85
  cursor: { type: 'string' }
86
86
  })
87
87
  }
@@ -313,6 +313,16 @@ function installRequestContractSupport () {
313
313
  const firstError = pickFirstError(errors)
314
314
  context.throwParamError(firstError?.code || 'INVALID_PAGE', firstError?.message || 'Invalid page parameters.')
315
315
  }
316
+ for (const direction of ['after', 'before']) {
317
+ if (Object.hasOwn(knownPageParams, direction) && !validatedObject[direction]?.trim()) {
318
+ context.throwParamError('INVALID_PAGE', `page[${direction}] must contain a cursor.`)
319
+ }
320
+ }
321
+
322
+ if ((validatedObject.after !== undefined && validatedObject.before !== undefined) ||
323
+ (validatedObject.number !== undefined && (validatedObject.after !== undefined || validatedObject.before !== undefined))) {
324
+ context.throwParamError('INVALID_PAGE', 'Use either page[number], page[after], or page[before].')
325
+ }
316
326
 
317
327
  return {
318
328
  ...context.value,
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  getFieldValue as getLegacyFieldValue,
3
3
  getIdColumn,
4
+ getStorageInfo,
4
5
  getStorageColumn,
5
6
  translateAttributesForStorage,
6
7
  } from './storage-mapping.js'
@@ -177,7 +178,20 @@ const createLegacyAdapter = ({ knex, schemaInfo }) => {
177
178
  const getFieldValue = (record, fieldName) => getLegacyFieldValue(record, schemaInfo, fieldName)
178
179
  const translateFilterValue = (field, value) => {
179
180
  const searchField = schemaInfo.searchSchemaStructure?.[field]
180
- const schemaField = schemaInfo.schemaStructure?.[field]
181
+ const fieldName = searchField?.actualField || field
182
+ const schemaField = schemaInfo.schemaStructure?.[fieldName]
183
+ const fieldStorage = getStorageInfo(schemaInfo).fields[fieldName]
184
+ if (fieldStorage?.serialize) {
185
+ const serialize = (entry) => fieldStorage.serialize(entry, {
186
+ fieldName,
187
+ columnName: fieldStorage.column,
188
+ definition: fieldStorage.definition,
189
+ schemaInfo,
190
+ context: null,
191
+ operation: 'filter'
192
+ })
193
+ return Array.isArray(value) ? value.map(serialize) : serialize(value)
194
+ }
181
195
  const definition = searchField || schemaField || {}
182
196
  const isRelationship = Boolean(
183
197
  searchField?.isRelationship ||