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,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
@@ -18,7 +18,6 @@ import { getUrlPrefix, buildResourceUrl, buildRelationshipUrl } from './lib/quer
18
18
  import {
19
19
  normalizeId,
20
20
  resolveFieldInfo,
21
- coerceValueForDefinition,
22
21
  normalizeFilterValues,
23
22
  ensureFilterableField,
24
23
  } from './lib/anyapi/utils/descriptor-helpers.js'
@@ -48,13 +47,15 @@ import {
48
47
  translateCanonicalRecordFromStorage,
49
48
  } from './lib/storage/canonical-storage-mapping.js'
50
49
  import {
51
- buildCursorPredicateChains,
50
+ applyCursorPredicate,
51
+ validateCursorValues,
52
52
  applyQueryFieldOrder,
53
53
  buildEffectiveSortList,
54
54
  parseSortEntry
55
55
  } from './lib/querying/query-field-sort-helpers.js'
56
56
  import { unwrapQueryBuilderState } from './lib/querying/query-builder-utils.js'
57
57
  import { serializeJsonApiQuery } from './lib/querying-writing/connectors-query-parser.js'
58
+ import { normalizeValueForDatabaseStorage } from './lib/querying-writing/database-value-normalizers.js'
58
59
 
59
60
  const DEFAULT_TENANT = 'default'
60
61
  const LINKS_TABLE = 'any_links'
@@ -237,7 +238,7 @@ export const RestApiAnyapiKnexPlugin = {
237
238
  }
238
239
  }
239
240
 
240
- const applySortingToQuery = ({ query, sort, descriptor, scope, queryFieldRuntimeByField = new Map() }) => {
241
+ const applySortingToQuery = ({ query, sort, descriptor, scope, adapter, before = false, queryFieldRuntimeByField = new Map() }) => {
241
242
  const effectiveSort = buildEffectiveSortList(sort, {
242
243
  defaultSort: scope?.vars?.defaultSort,
243
244
  idField: 'id'
@@ -245,10 +246,11 @@ export const RestApiAnyapiKnexPlugin = {
245
246
  const descriptors = []
246
247
 
247
248
  for (const entry of effectiveSort) {
248
- const { field, direction, sqlDirection } = parseSortEntry(entry)
249
+ const { field, direction } = parseSortEntry(entry)
250
+ const queryDirection = before ? (direction === 'asc' ? 'desc' : 'asc') : direction
249
251
  const queryFieldRuntime = queryFieldRuntimeByField.get(field)
250
252
  if (queryFieldRuntime) {
251
- applyQueryFieldOrder(query, queryFieldRuntime, sqlDirection)
253
+ applyQueryFieldOrder(query, queryFieldRuntime, queryDirection.toUpperCase(), before ? 'first' : 'last')
252
254
  descriptors.push({
253
255
  field,
254
256
  direction,
@@ -261,7 +263,8 @@ export const RestApiAnyapiKnexPlugin = {
261
263
 
262
264
  const fieldInfo = resolveFieldInfo(descriptor, field)
263
265
  if (!fieldInfo?.column) continue
264
- query.orderBy(fieldInfo.column, direction)
266
+ query.orderByRaw(`?? IS NULL ${before ? 'DESC' : 'ASC'}`, [adapter.translateColumn(fieldInfo.column)])
267
+ query.orderBy(fieldInfo.column, queryDirection)
265
268
  descriptors.push({
266
269
  field,
267
270
  column: fieldInfo.column,
@@ -328,55 +331,21 @@ export const RestApiAnyapiKnexPlugin = {
328
331
  }
329
332
  }
330
333
 
331
- const coerceCursorValues = (cursorMap) => {
332
- const typed = {}
333
- for (const descriptor of descriptors) {
334
- if (descriptor.field === undefined) continue
335
- const raw = cursorMap[descriptor.field]
336
- if (raw === undefined) continue
337
- typed[descriptor.field] = coerceValueForDefinition(
338
- raw,
339
- descriptor.definition,
340
- { isRelationship: descriptor.isRelationship }
341
- )
342
- }
343
- return typed
344
- }
345
-
346
334
  const applyRawCursorPredicate = (cursorValues, operatorSelector) => {
347
- const predicateChains = buildCursorPredicateChains(
335
+ applyCursorPredicate(
336
+ query,
348
337
  descriptors,
349
338
  cursorValues,
350
339
  operatorSelector,
351
- { onMissingValue: () => {} }
340
+ (builder, descriptor, operator, value) => builder.where(
341
+ descriptor.column,
342
+ operator,
343
+ normalizeValueForDatabaseStorage(
344
+ value, descriptor.isRelationship ? null : descriptor.definition?.type,
345
+ { temporalPrecision: descriptor.definition?.temporalPrecision }
346
+ )
347
+ )
352
348
  )
353
-
354
- if (predicateChains.length === 0) {
355
- return
356
- }
357
-
358
- const predicateParts = []
359
- const predicateBindings = []
360
-
361
- predicateChains.forEach((chain) => {
362
- const chainParts = chain.map(({ descriptor, operator, value }) => {
363
- const normalizedValue = adapter?.translateFilterValue
364
- ? adapter.translateFilterValue(descriptor.field, value)
365
- : value
366
- if (descriptor.queryFieldRuntime) {
367
- predicateBindings.push(...(descriptor.queryFieldRuntime.bindings || []), normalizedValue)
368
- return `(${descriptor.queryFieldRuntime.sql}) ${operator} ?`
369
- }
370
-
371
- const columnRef = adapter ? adapter.translateColumn(descriptor.column) : descriptor.column
372
- predicateBindings.push(normalizedValue)
373
- return `${columnRef} ${operator} ?`
374
- })
375
-
376
- predicateParts.push(`(${chainParts.join(' AND ')})`)
377
- })
378
-
379
- query.whereRaw(`(${predicateParts.join(' OR ')})`, predicateBindings)
380
349
  }
381
350
 
382
351
  const ensurePageSize = () => {
@@ -414,11 +383,11 @@ export const RestApiAnyapiKnexPlugin = {
414
383
 
415
384
  if (pageParams.after) {
416
385
  const cursorMap = parseCursorOrThrow(pageParams.after, 'after')
417
- const cursorValues = coerceCursorValues(cursorMap)
386
+ const cursorValues = validateCursorValues(descriptors, cursorMap, 'after')
418
387
  applyRawCursorPredicate(cursorValues, (direction) => (direction === 'desc' ? '<' : '>'))
419
388
  } else if (pageParams.before) {
420
389
  const cursorMap = parseCursorOrThrow(pageParams.before, 'before')
421
- const cursorValues = coerceCursorValues(cursorMap)
390
+ const cursorValues = validateCursorValues(descriptors, cursorMap, 'before')
422
391
  applyRawCursorPredicate(cursorValues, (direction) => (direction === 'desc' ? '>' : '<'))
423
392
  }
424
393
 
@@ -445,11 +414,11 @@ export const RestApiAnyapiKnexPlugin = {
445
414
  }
446
415
  }
447
416
 
448
- query.limit(defaultLimit)
417
+ query.limit(Math.min(defaultLimit, maxLimit))
449
418
  return {
450
419
  mode: 'default',
451
420
  page: 1,
452
- pageSize: defaultLimit,
421
+ pageSize: Math.min(defaultLimit, maxLimit),
453
422
  sortDescriptors: descriptors,
454
423
  }
455
424
  }
@@ -2186,6 +2155,8 @@ export const RestApiAnyapiKnexPlugin = {
2186
2155
  sort: queryParams.sort,
2187
2156
  descriptor,
2188
2157
  scope,
2158
+ adapter,
2159
+ before: Boolean(queryParams.page?.before),
2189
2160
  queryFieldRuntimeByField: selectionState.queryFieldRuntimeByField
2190
2161
  })
2191
2162
 
@@ -2222,6 +2193,10 @@ export const RestApiAnyapiKnexPlugin = {
2222
2193
  rows = rows.slice(0, pageSize)
2223
2194
  cursorRecords = cursorRecords.slice(0, pageSize)
2224
2195
  }
2196
+ if (queryParams.page?.before) {
2197
+ rows.reverse()
2198
+ cursorRecords.reverse()
2199
+ }
2225
2200
  }
2226
2201
 
2227
2202
  const data = rows.map((row) => {
@@ -2313,14 +2288,17 @@ export const RestApiAnyapiKnexPlugin = {
2313
2288
  )
2314
2289
  } else if (paginationInfo.mode === 'cursor' && cursorRecords) {
2315
2290
  const cursorFields = paginationInfo.sortDescriptors.map((descriptorEntry) => descriptorEntry.field)
2291
+ const cursorOptions = {
2292
+ schemaInfo,
2293
+ definitions: Object.fromEntries(paginationInfo.sortDescriptors.map(({ field, definition }) => [field, definition])),
2294
+ before: Boolean(queryParams.page?.before)
2295
+ }
2316
2296
  const paginationMeta = buildCursorMeta(
2317
2297
  cursorRecords,
2318
2298
  paginationInfo.pageSize,
2319
2299
  hasMore,
2320
2300
  cursorFields,
2321
- {
2322
- schemaInfo
2323
- }
2301
+ cursorOptions
2324
2302
  )
2325
2303
  context.returnMeta.paginationMeta = paginationMeta
2326
2304
  const urlPrefix = getUrlPrefix(context, scope)
@@ -2332,9 +2310,7 @@ export const RestApiAnyapiKnexPlugin = {
2332
2310
  paginationInfo.pageSize,
2333
2311
  hasMore,
2334
2312
  cursorFields,
2335
- {
2336
- schemaInfo
2337
- }
2313
+ cursorOptions
2338
2314
  )
2339
2315
  }
2340
2316
 
@@ -33,6 +33,7 @@ import { getUrlPrefix } from './lib/querying/url-helpers.js'
33
33
  import { createStorageAdapter } from './lib/storage/storage-adapter.js'
34
34
  import {
35
35
  applyCursorPredicate,
36
+ validateCursorValues,
36
37
  applyQueryFieldOrder,
37
38
  buildEffectiveSortList,
38
39
  parseSortEntry
@@ -113,6 +114,7 @@ export const RestApiKnexPlugin = {
113
114
  sortableFields,
114
115
  storageAdapter,
115
116
  defaultSort,
117
+ before = false,
116
118
  queryFieldRuntimeByField = new Map()
117
119
  }) => {
118
120
  const effectiveSort = buildEffectiveSortList(sort, { defaultSort, idField: 'id' })
@@ -120,6 +122,7 @@ export const RestApiKnexPlugin = {
120
122
 
121
123
  for (const sortEntry of effectiveSort) {
122
124
  const { field, direction, sqlDirection } = parseSortEntry(sortEntry)
125
+ const queryDirection = before ? (direction === 'asc' ? 'desc' : 'asc') : direction
123
126
 
124
127
  if (field !== 'id' && sortableFields?.length > 0 && !sortableFields.includes(field)) {
125
128
  log.warn(`Ignoring non-sortable field: ${field}`)
@@ -128,7 +131,7 @@ export const RestApiKnexPlugin = {
128
131
 
129
132
  const queryFieldRuntime = queryFieldRuntimeByField.get(field)
130
133
  if (queryFieldRuntime) {
131
- applyQueryFieldOrder(query, queryFieldRuntime, sqlDirection)
134
+ applyQueryFieldOrder(query, queryFieldRuntime, queryDirection.toUpperCase(), before ? 'first' : 'last')
132
135
  descriptors.push({
133
136
  field,
134
137
  direction: sqlDirection,
@@ -145,11 +148,15 @@ export const RestApiKnexPlugin = {
145
148
  dbField = searchField.actualField
146
149
  }
147
150
 
148
- const translatedField = storageAdapter?.translateColumn
151
+ const storageColumn = storageAdapter?.translateColumn
149
152
  ? storageAdapter.translateColumn(dbField)
150
153
  : dbField
154
+ const translatedField = storageColumn.includes('.')
155
+ ? storageColumn
156
+ : `${storageAdapter?.getTableName?.() || schemaInfo.tableName}.${storageColumn}`
151
157
 
152
- query.orderBy(translatedField, direction)
158
+ query.orderByRaw(`?? IS NULL ${before ? 'DESC' : 'ASC'}`, [translatedField])
159
+ query.orderBy(translatedField, queryDirection)
153
160
  descriptors.push({
154
161
  field,
155
162
  direction: sqlDirection,
@@ -659,6 +666,7 @@ export const RestApiKnexPlugin = {
659
666
  schemaInfo,
660
667
  sortableFields,
661
668
  defaultSort: scope.vars.defaultSort,
669
+ before: Boolean(queryParams.page?.before),
662
670
  storageAdapter,
663
671
  queryFieldRuntimeByField
664
672
  })
@@ -671,13 +679,13 @@ export const RestApiKnexPlugin = {
671
679
  queryParams.page.after !== undefined ||
672
680
  queryParams.page.before !== undefined)
673
681
 
674
- if (hasPageParams) {
675
- const requestedSize = queryParams.page.size || scope.vars.queryDefaultLimit || DEFAULT_QUERY_LIMIT
676
- const pageSize = Math.min(
677
- requestedSize,
678
- scope.vars.queryMaxLimit || DEFAULT_MAX_QUERY_LIMIT
679
- )
682
+ const requestedSize = queryParams.page?.size || scope.vars.queryDefaultLimit || DEFAULT_QUERY_LIMIT
683
+ const pageSize = Math.min(
684
+ requestedSize,
685
+ scope.vars.queryMaxLimit || DEFAULT_MAX_QUERY_LIMIT
686
+ )
680
687
 
688
+ if (hasPageParams) {
681
689
  // Validate page size
682
690
  if (requestedSize <= 0) {
683
691
  throw new RestApiValidationError(
@@ -721,6 +729,7 @@ export const RestApiKnexPlugin = {
721
729
  )
722
730
  }
723
731
 
732
+ cursorData = validateCursorValues(sortDescriptors, cursorData, 'after')
724
733
  applyCursorPredicate(
725
734
  query,
726
735
  sortDescriptors,
@@ -755,6 +764,7 @@ export const RestApiKnexPlugin = {
755
764
  )
756
765
  }
757
766
 
767
+ cursorData = validateCursorValues(sortDescriptors, cursorData, 'before')
758
768
  applyCursorPredicate(
759
769
  query,
760
770
  sortDescriptors,
@@ -778,8 +788,7 @@ export const RestApiKnexPlugin = {
778
788
  }
779
789
  } else {
780
790
  // No pagination params provided - apply default limit
781
- const defaultLimit = scope.vars.queryDefaultLimit || DEFAULT_QUERY_LIMIT
782
- query.limit(defaultLimit)
791
+ query.limit(pageSize)
783
792
  }
784
793
 
785
794
  // Execute query
@@ -793,7 +802,6 @@ export const RestApiKnexPlugin = {
793
802
  // Execute count query for pagination if offset-based pagination is used
794
803
  if (queryParams.page?.number !== undefined || (queryParams.page?.size !== undefined && !queryParams.page?.after && !queryParams.page?.before)) {
795
804
  const page = parseInt(queryParams.page?.number) || 1
796
- const pageSize = parseInt(queryParams.page?.size) || scope.vars.queryDefaultLimit || DEFAULT_QUERY_LIMIT
797
805
 
798
806
  // Only execute count query if enabled
799
807
  if (scope.vars.enablePaginationCounts) {
@@ -855,8 +863,6 @@ export const RestApiKnexPlugin = {
855
863
  // Generate cursor metadata when using cursor parameters OR when only size is specified (no page number)
856
864
  if (queryParams.page?.after || queryParams.page?.before ||
857
865
  (queryParams.page?.size && queryParams.page?.number === undefined)) {
858
- const pageSize = parseInt(queryParams.page?.size) || scope.vars.queryDefaultLimit || DEFAULT_QUERY_LIMIT
859
-
860
866
  // Check if there are more records
861
867
  // We fetched pageSize + 1 records to detect if there are more
862
868
  const hasMore = records.length > pageSize
@@ -865,12 +871,16 @@ export const RestApiKnexPlugin = {
865
871
  if (hasMore) {
866
872
  records.pop()
867
873
  }
874
+ if (queryParams.page?.before) records.reverse()
868
875
 
869
876
  const sortFields = sortDescriptors.map((descriptor) => descriptor.field)
877
+ const cursorOptions = {
878
+ schemaInfo,
879
+ definitions: Object.fromEntries(sortDescriptors.map(({ field, definition }) => [field, definition])),
880
+ before: Boolean(queryParams.page?.before)
881
+ }
870
882
 
871
- context.returnMeta.paginationMeta = buildCursorMeta(records, pageSize, hasMore, sortFields, {
872
- schemaInfo
873
- })
883
+ context.returnMeta.paginationMeta = buildCursorMeta(records, pageSize, hasMore, sortFields, cursorOptions)
874
884
  const urlPrefix = getUrlPrefix(context, scope)
875
885
  context.returnMeta.paginationLinks = generateCursorPaginationLinks(
876
886
  urlPrefix,
@@ -880,9 +890,7 @@ export const RestApiKnexPlugin = {
880
890
  pageSize,
881
891
  hasMore,
882
892
  sortFields,
883
- {
884
- schemaInfo
885
- }
893
+ cursorOptions
886
894
  )
887
895
  }
888
896
 
@@ -13,6 +13,8 @@ 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'
17
+ import { isRestApiError } from '../../../lib/error-context.js'
16
18
 
17
19
  export { normalizeReturnRecordMode as normalizeReturnValue }
18
20
 
@@ -582,6 +584,7 @@ export const applyFieldSetters = async (attributes, schemaInfo, context, api, he
582
584
  setterContext
583
585
  )
584
586
  } catch (error) {
587
+ if (isRestApiError(error)) throw error
585
588
  const message = error instanceof Error ? error.message : String(error)
586
589
  throw new RestApiValidationError(
587
590
  `Setter for field '${fieldName}' failed: ${message}`,
@@ -639,6 +642,7 @@ export async function handleRecordReturnAfterWrite ({
639
642
  })
640
643
  context.minimalRecord = currentRecord
641
644
  } catch (error) {
645
+ if (isRestApiError(error)) throw error
642
646
  enhancedLog.warn(`Could not fetch minimal record after ${context.method} operation`, { error, id: context.id })
643
647
  }
644
648
  }
@@ -671,6 +675,11 @@ export async function handleRecordReturnAfterWrite ({
671
675
  }
672
676
  await runHooks('finish')
673
677
  await runHooks(`finish${methodSpecificHookSuffix}`)
678
+ context.responseRecord = normalizeRecordAttributes(context.responseRecord, scopes, {
679
+ source: 'response',
680
+ simplified: context.simplified,
681
+ resourceType: scopeName
682
+ })
674
683
  return context.responseRecord
675
684
  }
676
685
 
@@ -690,6 +699,12 @@ export async function handleRecordReturnAfterWrite ({
690
699
  await runHooks('finish')
691
700
  await runHooks(`finish${methodSpecificHookSuffix}`)
692
701
 
702
+ context.responseRecord = normalizeRecordAttributes(context.responseRecord, scopes, {
703
+ source: 'response',
704
+ simplified: context.simplified,
705
+ resourceType: scopeName
706
+ })
707
+
693
708
  // No transformation needed - GET already returns the correct format
694
709
  // based on context.simplified
695
710
  return context.responseRecord
@@ -1,4 +1,5 @@
1
1
  import { filterHiddenFields } from '../lib/querying-writing/field-utils.js'
2
+ import { isRestApiError } from '../../../lib/error-context.js'
2
3
 
3
4
  /**
4
5
  * enrichAttributes
@@ -64,6 +65,7 @@ export default async function enrichAttributesMethod ({ context, params, runHook
64
65
  getterContext
65
66
  )
66
67
  } catch (error) {
68
+ if (isRestApiError(error)) throw error
67
69
  console.error(`Error in getter for field '${fieldName}' in ${scopeName}:`, error)
68
70
  // Keep current value on error (don't break the whole request)
69
71
  }
@@ -144,6 +146,7 @@ export default async function enrichAttributesMethod ({ context, params, runHook
144
146
  // and returns: ((100 - 60) / 100 * 100) = "40.00"
145
147
  filteredAttributes[fieldName] = await fieldDef.compute(computeContext)
146
148
  } catch (error) {
149
+ if (isRestApiError(error)) throw error
147
150
  // Log error but don't fail the request - computed fields shouldn't break API
148
151
  console.error(`Error computing field '${fieldName}' for ${scopeName}:`, error)
149
152
  filteredAttributes[fieldName] = null
@@ -1,6 +1,6 @@
1
1
  import { RestApiResourceError } from '../../../lib/rest-api-errors.js'
2
2
  import { normalizeRecordAttributes } from '../lib/querying-writing/database-value-normalizers.js'
3
- import { getRequestedComputedFields } from '../lib/querying-writing/knex-field-helpers.js'
3
+ import { getRequestedComputedFields, validateRequestedFieldsets } from '../lib/querying-writing/knex-field-helpers.js'
4
4
  import { transformJsonApiToSimplified } from '../lib/querying-writing/simplified-helpers.js'
5
5
  import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
6
6
  import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
@@ -106,6 +106,7 @@ export default async function getMethod ({
106
106
  originalContext: context,
107
107
  })
108
108
 
109
+ await validateRequestedFieldsets(context, scopes)
109
110
  await runHooks('beforeData')
110
111
  await runHooks('beforeDataGet')
111
112
 
@@ -181,6 +182,12 @@ export default async function getMethod ({
181
182
  await runHooks('finish')
182
183
  await runHooks('finishGet')
183
184
 
185
+ // Enrichers and finish hooks run after the database pass and may introduce
186
+ // native values. Reassert the public temporal contract at the return boundary.
187
+ context.record = normalizeRecordAttributes(context.record, scopes, {
188
+ source: 'response'
189
+ })
190
+
184
191
  // Get schema info for transformation
185
192
  context.schemaInfo = scopes[scopeName].vars.schemaInfo
186
193
 
@@ -1,5 +1,5 @@
1
1
  import { normalizeRecordAttributes } from '../lib/querying-writing/database-value-normalizers.js'
2
- import { getRequestedComputedFields } from '../lib/querying-writing/knex-field-helpers.js'
2
+ import { getRequestedComputedFields, validateRequestedFieldsets } from '../lib/querying-writing/knex-field-helpers.js'
3
3
  import { getEffectiveSortableFields } from '../lib/querying/query-field-sort-helpers.js'
4
4
  import { transformJsonApiToSimplified } from '../lib/querying-writing/simplified-helpers.js'
5
5
  import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
@@ -90,6 +90,7 @@ export default async function queryMethod ({
90
90
  originalContext: context,
91
91
  })
92
92
 
93
+ await validateRequestedFieldsets(context, scopes)
93
94
  await runHooks('beforeData')
94
95
  await runHooks('beforeDataQuery')
95
96
  context.record = await helpers.dataQuery({
@@ -154,6 +155,12 @@ export default async function queryMethod ({
154
155
  await runHooks('finish')
155
156
  await runHooks('finishQuery')
156
157
 
158
+ // Enrichers and finish hooks run after the database pass and may introduce
159
+ // native values. Reassert the public temporal contract at the return boundary.
160
+ context.record = normalizeRecordAttributes(context.record, scopes, {
161
+ source: 'response'
162
+ })
163
+
157
164
  // Transform output if in simplified mode
158
165
  if (context.simplified) {
159
166
  // Convert JSON:API response back to simplified format
@@ -127,9 +127,7 @@ maybeDescribe('AnyAPI Cursor Pagination', () => {
127
127
  simplified: false,
128
128
  })
129
129
 
130
- const beforeCursor =
131
- secondPage.meta.pagination.cursor?.next ||
132
- `id:${encodeURIComponent(secondPage.data[0].id)}`
130
+ const beforeCursor = `id:${encodeURIComponent(secondPage.data[0].id)}`
133
131
 
134
132
  const previousPage = await api.resources.books.query({
135
133
  queryParams: {
@@ -86,12 +86,31 @@ describe('Computed Fields and Sparse Fieldsets', () => {
86
86
  assert.equal(product.price, 99.99)
87
87
  assert.equal(product.profit_margin, 55.00)
88
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')
89
91
 
90
92
  // normallyHidden fields should not be included
91
93
  assert.equal(product.cost, undefined)
92
94
  assert.equal(product.internal_notes, undefined)
93
95
  })
94
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
+
95
114
  it('should handle division by zero in computed fields', async () => {
96
115
  const freeProduct = await api.resources.products.post({
97
116
  name: 'Free Sample',
@@ -132,6 +151,8 @@ describe('Computed Fields and Sparse Fieldsets', () => {
132
151
  assert.equal(product.id, testData.product.id)
133
152
  assert.equal(product.name, 'Premium Widget')
134
153
  assert.equal(product.profit_margin, 55.00)
154
+ assert.equal(product.calculated_at, undefined)
155
+ assert.equal(product.calculated_time, undefined)
135
156
 
136
157
  // Other fields should not be included
137
158
  assert.equal(product.price, undefined)
@@ -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
  })
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
3
3
  import knexLib from 'knex'
4
4
  import { cleanTables } from './helpers/test-utils.js'
5
5
  import { createFileUploadApi } from './fixtures/api-configs.js'
6
+ import { RestApiPayloadError, RestApiResourceError } from '../lib/rest-api-errors.js'
6
7
 
7
8
  const knex = knexLib({
8
9
  client: 'better-sqlite3',
@@ -115,4 +116,40 @@ describe('File handling cleanup', () => {
115
116
  assert.deepEqual(storage.uploaded, ['/uploads/valid.png'])
116
117
  assert.deepEqual(storage.deleted, ['/uploads/valid.png'])
117
118
  })
119
+
120
+ it('preserves typed errors from file detectors', async () => {
121
+ const detector = api.rest.fileDetectors[0]
122
+ const originalParse = detector.parse
123
+ const error = new RestApiPayloadError('Malformed upload', { path: 'attachment' })
124
+ detectorState.payload = { fields: { title: 'Upload' }, files: {} }
125
+ detector.parse = async () => { throw error }
126
+ try {
127
+ await assert.rejects(api.resources.documents.post({
128
+ inputRecord: { data: { type: 'documents', attributes: { title: 'Upload' } } }, simplified: false
129
+ }), actual => actual === error)
130
+ assert.equal((await api.resources.documents.query({ simplified: false })).data.length, 0)
131
+ } finally {
132
+ detector.parse = originalParse
133
+ }
134
+ })
135
+
136
+ it('preserves typed upload errors and still cleans temporary files', async () => {
137
+ const originalUpload = storage.upload
138
+ const error = new RestApiResourceError('Upload forbidden', { subtype: 'forbidden' })
139
+ let cleanupCalls = 0
140
+ detectorState.payload = {
141
+ fields: { title: 'Upload' },
142
+ files: { attachment: createTestFile({ cleanup: async () => { cleanupCalls++ } }) }
143
+ }
144
+ storage.upload = async () => { throw error }
145
+ try {
146
+ await assert.rejects(api.resources.documents.post({
147
+ inputRecord: { data: { type: 'documents', attributes: {} } }, simplified: false
148
+ }), actual => actual === error)
149
+ assert.equal(cleanupCalls, 1)
150
+ assert.equal((await api.resources.documents.query({ simplified: false })).data.length, 0)
151
+ } finally {
152
+ storage.upload = originalUpload
153
+ }
154
+ })
118
155
  })