json-rest-api 1.0.24 → 1.0.25

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 (82) hide show
  1. package/README.md +2 -1
  2. package/docs/API.md +12 -9
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +5 -2
  4. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
  5. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
  6. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
  7. package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
  8. package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
  9. package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
  10. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  11. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  12. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  13. package/docs/GUIDE/index.md +4 -1
  14. package/fixing_plan.md +112 -0
  15. package/index.js +2 -0
  16. package/package.json +1 -1
  17. package/plugins/core/bulk-operations-plugin.js +5 -4
  18. package/plugins/core/connectors/express-plugin.js +6 -1
  19. package/plugins/core/connectors/fastify-plugin.js +5 -1
  20. package/plugins/core/connectors/lib/connector-core.js +17 -11
  21. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  22. package/plugins/core/file-handling-plugin.js +126 -96
  23. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  24. package/plugins/core/lib/anyapi/schema-utils.js +1 -0
  25. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
  26. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
  27. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  28. package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
  29. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  30. package/plugins/core/lib/querying/url-helpers.js +9 -21
  31. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  32. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  33. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  34. package/plugins/core/lib/querying-writing/knex-field-helpers.js +6 -1
  35. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  36. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  37. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  38. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
  39. package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
  40. package/plugins/core/lib/storage/storage-adapter.js +74 -13
  41. package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
  42. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  43. package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
  44. package/plugins/core/rest-api-knex-plugin.js +70 -60
  45. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
  46. package/plugins/core/rest-api-plugin-methods/common.js +85 -63
  47. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
  48. package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
  49. package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
  50. package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
  51. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
  52. package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
  53. package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
  54. package/plugins/core/rest-api-plugin-methods/post.js +4 -7
  55. package/plugins/core/rest-api-plugin-methods/put.js +4 -7
  56. package/plugins/core/rest-api-plugin.js +6 -36
  57. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  58. package/plugins/storage/local-storage.js +44 -19
  59. package/plugins/storage/s3-storage.js +21 -55
  60. package/tests/README.md +1 -1
  61. package/tests/anyapi-cursor-pagination.test.js +7 -1
  62. package/tests/anyapi-custom-idproperty.test.js +163 -0
  63. package/tests/fastify-plugin.test.js +28 -0
  64. package/tests/field-setters.test.js +10 -10
  65. package/tests/file-handling.test.js +118 -0
  66. package/tests/fixtures/api-configs.js +362 -4
  67. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  68. package/tests/http-connectors-parity.test.js +165 -5
  69. package/tests/local-storage.test.js +96 -0
  70. package/tests/pagination-cursor-multifield.test.js +121 -0
  71. package/tests/pagination-enhanced.test.js +66 -15
  72. package/tests/post-commit-hooks.test.js +120 -0
  73. package/tests/public-exports.test.js +15 -0
  74. package/tests/put-create-parity.test.js +216 -0
  75. package/tests/relationship-endpoints.test.js +259 -0
  76. package/tests/return-record-settings.test.js +184 -0
  77. package/tests/row-policy.test.js +433 -0
  78. package/tests/s3-storage.test.js +38 -0
  79. package/tests/searchschema-merge.test.js +17 -0
  80. package/tests/socketio.test.js +62 -0
  81. package/tests/storage-mapping.test.js +1 -0
  82. package/tests/test-development-guide.md +5 -5
@@ -1,3 +1,11 @@
1
+ import { getFieldValue } from '../storage/storage-mapping.js'
2
+ import { serializeJsonApiQuery } from '../querying-writing/connectors-query-parser.js'
3
+
4
+ const buildJsonApiLink = (baseUrl, queryParams, page) => {
5
+ const queryString = serializeJsonApiQuery(queryParams, { page })
6
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
7
+ }
8
+
1
9
  /**
2
10
  * Calculates pagination metadata from query results
3
11
  *
@@ -127,7 +135,7 @@ export const calculatePaginationMeta = (total, page, pageSize) => {
127
135
  * Purpose:
128
136
  * - JSON:API spec requires pagination links for easy navigation
129
137
  * - Preserves all other query parameters (filters, sorts, includes)
130
- * - Handles complex nested parameters like filter[author][name]=John
138
+ * - Uses the shared JSON:API query serializer for transport-supported params
131
139
  * - Only includes prev/next when applicable
132
140
  *
133
141
  * Data flow:
@@ -143,52 +151,20 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
143
151
  const { page, pageCount, pageSize } = paginationMeta
144
152
  const links = {}
145
153
 
146
- const otherParams = Object.entries(queryParams)
147
- .filter(([key]) => key !== 'page')
148
- .flatMap(([key, value]) => {
149
- if (Array.isArray(value)) {
150
- return value.length === 0
151
- ? []
152
- : value.map((v) => `${key}=${encodeURIComponent(v)}`)
153
- }
154
- if (typeof value === 'object' && value !== null) {
155
- const parts = []
156
- const processObject = (obj, prefix) => {
157
- Object.entries(obj).forEach(([k, v]) => {
158
- const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`
159
- if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
160
- processObject(v, newKey)
161
- } else if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
162
- parts.push(`${newKey}=${encodeURIComponent(v)}`)
163
- }
164
- })
165
- }
166
- processObject(value, key)
167
- return parts
168
- }
169
- if (value === undefined || value === null) {
170
- return []
171
- }
172
- return [`${key}=${encodeURIComponent(value)}`]
173
- })
174
- .filter(Boolean)
175
- .join('&')
176
-
177
154
  const baseUrl = `${urlPrefix}/${scopeName}`
178
- const queryPrefix = otherParams ? `?${otherParams}&` : '?'
179
155
 
180
- links.self = `${baseUrl}${queryPrefix}page[number]=${page}&page[size]=${pageSize}`
156
+ links.self = buildJsonApiLink(baseUrl, queryParams, { number: page, size: pageSize })
181
157
 
182
158
  if (pageCount !== undefined) {
183
- links.first = `${baseUrl}${queryPrefix}page[number]=1&page[size]=${pageSize}`
184
- links.last = `${baseUrl}${queryPrefix}page[number]=${pageCount}&page[size]=${pageSize}`
159
+ links.first = buildJsonApiLink(baseUrl, queryParams, { number: 1, size: pageSize })
160
+ links.last = buildJsonApiLink(baseUrl, queryParams, { number: pageCount, size: pageSize })
185
161
 
186
162
  if (page > 1) {
187
- links.prev = `${baseUrl}${queryPrefix}page[number]=${page - 1}&page[size]=${pageSize}`
163
+ links.prev = buildJsonApiLink(baseUrl, queryParams, { number: page - 1, size: pageSize })
188
164
  }
189
165
 
190
166
  if (page < pageCount) {
191
- links.next = `${baseUrl}${queryPrefix}page[number]=${page + 1}&page[size]=${pageSize}`
167
+ links.next = buildJsonApiLink(baseUrl, queryParams, { number: page + 1, size: pageSize })
192
168
  }
193
169
  }
194
170
 
@@ -257,11 +233,13 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
257
233
  * 4. Cursor used in 'next' link for fetching subsequent pages
258
234
  * 5. Enables efficient "WHERE (field1, field2) > (val1, val2)" queries
259
235
  */
260
- export const createCursor = (record, sortFields = ['id']) => {
236
+ export const createCursor = (record, sortFields = ['id'], { schemaInfo = null } = {}) => {
261
237
  const parts = []
262
238
  sortFields.forEach(field => {
263
- if (record[field] !== undefined) {
264
- const value = record[field]
239
+ const value = schemaInfo
240
+ ? getFieldValue(record, schemaInfo, field)
241
+ : record[field]
242
+ if (value !== undefined) {
265
243
  const stringValue = value instanceof Date ? value.toISOString() : String(value)
266
244
  parts.push(`${field}:${encodeURIComponent(stringValue)}`)
267
245
  }
@@ -427,60 +405,36 @@ export const parseCursor = (cursor) => {
427
405
  * 4. Added to response.links for navigation
428
406
  * 5. Client uses next link to fetch subsequent pages
429
407
  */
430
- export const generateCursorPaginationLinks = (urlPrefix, scopeName, queryParams, records, pageSize, hasMore, sortFields = ['id']) => {
408
+ export const generateCursorPaginationLinks = (
409
+ urlPrefix,
410
+ scopeName,
411
+ queryParams,
412
+ records,
413
+ pageSize,
414
+ hasMore,
415
+ sortFields = ['id'],
416
+ { schemaInfo = null } = {}
417
+ ) => {
431
418
  // Allow empty urlPrefix to generate relative links
432
419
  if (!records.length) return null
433
420
 
434
421
  const links = {}
435
422
  const baseUrl = `${urlPrefix}/${scopeName}`
436
-
437
- const otherParams = Object.entries(queryParams)
438
- .filter(([key]) => key !== 'page')
439
- .flatMap(([key, value]) => {
440
- if (Array.isArray(value)) {
441
- return value.length === 0
442
- ? []
443
- : value.map((v) => `${key}=${encodeURIComponent(v)}`)
444
- }
445
- if (typeof value === 'object' && value !== null) {
446
- const parts = []
447
- const processObject = (obj, prefix) => {
448
- Object.entries(obj).forEach(([k, v]) => {
449
- const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`
450
- if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
451
- processObject(v, newKey)
452
- } else if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
453
- parts.push(`${newKey}=${encodeURIComponent(v)}`)
454
- }
455
- })
456
- }
457
- processObject(value, key)
458
- return parts
459
- }
460
- if (value === undefined || value === null) {
461
- return []
462
- }
463
- return [`${key}=${encodeURIComponent(value)}`]
464
- })
465
- .filter(Boolean)
466
- .join('&')
467
-
468
- const queryPrefix = otherParams ? `?${otherParams}&` : '?'
423
+ const selfPage = { size: pageSize }
469
424
 
470
425
  if (queryParams.page?.after) {
471
- links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[after]=${queryParams.page.after}`
426
+ selfPage.after = queryParams.page.after
472
427
  } else if (queryParams.page?.before) {
473
- links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[before]=${queryParams.page.before}`
474
- } else {
475
- links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}`
428
+ selfPage.before = queryParams.page.before
476
429
  }
477
430
 
478
- links.first = `${baseUrl}${queryPrefix}page[size]=${pageSize}`
431
+ links.self = buildJsonApiLink(baseUrl, queryParams, selfPage)
432
+ links.first = buildJsonApiLink(baseUrl, queryParams, { size: pageSize })
479
433
 
480
434
  if (hasMore && records.length > 0) {
481
435
  const lastRecord = records[records.length - 1]
482
- const nextCursor = createCursor(lastRecord, sortFields)
483
- links.next = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[after]=${nextCursor}`
436
+ const nextCursor = createCursor(lastRecord, sortFields, { schemaInfo })
437
+ links.next = buildJsonApiLink(baseUrl, queryParams, { size: pageSize, after: nextCursor })
484
438
  }
485
439
 
486
440
  return links
@@ -543,7 +497,7 @@ export const generateCursorPaginationLinks = (urlPrefix, scopeName, queryParams,
543
497
  * 3. Added to response.meta.pagination
544
498
  * 4. Clients can use cursor directly or use the next link
545
499
  */
546
- export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id']) => {
500
+ export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'], { schemaInfo = null } = {}) => {
547
501
  const meta = {
548
502
  pageSize,
549
503
  hasMore
@@ -552,7 +506,7 @@ export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'])
552
506
  if (hasMore && records.length > 0) {
553
507
  const lastRecord = records[records.length - 1]
554
508
  meta.cursor = {
555
- next: createCursor(lastRecord, sortFields)
509
+ next: createCursor(lastRecord, sortFields, { schemaInfo })
556
510
  }
557
511
  }
558
512
 
@@ -76,7 +76,11 @@
76
76
 
77
77
  import { applyFieldSelectionToQuery, buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
78
78
  import { toJsonApiRecord } from './knex-json-api-transformers-querying.js'
79
- import { buildWindowedIncludeQuery, applyStandardIncludeConfig, buildOrderByClause } from './knex-window-queries.js'
79
+ import {
80
+ buildWindowedIncludeSubquery,
81
+ applyStandardIncludeConfig,
82
+ buildOrderByClause
83
+ } from './knex-window-queries.js'
80
84
  import { unwrapQueryBuilderState } from './query-builder-utils.js'
81
85
  import { RELATIONSHIPS_KEY, RELATIONSHIP_METADATA_KEY, ROW_NUMBER_KEY, COMPUTED_DEPENDENCIES_KEY, DEFAULT_QUERY_LIMIT } from '../querying-writing/knex-constants.js'
82
86
  import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
@@ -149,7 +153,8 @@ const applyScopeFiltersToIncludeQuery = async ({
149
153
  scopeName,
150
154
  requestContext,
151
155
  db,
152
- tableName
156
+ tableName,
157
+ queryPurpose = 'include'
153
158
  }) => {
154
159
  const scopeObject = scopes[scopeName]
155
160
  if (!scopeObject?.applyQueryFilters) {
@@ -165,6 +170,7 @@ const applyScopeFiltersToIncludeQuery = async ({
165
170
  tableName: tableName || scopeObject.vars.schemaInfo.tableName,
166
171
  db,
167
172
  schemaInfo: scopeObject.vars.schemaInfo,
173
+ queryPurpose,
168
174
  storageAdapter
169
175
  }, buildScopedIncludeContext({
170
176
  requestContext,
@@ -981,46 +987,38 @@ export const loadHasMany = async (scope, deps) => {
981
987
 
982
988
  // Check if we should use window functions
983
989
  if (relDef.include?.strategy === 'window') {
984
- try {
985
- // Try to build window function query
986
- if (fieldSelectionInfo?.queryFieldsToSelect?.length) {
987
- throw new Error('Query projection fields require the standard include query path.')
988
- }
990
+ let windowedQuery
989
991
 
990
- const selectFields = fieldSelectionInfo?.fieldsToSelect || null
991
-
992
- query = buildWindowedIncludeQuery(
992
+ if (fieldSelectionInfo?.queryFieldsToSelect?.length) {
993
+ log.debug('[INCLUDE] Query projection fields require the standard include query path')
994
+ } else {
995
+ windowedQuery = buildWindowedIncludeSubquery(
993
996
  knex,
994
997
  targetTable,
995
998
  foreignKey,
996
999
  mainIds,
997
- selectFields,
1000
+ fieldSelectionInfo?.fieldsToSelect || null,
998
1001
  relDef.include || {},
999
1002
  capabilities,
1000
- targetScopeObject.vars // Pass target scope vars
1003
+ targetScopeObject.vars
1001
1004
  )
1005
+ }
1006
+
1007
+ if (windowedQuery) {
1002
1008
  const scopedQueryState = await applyScopeFiltersToIncludeQuery({
1003
- query,
1009
+ query: windowedQuery.subquery,
1004
1010
  scopes,
1005
1011
  scopeName: targetScope,
1006
1012
  requestContext,
1007
1013
  db: knex,
1008
1014
  tableName: targetTable
1009
1015
  })
1010
- query = scopedQueryState.query
1016
+ query = knex
1017
+ .select('*')
1018
+ .from(scopedQueryState.query.as('_windowed'))
1019
+ .where(ROW_NUMBER_KEY, '<=', windowedQuery.effectiveLimit)
1011
1020
  usingWindowFunction = true
1012
1021
  log.debug('[INCLUDE] Using window function strategy with limits')
1013
- } catch (error) {
1014
- // If window functions not supported, this will throw a clear error
1015
- if (error.details?.requiredFeature === 'window_functions') {
1016
- throw error // Re-throw the descriptive error
1017
- }
1018
- // For other errors, fall back to standard query
1019
- log.warn('[INCLUDE] Window function query failed, falling back to standard query:', {
1020
- error: error.message,
1021
- stack: error.stack
1022
- })
1023
- usingWindowFunction = false
1024
1022
  }
1025
1023
  }
1026
1024
 
@@ -2114,7 +2112,8 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
2114
2112
  scopeName: relDef.target,
2115
2113
  requestContext,
2116
2114
  db: knex,
2117
- tableName: targetTable
2115
+ tableName: targetTable,
2116
+ queryPurpose: 'relationship-identifiers'
2118
2117
  })
2119
2118
  query = scopedQueryState.query
2120
2119
 
@@ -2159,7 +2158,8 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
2159
2158
  scopeName: targetScopeName,
2160
2159
  requestContext,
2161
2160
  db: knex,
2162
- tableName: targetTable
2161
+ tableName: targetTable,
2162
+ queryPurpose: 'relationship-identifiers'
2163
2163
  })
2164
2164
  query = scopedQueryState.query
2165
2165
 
@@ -2198,7 +2198,8 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
2198
2198
  scopeName: targetScope,
2199
2199
  requestContext,
2200
2200
  db: knex,
2201
- tableName: targetTable
2201
+ tableName: targetTable,
2202
+ queryPurpose: 'relationship-identifiers'
2202
2203
  })
2203
2204
  query = scopedQueryState.query
2204
2205
 
@@ -2,82 +2,21 @@ import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
2
2
  import { ROW_NUMBER_KEY, DEFAULT_QUERY_LIMIT, DEFAULT_MAX_QUERY_LIMIT, DEFAULT_MAX_INCLUDE_LIMIT } from '../querying-writing/knex-constants.js'
3
3
 
4
4
  /**
5
- * Builds a window function query for limited includes per parent record
5
+ * Builds the inner window-function query used to limit included children per
6
+ * parent. Mandatory target filters must be applied to this inner query before
7
+ * the caller wraps it and filters by ROW_NUMBER.
6
8
  *
7
9
  * @param {Object} knex - Knex instance
8
10
  * @param {string} tableName - Target table name
9
11
  * @param {string} foreignKey - Foreign key field
10
12
  * @param {Array} parentIds - Parent record IDs
11
- * @param {Array} fieldsToSelect - Fields to select
12
- * @param {Object} includeConfig - Include configuration (limit, orderBy)
13
+ * @param {Array|string} fieldsToSelect - Fields to select, or '*'
14
+ * @param {Object} includeConfig - Include limit and ordering
13
15
  * @param {Object} capabilities - Database capabilities
14
- * @param {Object} scopeVars - Scope variables for defaults and limits
15
- * @returns {Object} Knex query
16
- *
17
- * @example
18
- * // Input: Load max 3 comments per article, ordered by newest first
19
- * const query = buildWindowedIncludeQuery(
20
- * knex,
21
- * 'comments',
22
- * 'article_id',
23
- * [1, 2, 3], // Article IDs
24
- * ['id', 'text', 'created_at'],
25
- * { limit: 3, orderBy: ['-created_at'] },
26
- * { windowFunctions: true },
27
- * { queryDefaultLimit: 10 }
28
- * );
29
- *
30
- * // Generated SQL:
31
- * // WITH _windowed AS (
32
- * // SELECT id, text, created_at, article_id,
33
- * // ROW_NUMBER() OVER (
34
- * // PARTITION BY article_id
35
- * // ORDER BY created_at DESC
36
- * // ) as __$jsonrestapi_rn$__
37
- * // FROM comments
38
- * // WHERE article_id IN (1, 2, 3)
39
- * // )
40
- * // SELECT * FROM _windowed
41
- * // WHERE __$jsonrestapi_rn$__ <= 3
42
- *
43
- * // Result: Each article gets max 3 comments, not 3 total
44
- *
45
- * @example
46
- * // Input: Database doesn't support window functions
47
- * const query = buildWindowedIncludeQuery(
48
- * knex,
49
- * 'comments',
50
- * 'article_id',
51
- * [1, 2, 3],
52
- * '*',
53
- * { limit: 5 },
54
- * { windowFunctions: false, dbInfo: { client: 'mysql', version: '5.7' } }
55
- * );
56
- *
57
- * // Throws RestApiResourceError:
58
- * // "Include limits require window function support. Your database (mysql 5.7)
59
- * // does not support this feature. Window functions are supported in:
60
- * // PostgreSQL 8.4+, MySQL 8.0+, MariaDB 10.2+, SQLite 3.25+, SQL Server 2005+"
61
- *
62
- * @description
63
- * Used by:
64
- * - loadHasMany when strategy: 'window' and database supports window functions
65
- * - Enables per-parent limits for one-to-many relationships
66
- *
67
- * Purpose:
68
- * - Solves the "N+1 limit" problem where you want X records per parent
69
- * - Without window functions, LIMIT 10 gives 10 total across all parents
70
- * - With window functions, each parent gets up to 10 related records
71
- * - Critical for consistent API responses with includes
72
- *
73
- * Data flow:
74
- * 1. Creates subquery with ROW_NUMBER() partitioned by foreign key
75
- * 2. Numbers rows within each partition based on orderBy
76
- * 3. Outer query filters to keep only rows within limit
77
- * 4. Returns query ready for execution
78
- * 5. Caller removes the ROW_NUMBER column from results
16
+ * @param {Object} scopeVars - Resource query limits
17
+ * @returns {{ subquery: Object, effectiveLimit: number }} Inner query and validated limit
79
18
  */
80
- export const buildWindowedIncludeQuery = (
19
+ export const buildWindowedIncludeSubquery = (
81
20
  knex,
82
21
  tableName,
83
22
  foreignKey,
@@ -137,13 +76,7 @@ export const buildWindowedIncludeQuery = (
137
76
  subquery.select(fieldsWithFK)
138
77
  }
139
78
 
140
- // Wrap in outer query to filter by row number
141
- const query = knex
142
- .select('*')
143
- .from(subquery.as('_windowed'))
144
- .where(ROW_NUMBER_KEY, '<=', effectiveLimit)
145
-
146
- return query
79
+ return { subquery, effectiveLimit }
147
80
  }
148
81
 
149
82
  /**
@@ -175,7 +108,7 @@ export const buildWindowedIncludeQuery = (
175
108
  *
176
109
  * @description
177
110
  * Used by:
178
- * - buildWindowedIncludeQuery for PARTITION BY ordering
111
+ * - buildWindowedIncludeSubquery for PARTITION BY ordering
179
112
  * - applyStandardIncludeConfig for regular ORDER BY
180
113
  * - Query builders that need consistent sort syntax
181
114
  *
@@ -11,13 +11,12 @@
11
11
  * Priority order:
12
12
  * 1. context.urlPrefixOverride - Explicit override (from hooks or middleware)
13
13
  * 2. context.urlPrefix - Pre-calculated URL prefix
14
- * 3. Calculate from request if provided
15
- * 4. scope.vars.transport?.mountPath - Fallback to mount path
14
+ * 3. scope.vars.transport?.publicBaseUrl - Explicit public URL prefix
15
+ * 4. scope.vars.transport?.mountPath - Fallback to relative mount path
16
16
  * 5. '' - Empty string as final fallback
17
17
  *
18
18
  * @param {Object} context - Request context (may have urlPrefixOverride or urlPrefix)
19
19
  * @param {Object} scope - Scope/resource object with transport vars
20
- * @param {Object} req - Optional Express request object for auto-calculation
21
20
  * @returns {string} The final URL prefix to use
22
21
  *
23
22
  * @example
@@ -25,10 +24,10 @@
25
24
  * const urlPrefix = getUrlPrefix(context, scope);
26
25
  *
27
26
  * @example
28
- * // With request object for auto-calculation
29
- * const urlPrefix = getUrlPrefix({}, api, req);
27
+ * // With explicit public base URL
28
+ * const urlPrefix = getUrlPrefix({}, { vars: { transport: { publicBaseUrl: 'https://api.example.com/api' } } });
30
29
  */
31
- export function getUrlPrefix (context, scope, req = null) {
30
+ export function getUrlPrefix (context, scope) {
32
31
  // Priority 1: Explicit override
33
32
  if (context?.urlPrefixOverride) {
34
33
  return context.urlPrefixOverride
@@ -39,21 +38,10 @@ export function getUrlPrefix (context, scope, req = null) {
39
38
  return context.urlPrefix
40
39
  }
41
40
 
42
- // Priority 3: Calculate from request if provided
43
- if (req) {
44
- const protocol = req.get?.('x-forwarded-proto') ||
45
- req.headers?.['x-forwarded-proto'] ||
46
- req.protocol ||
47
- 'http'
48
- const host = req.get?.('x-forwarded-host') ||
49
- req.headers?.['x-forwarded-host'] ||
50
- req.get?.('host') ||
51
- req.headers?.host
52
- const mountPath = scope?.vars?.transport?.mountPath || ''
53
-
54
- if (host) {
55
- return `${protocol}://${host}${mountPath}`
56
- }
41
+ // Priority 3: Explicit public URL prefix configured by the transport
42
+ const publicBaseUrl = scope?.vars?.transport?.publicBaseUrl
43
+ if (publicBaseUrl) {
44
+ return String(publicBaseUrl).replace(/\/+$/, '')
57
45
  }
58
46
 
59
47
  // Priority 4: Fallback to mount path
@@ -163,3 +163,54 @@ export function parseJsonApiQuery (queryString) {
163
163
 
164
164
  return result
165
165
  }
166
+
167
+ const hasSerializableValue = (value) => (
168
+ value !== undefined &&
169
+ value !== null &&
170
+ !(Array.isArray(value) && value.length === 0)
171
+ )
172
+
173
+ const stringifyQueryValue = (value) => {
174
+ if (value instanceof Date) return value.toISOString()
175
+ if (Array.isArray(value)) return value.map(stringifyQueryValue).join(',')
176
+ return String(value)
177
+ }
178
+
179
+ const appendCommaList = (params, key, value) => {
180
+ if (!hasSerializableValue(value)) return
181
+ const serialized = Array.isArray(value)
182
+ ? value.map(stringifyQueryValue).filter((entry) => entry.length > 0).join(',')
183
+ : stringifyQueryValue(value)
184
+
185
+ if (serialized) {
186
+ params.set(key, serialized)
187
+ }
188
+ }
189
+
190
+ const appendScopedParams = (params, publicName, values) => {
191
+ if (!values || typeof values !== 'object' || Array.isArray(values)) return
192
+
193
+ for (const [key, value] of Object.entries(values)) {
194
+ if (!key || !hasSerializableValue(value)) continue
195
+ params.set(`${publicName}[${key}]`, stringifyQueryValue(value))
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Serializes internal REST API query parameters to public JSON:API query string form.
201
+ *
202
+ * This is intentionally the inverse of parseJsonApiQuery for the supported transport
203
+ * contract: include, sort, filter[...], fields[...], and page[...].
204
+ */
205
+ export function serializeJsonApiQuery (queryParams = {}, { page } = {}) {
206
+ const params = new URLSearchParams()
207
+ const effectivePage = page === undefined ? queryParams.page : page
208
+
209
+ appendCommaList(params, 'include', queryParams.include)
210
+ appendCommaList(params, 'sort', queryParams.sort)
211
+ appendScopedParams(params, 'filter', queryParams.filters || queryParams.filter)
212
+ appendScopedParams(params, 'fields', queryParams.fields)
213
+ appendScopedParams(params, 'page', effectivePage)
214
+
215
+ return params.toString()
216
+ }
@@ -63,7 +63,7 @@ const parseVersion = (versionString) => {
63
63
  *
64
64
  * @description
65
65
  * Used by:
66
- * - buildWindowedIncludeQuery to validate before using window functions
66
+ * - buildWindowedIncludeSubquery to validate before using window functions
67
67
  * - rest-api-knex-plugin to determine query strategies
68
68
  *
69
69
  * Purpose:
@@ -48,7 +48,7 @@
48
48
  * 4. Handles MySQL datetime format (no T separator)
49
49
  * 5. Forces UTC interpretation to prevent timezone issues
50
50
  */
51
- function normalizeDateValue (value, type) {
51
+ export function normalizeDateValue (value, type) {
52
52
  // Handle null/undefined
53
53
  if (value === null || value === undefined) {
54
54
  return null
@@ -81,25 +81,34 @@ function normalizeDateValue (value, type) {
81
81
 
82
82
  // Handle string values
83
83
  if (typeof value === 'string') {
84
+ const trimmed = value.trim()
85
+
86
+ if (/^-?\d+$/.test(trimmed)) {
87
+ const numericDate = new Date(Number(trimmed))
88
+ if (!isNaN(numericDate.getTime())) {
89
+ return numericDate
90
+ }
91
+ }
92
+
84
93
  // Detect MySQL datetime format: 'YYYY-MM-DD HH:MM:SS'
85
94
  // These have no T separator and no timezone indicator
86
95
  const isMySQLDateTime = type === 'dateTime' &&
87
- /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(value) &&
88
- !value.includes('T') &&
89
- !value.includes('Z') &&
90
- !value.includes('+') &&
91
- !value.includes('-', 10) // Don't match date separators
96
+ /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(trimmed) &&
97
+ !trimmed.includes('T') &&
98
+ !trimmed.includes('Z') &&
99
+ !trimmed.includes('+') &&
100
+ !trimmed.includes('-', 10) // Don't match date separators
92
101
 
93
102
  if (isMySQLDateTime) {
94
103
  // Convert to ISO format and force UTC interpretation
95
104
  // '2024-01-15 10:30:00' becomes '2024-01-15T10:30:00Z'
96
- return new Date(value.replace(' ', 'T') + 'Z')
105
+ return new Date(trimmed.replace(' ', 'T') + 'Z')
97
106
  }
98
107
 
99
108
  // For date-only fields, ensure parsing at UTC midnight
100
109
  // This prevents timezone shifts when parsing dates
101
- if (type === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
102
- return new Date(value + 'T00:00:00Z')
110
+ if (type === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
111
+ return new Date(trimmed + 'T00:00:00Z')
103
112
  }
104
113
  }
105
114
 
@@ -150,7 +150,8 @@ export const buildFieldSelection = async (scope, deps) => {
150
150
  const scopeSchemaInfo = scope?.vars?.schemaInfo || {}
151
151
  const {
152
152
  schemaInstance,
153
- computed: computedFields = {}
153
+ computed: computedFields = {},
154
+ idProperty = 'id'
154
155
  } = scopeSchemaInfo
155
156
  let schemaStructure = scopeSchemaInfo.schemaStructure
156
157
  const queryFields = scope?.vars?.queryFields || {}
@@ -196,6 +197,8 @@ export const buildFieldSelection = async (scope, deps) => {
196
197
  // Sparse fieldsets requested - only select specified fields
197
198
  // Example: ?fields[products]=name,price,profit_margin
198
199
  requested.forEach(field => {
200
+ if (field === 'id' || field === idProperty) return
201
+
199
202
  if (queryFieldNames.has(field)) {
200
203
  if (queryFields[field]?.hidden === true) return
201
204
  queryFieldsToSelect.add(field)
@@ -261,6 +264,8 @@ export const buildFieldSelection = async (scope, deps) => {
261
264
  // No sparse fieldsets - return all visible fields
262
265
  // This is the default behavior when no ?fields parameter is provided
263
266
  Object.entries(schemaStructure).forEach(([field, fieldDef]) => {
267
+ if (field === 'id' || field === idProperty) return
268
+
264
269
  // Skip virtual fields - they don't exist in database
265
270
  if (fieldDef.virtual === true) return
266
271