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
package/docs/API.md CHANGED
@@ -2549,6 +2549,8 @@ The library uses standard JSON:API error format:
2549
2549
 
2550
2550
  ### Custom Error Handling
2551
2551
 
2552
+ Typed API errors thrown by getters, setters, computed fields, file detectors, or upload adapters retain their original code and details. For example, throwing `new RestApiResourceError('Upload forbidden', { subtype: 'forbidden' })` from an upload adapter produces a 403 response and still runs temporary-file cleanup.
2553
+
2552
2554
  ```javascript
2553
2555
  // In hooks
2554
2556
  api.resource('articles').hook('beforeDataPost', async (context) => {
@@ -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
  },
@@ -96,6 +96,8 @@ await api.resources.post_tags.createKnexTable();
96
96
 
97
97
  **Basic Pagination**
98
98
 
99
+ When `sortableFields` is omitted or empty, visible stored schema fields and relationship aliases can be sorted. Query fields marked `sortable: true` are added to that set. Unknown fields and ordinary computed fields are rejected with 422. If a sort field is repeated, its first occurrence determines the direction.
100
+
99
101
  The API supports two types of pagination:
100
102
 
101
103
  1. **Offset-based pagination** using `page[number]` and `page[size]` parameters
@@ -671,6 +673,13 @@ Next multiField page: {
671
673
 
672
674
  **Cursor Pagination Notes:**
673
675
  - Cursors are simple strings containing the sort field values (format: `field:value,field2:value2`)
676
+ - Sparse fieldsets may omit sort fields from the response; the Knex query still fetches those values internally to construct a complete cursor.
677
+ - A cursor missing any active sort field is rejected with a validation error. Keep the same sort order when following a cursor.
678
+ - Cursor values are validated against the sort field types. Invalid dates, numbers, or duplicate cursor fields produce HTTP 422.
679
+ - `page[size]` and `page[number]` must be positive integers. Choose one of `page[number]`, `page[after]`, or `page[before]`; these cannot be combined.
680
+ - Null sort values appear last in either sort direction. Generated cursors distinguish null from the literal string `"null"`; treat cursor strings as opaque values.
681
+ - `page[before]` returns the adjacent preceding records in the requested sort order. Use a cursor for the first record of the current page to step back. When a backward response has more preceding records, its `links.next` and `meta.pagination.cursor.next` continue in the `before` direction.
682
+ - Requested page sizes above `queryMaxLimit` are capped consistently for returned records, pagination metadata, and links.
674
683
  - The API automatically generates appropriate WHERE clauses based on sort direction
675
684
  - Cursor pagination is more efficient for large datasets as it doesn't need to count all records
676
685
  - Works seamlessly with any sort field, not just timestamps
@@ -132,7 +132,7 @@ await api.addResource('users', {
132
132
  });
133
133
  ```
134
134
 
135
- If a setter throws or rejects, the write fails with a validation error and the transaction rolls back instead of storing the untransformed value.
135
+ If a setter throws or rejects, the write fails and the transaction rolls back. Typed API errors retain their code and details; other setter failures become validation errors. Getters and computed fields also propagate typed API errors, including `RestApiTemporalDataError`, so invalid output cannot be silently accepted.
136
136
 
137
137
  ### Getters - Transform After Retrieval
138
138
 
@@ -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,28 @@ 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.
88
+
89
+ The built-in `dateTime` conversion supports millisecond precision. A value such
90
+ as `2026-09-01T10:20:30.123456Z` fails with HTTP 422 before the write, because
91
+ converting it to `Date` would lose information. Trailing zeroes beyond three
92
+ digits are accepted (`.123000Z` represents exactly `.123Z`). To retain finer
93
+ precision in table-backed storage, use a custom `storage.serialize` function
94
+ and a database column and driver that preserve it. The same serializer is used
95
+ for filters and cursor comparisons, with `operation: 'filter'` and `context: null`.
96
+
97
+ Response normalization preserves fractional digits returned as strings by
98
+ storage, up to the field's declared precision, including during conversion of
99
+ timezone offsets to UTC. It also converts valid epoch strings and `bigint`
100
+ values from database drivers to JSON numbers, rejecting invalid or out-of-range
101
+ epoch data.
82
102
 
83
103
  ## Output Normalization
84
104
 
@@ -105,9 +125,16 @@ All date/time values are normalized when returned from the API:
105
125
  ### Key Normalization Behaviors:
106
126
 
107
127
  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
128
+ 2. **JSON Temporal Values**: `date`, `dateTime`, and `time` attributes are returned as strings that match the same public schema contract accepted on input
109
129
  3. **UTC Assumption**: MySQL DATETIME values (which lack timezone info) are assumed to be UTC
110
130
 
131
+ Normalization runs once after the database read and again at the final response
132
+ boundary. The final pass covers getters, computed fields, query projections,
133
+ and finishing hooks that produce native `Date` values, including nested
134
+ relationships in simplified write responses. A malformed non-null temporal value is not
135
+ reported as `null`; it fails with `REST_API_TEMPORAL_DATA_INVALID` and HTTP 500
136
+ so stored-data or enrichment defects remain visible.
137
+
111
138
  ## Database-Specific Handling
112
139
 
113
140
  ### MySQL
@@ -164,7 +191,7 @@ If migrating from a system that stores dates differently:
164
191
 
165
192
  1. **Local Time Storage**: Convert all dates to UTC before importing
166
193
  2. **String Storage**: Ensure strings match expected formats
167
- 3. **Numeric Timestamps**: Use `timestamp` type for Unix timestamps
194
+ 3. **Numeric Timestamps**: Use `epochMilliseconds` or `epochSeconds`, matching the producer's actual unit
168
195
 
169
196
  ### Database Configuration
170
197
 
@@ -206,23 +233,24 @@ For optimal date handling, configure your database connection:
206
233
 
207
234
  ### Issue 3: Time Values Need Date Context
208
235
  **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
236
+ **Cause**: Time values intentionally have no date or timezone context
237
+ **Solution**: Keep recurring wall-clock values as `time`; use `dateTime` when an instant is required
211
238
 
212
239
  ## Technical Implementation Details
213
240
 
214
241
  The date/time handling is implemented in two key areas:
215
242
 
216
243
  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)
244
+ - Validates strict JSON temporal strings on write operations
245
+ - Preserves accepted strings exactly
246
+ - Rejects JavaScript `Date` objects and permissive legacy date formats
221
247
 
222
- 2. **Output Normalization** (`database-value-normalizers.js`):
248
+ 2. **Storage and Output Normalization** (`storage` adapters and `database-value-normalizers.js`):
249
+ - Converts validated `date` and `dateTime` strings at the database boundary
250
+ - Uses the same database-native representation for writes and query comparisons
223
251
  - Handles database-specific quirks (MySQL timezone issues)
224
- - Ensures Date objects are properly created from database values
252
+ - Parses database date values before emitting schema-valid JSON strings
225
253
  - Fixes MySQL datetime strings by assuming UTC
226
254
  - Maintains consistency across different database engines
227
255
 
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.
256
+ 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
@@ -34,6 +34,7 @@ export { S3Storage } from './plugins/storage/s3-storage.js'
34
34
  // Error classes for consumers who need them
35
35
  export {
36
36
  RestApiFieldsetError,
37
+ RestApiTemporalDataError,
37
38
  RestApiValidationError,
38
39
  RestApiResourceError,
39
40
  RestApiPayloadError
@@ -1,6 +1,7 @@
1
1
  import { HookedApiError } from 'hooked-api'
2
2
 
3
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'
4
5
 
5
6
  /**
6
7
  * REST API specific error classes that can be mapped to HTTP status codes
@@ -125,3 +126,37 @@ export class RestApiFieldsetError extends HookedApiError {
125
126
  }
126
127
  }
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.27",
3
+ "version": "1.0.29",
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,4 +1,7 @@
1
- import { REST_API_FIELDSET_ERROR_CODE } from '../../../../lib/rest-api-errors.js'
1
+ import {
2
+ REST_API_FIELDSET_ERROR_CODE,
3
+ REST_API_TEMPORAL_DATA_ERROR_CODE
4
+ } from '../../../../lib/rest-api-errors.js'
2
5
 
3
6
  const JSON_API_WRITE_CONTENT_TYPES = [
4
7
  'application/vnd.api+json',
@@ -73,6 +76,14 @@ export function mapRestApiErrorToHttp (error) {
73
76
  title: 'Invalid Sparse Fieldset',
74
77
  detail: error.message
75
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
+ }]
76
87
  } else if (error.code === 'REST_API_VALIDATION') {
77
88
  status = 422
78
89
  if (error.details?.violations?.length) {
@@ -33,6 +33,7 @@
33
33
  */
34
34
 
35
35
  import { RestApiValidationError } from '../../lib/rest-api-errors.js'
36
+ import { isRestApiError } from '../../lib/error-context.js'
36
37
 
37
38
  export const FileHandlingPlugin = {
38
39
  name: 'file-handling',
@@ -165,6 +166,7 @@ export const FileHandlingPlugin = {
165
166
  break
166
167
  }
167
168
  } catch (error) {
169
+ if (isRestApiError(error)) throw error
168
170
  log.warn(`Detector '${detector.name}' failed:`, error)
169
171
  }
170
172
  }
@@ -269,6 +271,7 @@ export const FileHandlingPlugin = {
269
271
  })
270
272
  log.debug(`Uploaded file for field '${fieldConfig.field}' to: ${storedUrl}`)
271
273
  } catch (error) {
274
+ if (isRestApiError(error)) throw error
272
275
  throw new RestApiValidationError(
273
276
  `Failed to upload file for field '${fieldConfig.field}': ${error.message}`,
274
277
  {
@@ -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 })
@@ -157,7 +158,7 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
157
158
 
158
159
  if (pageCount !== undefined) {
159
160
  links.first = buildJsonApiLink(baseUrl, queryParams, { number: 1, size: pageSize })
160
- links.last = buildJsonApiLink(baseUrl, queryParams, { number: pageCount, size: pageSize })
161
+ links.last = buildJsonApiLink(baseUrl, queryParams, { number: Math.max(1, pageCount), size: pageSize })
161
162
 
162
163
  if (page > 1) {
163
164
  links.prev = buildJsonApiLink(baseUrl, queryParams, { number: page - 1, size: pageSize })
@@ -233,15 +234,28 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
233
234
  * 4. Cursor used in 'next' link for fetching subsequent pages
234
235
  * 5. Enables efficient "WHERE (field1, field2) > (val1, val2)" queries
235
236
  */
236
- export const createCursor = (record, sortFields = ['id'], { schemaInfo = null } = {}) => {
237
+ export const createCursor = (record, sortFields = ['id'], { schemaInfo = null, definitions = {} } = {}) => {
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 = definitions[field] || schemaInfo?.schemaStructure?.[field]
244
+ if (value === null) {
245
+ parts.push(`${field}:~null`)
246
+ return
247
+ }
248
+ if (['date', 'dateTime', 'time'].includes(definition?.type) && value != null) {
249
+ value = normalizeDateValue(value, definition.type, {
250
+ temporalPrecision: definition.temporalPrecision,
251
+ fieldName: field,
252
+ resourceType: schemaInfo?.scopeName || schemaInfo?.tableName,
253
+ source: 'cursor'
254
+ })
255
+ }
242
256
  if (value !== undefined) {
243
257
  const stringValue = value instanceof Date ? value.toISOString() : String(value)
244
- parts.push(`${field}:${encodeURIComponent(stringValue)}`)
258
+ parts.push(`${field}:${encodeURIComponent(stringValue).replace(/^~/, '%7E')}`)
245
259
  }
246
260
  })
247
261
  return parts.join(',')
@@ -301,7 +315,7 @@ export const createCursor = (record, sortFields = ['id'], { schemaInfo = null }
301
315
  */
302
316
  export const parseCursor = (cursor) => {
303
317
  try {
304
- const data = {}
318
+ const data = Object.create(null)
305
319
  if (!cursor || cursor.trim() === '') {
306
320
  throw new Error('Empty cursor')
307
321
  }
@@ -321,7 +335,8 @@ export const parseCursor = (cursor) => {
321
335
  throw new Error('Invalid cursor format: empty field name')
322
336
  }
323
337
 
324
- data[field] = decodeURIComponent(encodedValue)
338
+ if (Object.hasOwn(data, field)) throw new Error(`Duplicate cursor field '${field}'`)
339
+ data[field] = encodedValue === '~null' ? null : decodeURIComponent(encodedValue)
325
340
  }
326
341
 
327
342
  return data
@@ -413,7 +428,7 @@ export const generateCursorPaginationLinks = (
413
428
  pageSize,
414
429
  hasMore,
415
430
  sortFields = ['id'],
416
- { schemaInfo = null } = {}
431
+ { schemaInfo = null, definitions = {} } = {}
417
432
  ) => {
418
433
  // Allow empty urlPrefix to generate relative links
419
434
  if (!records.length) return null
@@ -432,9 +447,10 @@ export const generateCursorPaginationLinks = (
432
447
  links.first = buildJsonApiLink(baseUrl, queryParams, { size: pageSize })
433
448
 
434
449
  if (hasMore && records.length > 0) {
435
- const lastRecord = records[records.length - 1]
436
- const nextCursor = createCursor(lastRecord, sortFields, { schemaInfo })
437
- links.next = buildJsonApiLink(baseUrl, queryParams, { size: pageSize, after: nextCursor })
450
+ const direction = queryParams.page?.before ? 'before' : 'after'
451
+ const boundary = direction === 'before' ? records[0] : records[records.length - 1]
452
+ const nextCursor = createCursor(boundary, sortFields, { schemaInfo, definitions })
453
+ links.next = buildJsonApiLink(baseUrl, queryParams, { size: pageSize, [direction]: nextCursor })
438
454
  }
439
455
 
440
456
  return links
@@ -497,16 +513,16 @@ export const generateCursorPaginationLinks = (
497
513
  * 3. Added to response.meta.pagination
498
514
  * 4. Clients can use cursor directly or use the next link
499
515
  */
500
- export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'], { schemaInfo = null } = {}) => {
516
+ export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'], { schemaInfo = null, definitions = {}, before = false } = {}) => {
501
517
  const meta = {
502
518
  pageSize,
503
519
  hasMore
504
520
  }
505
521
 
506
522
  if (hasMore && records.length > 0) {
507
- const lastRecord = records[records.length - 1]
523
+ const lastRecord = before ? records[0] : records[records.length - 1]
508
524
  meta.cursor = {
509
- next: createCursor(lastRecord, sortFields, { schemaInfo })
525
+ next: createCursor(lastRecord, sortFields, { schemaInfo, definitions })
510
526
  }
511
527
  }
512
528