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
package/docs/API.md CHANGED
@@ -2052,6 +2052,11 @@ const result = await api.resources.articles.query({
2052
2052
  // GET /articles?fields[articles]=title,summary&fields[users]=name,avatar
2053
2053
  ```
2054
2054
 
2055
+ An unknown field raises `RestApiFieldsetError` with the stable code
2056
+ `REST_API_FIELDSET_INVALID` and HTTP status `400`. The same error is preserved
2057
+ for primary resources and included relationships, so transports can classify it
2058
+ without parsing error messages.
2059
+
2055
2060
  ### Including Related Resources
2056
2061
 
2057
2062
  Load related resources in a single request:
@@ -13,7 +13,7 @@ await api.addResource('posts', {
13
13
  content: { type: 'string', required: true },
14
14
  view_count: { type: 'number', default: 0, indexed: true },
15
15
  published_at: { type: 'dateTime', indexed: true },
16
- created_at: { type: 'dateTime', defaultTo: Date.now, indexed: true }
16
+ created_at: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString(), indexed: true }
17
17
  },
18
18
  relationships: {
19
19
  comments: {
@@ -54,7 +54,7 @@ await api.addResource('comments', {
54
54
  schema: {
55
55
  content: { type: 'string', required: true },
56
56
  author_name: { type: 'string', required: true, max: 100 },
57
- created_at: { type: 'dateTime', defaultTo: Date.now, indexed: true },
57
+ created_at: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString(), indexed: true },
58
58
  likes: { type: 'number', default: 0, indexed: true },
59
59
  post_id: { type: 'id', belongsTo: 'posts', as: 'post', required: true }
60
60
  },
@@ -970,7 +970,7 @@ await api.addResource('posts', {
970
970
  title: { type: 'string', required: true },
971
971
  content: { type: 'string', required: true },
972
972
  author_id: { type: 'number', belongsTo: 'authors', as: 'author' },
973
- published_at: { type: 'dateTime', default: 'now()' }
973
+ published_at: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() }
974
974
  },
975
975
  tableName: 'posts'
976
976
  });
@@ -1064,7 +1064,7 @@ await api.addResource('posts', {
1064
1064
  getter: (value) => value?.trim()
1065
1065
  },
1066
1066
  author_id: { type: 'number', belongsTo: 'authors', as: 'author' },
1067
- published_at: { type: 'dateTime', default: 'now()' }
1067
+ published_at: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() }
1068
1068
  },
1069
1069
  tableName: 'posts'
1070
1070
  });
@@ -6,7 +6,7 @@ This document explains how the JSON REST API handles date and time values throug
6
6
 
7
7
  ## Supported Date/Time Types
8
8
 
9
- The API supports three temporal data types in schemas:
9
+ The API supports three string temporal types and two numeric epoch types in schemas:
10
10
 
11
11
  ### 1. `date`
12
12
  - **Format**: `YYYY-MM-DD`
@@ -16,8 +16,8 @@ The API supports three temporal data types in schemas:
16
16
  - **JSON Output**: ISO 8601 date string
17
17
 
18
18
  ### 2. `dateTime`
19
- - **Format**: `YYYY-MM-DD HH:MM:SS` (input) / ISO 8601 (output)
20
- - **Example Input**: `2024-01-15 14:30:00`
19
+ - **Format**: RFC 3339 with seconds and an explicit `Z` or `±HH:MM` offset
20
+ - **Example Input**: `2024-01-15T14:30:00Z`
21
21
  - **Example Output**: `2024-01-15T14:30:00.000Z`
22
22
  - **Usage**: Timestamps, created/updated times, or any date with time
23
23
  - **Database Storage**: DATETIME (MySQL) or TIMESTAMP (PostgreSQL)
@@ -30,6 +30,13 @@ The API supports three temporal data types in schemas:
30
30
  - **Database Storage**: TIME column type
31
31
  - **JSON Output**: ISO 8601 time string
32
32
 
33
+ ### 4. `epochMilliseconds` and `epochSeconds`
34
+ - **Format**: An integer number or canonical base-10 integer string
35
+ - **Example**: `1767323045000` (`epochMilliseconds`) or `1767323045` (`epochSeconds`)
36
+ - **Usage**: Systems whose temporal contract is explicitly a Unix epoch value
37
+ - **Database Storage**: BIGINT for table-backed resources
38
+ - **JSON Output**: Integer number in the same unit declared by the schema type
39
+
33
40
  ## Schema Definition
34
41
 
35
42
  Define date/time fields in your schema like this:
@@ -37,12 +44,18 @@ Define date/time fields in your schema like this:
37
44
  ```javascript
38
45
  const articleSchema = {
39
46
  publishedDate: { type: 'date', required: true },
40
- createdAt: { type: 'dateTime', defaultTo: 'now()' },
41
- updatedAt: { type: 'dateTime', defaultTo: 'now()' },
47
+ createdAt: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
48
+ updatedAt: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
42
49
  dailyPostTime: { type: 'time', nullable: true }
43
50
  };
44
51
  ```
45
52
 
53
+ `Date.prototype.toISOString()` always emits three fractional-second digits, so
54
+ use it directly with unspecified precision or `temporalPrecision: 3` (or
55
+ higher). A lower precision requires a producer that returns no more than the
56
+ configured number of digits. Validation rejects excess input precision rather
57
+ than silently truncating it.
58
+
46
59
  ## Input Validation
47
60
 
48
61
  ### On Write Operations (POST/PUT/PATCH)
@@ -64,21 +77,14 @@ The API validates and normalizes date/time inputs:
64
77
  ```
65
78
 
66
79
  **Accepted Input Formats:**
67
- - **date**:
68
- - `YYYY-MM-DD` (parsed at UTC midnight)
69
- - ISO 8601 date strings
70
- - Any JavaScript Date parseable string
71
- - **dateTime**:
72
- - ISO 8601 strings (`2024-01-15T14:30:00Z`) - recommended
73
- - `YYYY-MM-DD HH:MM:SS` (assumed UTC)
74
- - JavaScript Date parseable strings
75
- - Unix timestamps (as numbers)
76
- - **time**:
77
- - `HH:MM:SS` or `HH:MM`
78
- - Extracted from datetime strings
80
+ - **date**: a real calendar date in `YYYY-MM-DD` form
81
+ - **dateTime**: an RFC 3339 string with seconds and an explicit timezone, such as `2024-01-15T14:30:00Z` or `2024-01-15T22:30:00+08:00`
82
+ - **time**: an offset-free `HH:MM[:SS[.fraction]]` string
83
+
84
+ JavaScript `Date` objects, SQL datetime strings, Unix timestamps, locale text, and other values accepted by `Date.parse()` are not valid JSON temporal inputs. Use `epochMilliseconds` or `epochSeconds` schema fields for Unix timestamps.
79
85
 
80
86
  **Storage Format:**
81
- All date/time values are converted to JavaScript Date objects before storage, allowing the database driver to handle the appropriate formatting for each database system.
87
+ Schema validation preserves valid JSON temporal strings exactly. The built-in storage adapters convert `date` and `dateTime` strings to database-native `Date` values at the database boundary, while `time` remains a string. A custom field-level `storage.serialize` function receives the validated string and owns any custom database conversion.
82
88
 
83
89
  ## Output Normalization
84
90
 
@@ -105,9 +111,15 @@ All date/time values are normalized when returned from the API:
105
111
  ### Key Normalization Behaviors:
106
112
 
107
113
  1. **Boolean Normalization**: Database values of `1`/`0` are converted to `true`/`false`
108
- 2. **Date Objects**: All date/time values are returned as JavaScript Date objects internally, then serialized to ISO 8601 strings in JSON responses
114
+ 2. **JSON Temporal Values**: `date`, `dateTime`, and `time` attributes are returned as strings that match the same public schema contract accepted on input
109
115
  3. **UTC Assumption**: MySQL DATETIME values (which lack timezone info) are assumed to be UTC
110
116
 
117
+ Normalization runs once after the database read and again at the final response
118
+ boundary. The final pass covers getters, computed fields, and finishing hooks
119
+ that produce native `Date` values. A malformed non-null temporal value is not
120
+ reported as `null`; it fails with `REST_API_TEMPORAL_DATA_INVALID` and HTTP 500
121
+ so stored-data or enrichment defects remain visible.
122
+
111
123
  ## Database-Specific Handling
112
124
 
113
125
  ### MySQL
@@ -164,7 +176,7 @@ If migrating from a system that stores dates differently:
164
176
 
165
177
  1. **Local Time Storage**: Convert all dates to UTC before importing
166
178
  2. **String Storage**: Ensure strings match expected formats
167
- 3. **Numeric Timestamps**: Use `timestamp` type for Unix timestamps
179
+ 3. **Numeric Timestamps**: Use `epochMilliseconds` or `epochSeconds`, matching the producer's actual unit
168
180
 
169
181
  ### Database Configuration
170
182
 
@@ -206,23 +218,24 @@ For optimal date handling, configure your database connection:
206
218
 
207
219
  ### Issue 3: Time Values Need Date Context
208
220
  **Symptom**: Can't perform date arithmetic on time-only values
209
- **Cause**: Time values lack date context
210
- **Solution**: The API attaches times to epoch date (1970-01-01) in UTC
221
+ **Cause**: Time values intentionally have no date or timezone context
222
+ **Solution**: Keep recurring wall-clock values as `time`; use `dateTime` when an instant is required
211
223
 
212
224
  ## Technical Implementation Details
213
225
 
214
226
  The date/time handling is implemented in two key areas:
215
227
 
216
228
  1. **Input Validation** (`json-rest-schema`):
217
- - Validates format on write operations
218
- - Converts all date inputs to JavaScript Date objects
219
- - Ensures date-only values parse at UTC midnight
220
- - Returns Date objects for storage (Knex handles DB-specific formatting)
229
+ - Validates strict JSON temporal strings on write operations
230
+ - Preserves accepted strings exactly
231
+ - Rejects JavaScript `Date` objects and permissive legacy date formats
221
232
 
222
- 2. **Output Normalization** (`database-value-normalizers.js`):
233
+ 2. **Storage and Output Normalization** (`storage` adapters and `database-value-normalizers.js`):
234
+ - Converts validated `date` and `dateTime` strings at the database boundary
235
+ - Uses the same database-native representation for writes and query comparisons
223
236
  - Handles database-specific quirks (MySQL timezone issues)
224
- - Ensures Date objects are properly created from database values
237
+ - Parses database date values before emitting schema-valid JSON strings
225
238
  - Fixes MySQL datetime strings by assuming UTC
226
239
  - Maintains consistency across different database engines
227
240
 
228
- This two-stage approach ensures data integrity on input and consistent formatting on output, regardless of the underlying database system. The key insight is that JavaScript Date objects are used as the common format throughout the pipeline, with database drivers handling the conversion to/from their native formats.
241
+ This separation keeps the public JSON contract strict and serializable while still giving database drivers native values at the storage boundary.
package/index.js CHANGED
@@ -33,6 +33,8 @@ export { S3Storage } from './plugins/storage/s3-storage.js'
33
33
 
34
34
  // Error classes for consumers who need them
35
35
  export {
36
+ RestApiFieldsetError,
37
+ RestApiTemporalDataError,
36
38
  RestApiValidationError,
37
39
  RestApiResourceError,
38
40
  RestApiPayloadError
@@ -0,0 +1,21 @@
1
+ import { HookedApiError } from 'hooked-api'
2
+
3
+ export function isRestApiError (error) {
4
+ return error instanceof HookedApiError
5
+ }
6
+
7
+ /**
8
+ * Add operational context to unexpected failures without erasing typed API
9
+ * errors that callers and transports need to classify.
10
+ */
11
+ export function wrapUnexpectedError (error, { message, context } = {}) {
12
+ if (isRestApiError(error)) {
13
+ return error
14
+ }
15
+
16
+ const wrappedError = new Error(`${message}: ${error.message}`, {
17
+ cause: error
18
+ })
19
+ wrappedError.context = context
20
+ return wrappedError
21
+ }
@@ -1,5 +1,8 @@
1
1
  import { HookedApiError } from 'hooked-api'
2
2
 
3
+ export const REST_API_FIELDSET_ERROR_CODE = 'REST_API_FIELDSET_INVALID'
4
+ export const REST_API_TEMPORAL_DATA_ERROR_CODE = 'REST_API_TEMPORAL_DATA_INVALID'
5
+
3
6
  /**
4
7
  * REST API specific error classes that can be mapped to HTTP status codes
5
8
  * by the HTTP plugin or other protocol plugins
@@ -90,3 +93,70 @@ export class RestApiPayloadError extends HookedApiError {
90
93
  }
91
94
  }
92
95
  }
96
+
97
+ /**
98
+ * Invalid JSON:API sparse fieldset.
99
+ *
100
+ * The structured code lets transports distinguish invalid client input from
101
+ * query or include failures without parsing the human-readable message.
102
+ */
103
+ export class RestApiFieldsetError extends HookedApiError {
104
+ constructor ({ field, resourceType } = {}) {
105
+ super(
106
+ `Unknown sparse field '${field}' requested for '${resourceType}'`,
107
+ REST_API_FIELDSET_ERROR_CODE
108
+ )
109
+ this.type = 'rest_api_fieldset'
110
+ this.statusCode = 400
111
+ this.details = {
112
+ field,
113
+ resourceType
114
+ }
115
+ }
116
+
117
+ toJSON () {
118
+ return {
119
+ name: this.name,
120
+ message: this.message,
121
+ code: this.code,
122
+ type: this.type,
123
+ statusCode: this.statusCode,
124
+ details: this.details,
125
+ stack: this.stack
126
+ }
127
+ }
128
+ }
129
+
130
+ /**
131
+ * A non-null temporal value produced by storage or response enrichment does
132
+ * not satisfy the resource's public temporal contract.
133
+ */
134
+ export class RestApiTemporalDataError extends HookedApiError {
135
+ constructor ({ field, resourceType, fieldType, source = 'database' } = {}) {
136
+ const fieldPath = [resourceType, field].filter(Boolean).join('.') || 'unknown field'
137
+ super(
138
+ `Invalid ${source} value for ${fieldType || 'temporal'} field '${fieldPath}'.`,
139
+ REST_API_TEMPORAL_DATA_ERROR_CODE
140
+ )
141
+ this.type = 'rest_api_temporal_data'
142
+ this.statusCode = 500
143
+ this.details = {
144
+ field,
145
+ resourceType,
146
+ fieldType,
147
+ source
148
+ }
149
+ }
150
+
151
+ toJSON () {
152
+ return {
153
+ name: this.name,
154
+ message: this.message,
155
+ code: this.code,
156
+ type: this.type,
157
+ statusCode: this.statusCode,
158
+ details: this.details,
159
+ stack: this.stack
160
+ }
161
+ }
162
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "json-rest-api",
3
- "version": "1.0.26",
3
+ "version": "1.0.28",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -29,7 +29,7 @@
29
29
  "description": "REST API plugin for hooked-api with JSON:API compliance",
30
30
  "dependencies": {
31
31
  "hooked-api": "^1.0.24",
32
- "json-rest-schema": "^1.0.14",
32
+ "json-rest-schema": "^1.0.17",
33
33
  "semver": "^7.7.2"
34
34
  },
35
35
  "peerDependencies": {
@@ -1,3 +1,8 @@
1
+ import {
2
+ REST_API_FIELDSET_ERROR_CODE,
3
+ REST_API_TEMPORAL_DATA_ERROR_CODE
4
+ } from '../../../../lib/rest-api-errors.js'
5
+
1
6
  const JSON_API_WRITE_CONTENT_TYPES = [
2
7
  'application/vnd.api+json',
3
8
  'application/json'
@@ -63,6 +68,22 @@ export function mapRestApiErrorToHttp (error) {
63
68
  detail: issue.message,
64
69
  ...(issue.instancePath ? { source: { pointer: issue.instancePath } } : {})
65
70
  }))
71
+ } else if (error.code === REST_API_FIELDSET_ERROR_CODE) {
72
+ status = 400
73
+ errors = [{
74
+ status: '400',
75
+ code: error.code,
76
+ title: 'Invalid Sparse Fieldset',
77
+ detail: error.message
78
+ }]
79
+ } else if (error.code === REST_API_TEMPORAL_DATA_ERROR_CODE) {
80
+ status = 500
81
+ errors = [{
82
+ status: '500',
83
+ code: error.code,
84
+ title: 'Invalid Temporal Data',
85
+ detail: error.message
86
+ }]
66
87
  } else if (error.code === 'REST_API_VALIDATION') {
67
88
  status = 422
68
89
  if (error.details?.violations?.length) {
@@ -219,8 +219,9 @@ export const TYPE_TO_POOL = new Map([
219
219
  ['date', 'date'],
220
220
  ['datetime', 'date'],
221
221
  ['dateTime', 'date'],
222
- ['timestamp', 'date'],
223
222
  ['time', 'date'],
223
+ ['epochMilliseconds', 'number'],
224
+ ['epochSeconds', 'number'],
224
225
  ['json', 'json'],
225
226
  ['object', 'json'],
226
227
  ])
@@ -1,5 +1,5 @@
1
1
  import { RestApiValidationError } from '../../../../../lib/rest-api-errors.js'
2
- import { normalizeDateValue } from '../../querying-writing/database-value-normalizers.js'
2
+ import { normalizeValueForDatabaseStorage } from '../../querying-writing/database-value-normalizers.js'
3
3
 
4
4
  export const normalizeId = (value) => (value === null || value === undefined ? null : String(value))
5
5
 
@@ -93,7 +93,9 @@ export const coerceValueForDefinition = (value, definition, { isRelationship } =
93
93
  }
94
94
 
95
95
  if (['date', 'dateTime', 'time'].includes(type)) {
96
- return normalizeDateValue(value, type)
96
+ return normalizeValueForDatabaseStorage(value, type, {
97
+ temporalPrecision: definition?.temporalPrecision
98
+ })
97
99
  }
98
100
 
99
101
  return value
@@ -300,7 +300,8 @@ function normalizeColumnShape (definition = {}) {
300
300
  return 'datetime'
301
301
  case 'time':
302
302
  return 'time'
303
- case 'timestamp':
303
+ case 'epochMilliseconds':
304
+ case 'epochSeconds':
304
305
  return 'integer'
305
306
  case 'array':
306
307
  case 'object':
@@ -344,7 +345,8 @@ function normalizeDesiredTypeKind (definition = {}) {
344
345
  case 'blob':
345
346
  case 'file':
346
347
  return 'binary'
347
- case 'timestamp':
348
+ case 'epochMilliseconds':
349
+ case 'epochSeconds':
348
350
  return 'integer'
349
351
  case 'string':
350
352
  case 'none':
@@ -361,6 +363,10 @@ function normalizeComparableDefault (value) {
361
363
  return String(value)
362
364
  }
363
365
 
366
+ const hasStaticDefault = (definition = {}) => (
367
+ definition.defaultTo !== undefined && typeof definition.defaultTo !== 'function'
368
+ )
369
+
364
370
  function normalizeDesiredColumn (tableContext, fieldName, definition, options = {}) {
365
371
  const nullable = definition.nullable === true
366
372
  ? true
@@ -375,8 +381,10 @@ function normalizeDesiredColumn (tableContext, fieldName, definition, options =
375
381
  typeKind: normalizeDesiredTypeKind(definition),
376
382
  nullable,
377
383
  required: definition.required === true,
378
- hasDefault: definition.defaultTo !== undefined,
379
- defaultValue: normalizeComparableDefault(definition.defaultTo),
384
+ hasDefault: hasStaticDefault(definition),
385
+ defaultValue: hasStaticDefault(definition)
386
+ ? normalizeComparableDefault(definition.defaultTo)
387
+ : undefined,
380
388
  unsigned: definition.type === 'id' ? definition.unsigned !== false : definition.unsigned === true,
381
389
  maxLength: definition.maxLength ?? null,
382
390
  numericPrecision: definition.precision ?? null,
@@ -499,8 +507,9 @@ function mapTypeToKnex (table, columnName, definition, options = {}) {
499
507
  ? table.time(columnName, { precision: temporalPrecision })
500
508
  : table.time(columnName)
501
509
 
502
- case 'timestamp':
503
- return table.integer(columnName)
510
+ case 'epochMilliseconds':
511
+ case 'epochSeconds':
512
+ return table.bigInteger(columnName)
504
513
 
505
514
  case 'array':
506
515
  case 'object':
@@ -524,7 +533,7 @@ function applyColumnConstraints (column, definition) {
524
533
  column.notNullable()
525
534
  }
526
535
 
527
- if (definition.defaultTo !== undefined) {
536
+ if (hasStaticDefault(definition)) {
528
537
  column.defaultTo(definition.defaultTo)
529
538
  }
530
539
 
@@ -631,8 +640,9 @@ function buildColumnBuilderCode (columnName, definition, options = {}) {
631
640
  }
632
641
  break
633
642
 
634
- case 'timestamp':
635
- line = `table.integer(${quoteJsString(columnName)})`
643
+ case 'epochMilliseconds':
644
+ case 'epochSeconds':
645
+ line = `table.bigInteger(${quoteJsString(columnName)})`
636
646
  break
637
647
 
638
648
  case 'array':
@@ -658,7 +668,7 @@ function buildColumnBuilderCode (columnName, definition, options = {}) {
658
668
  line += '.notNullable()'
659
669
  }
660
670
 
661
- if (definition.defaultTo !== undefined) {
671
+ if (hasStaticDefault(definition)) {
662
672
  line += `.defaultTo(${formatCodeLiteral(definition.defaultTo)})`
663
673
  }
664
674
 
@@ -1,5 +1,6 @@
1
1
  import { getFieldValue } from '../storage/storage-mapping.js'
2
2
  import { serializeJsonApiQuery } from '../querying-writing/connectors-query-parser.js'
3
+ import { normalizeDateValue } from '../querying-writing/database-value-normalizers.js'
3
4
 
4
5
  const buildJsonApiLink = (baseUrl, queryParams, page) => {
5
6
  const queryString = serializeJsonApiQuery(queryParams, { page })
@@ -236,9 +237,18 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
236
237
  export const createCursor = (record, sortFields = ['id'], { schemaInfo = null } = {}) => {
237
238
  const parts = []
238
239
  sortFields.forEach(field => {
239
- const value = schemaInfo
240
+ let value = schemaInfo
240
241
  ? getFieldValue(record, schemaInfo, field)
241
242
  : record[field]
243
+ const definition = schemaInfo?.schemaStructure?.[field]
244
+ if (['date', 'dateTime', 'time'].includes(definition?.type) && value != null) {
245
+ value = normalizeDateValue(value, definition.type, {
246
+ temporalPrecision: definition.temporalPrecision,
247
+ fieldName: field,
248
+ resourceType: schemaInfo?.scopeName || schemaInfo?.tableName,
249
+ source: 'cursor'
250
+ })
251
+ }
242
252
  if (value !== undefined) {
243
253
  const stringValue = value instanceof Date ? value.toISOString() : String(value)
244
254
  parts.push(`${field}:${encodeURIComponent(stringValue)}`)
@@ -1,4 +1,5 @@
1
1
  import { buildIncludedResources } from './knex-relationship-includes.js'
2
+ import { isRestApiError, wrapUnexpectedError } from '../../../../lib/error-context.js'
2
3
 
3
4
  /**
4
5
  * Processes the ?include= parameter to load related resources efficiently
@@ -143,6 +144,8 @@ export const processIncludes = async (scope, records, deps) => {
143
144
 
144
145
  return includeResult.included
145
146
  } catch (error) {
147
+ if (isRestApiError(error)) throw error
148
+
146
149
  const { log, context } = deps || {}
147
150
 
148
151
  if (log) {
@@ -157,10 +160,13 @@ export const processIncludes = async (scope, records, deps) => {
157
160
  console.error('[PROCESS-INCLUDES] Error processing includes:', error)
158
161
  }
159
162
 
160
- const enhancedError = new Error(
161
- `Failed to process includes${context?.scopeName ? ` for scope '${context.scopeName}'` : ''}: ${error.message}`
162
- )
163
- enhancedError.originalError = error
164
- throw enhancedError
163
+ throw wrapUnexpectedError(error, {
164
+ message: `Failed to process includes${context?.scopeName ? ` for scope '${context.scopeName}'` : ''}`,
165
+ context: {
166
+ scopeName: context?.scopeName,
167
+ includeParam: context?.queryParams?.include,
168
+ recordCount: records?.length || 0
169
+ }
170
+ })
165
171
  }
166
172
  }