json-rest-api 1.0.23 → 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 (106) hide show
  1. package/README.md +24 -2
  2. package/docs/API.md +157 -33
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
  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_Knex_Schema_And_Migrations.md +122 -2
  10. package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
  11. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  12. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  13. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  14. package/docs/GUIDE/index.md +4 -1
  15. package/fixing_plan.md +112 -0
  16. package/index.js +2 -0
  17. package/lib/enhanced-logger.js +1 -1
  18. package/package.json +6 -2
  19. package/plugins/core/bulk-operations-plugin.js +31 -38
  20. package/plugins/core/connectors/express-plugin.js +6 -7
  21. package/plugins/core/connectors/fastify-plugin.js +5 -1
  22. package/plugins/core/connectors/lib/busboy-detector.js +4 -4
  23. package/plugins/core/connectors/lib/connector-core.js +17 -11
  24. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  25. package/plugins/core/file-handling-plugin.js +126 -96
  26. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  27. package/plugins/core/lib/anyapi/schema-utils.js +44 -4
  28. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
  29. package/plugins/core/lib/dbIntrospection.js +1 -1
  30. package/plugins/core/lib/dbTablesOperations.js +10 -3
  31. package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
  32. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
  33. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  34. package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
  35. package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
  36. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  37. package/plugins/core/lib/querying/url-helpers.js +9 -21
  38. package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
  39. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  40. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  41. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  42. package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -3
  43. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  44. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  45. package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
  46. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  47. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
  48. package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
  49. package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
  50. package/plugins/core/lib/storage/storage-adapter.js +76 -14
  51. package/plugins/core/lib/storage/storage-mapping.js +86 -16
  52. package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
  53. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  54. package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
  55. package/plugins/core/rest-api-knex-plugin.js +93 -76
  56. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
  57. package/plugins/core/rest-api-plugin-methods/common.js +112 -72
  58. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
  59. package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
  60. package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
  61. package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
  62. package/plugins/core/rest-api-plugin-methods/get.js +7 -1
  63. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
  64. package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
  65. package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
  66. package/plugins/core/rest-api-plugin-methods/post.js +12 -7
  67. package/plugins/core/rest-api-plugin-methods/put.js +35 -10
  68. package/plugins/core/rest-api-plugin-methods/release.js +0 -2
  69. package/plugins/core/rest-api-plugin.js +11 -37
  70. package/plugins/core/rest-api-positioning-plugin.js +1 -4
  71. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  72. package/plugins/core/socketio-plugin.js +0 -1
  73. package/plugins/storage/local-storage.js +45 -20
  74. package/plugins/storage/s3-storage.js +23 -54
  75. package/quickTest.js +1 -0
  76. package/scripts/run-docs.js +80 -84
  77. package/test-debug.js +1 -1
  78. package/tests/README.md +8 -1
  79. package/tests/anyapi-basic.test.js +3 -0
  80. package/tests/anyapi-cursor-pagination.test.js +7 -1
  81. package/tests/anyapi-custom-idproperty.test.js +163 -0
  82. package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
  83. package/tests/db-introspection.test.js +4 -1
  84. package/tests/db-table-operations.test.js +25 -20
  85. package/tests/fastify-plugin.test.js +49 -6
  86. package/tests/field-setters.test.js +10 -10
  87. package/tests/file-handling.test.js +118 -0
  88. package/tests/fixtures/api-configs.js +495 -4
  89. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  90. package/tests/http-connectors-parity.test.js +165 -5
  91. package/tests/id-normalization.test.js +444 -0
  92. package/tests/local-storage.test.js +96 -0
  93. package/tests/logical-id-write-validation.test.js +151 -0
  94. package/tests/pagination-cursor-multifield.test.js +132 -11
  95. package/tests/pagination-enhanced.test.js +66 -15
  96. package/tests/post-commit-hooks.test.js +120 -0
  97. package/tests/public-exports.test.js +15 -0
  98. package/tests/put-create-parity.test.js +216 -0
  99. package/tests/relationship-endpoints.test.js +259 -0
  100. package/tests/return-record-settings.test.js +184 -0
  101. package/tests/row-policy.test.js +433 -0
  102. package/tests/s3-storage.test.js +38 -0
  103. package/tests/searchschema-merge.test.js +17 -0
  104. package/tests/socketio.test.js +62 -0
  105. package/tests/storage-mapping.test.js +56 -6
  106. package/tests/test-development-guide.md +5 -5
@@ -75,9 +75,12 @@
75
75
  */
76
76
 
77
77
  import { applyFieldSelectionToQuery, buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
78
- import { getForeignKeyFields } from '../querying-writing/field-utils.js'
79
78
  import { toJsonApiRecord } from './knex-json-api-transformers-querying.js'
80
- import { buildWindowedIncludeQuery, applyStandardIncludeConfig, buildOrderByClause } from './knex-window-queries.js'
79
+ import {
80
+ buildWindowedIncludeSubquery,
81
+ applyStandardIncludeConfig,
82
+ buildOrderByClause
83
+ } from './knex-window-queries.js'
81
84
  import { unwrapQueryBuilderState } from './query-builder-utils.js'
82
85
  import { RELATIONSHIPS_KEY, RELATIONSHIP_METADATA_KEY, ROW_NUMBER_KEY, COMPUTED_DEPENDENCIES_KEY, DEFAULT_QUERY_LIMIT } from '../querying-writing/knex-constants.js'
83
86
  import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
@@ -150,7 +153,8 @@ const applyScopeFiltersToIncludeQuery = async ({
150
153
  scopeName,
151
154
  requestContext,
152
155
  db,
153
- tableName
156
+ tableName,
157
+ queryPurpose = 'include'
154
158
  }) => {
155
159
  const scopeObject = scopes[scopeName]
156
160
  if (!scopeObject?.applyQueryFilters) {
@@ -166,6 +170,7 @@ const applyScopeFiltersToIncludeQuery = async ({
166
170
  tableName: tableName || scopeObject.vars.schemaInfo.tableName,
167
171
  db,
168
172
  schemaInfo: scopeObject.vars.schemaInfo,
173
+ queryPurpose,
169
174
  storageAdapter
170
175
  }, buildScopedIncludeContext({
171
176
  requestContext,
@@ -491,8 +496,6 @@ export const loadBelongsTo = async (scope, deps) => {
491
496
  // Get target table info
492
497
  const targetTableName = scopes[targetScope].vars.schemaInfo.tableName
493
498
  const targetIdColumn = getIdColumnForScope(scopes, targetScope)
494
- const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
495
-
496
499
  // Build field selection for sparse fieldsets
497
500
  const targetScopeObject = scopes[targetScope]
498
501
  const storageAdapter = targetScopeObject.vars.storageAdapter
@@ -548,9 +551,9 @@ export const loadBelongsTo = async (scope, deps) => {
548
551
 
549
552
  // Create lookup map
550
553
  const targetById = {}
551
- targetRecords.forEach(record => {
552
- targetById[getFieldValueForScope(scopes, targetScope, record, 'id')] = record
553
- })
554
+ targetRecords.forEach(record => {
555
+ targetById[getFieldValueForScope(scopes, targetScope, record, 'id')] = record
556
+ })
554
557
 
555
558
  // Set relationships on original records
556
559
  const targetRecordsToProcess = []
@@ -695,7 +698,7 @@ export const loadHasMany = async (scope, deps) => {
695
698
  const otherKey = relDef.otherKey
696
699
 
697
700
  if (!foreignKey || !otherKey) {
698
- throw new Error(`Missing foreignKey or otherKey in many-to-many relationship '${relName}' for scope '${scopeName}'`)
701
+ throw new Error(`Missing foreignKey or otherKey in many-to-many relationship '${includeName}' for scope '${scopeName}'`)
699
702
  }
700
703
 
701
704
  // For manyToMany, the target scope is the relationship name itself
@@ -737,12 +740,11 @@ export const loadHasMany = async (scope, deps) => {
737
740
  })
738
741
 
739
742
  // Step 3: Build field selection for sparse fieldsets
740
- const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
741
743
  const targetScopeObject = scopes[targetScope]
742
744
  const storageAdapter = targetScopeObject.vars.storageAdapter
743
745
  const targetIdColumn = getIdColumnForScope(scopes, targetScope)
744
- const fieldSelectionInfo = fields?.[targetScope]
745
- ? await buildFieldSelection(targetScopeObject, {
746
+ const fieldSelectionInfo = fields?.[targetScope]
747
+ ? await buildFieldSelection(targetScopeObject, {
746
748
  context: {
747
749
  ...(requestContext || {}),
748
750
  scopeName: targetScope,
@@ -958,7 +960,7 @@ export const loadHasMany = async (scope, deps) => {
958
960
  const foreignKey = relDef.foreignKey
959
961
 
960
962
  if (!foreignKey) {
961
- throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`)
963
+ throw new Error(`Missing foreignKey in hasMany relationship '${includeName}' for scope '${scopeName}'`)
962
964
  }
963
965
 
964
966
  log.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
@@ -967,7 +969,6 @@ export const loadHasMany = async (scope, deps) => {
967
969
  })
968
970
 
969
971
  // Build field selection for sparse fieldsets
970
- const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
971
972
  const targetScopeObject = scopes[targetScope]
972
973
  const storageAdapter = targetScopeObject.vars.storageAdapter
973
974
  const fieldSelectionInfo = fields?.[targetScope]
@@ -986,46 +987,38 @@ export const loadHasMany = async (scope, deps) => {
986
987
 
987
988
  // Check if we should use window functions
988
989
  if (relDef.include?.strategy === 'window') {
989
- try {
990
- // Try to build window function query
991
- if (fieldSelectionInfo?.queryFieldsToSelect?.length) {
992
- throw new Error('Query projection fields require the standard include query path.')
993
- }
990
+ let windowedQuery
994
991
 
995
- const selectFields = fieldSelectionInfo?.fieldsToSelect || null
996
-
997
- 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(
998
996
  knex,
999
997
  targetTable,
1000
998
  foreignKey,
1001
999
  mainIds,
1002
- selectFields,
1000
+ fieldSelectionInfo?.fieldsToSelect || null,
1003
1001
  relDef.include || {},
1004
1002
  capabilities,
1005
- targetScopeObject.vars // Pass target scope vars
1003
+ targetScopeObject.vars
1006
1004
  )
1005
+ }
1006
+
1007
+ if (windowedQuery) {
1007
1008
  const scopedQueryState = await applyScopeFiltersToIncludeQuery({
1008
- query,
1009
+ query: windowedQuery.subquery,
1009
1010
  scopes,
1010
1011
  scopeName: targetScope,
1011
1012
  requestContext,
1012
1013
  db: knex,
1013
1014
  tableName: targetTable
1014
1015
  })
1015
- query = scopedQueryState.query
1016
+ query = knex
1017
+ .select('*')
1018
+ .from(scopedQueryState.query.as('_windowed'))
1019
+ .where(ROW_NUMBER_KEY, '<=', windowedQuery.effectiveLimit)
1016
1020
  usingWindowFunction = true
1017
1021
  log.debug('[INCLUDE] Using window function strategy with limits')
1018
- } catch (error) {
1019
- // If window functions not supported, this will throw a clear error
1020
- if (error.details?.requiredFeature === 'window_functions') {
1021
- throw error // Re-throw the descriptive error
1022
- }
1023
- // For other errors, fall back to standard query
1024
- log.warn('[INCLUDE] Window function query failed, falling back to standard query:', {
1025
- error: error.message,
1026
- stack: error.stack
1027
- })
1028
- usingWindowFunction = false
1029
1022
  }
1030
1023
  }
1031
1024
 
@@ -1199,10 +1192,10 @@ export const loadHasOne = async (scope, deps) => {
1199
1192
  const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope
1200
1193
  const { context: { scopes, log, knex, capabilities, requestContext } } = deps
1201
1194
 
1202
- log = log?.child ? log.child({ method: 'loadHasOne' }) : console
1195
+ const childLog = log?.child ? log.child({ method: 'loadHasOne' }) : console
1203
1196
 
1204
1197
  if (!records || records.length === 0) {
1205
- log.trace('[INCLUDE] No records to load hasOne for')
1198
+ childLog.trace('[INCLUDE] No records to load hasOne for')
1206
1199
  return
1207
1200
  }
1208
1201
 
@@ -1210,7 +1203,7 @@ export const loadHasOne = async (scope, deps) => {
1210
1203
  const mainIds = [...new Set(records.map(record => getFieldValueForScope(scopes, scopeName, record, 'id')).filter(Boolean))]
1211
1204
 
1212
1205
  if (mainIds.length === 0) {
1213
- log.trace('[INCLUDE] No parent IDs found, skipping hasOne load')
1206
+ childLog.trace('[INCLUDE] No parent IDs found, skipping hasOne load')
1214
1207
  return
1215
1208
  }
1216
1209
 
@@ -1222,12 +1215,11 @@ export const loadHasOne = async (scope, deps) => {
1222
1215
  throw new Error(`Missing foreignKey in hasOne relationship '${includeName}' for scope '${scopeName}'`)
1223
1216
  }
1224
1217
 
1225
- log.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
1218
+ childLog.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
1226
1219
  whereIn: mainIds
1227
1220
  })
1228
1221
 
1229
1222
  // Build query for hasOne - expects single result per parent
1230
- const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
1231
1223
  const targetScopeObject = scopes[targetScope]
1232
1224
  const storageAdapter = targetScopeObject.vars.storageAdapter
1233
1225
 
@@ -1629,7 +1621,6 @@ export const loadReversePolymorphic = async (scope, deps) => {
1629
1621
  })
1630
1622
 
1631
1623
  // Build field selection for sparse fieldsets
1632
- const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
1633
1624
  const targetScopeObject = scopes[targetScope]
1634
1625
  const storageAdapter = targetScopeObject.vars.storageAdapter
1635
1626
  const targetIdColumn = getIdColumnForScope(scopes, targetScope)
@@ -2121,7 +2112,8 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
2121
2112
  scopeName: relDef.target,
2122
2113
  requestContext,
2123
2114
  db: knex,
2124
- tableName: targetTable
2115
+ tableName: targetTable,
2116
+ queryPurpose: 'relationship-identifiers'
2125
2117
  })
2126
2118
  query = scopedQueryState.query
2127
2119
 
@@ -2166,7 +2158,8 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
2166
2158
  scopeName: targetScopeName,
2167
2159
  requestContext,
2168
2160
  db: knex,
2169
- tableName: targetTable
2161
+ tableName: targetTable,
2162
+ queryPurpose: 'relationship-identifiers'
2170
2163
  })
2171
2164
  query = scopedQueryState.query
2172
2165
 
@@ -2205,7 +2198,8 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
2205
2198
  scopeName: targetScope,
2206
2199
  requestContext,
2207
2200
  db: knex,
2208
- tableName: targetTable
2201
+ tableName: targetTable,
2202
+ queryPurpose: 'relationship-identifiers'
2209
2203
  })
2210
2204
  query = scopedQueryState.query
2211
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
@@ -1,6 +1,6 @@
1
1
  import { createSchema } from 'json-rest-schema'
2
2
  import { ensureSearchFieldsAreIndexed, generateSearchSchemaFromSchema, sortFieldsByDependencies } from './schema-helpers.js'
3
- import { buildStorageInfo } from '../storage/storage-mapping.js'
3
+ import { buildStorageInfo, normalizeStorageConfig } from '../storage/storage-mapping.js'
4
4
 
5
5
  /**
6
6
  * Compiles and enriches schemas for a resource scope
@@ -155,7 +155,7 @@ export async function compileSchemas (scope, deps) {
155
155
  }
156
156
 
157
157
  // Default enrichment: Add type to belongsTo fields
158
- for (const [fieldName, fieldDef] of Object.entries(enrichedFields)) {
158
+ for (const [, fieldDef] of Object.entries(enrichedFields)) {
159
159
  if (fieldDef.belongsTo && !fieldDef.type) {
160
160
  fieldDef.type = 'id'
161
161
  }
@@ -174,7 +174,7 @@ export async function compileSchemas (scope, deps) {
174
174
 
175
175
  if (isProbablyPivot) {
176
176
  // Make all belongsTo fields searchable for pivot operations
177
- for (const [fieldName, fieldDef] of belongsToFields) {
177
+ for (const [, fieldDef] of belongsToFields) {
178
178
  if (!fieldDef.search) {
179
179
  fieldDef.search = true
180
180
  }
@@ -206,6 +206,7 @@ export async function compileSchemas (scope, deps) {
206
206
  scope.scopeOptions.searchSchema
207
207
  )
208
208
 
209
+ let searchSchemaObject
209
210
  if (rawSearchFields) {
210
211
  // Mark all search fields as indexed for database optimization.
211
212
  // This ensures that any field used for filtering will have a database index created,
@@ -224,9 +225,9 @@ export async function compileSchemas (scope, deps) {
224
225
  await runHooks('searchSchema:enrich', searchSchemaContext)
225
226
 
226
227
  // Create searchSchema object
227
- var searchSchemaObject = createSchema(rawSearchFields)
228
+ searchSchemaObject = createSchema(rawSearchFields)
228
229
  } else {
229
- var searchSchemaObject = createSchema({})
230
+ searchSchemaObject = createSchema({})
230
231
  }
231
232
 
232
233
  // Build schemaRelationships including polymorphic fields from schema
@@ -317,9 +318,11 @@ export async function compileSchemas (scope, deps) {
317
318
  }
318
319
 
319
320
  const idProperty = scope.scopeOptions.idProperty || scope.vars.idProperty || 'id'
321
+ const storage = normalizeStorageConfig(scope.scopeOptions.storage)
320
322
  const storageInfo = buildStorageInfo({
321
323
  schemaStructure: schemaObject.structure,
322
- idProperty
324
+ idProperty,
325
+ storage
323
326
  })
324
327
 
325
328
  // Cache everything
@@ -335,6 +338,7 @@ export async function compileSchemas (scope, deps) {
335
338
  schemaRelationships,
336
339
  tableName: scope.scopeOptions.tableName || scopeName,
337
340
  idProperty,
341
+ storage,
338
342
  storageInfo,
339
343
  indexes: Array.isArray(scope.scopeOptions.indexes) ? [...scope.scopeOptions.indexes] : [],
340
344
  foreignKeys: Array.isArray(scope.scopeOptions.foreignKeys) ? [...scope.scopeOptions.foreignKeys] : [],
@@ -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)
@@ -257,11 +260,12 @@ export const buildFieldSelection = async (scope, deps) => {
257
260
  }
258
261
  })
259
262
  }
260
-
261
263
  } else {
262
264
  // No sparse fieldsets - return all visible fields
263
265
  // This is the default behavior when no ?fields parameter is provided
264
266
  Object.entries(schemaStructure).forEach(([field, fieldDef]) => {
267
+ if (field === 'id' || field === idProperty) return
268
+
265
269
  // Skip virtual fields - they don't exist in database
266
270
  if (fieldDef.virtual === true) return
267
271
 
@@ -283,7 +287,7 @@ export const buildFieldSelection = async (scope, deps) => {
283
287
  // So we need to include their dependencies even if normallyHidden
284
288
  // Example: profit_margin depends on 'cost' which is normallyHidden
285
289
  // We fetch 'cost' for calculation but don't return it in response
286
- for (const [fieldName, fieldDef] of Object.entries(computedFields)) {
290
+ for (const [, fieldDef] of Object.entries(computedFields)) {
287
291
  if (fieldDef.dependencies) {
288
292
  for (const dep of fieldDef.dependencies) {
289
293
  const depFieldDef = schemaStructure[dep]