json-rest-api 1.0.26 → 1.0.28

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 (30) hide show
  1. package/docs/API.md +5 -0
  2. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +2 -2
  3. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -2
  4. package/docs/GUIDE/GUIDE_Y_Appendices.md +42 -29
  5. package/index.js +2 -0
  6. package/lib/error-context.js +21 -0
  7. package/lib/rest-api-errors.js +70 -0
  8. package/package.json +2 -2
  9. package/plugins/core/connectors/lib/transport-http-helpers.js +21 -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 +11 -1
  14. package/plugins/core/lib/querying/knex-process-includes.js +11 -5
  15. package/plugins/core/lib/querying/knex-relationship-includes.js +63 -46
  16. package/plugins/core/lib/querying-writing/database-value-normalizers.js +231 -143
  17. package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -1
  18. package/plugins/core/lib/storage/canonical-storage-mapping.js +11 -1
  19. package/plugins/core/lib/storage/storage-adapter.js +4 -2
  20. package/plugins/core/lib/storage/storage-mapping.js +7 -1
  21. package/plugins/core/rest-api-plugin-methods/common.js +5 -0
  22. package/plugins/core/rest-api-plugin-methods/get.js +6 -0
  23. package/plugins/core/rest-api-plugin-methods/query.js +6 -0
  24. package/tests/computed-fields.test.js +90 -2
  25. package/tests/db-table-operations.test.js +13 -1
  26. package/tests/fixtures/api-configs.js +83 -3
  27. package/tests/includes.test.js +49 -0
  28. package/tests/pagination-cursor-multifield.test.js +4 -0
  29. package/tests/relationship-include-storage-adapter.test.js +56 -0
  30. package/tests/storage-mapping.test.js +112 -10
@@ -1,132 +1,214 @@
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 } 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
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
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
+ }
107
99
 
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')
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
+ return parsed
110
+ }
111
+
112
+ /**
113
+ * Converts a database temporal value to the public JSON representation used by
114
+ * json-rest-schema: YYYY-MM-DD for date, RFC 3339 for dateTime, and an
115
+ * offset-free string for time.
116
+ */
117
+ export function normalizeDateValue (value, type, {
118
+ temporalPrecision,
119
+ fieldName,
120
+ resourceType,
121
+ source = 'database'
122
+ } = {}) {
123
+ if (value === null || value === undefined) return null
124
+
125
+ if (type === 'time') {
126
+ let timeValue = null
127
+ if (typeof value === 'string') {
128
+ timeValue = limitFractionalSecondPrecision(value.trim(), temporalPrecision)
129
+ }
130
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
131
+ timeValue = limitFractionalSecondPrecision(value.toISOString().slice(11, -1), temporalPrecision)
112
132
  }
133
+ return validateTemporalJsonValue(timeValue, type, {
134
+ temporalPrecision,
135
+ fieldName,
136
+ resourceType,
137
+ source
138
+ })
113
139
  }
114
140
 
115
- // Handle numeric values (Unix timestamps)
116
- if (typeof value === 'number') {
117
- return new Date(value)
141
+ if (type === 'date' && typeof value === 'string' && DATE_VALUE_PATTERN.test(value.trim())) {
142
+ return validateTemporalJsonValue(value.trim(), type, {
143
+ fieldName,
144
+ resourceType,
145
+ source
146
+ })
118
147
  }
119
148
 
120
- // Try to parse any other format
121
- const dateObj = new Date(value)
149
+ const dateValue = parseKnownDatabaseDate(value, { fieldName, resourceType, type, source })
150
+ const isoValue = dateValue.toISOString()
151
+ const normalized = type === 'date'
152
+ ? isoValue.slice(0, 10)
153
+ : limitFractionalSecondPrecision(isoValue, temporalPrecision)
154
+ return validateTemporalJsonValue(normalized, type, {
155
+ temporalPrecision,
156
+ fieldName,
157
+ resourceType,
158
+ source
159
+ })
160
+ }
122
161
 
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
162
+ /**
163
+ * Converts validated JSON temporal values to the native values expected by
164
+ * database drivers. JSON schema validation keeps date and dateTime values as
165
+ * strings; database writes and comparisons must normalize them consistently.
166
+ * Time values remain strings because database drivers accept their wire shape.
167
+ */
168
+ export function normalizeValueForDatabaseStorage (value, type, {
169
+ temporalPrecision,
170
+ fieldName,
171
+ resourceType,
172
+ source = 'storage'
173
+ } = {}) {
174
+ if (!['date', 'dateTime', 'time'].includes(type)) return value
175
+ if (value === null || value === undefined) return null
176
+
177
+ if (type === 'time') {
178
+ if (typeof value !== 'string') {
179
+ throwTemporalDataError({ fieldName, resourceType, type, source })
180
+ }
181
+ return validateTemporalJsonValue(value, type, {
182
+ temporalPrecision,
183
+ fieldName,
184
+ resourceType,
185
+ source
186
+ })
127
187
  }
128
188
 
129
- return dateObj
189
+ let publicValue = value
190
+ if (value instanceof Date) {
191
+ if (Number.isNaN(value.getTime())) {
192
+ throwTemporalDataError({ fieldName, resourceType, type, source })
193
+ }
194
+ publicValue = type === 'date'
195
+ ? value.toISOString().slice(0, 10)
196
+ : limitFractionalSecondPrecision(value.toISOString(), temporalPrecision)
197
+ }
198
+
199
+ const validatedValue = validateTemporalJsonValue(publicValue, type, {
200
+ temporalPrecision,
201
+ fieldName,
202
+ resourceType,
203
+ source
204
+ })
205
+ const databaseValue = new Date(
206
+ type === 'date' ? `${validatedValue}T00:00:00Z` : validatedValue
207
+ )
208
+ if (Number.isNaN(databaseValue.getTime())) {
209
+ throwTemporalDataError({ fieldName, resourceType, type, source })
210
+ }
211
+ return databaseValue
130
212
  }
131
213
 
132
214
  /**
@@ -155,13 +237,13 @@ export function normalizeDateValue (value, type) {
155
237
  *
156
238
  * const normalized = normalizeAttributes(attributes, schema);
157
239
  *
158
- * // Output: Properly typed values
240
+ * // Output: JSON-safe values
159
241
  * // {
160
242
  * // id: 1,
161
243
  * // 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
244
+ * // created_at: '2024-01-15T10:30:00.000Z',
245
+ * // birth_date: '1990-05-20',
246
+ * // shift_time: '09:00:00'
165
247
  * // }
166
248
  *
167
249
  * @example
@@ -206,7 +288,10 @@ export function normalizeDateValue (value, type) {
206
288
  * 4. Normalizes dates using normalizeDateValue
207
289
  * 5. Returns new object with normalized values
208
290
  */
209
- export function normalizeAttributes (attributes, schemaStructure) {
291
+ export function normalizeAttributes (attributes, schemaStructure, {
292
+ resourceType,
293
+ source = 'database'
294
+ } = {}) {
210
295
  if (!attributes || !schemaStructure) {
211
296
  return attributes
212
297
  }
@@ -232,7 +317,12 @@ export function normalizeAttributes (attributes, schemaStructure) {
232
317
 
233
318
  // Normalize date/dateTime/time values
234
319
  if (fieldDef.type === 'date' || fieldDef.type === 'dateTime' || fieldDef.type === 'time') {
235
- normalized[fieldName] = normalizeDateValue(value, fieldDef.type)
320
+ normalized[fieldName] = normalizeDateValue(value, fieldDef.type, {
321
+ temporalPrecision: fieldDef.temporalPrecision,
322
+ fieldName,
323
+ resourceType,
324
+ source
325
+ })
236
326
  }
237
327
  }
238
328
 
@@ -278,8 +368,8 @@ export function normalizeAttributes (attributes, schemaStructure) {
278
368
  * // id: '1',
279
369
  * // attributes: {
280
370
  * // title: 'My Article',
281
- * // is_published: true, // 1 → true
282
- * // published_at: Date('2024-01-15T10:00:00Z') // Date object
371
+ * // is_published: true,
372
+ * // published_at: '2024-01-15T10:00:00.000Z'
283
373
  * // }
284
374
  * // },
285
375
  * // included: [{
@@ -287,8 +377,8 @@ export function normalizeAttributes (attributes, schemaStructure) {
287
377
  * // id: '10',
288
378
  * // attributes: {
289
379
  * // name: 'John',
290
- * // is_admin: false, // 0 → false
291
- * // last_login: Date('2024-01-14T15:30:00Z') // Date object
380
+ * // is_admin: false,
381
+ * // last_login: '2024-01-14T15:30:00.000Z'
292
382
  * // }
293
383
  * // }]
294
384
  * // }
@@ -330,47 +420,45 @@ export function normalizeAttributes (attributes, schemaStructure) {
330
420
  * 4. Processes included array the same way
331
421
  * 5. Returns complete response with normalized values
332
422
  */
333
- export function normalizeRecordAttributes (record, scopes) {
423
+ export function normalizeRecordAttributes (record, scopes, { source = 'database' } = {}) {
334
424
  if (!record || !scopes) {
335
425
  return record
336
426
  }
337
427
 
428
+ const normalizeEntry = (entry) => {
429
+ const schemaInfo = scopes[entry?.type]?.vars?.schemaInfo
430
+ if (!schemaInfo || !entry?.attributes) return
431
+
432
+ entry.attributes = normalizeAttributes(
433
+ entry.attributes,
434
+ {
435
+ ...(schemaInfo.schemaStructure || {}),
436
+ ...(schemaInfo.computed || {})
437
+ },
438
+ {
439
+ resourceType: entry.type,
440
+ source
441
+ }
442
+ )
443
+ }
444
+
338
445
  // Normalize main data records
339
446
  if (record.data) {
340
447
  if (Array.isArray(record.data)) {
341
448
  // Handle array of records (query result)
342
449
  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
- }
450
+ normalizeEntry(entry)
350
451
  }
351
452
  } else {
352
453
  // 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
- }
454
+ normalizeEntry(record.data)
361
455
  }
362
456
  }
363
457
 
364
458
  // Normalize included records
365
459
  if (record.included && Array.isArray(record.included)) {
366
460
  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
- }
461
+ normalizeEntry(entry)
374
462
  }
375
463
  }
376
464
 
@@ -1,6 +1,7 @@
1
1
  import { buildQuerySelection } from '../querying/knex-query-helpers-base.js'
2
2
  import { createSelectTranslator } from '../storage/storage-adapter.js'
3
3
  import { buildQueryFieldRuntimes } from './query-field-helpers.js'
4
+ import { RestApiFieldsetError } from '../../../../lib/rest-api-errors.js'
4
5
 
5
6
  /**
6
7
  * Checks if a field exists only in code, not in the database
@@ -211,7 +212,12 @@ export const buildFieldSelection = async (scope, deps) => {
211
212
  if (nonDatabaseFields.has(field)) return
212
213
 
213
214
  const fieldDef = schemaStructure[field]
214
- if (!fieldDef) throw new Error(`Unknown sparse field '${field}' requested for '${scopeName}'`)
215
+ if (!fieldDef) {
216
+ throw new RestApiFieldsetError({
217
+ field,
218
+ resourceType: scopeName
219
+ })
220
+ }
215
221
 
216
222
  if (fieldDef.belongsToPolymorphic) {
217
223
  return
@@ -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') {
@@ -9,7 +9,7 @@ import {
9
9
  getCanonicalResourceIdColumn,
10
10
  translateCanonicalAttributesForStorage,
11
11
  } from './canonical-storage-mapping.js'
12
- import { normalizeDateValue } from '../querying-writing/database-value-normalizers.js'
12
+ import { normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
13
13
 
14
14
  const passthrough = (value) => value
15
15
  const identityTranslate = (_field, value) => value
@@ -77,7 +77,9 @@ const normalizeFilterValueForDefinition = (value, definition = {}, { isRelations
77
77
  }
78
78
 
79
79
  if (['date', 'dateTime', 'time'].includes(type)) {
80
- return normalizeDateValue(value, type)
80
+ return normalizeValueForDatabaseStorage(value, type, {
81
+ temporalPrecision: definition?.temporalPrecision
82
+ })
81
83
  }
82
84
 
83
85
  return value
@@ -1,3 +1,5 @@
1
+ import { normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
2
+
1
3
  const EMPTY_STORAGE_INFO = Object.freeze({
2
4
  idColumn: 'id',
3
5
  storage: Object.freeze({
@@ -242,7 +244,11 @@ export const translateAttributesForStorage = (attributes, schemaInfo = {}, optio
242
244
  context,
243
245
  operation
244
246
  })
245
- : value
247
+ : normalizeValueForDatabaseStorage(value, fieldStorage?.definition?.type, {
248
+ temporalPrecision: fieldStorage?.definition?.temporalPrecision,
249
+ fieldName,
250
+ resourceType: context?.scopeName || schemaInfo.tableName
251
+ })
246
252
 
247
253
  translated[columnName] = serializedValue
248
254
  return translated
@@ -13,6 +13,7 @@ import {
13
13
  getRequestContracts,
14
14
  validateRequestContractOrThrow
15
15
  } from '../lib/querying-writing/request-contracts.js'
16
+ import { normalizeRecordAttributes } from '../lib/querying-writing/database-value-normalizers.js'
16
17
 
17
18
  export { normalizeReturnRecordMode as normalizeReturnValue }
18
19
 
@@ -690,6 +691,10 @@ export async function handleRecordReturnAfterWrite ({
690
691
  await runHooks('finish')
691
692
  await runHooks(`finish${methodSpecificHookSuffix}`)
692
693
 
694
+ context.responseRecord = normalizeRecordAttributes(context.responseRecord, scopes, {
695
+ source: 'response'
696
+ })
697
+
693
698
  // No transformation needed - GET already returns the correct format
694
699
  // based on context.simplified
695
700
  return context.responseRecord
@@ -181,6 +181,12 @@ export default async function getMethod ({
181
181
  await runHooks('finish')
182
182
  await runHooks('finishGet')
183
183
 
184
+ // Enrichers and finish hooks run after the database pass and may introduce
185
+ // native values. Reassert the public temporal contract at the return boundary.
186
+ context.record = normalizeRecordAttributes(context.record, scopes, {
187
+ source: 'response'
188
+ })
189
+
184
190
  // Get schema info for transformation
185
191
  context.schemaInfo = scopes[scopeName].vars.schemaInfo
186
192
 
@@ -154,6 +154,12 @@ export default async function queryMethod ({
154
154
  await runHooks('finish')
155
155
  await runHooks('finishQuery')
156
156
 
157
+ // Enrichers and finish hooks run after the database pass and may introduce
158
+ // native values. Reassert the public temporal contract at the return boundary.
159
+ context.record = normalizeRecordAttributes(context.record, scopes, {
160
+ source: 'response'
161
+ })
162
+
157
163
  // Transform output if in simplified mode
158
164
  if (context.simplified) {
159
165
  // Convert JSON:API response back to simplified format