json-rest-api 1.0.27 → 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.
Files changed (39) hide show
  1. package/docs/API.md +2 -0
  2. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +11 -2
  3. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +3 -3
  4. package/docs/GUIDE/GUIDE_Y_Appendices.md +57 -29
  5. package/index.js +1 -0
  6. package/lib/rest-api-errors.js +35 -0
  7. package/package.json +2 -2
  8. package/plugins/core/connectors/lib/transport-http-helpers.js +12 -1
  9. package/plugins/core/file-handling-plugin.js +3 -0
  10. package/plugins/core/lib/anyapi/schema-utils.js +2 -1
  11. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +4 -2
  12. package/plugins/core/lib/dbTablesOperations.js +20 -10
  13. package/plugins/core/lib/querying/knex-pagination-helpers.js +29 -13
  14. package/plugins/core/lib/querying/knex-relationship-includes.js +18 -7
  15. package/plugins/core/lib/querying/query-field-sort-helpers.js +93 -15
  16. package/plugins/core/lib/querying/sort-helpers.js +8 -1
  17. package/plugins/core/lib/querying-writing/database-value-normalizers.js +297 -143
  18. package/plugins/core/lib/querying-writing/knex-field-helpers.js +15 -1
  19. package/plugins/core/lib/querying-writing/request-contracts.js +16 -6
  20. package/plugins/core/lib/storage/canonical-storage-mapping.js +11 -1
  21. package/plugins/core/lib/storage/storage-adapter.js +19 -3
  22. package/plugins/core/lib/storage/storage-mapping.js +7 -1
  23. package/plugins/core/rest-api-anyapi-knex-plugin.js +36 -60
  24. package/plugins/core/rest-api-knex-plugin.js +28 -20
  25. package/plugins/core/rest-api-plugin-methods/common.js +15 -0
  26. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +3 -0
  27. package/plugins/core/rest-api-plugin-methods/get.js +8 -1
  28. package/plugins/core/rest-api-plugin-methods/query.js +8 -1
  29. package/tests/anyapi-cursor-pagination.test.js +1 -3
  30. package/tests/computed-fields.test.js +21 -0
  31. package/tests/db-table-operations.test.js +13 -1
  32. package/tests/file-handling.test.js +37 -0
  33. package/tests/fixtures/api-configs.js +140 -3
  34. package/tests/pagination-boundaries.test.js +154 -0
  35. package/tests/pagination-contract.test.js +122 -0
  36. package/tests/pagination-cursor-multifield.test.js +5 -3
  37. package/tests/relationship-include-storage-adapter.test.js +56 -0
  38. package/tests/storage-mapping.test.js +112 -10
  39. package/tests/temporal-boundaries.test.js +208 -0
@@ -1,132 +1,231 @@
1
- /**
2
- * Normalizes a date value to appropriate format based on field type
3
- *
4
- * @param {*} value - The date value from the database
5
- * @param {string} type - The field type ('date', 'dateTime', or 'time')
6
- * @returns {Date|string|null} Date object for date/dateTime, string for time, or null
7
- *
8
- * @example
9
- * // Input: Already a Date object
10
- * normalizeDateValue(new Date('2024-01-15T10:30:00Z'), 'dateTime');
11
- * // Output: Date object (unchanged)
12
- *
13
- * @example
14
- * // Input: MySQL datetime string without timezone
15
- * normalizeDateValue('2024-01-15 10:30:00', 'dateTime');
16
- * // Output: Date object parsed as UTC (2024-01-15T10:30:00Z)
17
- *
18
- * @example
19
- * // Input: Date-only string
20
- * normalizeDateValue('2024-01-15', 'date');
21
- * // Output: Date object at UTC midnight (2024-01-15T00:00:00Z)
22
- *
23
- * @example
24
- * // Input: Time field (always returns string)
25
- * normalizeDateValue('14:30:45', 'time');
26
- * // Output: "14:30:45" (string)
27
- *
28
- * @example
29
- * // Input: Date object for time field
30
- * normalizeDateValue(new Date('2024-01-15T14:30:45Z'), 'time');
31
- * // Output: "14:30:45" (extracts time portion)
32
- *
33
- * @description
34
- * Used by:
35
- * - normalizeAttributes to process individual field values
36
- * - Applied to all date/time fields fetched from database
37
- *
38
- * Purpose:
39
- * - Handles database-specific date formats (especially MySQL)
40
- * - Prevents timezone shifts for date-only fields
41
- * - Ensures consistent Date objects across databases
42
- * - Keeps time fields as HH:MM:SS strings
43
- *
44
- * Data flow:
45
- * 1. Checks for null/undefined (returns null)
46
- * 2. For time fields: ensures HH:MM:SS string format
47
- * 3. For date/dateTime: converts to Date objects
48
- * 4. Handles MySQL datetime format (no T separator)
49
- * 5. Forces UTC interpretation to prevent timezone issues
50
- */
51
- export function normalizeDateValue (value, type) {
52
- // Handle null/undefined
53
- if (value === null || value === undefined) {
54
- return null
55
- }
1
+ import { createSchema } from 'json-rest-schema'
2
+ import { RestApiTemporalDataError, RestApiValidationError } from '../../../../lib/rest-api-errors.js'
56
3
 
57
- // Handle time type specially - always return as string
58
- if (type === 'time') {
59
- // If it's already a properly formatted time string, return as-is
60
- if (typeof value === 'string' && /^\d{2}:\d{2}:\d{2}/.test(value)) {
61
- return value
62
- }
63
- // If we got a Date object for a time field, extract the time portion
64
- if (value instanceof Date) {
65
- return value.toISOString().slice(11, 19) // Extract HH:MM:SS
4
+ const DATE_VALUE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
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)$/
6
+ const SQL_DATETIME_PATTERN = /^(\d{4}-\d{2}-\d{2}) ((?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?)$/
7
+ const INTEGER_VALUE_PATTERN = /^-?\d+$/
8
+ const temporalContractCache = new Map()
9
+
10
+ const limitFractionalSecondPrecision = (value, temporalPrecision) => {
11
+ if (!Number.isInteger(temporalPrecision)) return value
12
+
13
+ return value.replace(
14
+ /(\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z)?$/,
15
+ (_match, wholeSeconds, fraction = '', utcSuffix = '') => {
16
+ const limitedFraction = fraction.slice(0, temporalPrecision)
17
+ return `${wholeSeconds}${limitedFraction ? `.${limitedFraction}` : ''}${utcSuffix}`
66
18
  }
67
- // Try to parse and extract time
68
- if (typeof value === 'string' || typeof value === 'number') {
69
- const d = new Date(value)
70
- if (!isNaN(d.getTime())) {
71
- return d.toISOString().slice(11, 19)
19
+ )
20
+ }
21
+
22
+ const getTemporalContract = (type, temporalPrecision) => {
23
+ const cacheKey = `${type}:${Number.isInteger(temporalPrecision) ? temporalPrecision : 'default'}`
24
+ if (!temporalContractCache.has(cacheKey)) {
25
+ temporalContractCache.set(cacheKey, createSchema({
26
+ value: {
27
+ type,
28
+ ...(Number.isInteger(temporalPrecision) ? { temporalPrecision } : {})
72
29
  }
73
- }
74
- return null
30
+ }))
31
+ }
32
+ return temporalContractCache.get(cacheKey)
33
+ }
34
+
35
+ const throwTemporalDataError = ({ fieldName, resourceType, type, source }) => {
36
+ throw new RestApiTemporalDataError({
37
+ field: fieldName,
38
+ resourceType,
39
+ fieldType: type,
40
+ source
41
+ })
42
+ }
43
+
44
+ const validateTemporalJsonValue = (value, type, {
45
+ temporalPrecision,
46
+ fieldName,
47
+ resourceType,
48
+ source
49
+ } = {}) => {
50
+ const { validatedObject, errors } = getTemporalContract(type, temporalPrecision).patch({ value })
51
+ if (Object.keys(errors).length > 0) {
52
+ throwTemporalDataError({ fieldName, resourceType, type, source })
53
+ }
54
+ return validatedObject.value
55
+ }
56
+
57
+ const parseEpochDatabaseValue = (value) => {
58
+ let epochValue = null
59
+ if (typeof value === 'number' && Number.isInteger(value)) {
60
+ epochValue = value
61
+ } else if (typeof value === 'bigint') {
62
+ epochValue = Number(value)
63
+ } else if (typeof value === 'string' && INTEGER_VALUE_PATTERN.test(value.trim())) {
64
+ epochValue = Number(value.trim())
75
65
  }
76
66
 
77
- // For date and dateTime: Already a Date object? Return as-is
67
+ if (!Number.isSafeInteger(epochValue)) return null
68
+ const parsed = new Date(epochValue)
69
+ return Number.isNaN(parsed.getTime()) ? null : parsed
70
+ }
71
+
72
+ const parseKnownDatabaseDate = (value, { fieldName, resourceType, type, source }) => {
78
73
  if (value instanceof Date) {
79
- return value
74
+ if (!Number.isNaN(value.getTime())) return value.toISOString()
75
+ throwTemporalDataError({ fieldName, resourceType, type, source })
80
76
  }
81
77
 
82
- // Handle string values
83
- if (typeof value === 'string') {
84
- const trimmed = value.trim()
78
+ const epochDate = parseEpochDatabaseValue(value)
79
+ if (epochDate) return epochDate.toISOString()
85
80
 
86
- if (/^-?\d+$/.test(trimmed)) {
87
- const numericDate = new Date(Number(trimmed))
88
- if (!isNaN(numericDate.getTime())) {
89
- return numericDate
90
- }
91
- }
81
+ if (typeof value !== 'string') {
82
+ throwTemporalDataError({ fieldName, resourceType, type, source })
83
+ }
92
84
 
93
- // Detect MySQL datetime format: 'YYYY-MM-DD HH:MM:SS'
94
- // These have no T separator and no timezone indicator
95
- const isMySQLDateTime = type === 'dateTime' &&
96
- /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(trimmed) &&
97
- !trimmed.includes('T') &&
98
- !trimmed.includes('Z') &&
99
- !trimmed.includes('+') &&
100
- !trimmed.includes('-', 10) // Don't match date separators
101
-
102
- if (isMySQLDateTime) {
103
- // Convert to ISO format and force UTC interpretation
104
- // '2024-01-15 10:30:00' becomes '2024-01-15T10:30:00Z'
105
- return new Date(trimmed.replace(' ', 'T') + 'Z')
106
- }
85
+ const trimmed = value.trim()
86
+ const sqlMatch = SQL_DATETIME_PATTERN.exec(trimmed)
87
+ const rfc3339Match = RFC3339_DATETIME_PATTERN.exec(trimmed)
88
+ let dateTimeValue = null
89
+
90
+ if (sqlMatch) {
91
+ dateTimeValue = `${sqlMatch[1]}T${sqlMatch[2]}Z`
92
+ } else if (rfc3339Match) {
93
+ dateTimeValue = trimmed
94
+ }
95
+
96
+ if (!dateTimeValue) {
97
+ throwTemporalDataError({ fieldName, resourceType, type, source })
98
+ }
99
+
100
+ validateTemporalJsonValue(dateTimeValue, 'dateTime', {
101
+ fieldName,
102
+ resourceType,
103
+ source
104
+ })
105
+ const parsed = new Date(dateTimeValue)
106
+ if (Number.isNaN(parsed.getTime())) {
107
+ throwTemporalDataError({ fieldName, resourceType, type, source })
108
+ }
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()
114
+ }
115
+
116
+ /**
117
+ * Converts a database temporal value to the public JSON representation used by
118
+ * json-rest-schema: YYYY-MM-DD for date, RFC 3339 for dateTime, and an
119
+ * offset-free string for time.
120
+ */
121
+ export function normalizeDateValue (value, type, {
122
+ temporalPrecision,
123
+ fieldName,
124
+ resourceType,
125
+ source = 'database'
126
+ } = {}) {
127
+ if (value === null || value === undefined) return null
107
128
 
108
- // For date-only fields, ensure parsing at UTC midnight
109
- // This prevents timezone shifts when parsing dates
110
- if (type === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
111
- return new Date(trimmed + 'T00:00:00Z')
129
+ if (type === 'time') {
130
+ let timeValue = null
131
+ if (typeof value === 'string') {
132
+ timeValue = limitFractionalSecondPrecision(value.trim(), temporalPrecision)
133
+ }
134
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
135
+ timeValue = limitFractionalSecondPrecision(value.toISOString().slice(11, -1), temporalPrecision)
112
136
  }
137
+ return validateTemporalJsonValue(timeValue, type, {
138
+ temporalPrecision,
139
+ fieldName,
140
+ resourceType,
141
+ source
142
+ })
113
143
  }
114
144
 
115
- // Handle numeric values (Unix timestamps)
116
- if (typeof value === 'number') {
117
- return new Date(value)
145
+ if (type === 'date' && typeof value === 'string' && DATE_VALUE_PATTERN.test(value.trim())) {
146
+ return validateTemporalJsonValue(value.trim(), type, {
147
+ fieldName,
148
+ resourceType,
149
+ source
150
+ })
118
151
  }
119
152
 
120
- // Try to parse any other format
121
- const dateObj = new Date(value)
153
+ const isoValue = parseKnownDatabaseDate(value, { fieldName, resourceType, type, source })
154
+ const normalized = type === 'date'
155
+ ? isoValue.slice(0, 10)
156
+ : limitFractionalSecondPrecision(isoValue, temporalPrecision)
157
+ return validateTemporalJsonValue(normalized, type, {
158
+ temporalPrecision,
159
+ fieldName,
160
+ resourceType,
161
+ source
162
+ })
163
+ }
164
+
165
+ /**
166
+ * Converts validated JSON temporal values to the native values expected by
167
+ * database drivers. JSON schema validation keeps date and dateTime values as
168
+ * strings; database writes and comparisons must normalize them consistently.
169
+ * Time values remain strings because database drivers accept their wire shape.
170
+ */
171
+ export function normalizeValueForDatabaseStorage (value, type, {
172
+ temporalPrecision,
173
+ fieldName,
174
+ resourceType,
175
+ source = 'storage'
176
+ } = {}) {
177
+ if (!['date', 'dateTime', 'time'].includes(type)) return value
178
+ if (value === null || value === undefined) return null
122
179
 
123
- // Check if date is valid
124
- if (isNaN(dateObj.getTime())) {
125
- console.warn(`Invalid date value for field type '${type}': ${value}`)
126
- return null // Return null for invalid dates
180
+ if (type === 'time') {
181
+ if (typeof value !== 'string') {
182
+ throwTemporalDataError({ fieldName, resourceType, type, source })
183
+ }
184
+ return validateTemporalJsonValue(value, type, {
185
+ temporalPrecision,
186
+ fieldName,
187
+ resourceType,
188
+ source
189
+ })
127
190
  }
128
191
 
129
- return dateObj
192
+ let publicValue = value
193
+ if (value instanceof Date) {
194
+ if (Number.isNaN(value.getTime())) {
195
+ throwTemporalDataError({ fieldName, resourceType, type, source })
196
+ }
197
+ publicValue = type === 'date'
198
+ ? value.toISOString().slice(0, 10)
199
+ : limitFractionalSecondPrecision(value.toISOString(), temporalPrecision)
200
+ }
201
+
202
+ const validatedValue = validateTemporalJsonValue(publicValue, type, {
203
+ temporalPrecision,
204
+ fieldName,
205
+ resourceType,
206
+ source
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
+ }
222
+ const databaseValue = new Date(
223
+ type === 'date' ? `${validatedValue}T00:00:00Z` : validatedValue
224
+ )
225
+ if (Number.isNaN(databaseValue.getTime())) {
226
+ throwTemporalDataError({ fieldName, resourceType, type, source })
227
+ }
228
+ return databaseValue
130
229
  }
131
230
 
132
231
  /**
@@ -155,13 +254,13 @@ export function normalizeDateValue (value, type) {
155
254
  *
156
255
  * const normalized = normalizeAttributes(attributes, schema);
157
256
  *
158
- * // Output: Properly typed values
257
+ * // Output: JSON-safe values
159
258
  * // {
160
259
  * // id: 1,
161
260
  * // is_active: true, // 1 → true
162
- * // created_at: Date('2024-01-15T10:30:00Z'), // Date object
163
- * // birth_date: Date('1990-05-20T00:00:00Z'), // Date at midnight UTC
164
- * // shift_time: '09:00:00' // String unchanged
261
+ * // created_at: '2024-01-15T10:30:00.000Z',
262
+ * // birth_date: '1990-05-20',
263
+ * // shift_time: '09:00:00'
165
264
  * // }
166
265
  *
167
266
  * @example
@@ -206,7 +305,10 @@ export function normalizeDateValue (value, type) {
206
305
  * 4. Normalizes dates using normalizeDateValue
207
306
  * 5. Returns new object with normalized values
208
307
  */
209
- export function normalizeAttributes (attributes, schemaStructure) {
308
+ export function normalizeAttributes (attributes, schemaStructure, {
309
+ resourceType,
310
+ source = 'database'
311
+ } = {}) {
210
312
  if (!attributes || !schemaStructure) {
211
313
  return attributes
212
314
  }
@@ -232,7 +334,18 @@ export function normalizeAttributes (attributes, schemaStructure) {
232
334
 
233
335
  // Normalize date/dateTime/time values
234
336
  if (fieldDef.type === 'date' || fieldDef.type === 'dateTime' || fieldDef.type === 'time') {
235
- normalized[fieldName] = normalizeDateValue(value, fieldDef.type)
337
+ normalized[fieldName] = normalizeDateValue(value, fieldDef.type, {
338
+ temporalPrecision: fieldDef.temporalPrecision,
339
+ fieldName,
340
+ resourceType,
341
+ source
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
+ )
236
349
  }
237
350
  }
238
351
 
@@ -278,8 +391,8 @@ export function normalizeAttributes (attributes, schemaStructure) {
278
391
  * // id: '1',
279
392
  * // attributes: {
280
393
  * // title: 'My Article',
281
- * // is_published: true, // 1 → true
282
- * // published_at: Date('2024-01-15T10:00:00Z') // Date object
394
+ * // is_published: true,
395
+ * // published_at: '2024-01-15T10:00:00.000Z'
283
396
  * // }
284
397
  * // },
285
398
  * // included: [{
@@ -287,8 +400,8 @@ export function normalizeAttributes (attributes, schemaStructure) {
287
400
  * // id: '10',
288
401
  * // attributes: {
289
402
  * // name: 'John',
290
- * // is_admin: false, // 0 → false
291
- * // last_login: Date('2024-01-14T15:30:00Z') // Date object
403
+ * // is_admin: false,
404
+ * // last_login: '2024-01-14T15:30:00.000Z'
292
405
  * // }
293
406
  * // }]
294
407
  * // }
@@ -330,49 +443,90 @@ export function normalizeAttributes (attributes, schemaStructure) {
330
443
  * 4. Processes included array the same way
331
444
  * 5. Returns complete response with normalized values
332
445
  */
333
- export function normalizeRecordAttributes (record, scopes) {
446
+ export function normalizeRecordAttributes (record, scopes, {
447
+ source = 'database',
448
+ simplified = false,
449
+ resourceType
450
+ } = {}) {
334
451
  if (!record || !scopes) {
335
452
  return record
336
453
  }
337
454
 
455
+ if (simplified) {
456
+ return normalizeSimplifiedRecord(record, resourceType, scopes, { source })
457
+ }
458
+
459
+ const normalizeEntry = (entry) => {
460
+ const schemaInfo = scopes[entry?.type]?.vars?.schemaInfo
461
+ if (!schemaInfo || !entry?.attributes) return
462
+
463
+ entry.attributes = normalizeAttributes(
464
+ entry.attributes,
465
+ {
466
+ ...(schemaInfo.schemaStructure || {}),
467
+ ...(schemaInfo.computed || {}),
468
+ ...(scopes[entry.type]?.vars?.queryFields || {})
469
+ },
470
+ {
471
+ resourceType: entry.type,
472
+ source
473
+ }
474
+ )
475
+ }
476
+
338
477
  // Normalize main data records
339
478
  if (record.data) {
340
479
  if (Array.isArray(record.data)) {
341
480
  // Handle array of records (query result)
342
481
  for (const entry of record.data) {
343
- const scope = scopes[entry.type]
344
- if (scope?.vars?.schemaInfo?.schemaStructure && entry.attributes) {
345
- entry.attributes = normalizeAttributes(
346
- entry.attributes,
347
- scope.vars.schemaInfo.schemaStructure
348
- )
349
- }
482
+ normalizeEntry(entry)
350
483
  }
351
484
  } else {
352
485
  // Handle single record (get result)
353
- const entry = record.data
354
- const scope = scopes[entry.type]
355
- if (scope?.vars?.schemaInfo?.schemaStructure && entry.attributes) {
356
- entry.attributes = normalizeAttributes(
357
- entry.attributes,
358
- scope.vars.schemaInfo.schemaStructure
359
- )
360
- }
486
+ normalizeEntry(record.data)
361
487
  }
362
488
  }
363
489
 
364
490
  // Normalize included records
365
491
  if (record.included && Array.isArray(record.included)) {
366
492
  for (const entry of record.included) {
367
- const scope = scopes[entry.type]
368
- if (scope?.vars?.schemaInfo?.schemaStructure && entry.attributes) {
369
- entry.attributes = normalizeAttributes(
370
- entry.attributes,
371
- scope.vars.schemaInfo.schemaStructure
372
- )
373
- }
493
+ normalizeEntry(entry)
374
494
  }
375
495
  }
376
496
 
377
497
  return record
378
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,3 +1,5 @@
1
+ import { normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
2
+
1
3
  export const translateCanonicalAttributesForStorage = (attributes = {}, descriptor = {}) => {
2
4
  const row = {}
3
5
 
@@ -8,7 +10,15 @@ export const translateCanonicalAttributesForStorage = (attributes = {}, descript
8
10
  if (slot.slotType === 'belongsTo') {
9
11
  row[slot.slot] = value == null ? null : String(value)
10
12
  } else {
11
- row[slot.slot] = value
13
+ row[slot.slot] = normalizeValueForDatabaseStorage(
14
+ value,
15
+ descriptor.schema?.[fieldName]?.type,
16
+ {
17
+ temporalPrecision: descriptor.schema?.[fieldName]?.temporalPrecision,
18
+ fieldName,
19
+ resourceType: descriptor.resource
20
+ }
21
+ )
12
22
  }
13
23
 
14
24
  if (slot.slotType === 'belongsTo') {
@@ -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'
@@ -9,7 +10,7 @@ import {
9
10
  getCanonicalResourceIdColumn,
10
11
  translateCanonicalAttributesForStorage,
11
12
  } from './canonical-storage-mapping.js'
12
- import { normalizeDateValue } from '../querying-writing/database-value-normalizers.js'
13
+ import { normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
13
14
 
14
15
  const passthrough = (value) => value
15
16
  const identityTranslate = (_field, value) => value
@@ -77,7 +78,9 @@ const normalizeFilterValueForDefinition = (value, definition = {}, { isRelations
77
78
  }
78
79
 
79
80
  if (['date', 'dateTime', 'time'].includes(type)) {
80
- return normalizeDateValue(value, type)
81
+ return normalizeValueForDatabaseStorage(value, type, {
82
+ temporalPrecision: definition?.temporalPrecision
83
+ })
81
84
  }
82
85
 
83
86
  return value
@@ -175,7 +178,20 @@ const createLegacyAdapter = ({ knex, schemaInfo }) => {
175
178
  const getFieldValue = (record, fieldName) => getLegacyFieldValue(record, schemaInfo, fieldName)
176
179
  const translateFilterValue = (field, value) => {
177
180
  const searchField = schemaInfo.searchSchemaStructure?.[field]
178
- 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
+ }
179
195
  const definition = searchField || schemaField || {}
180
196
  const isRelationship = Boolean(
181
197
  searchField?.isRelationship ||