json-rest-api 1.0.28 → 1.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -14,6 +14,7 @@ import {
14
14
  validateRequestContractOrThrow
15
15
  } from '../lib/querying-writing/request-contracts.js'
16
16
  import { normalizeRecordAttributes } from '../lib/querying-writing/database-value-normalizers.js'
17
+ import { isRestApiError } from '../../../lib/error-context.js'
17
18
 
18
19
  export { normalizeReturnRecordMode as normalizeReturnValue }
19
20
 
@@ -583,6 +584,7 @@ export const applyFieldSetters = async (attributes, schemaInfo, context, api, he
583
584
  setterContext
584
585
  )
585
586
  } catch (error) {
587
+ if (isRestApiError(error)) throw error
586
588
  const message = error instanceof Error ? error.message : String(error)
587
589
  throw new RestApiValidationError(
588
590
  `Setter for field '${fieldName}' failed: ${message}`,
@@ -640,6 +642,7 @@ export async function handleRecordReturnAfterWrite ({
640
642
  })
641
643
  context.minimalRecord = currentRecord
642
644
  } catch (error) {
645
+ if (isRestApiError(error)) throw error
643
646
  enhancedLog.warn(`Could not fetch minimal record after ${context.method} operation`, { error, id: context.id })
644
647
  }
645
648
  }
@@ -672,6 +675,11 @@ export async function handleRecordReturnAfterWrite ({
672
675
  }
673
676
  await runHooks('finish')
674
677
  await runHooks(`finish${methodSpecificHookSuffix}`)
678
+ context.responseRecord = normalizeRecordAttributes(context.responseRecord, scopes, {
679
+ source: 'response',
680
+ simplified: context.simplified,
681
+ resourceType: scopeName
682
+ })
675
683
  return context.responseRecord
676
684
  }
677
685
 
@@ -692,7 +700,9 @@ export async function handleRecordReturnAfterWrite ({
692
700
  await runHooks(`finish${methodSpecificHookSuffix}`)
693
701
 
694
702
  context.responseRecord = normalizeRecordAttributes(context.responseRecord, scopes, {
695
- source: 'response'
703
+ source: 'response',
704
+ simplified: context.simplified,
705
+ resourceType: scopeName
696
706
  })
697
707
 
698
708
  // No transformation needed - GET already returns the correct format
@@ -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
 
@@ -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({
@@ -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: {
@@ -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
  })
@@ -58,6 +58,63 @@ async function withTenantContext (tenantId, fn) {
58
58
  }
59
59
  }
60
60
 
61
+ export async function createTemporalBoundaryApi (knex) {
62
+ const api = new Api({ name: 'temporal-boundaries', log: { level: 'error' } })
63
+ await api.use(RestApiPlugin, {
64
+ simplifiedApi: false,
65
+ simplifiedTransport: false,
66
+ returnRecordApi: { post: 'full', put: 'full', patch: 'full' },
67
+ queryDefaultLimit: 2,
68
+ queryMaxLimit: 3
69
+ })
70
+ await api.use(QueryProjectionsPlugin)
71
+ await useStoragePlugin(api, knex)
72
+ const producedAt = {
73
+ type: 'dateTime', temporalPrecision: 0, computed: true,
74
+ compute: () => new Date('2026-09-08T01:02:03.456Z')
75
+ }
76
+ await api.addResource('people', {
77
+ schema: { id: { type: 'id' }, name: { type: 'string' }, producedAt },
78
+ relationships: { events: { type: 'hasMany', target: 'events', foreignKey: 'personId' } },
79
+ tableName: 'temporal_people'
80
+ })
81
+ await api.resources.people.createKnexTable()
82
+ mapTable('temporal_people', 'people')
83
+ await api.addResource('events', {
84
+ sortableFields: ['id', 'name', 'occurredAt', 'day', 'atTime', 'observedAtMs'],
85
+ schema: {
86
+ id: { type: 'id' },
87
+ name: { type: 'string', required: true },
88
+ occurredAt: { type: 'dateTime', temporalPrecision: 6, nullable: true, search: true },
89
+ day: { type: 'date', nullable: true, search: true },
90
+ atTime: { type: 'time', temporalPrecision: 3, nullable: true, search: true },
91
+ observedAtMs: { type: 'epochMilliseconds', nullable: true, search: true },
92
+ observedAtSeconds: { type: 'epochSeconds', nullable: true, search: true },
93
+ serializedAt: {
94
+ type: 'dateTime', temporalPrecision: 6, nullable: true, search: true,
95
+ storage: { serialize: value => value == null ? null : value.replace('T', ' ').replace(/Z$/, '') }
96
+ },
97
+ personId: { type: 'id', belongsTo: 'people', as: 'person', nullable: true },
98
+ subjectId: { type: 'number', nullable: true },
99
+ subjectType: { type: 'string', nullable: true },
100
+ producedAt
101
+ },
102
+ queryFields: {
103
+ projectedAt: {
104
+ type: 'dateTime', temporalPrecision: 3, sortable: true,
105
+ select: ({ knex, column }) => knex.raw('??', [column('occurredAt')])
106
+ }
107
+ },
108
+ relationships: {
109
+ subject: { belongsToPolymorphic: { types: ['people'], typeField: 'subjectType', idField: 'subjectId' } }
110
+ },
111
+ tableName: 'temporal_events'
112
+ })
113
+ await api.resources.events.createKnexTable()
114
+ mapTable('temporal_events', 'events')
115
+ return api
116
+ }
117
+
61
118
  /**
62
119
  * Creates a basic API configuration with Countries, Publishers, Authors, Books
63
120
  */