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
@@ -4,6 +4,23 @@ import knexLib from 'knex'
4
4
  import { cleanTables } from './helpers/test-utils.js'
5
5
  import { storageMode } from './helpers/storage-mode.js'
6
6
  import { createComputedFieldsApi } from './fixtures/api-configs.js'
7
+ import {
8
+ REST_API_FIELDSET_ERROR_CODE,
9
+ RestApiFieldsetError
10
+ } from '../index.js'
11
+ import { mapRestApiErrorToHttp } from '../plugins/core/connectors/lib/transport-http-helpers.js'
12
+
13
+ function assertFieldsetError (error, { field, resourceType }) {
14
+ assert(error instanceof RestApiFieldsetError)
15
+ assert.equal(error.code, REST_API_FIELDSET_ERROR_CODE)
16
+ assert.equal(error.statusCode, 400)
17
+ assert.deepEqual(error.details, { field, resourceType })
18
+ assert.equal(
19
+ error.message,
20
+ `Unknown sparse field '${field}' requested for '${resourceType}'`
21
+ )
22
+ return true
23
+ }
7
24
 
8
25
  // Create Knex instance for tests
9
26
  const knex = knexLib({
@@ -69,12 +86,31 @@ describe('Computed Fields and Sparse Fieldsets', () => {
69
86
  assert.equal(product.price, 99.99)
70
87
  assert.equal(product.profit_margin, 55.00)
71
88
  assert.equal(product.profit_amount, 54.99)
89
+ assert.equal(product.calculated_at, '2026-08-25T23:45:01Z')
90
+ assert.equal(product.calculated_time, '23:45:01.987')
72
91
 
73
92
  // normallyHidden fields should not be included
74
93
  assert.equal(product.cost, undefined)
75
94
  assert.equal(product.internal_notes, undefined)
76
95
  })
77
96
 
97
+ it('should normalize computed temporal values at the final return boundary', async () => {
98
+ const product = await api.resources.products.get({
99
+ id: testData.product.id,
100
+ queryParams: { include: ['reviews'] }
101
+ })
102
+ assert.equal(product.calculated_at, '2026-08-25T23:45:01Z')
103
+ assert.equal(product.calculated_time, '23:45:01.987')
104
+ assert.equal(product.reviews.length, 2)
105
+ for (const review of product.reviews) {
106
+ assert.equal(review.calculated_at, '2026-08-25T23:46:02Z')
107
+ }
108
+
109
+ const products = await api.resources.products.query()
110
+ assert.equal(products.data[0].calculated_at, '2026-08-25T23:45:01Z')
111
+ assert.equal(products.data[0].calculated_time, '23:45:01.987')
112
+ })
113
+
78
114
  it('should handle division by zero in computed fields', async () => {
79
115
  const freeProduct = await api.resources.products.post({
80
116
  name: 'Free Sample',
@@ -115,6 +151,8 @@ describe('Computed Fields and Sparse Fieldsets', () => {
115
151
  assert.equal(product.id, testData.product.id)
116
152
  assert.equal(product.name, 'Premium Widget')
117
153
  assert.equal(product.profit_margin, 55.00)
154
+ assert.equal(product.calculated_at, undefined)
155
+ assert.equal(product.calculated_time, undefined)
118
156
 
119
157
  // Other fields should not be included
120
158
  assert.equal(product.price, undefined)
@@ -365,7 +403,10 @@ describe('Computed Fields and Sparse Fieldsets', () => {
365
403
  fields: { products: 'name,unknown_field' }
366
404
  }
367
405
  }),
368
- /Unknown sparse field 'unknown_field'/
406
+ (error) => assertFieldsetError(error, {
407
+ field: 'unknown_field',
408
+ resourceType: 'products'
409
+ })
369
410
  )
370
411
  })
371
412
 
@@ -377,9 +418,56 @@ describe('Computed Fields and Sparse Fieldsets', () => {
377
418
  fields: { products: 'name,reviews_ids' }
378
419
  }
379
420
  }),
380
- /Unknown sparse field 'reviews_ids'/
421
+ (error) => assertFieldsetError(error, {
422
+ field: 'reviews_ids',
423
+ resourceType: 'products'
424
+ })
425
+ )
426
+ })
427
+
428
+ it('should preserve fieldset errors from empty included relationships', async () => {
429
+ const productWithoutReviews = await api.resources.products.post({
430
+ name: 'No Reviews',
431
+ price: 25,
432
+ cost: 10
433
+ })
434
+
435
+ await assert.rejects(
436
+ api.resources.products.get({
437
+ id: productWithoutReviews.id,
438
+ queryParams: {
439
+ include: ['reviews'],
440
+ fields: {
441
+ products: 'name',
442
+ reviews: 'definitelyHidden'
443
+ }
444
+ }
445
+ }),
446
+ (error) => assertFieldsetError(error, {
447
+ field: 'definitelyHidden',
448
+ resourceType: 'reviews'
449
+ })
381
450
  )
382
451
  })
452
+
453
+ it('should map fieldset errors to an HTTP 400 response', () => {
454
+ const error = new RestApiFieldsetError({
455
+ field: 'definitelyHidden',
456
+ resourceType: 'reviews'
457
+ })
458
+
459
+ assert.deepEqual(mapRestApiErrorToHttp(error), {
460
+ status: 400,
461
+ body: {
462
+ errors: [{
463
+ status: '400',
464
+ code: REST_API_FIELDSET_ERROR_CODE,
465
+ title: 'Invalid Sparse Fieldset',
466
+ detail: "Unknown sparse field 'definitelyHidden' requested for 'reviews'"
467
+ }]
468
+ }
469
+ })
470
+ })
383
471
  })
384
472
 
385
473
  describe('Async Computed Fields', () => {
@@ -176,7 +176,9 @@ describe('dbTablesOperations.createKnexTable', () => {
176
176
  it('defaults storage columns to snake_case and keeps per-field overrides', async () => {
177
177
  const schema = makeTableSchema({
178
178
  displayName: { type: 'string', required: true },
179
- lastSeenAt: { type: 'dateTime' },
179
+ lastSeenAt: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
180
+ observedAtMs: { type: 'epochMilliseconds' },
181
+ observedAtSeconds: { type: 'epochSeconds' },
180
182
  externalRef: { type: 'string', storage: { column: 'legacy_ref' } }
181
183
  })
182
184
 
@@ -186,6 +188,9 @@ describe('dbTablesOperations.createKnexTable', () => {
186
188
  assert.ok(info.id)
187
189
  assert.ok(info.display_name)
188
190
  assert.ok(info.last_seen_at)
191
+ assert.equal(info.last_seen_at.defaultValue, null)
192
+ assert.ok(info.observed_at_ms)
193
+ assert.ok(info.observed_at_seconds)
189
194
  assert.ok(info.legacy_ref)
190
195
  assert.equal(info.displayName, undefined)
191
196
  assert.equal(info.external_ref, undefined)
@@ -363,6 +368,9 @@ describe('dbTablesOperations.generateKnexMigration', () => {
363
368
  const schema = makeTableSchema({
364
369
  displayName: { type: 'string', required: true },
365
370
  loginCount: { type: 'number', defaultTo: 0 },
371
+ createdAt: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
372
+ observedAtMs: { type: 'epochMilliseconds' },
373
+ observedAtSeconds: { type: 'epochSeconds' },
366
374
  externalRef: { type: 'string', storage: { column: 'legacy_ref' } }
367
375
  })
368
376
 
@@ -370,6 +378,10 @@ describe('dbTablesOperations.generateKnexMigration', () => {
370
378
 
371
379
  assert.ok(migration.includes("table.string('display_name').notNullable()"))
372
380
  assert.ok(migration.includes("table.float('login_count').defaultTo(0)"))
381
+ assert.ok(migration.includes("table.datetime('created_at', { precision: 3 })"))
382
+ assert.equal(migration.includes("table.datetime('created_at', { precision: 3 }).defaultTo"), false)
383
+ assert.ok(migration.includes("table.bigInteger('observed_at_ms')"))
384
+ assert.ok(migration.includes("table.bigInteger('observed_at_seconds')"))
373
385
  assert.ok(migration.includes("table.string('legacy_ref')"))
374
386
  assert.ok(!migration.includes("table.string('displayName')"))
375
387
  })
@@ -334,6 +334,68 @@ export async function createCamelCaseBelongsToApi (knex, pluginOptions = {}) {
334
334
  return api
335
335
  }
336
336
 
337
+ export async function createRelationshipIncludeStorageAdapterApi (knex) {
338
+ const api = new Api({
339
+ name: 'relationship-include-storage-adapter-test',
340
+ log: { level: process.env.LOG_LEVEL || 'info' }
341
+ })
342
+
343
+ await api.use(RestApiPlugin, {
344
+ simplifiedApi: false,
345
+ simplifiedTransport: false
346
+ })
347
+ await api.use(RestApiKnexPlugin, { knex })
348
+
349
+ await knex.schema.createTable('uninitialized_pets', table => {
350
+ table.increments('id').primary()
351
+ table.string('first_name').notNullable()
352
+ table.string('last_name').notNullable()
353
+ })
354
+ await knex.schema.createTable('uninitialized_bookings', table => {
355
+ table.increments('id').primary()
356
+ table.string('reference').notNullable()
357
+ table.integer('pet_id').notNullable()
358
+ })
359
+
360
+ await api.addResource('pets', {
361
+ schema: {
362
+ id: { type: 'id' },
363
+ firstName: { type: 'string', required: true },
364
+ lastName: { type: 'string', required: true }
365
+ },
366
+ tableName: 'uninitialized_pets'
367
+ })
368
+
369
+ await api.addResource('bookings', {
370
+ schema: {
371
+ id: { type: 'id' },
372
+ reference: { type: 'string', required: true },
373
+ petId: {
374
+ type: 'id',
375
+ required: true,
376
+ belongsTo: 'pets',
377
+ as: 'pet'
378
+ }
379
+ },
380
+ tableName: 'uninitialized_bookings'
381
+ })
382
+
383
+ return api
384
+ }
385
+
386
+ export async function seedRelationshipIncludeStorageAdapterApi (knex) {
387
+ await knex('uninitialized_pets').insert({
388
+ id: 7,
389
+ first_name: 'Coco',
390
+ last_name: 'Spaniel'
391
+ })
392
+ await knex('uninitialized_bookings').insert({
393
+ id: 41,
394
+ reference: 'BOOK-41',
395
+ pet_id: 7
396
+ })
397
+ }
398
+
337
399
  /**
338
400
  * Creates a small API focused on resource ID normalization behavior.
339
401
  */
@@ -641,7 +703,7 @@ export async function createExtendedApi (knex) {
641
703
  title: { type: 'string', max: 200 },
642
704
  content: { type: 'string', required: true, max: 5000 },
643
705
  reviewer_name: { type: 'string', required: true, max: 100 },
644
- review_date: { type: 'dateTime', default: 'now()' },
706
+ review_date: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
645
707
  helpful_count: { type: 'number', default: 0 },
646
708
  reviewable_type: { type: 'string', required: true },
647
709
  reviewable_id: { type: 'number', required: true }
@@ -976,6 +1038,18 @@ export async function createComputedFieldsApi (knex, pluginOptions = {}) {
976
1038
  compute: ({ attributes }) => {
977
1039
  return Number((attributes.price - attributes.cost).toFixed(2))
978
1040
  }
1041
+ },
1042
+ calculated_at: {
1043
+ type: 'dateTime',
1044
+ temporalPrecision: 0,
1045
+ computed: true,
1046
+ compute: () => new Date('2026-08-25T23:45:01.987Z')
1047
+ },
1048
+ calculated_time: {
1049
+ type: 'time',
1050
+ temporalPrecision: 3,
1051
+ computed: true,
1052
+ compute: () => new Date('2026-08-25T23:45:01.987Z')
979
1053
  }
980
1054
  },
981
1055
  relationships: {
@@ -1016,6 +1090,12 @@ export async function createComputedFieldsApi (knex, pluginOptions = {}) {
1016
1090
  const helpfulnessScore = (attributes.helpful_votes / attributes.total_votes) * 100
1017
1091
  return helpfulnessScore > 70 && attributes.spam_score < 0.5
1018
1092
  }
1093
+ },
1094
+ calculated_at: {
1095
+ type: 'dateTime',
1096
+ temporalPrecision: 0,
1097
+ computed: true,
1098
+ compute: () => new Date('2026-08-25T23:46:02.654Z')
1019
1099
  }
1020
1100
  },
1021
1101
  tableName: 'test_reviews'
@@ -2090,7 +2170,7 @@ export async function createCustomIdPropertyApi (knex, pluginOptions = {}) {
2090
2170
  title: { type: 'string', max: 200 },
2091
2171
  content: { type: 'string', required: true, max: 5000 },
2092
2172
  reviewer_name: { type: 'string', required: true, max: 100 },
2093
- review_date: { type: 'dateTime', default: 'now()' },
2173
+ review_date: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
2094
2174
  reviewable_type: { type: 'string', required: true, search: true },
2095
2175
  reviewable_id: { type: 'number', required: true },
2096
2176
  // Define the polymorphic field in schema
@@ -2164,7 +2244,7 @@ export async function createCursorPaginationApi (knex) {
2164
2244
  price: { type: 'number', required: true },
2165
2245
  sku: { type: 'string', required: true, max: 50, unique: true },
2166
2246
  status: { type: 'string', max: 50, default: 'active' },
2167
- createdAt: { type: 'dateTime', default: 'now()' }
2247
+ createdAt: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() }
2168
2248
  },
2169
2249
  tableName: 'cursor_products'
2170
2250
  })
@@ -2,6 +2,10 @@ import { describe, it, before, beforeEach, after } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import knexLib from 'knex'
4
4
  import { createBasicApi, createExtendedApi } from './fixtures/api-configs.js'
5
+ import {
6
+ REST_API_FIELDSET_ERROR_CODE,
7
+ RestApiFieldsetError
8
+ } from '../index.js'
5
9
  import {
6
10
  validateJsonApiStructure,
7
11
  resourceIdentifier,
@@ -165,6 +169,51 @@ describe('Include/Sideloading Operations', () => {
165
169
  assert(includedTypes.includes('publishers'))
166
170
  assert(includedTypes.includes('countries'))
167
171
  })
172
+
173
+ it('should preserve typed fieldset errors across included relationships', async () => {
174
+ const requests = [
175
+ {
176
+ resource: basicApi.resources.countries,
177
+ id: testData.country.id,
178
+ include: 'books',
179
+ primaryType: 'countries',
180
+ includedType: 'books'
181
+ },
182
+ {
183
+ resource: basicApi.resources.books,
184
+ id: testData.book.id,
185
+ include: 'publisher',
186
+ primaryType: 'books',
187
+ includedType: 'publishers'
188
+ }
189
+ ]
190
+
191
+ for (const request of requests) {
192
+ await assert.rejects(
193
+ request.resource.get({
194
+ id: request.id,
195
+ queryParams: {
196
+ include: [request.include],
197
+ fields: {
198
+ [request.primaryType]: 'id',
199
+ [request.includedType]: 'definitelyHidden'
200
+ }
201
+ },
202
+ simplified: false
203
+ }),
204
+ (error) => {
205
+ assert(error instanceof RestApiFieldsetError)
206
+ assert.equal(error.code, REST_API_FIELDSET_ERROR_CODE)
207
+ assert.equal(error.statusCode, 400)
208
+ assert.deepEqual(error.details, {
209
+ field: 'definitelyHidden',
210
+ resourceType: request.includedType
211
+ })
212
+ return true
213
+ }
214
+ )
215
+ }
216
+ })
168
217
  })
169
218
 
170
219
  describe('Nested Includes', () => {
@@ -337,6 +337,10 @@ describe('Multi-field Cursor Pagination', () => {
337
337
  firstPage.data.map((record) => record.attributes.name),
338
338
  ['Newest Product', 'Recent Product']
339
339
  )
340
+ assert.deepEqual(
341
+ firstPage.data.map((record) => record.attributes.createdAt),
342
+ ['2026-05-03T10:00:00.000Z', '2026-05-03T09:00:00.000Z']
343
+ )
340
344
  assert.deepEqual(
341
345
  secondPage.data.map((record) => record.attributes.name),
342
346
  ['Older Product']
@@ -0,0 +1,56 @@
1
+ import { after, before, beforeEach, describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import knexLib from 'knex'
4
+ import {
5
+ createRelationshipIncludeStorageAdapterApi,
6
+ seedRelationshipIncludeStorageAdapterApi
7
+ } from './fixtures/api-configs.js'
8
+ import { cleanTables } from './helpers/test-utils.js'
9
+
10
+ const knex = knexLib({
11
+ client: 'better-sqlite3',
12
+ connection: {
13
+ filename: ':memory:'
14
+ },
15
+ useNullAsDefault: true
16
+ })
17
+
18
+ describe('Relationship include storage adapters', () => {
19
+ let api
20
+
21
+ before(async () => {
22
+ api = await createRelationshipIncludeStorageAdapterApi(knex)
23
+ })
24
+
25
+ beforeEach(async () => {
26
+ await cleanTables(knex, [
27
+ 'uninitialized_bookings',
28
+ 'uninitialized_pets'
29
+ ])
30
+ await seedRelationshipIncludeStorageAdapterApi(knex)
31
+ })
32
+
33
+ after(async () => {
34
+ await knex.destroy()
35
+ })
36
+
37
+ it('maps sparse fields for an included scope whose adapter is not initialized', async () => {
38
+ assert.equal(api.resources.pets.vars.storageAdapter, undefined)
39
+
40
+ const result = await api.resources.bookings.get({
41
+ id: 41,
42
+ queryParams: {
43
+ include: ['pet'],
44
+ fields: {
45
+ bookings: 'reference,petId',
46
+ pets: 'firstName'
47
+ }
48
+ },
49
+ simplified: false
50
+ })
51
+
52
+ const includedPet = result.included.find(resource => resource.type === 'pets')
53
+ assert(includedPet)
54
+ assert.deepEqual(includedPet.attributes, { firstName: 'Coco' })
55
+ })
56
+ })
@@ -4,6 +4,11 @@ import knexLib from 'knex'
4
4
  import { Api } from 'hooked-api'
5
5
  import { RestApiPlugin } from '../plugins/core/rest-api-plugin.js'
6
6
  import { RestApiKnexPlugin } from '../plugins/core/rest-api-knex-plugin.js'
7
+ import {
8
+ REST_API_TEMPORAL_DATA_ERROR_CODE,
9
+ RestApiTemporalDataError
10
+ } from '../lib/rest-api-errors.js'
11
+ import { mapRestApiErrorToHttp } from '../plugins/core/connectors/lib/transport-http-helpers.js'
7
12
  import {
8
13
  cleanTables,
9
14
  createJsonApiDocument,
@@ -23,14 +28,16 @@ const knex = knexLib({
23
28
  })
24
29
 
25
30
  let api
31
+ const DEFAULT_PROFILE_CREATED_AT = '2026-08-25T12:34:56.789Z'
32
+ const COMPUTED_PROFILE_DATE = new Date('2026-08-25T23:45:01.987Z')
26
33
 
27
34
  const serializeDateTimeForSql = (value) => {
28
35
  if (value == null) return null
29
- if (!(value instanceof Date)) {
30
- throw new Error('serializeDateTimeForSql expects a Date value after schema normalization')
36
+ if (typeof value !== 'string') {
37
+ throw new Error('serializeDateTimeForSql expects an RFC 3339 string after schema validation')
31
38
  }
32
39
 
33
- return value.toISOString().replace('T', ' ').slice(0, 19)
40
+ return new Date(value).toISOString().replace('T', ' ').slice(0, 19)
34
41
  }
35
42
 
36
43
  describe('Storage mapping', () => {
@@ -76,10 +83,27 @@ describe('Storage mapping', () => {
76
83
  lastSeenAt: {
77
84
  type: 'dateTime',
78
85
  nullable: true,
86
+ temporalPrecision: 0,
87
+ getter: (value) => value == null ? value : new Date(value),
79
88
  storage: {
80
89
  serialize: serializeDateTimeForSql
81
90
  }
82
91
  },
92
+ createdAt: {
93
+ type: 'dateTime',
94
+ temporalPrecision: 3,
95
+ defaultTo: () => new Date(DEFAULT_PROFILE_CREATED_AT).toISOString()
96
+ },
97
+ calculatedAt: {
98
+ type: 'dateTime',
99
+ temporalPrecision: 0,
100
+ computed: true,
101
+ compute: () => new Date(COMPUTED_PROFILE_DATE)
102
+ },
103
+ birthDate: { type: 'date', nullable: true },
104
+ openingTime: { type: 'time', nullable: true, temporalPrecision: 2 },
105
+ observedAtMs: { type: 'epochMilliseconds', nullable: true },
106
+ observedAtSeconds: { type: 'epochSeconds', nullable: true },
83
107
  countryId: {
84
108
  type: 'id',
85
109
  nullable: true,
@@ -126,7 +150,11 @@ describe('Storage mapping', () => {
126
150
  {
127
151
  displayName: 'Mercury',
128
152
  loginCount: 7,
129
- lastSeenAt: '2026-01-02T03:04:05Z',
153
+ lastSeenAt: '2026-01-02T11:04:05+08:00',
154
+ birthDate: '2020-02-29',
155
+ openingTime: '09:30:15.12',
156
+ observedAtMs: '1767323045000',
157
+ observedAtSeconds: '1767323045',
130
158
  externalRef: 'legacy-001',
131
159
  confirmationToken: 'secret-token'
132
160
  },
@@ -142,8 +170,13 @@ describe('Storage mapping', () => {
142
170
  loginCount: 7
143
171
  })
144
172
  assert.equal(createResult.data.attributes.confirmationToken, 'secret-token')
145
- assert.ok(createResult.data.attributes.lastSeenAt instanceof Date)
146
- assert.equal(createResult.data.attributes.lastSeenAt.toISOString(), '2026-01-02T03:04:05.000Z')
173
+ assert.equal(createResult.data.attributes.lastSeenAt, '2026-01-02T03:04:05Z')
174
+ assert.equal(createResult.data.attributes.createdAt, DEFAULT_PROFILE_CREATED_AT)
175
+ assert.equal(createResult.data.attributes.calculatedAt, '2026-08-25T23:45:01Z')
176
+ assert.equal(createResult.data.attributes.birthDate, '2020-02-29')
177
+ assert.equal(createResult.data.attributes.openingTime, '09:30:15.12')
178
+ assert.equal(createResult.data.attributes.observedAtMs, 1767323045000)
179
+ assert.equal(createResult.data.attributes.observedAtSeconds, 1767323045)
147
180
  assert.ok(!('display_name' in createResult.data.attributes))
148
181
  assert.ok(!('country_id' in createResult.data.attributes))
149
182
 
@@ -151,6 +184,8 @@ describe('Storage mapping', () => {
151
184
  assert.equal(dbRecord.display_name, 'Mercury')
152
185
  assert.equal(dbRecord.login_count, 7)
153
186
  assert.equal(dbRecord.last_seen_at, '2026-01-02 03:04:05')
187
+ assert.equal(dbRecord.observed_at_ms, 1767323045000)
188
+ assert.equal(dbRecord.observed_at_seconds, 1767323045)
154
189
  assert.equal(String(dbRecord.country_id), country.data.id)
155
190
  assert.equal(dbRecord.legacy_ref, 'legacy-001')
156
191
  assert.ok(!('displayName' in dbRecord))
@@ -169,8 +204,13 @@ describe('Storage mapping', () => {
169
204
  displayName: 'Mercury',
170
205
  loginCount: 7
171
206
  })
172
- assert.ok(getResult.data.attributes.lastSeenAt instanceof Date)
173
- assert.equal(getResult.data.attributes.lastSeenAt.toISOString(), '2026-01-02T03:04:05.000Z')
207
+ assert.equal(getResult.data.attributes.lastSeenAt, '2026-01-02T03:04:05Z')
208
+ assert.equal(getResult.data.attributes.createdAt, DEFAULT_PROFILE_CREATED_AT)
209
+ assert.equal(getResult.data.attributes.calculatedAt, '2026-08-25T23:45:01Z')
210
+ assert.equal(getResult.data.attributes.birthDate, '2020-02-29')
211
+ assert.equal(getResult.data.attributes.openingTime, '09:30:15.12')
212
+ assert.equal(getResult.data.attributes.observedAtMs, 1767323045000)
213
+ assert.equal(getResult.data.attributes.observedAtSeconds, 1767323045)
174
214
  assert.ok(!('display_name' in getResult.data.attributes))
175
215
  assertResourceRelationship(
176
216
  getResult.data,
@@ -193,6 +233,8 @@ describe('Storage mapping', () => {
193
233
  displayName: 'Mercury',
194
234
  externalRef: 'legacy-001'
195
235
  })
236
+ assert.equal(queryResult.data[0].attributes.createdAt, DEFAULT_PROFILE_CREATED_AT)
237
+ assert.equal(queryResult.data[0].attributes.calculatedAt, '2026-08-25T23:45:01Z')
196
238
  })
197
239
 
198
240
  it('applies storage mapping on PATCH updates and sparse fieldsets', async () => {
@@ -228,8 +270,7 @@ describe('Storage mapping', () => {
228
270
  assert.deepEqual(Object.keys(patchResult.data.attributes).sort(), ['confirmationToken', 'displayName', 'lastSeenAt'])
229
271
  assert.equal(patchResult.data.attributes.displayName, 'After')
230
272
  assert.equal(patchResult.data.attributes.confirmationToken, 'updated-token')
231
- assert.ok(patchResult.data.attributes.lastSeenAt instanceof Date)
232
- assert.equal(patchResult.data.attributes.lastSeenAt.toISOString(), '2026-02-03T04:05:06.000Z')
273
+ assert.equal(patchResult.data.attributes.lastSeenAt, '2026-02-03T04:05:06Z')
233
274
 
234
275
  const dbRecord = await knex('mapped_profiles').where('id', created.data.id).first()
235
276
  assert.equal(dbRecord.display_name, 'After')
@@ -237,6 +278,67 @@ describe('Storage mapping', () => {
237
278
  assert.ok(!('displayName' in dbRecord))
238
279
  })
239
280
 
281
+ it('rejects temporal values outside the public JSON contract before storage', async () => {
282
+ const invalidTemporalValues = [
283
+ { lastSeenAt: '2026-01-02 03:04:05' },
284
+ { lastSeenAt: new Date('2026-01-02T03:04:05Z') },
285
+ { birthDate: '2026-02-30' },
286
+ { openingTime: '09:30Z' }
287
+ ]
288
+
289
+ for (const [index, temporalAttributes] of invalidTemporalValues.entries()) {
290
+ await assert.rejects(
291
+ api.resources.profiles.post({
292
+ inputRecord: createJsonApiDocument('profiles', {
293
+ displayName: `Invalid temporal value ${index}`,
294
+ ...temporalAttributes
295
+ })
296
+ }),
297
+ (error) => error?.code === 'REST_API_VALIDATION'
298
+ )
299
+ }
300
+ })
301
+
302
+ it('fails loudly when storage returns an invalid non-null temporal value', async () => {
303
+ const insertedIds = await knex('mapped_profiles').insert({
304
+ display_name: 'Corrupt temporal row',
305
+ login_count: 0,
306
+ last_seen_at: 'not-a-date',
307
+ created_at: new Date(DEFAULT_PROFILE_CREATED_AT)
308
+ })
309
+ const profileId = insertedIds[0]
310
+
311
+ await assert.rejects(
312
+ api.resources.profiles.get({ id: profileId }),
313
+ (error) => {
314
+ assert(error instanceof RestApiTemporalDataError)
315
+ assert.equal(error.code, REST_API_TEMPORAL_DATA_ERROR_CODE)
316
+ assert.equal(
317
+ error.message,
318
+ "Invalid database value for dateTime field 'profiles.lastSeenAt'."
319
+ )
320
+ assert.deepEqual(error.details, {
321
+ field: 'lastSeenAt',
322
+ resourceType: 'profiles',
323
+ fieldType: 'dateTime',
324
+ source: 'database'
325
+ })
326
+ assert.deepEqual(mapRestApiErrorToHttp(error), {
327
+ status: 500,
328
+ body: {
329
+ errors: [{
330
+ status: '500',
331
+ code: REST_API_TEMPORAL_DATA_ERROR_CODE,
332
+ title: 'Invalid Temporal Data',
333
+ detail: "Invalid database value for dateTime field 'profiles.lastSeenAt'."
334
+ }]
335
+ }
336
+ })
337
+ return true
338
+ }
339
+ )
340
+ })
341
+
240
342
  it('can opt out of snake_case translation for an entire resource', async () => {
241
343
  const created = await api.resources.verbatim_profiles.post({
242
344
  inputRecord: createJsonApiDocument('verbatim_profiles', {