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
@@ -8,9 +8,8 @@ import {
8
8
  generateKnexMigrationDiff
9
9
  } from './lib/dbTablesOperations.js'
10
10
  import { introspectKnexTableSnapshot } from './lib/dbIntrospection.js'
11
- import { applyFieldSelectionToQuery, buildFieldSelection, isNonDatabaseField } from './lib/querying-writing/knex-field-helpers.js'
12
- import { getForeignKeyFields } from './lib/querying-writing/field-utils.js'
13
- import { toJsonApiRecord, buildJsonApiResponse } from './lib/querying/knex-json-api-transformers-querying.js'
11
+ import { applyFieldSelectionToQuery, buildFieldSelection } from './lib/querying-writing/knex-field-helpers.js'
12
+ import { buildJsonApiResponse } from './lib/querying/knex-json-api-transformers-querying.js'
14
13
  import { processBelongsToRelationships } from './lib/writing/knex-json-api-transformers-writing.js'
15
14
  import { toJsonApiRecordWithBelongsTo } from './lib/querying-writing/knex-json-api-transformers.js'
16
15
  import { processIncludes } from './lib/querying/knex-process-includes.js'
@@ -39,6 +38,7 @@ import {
39
38
  parseSortEntry
40
39
  } from './lib/querying/query-field-sort-helpers.js'
41
40
  import { unwrapQueryBuilderState } from './lib/querying/query-builder-utils.js'
41
+ import { serializeJsonApiQuery } from './lib/querying-writing/connectors-query-parser.js'
42
42
 
43
43
  export const RestApiKnexPlugin = {
44
44
  name: 'rest-api-knex',
@@ -46,9 +46,8 @@ export const RestApiKnexPlugin = {
46
46
 
47
47
  async install ({ helpers, vars, pluginOptions, api, log, scopes, addHook, addScopeMethod }) {
48
48
  // Try to import knex dynamically
49
- let knexLib
50
49
  try {
51
- knexLib = await import('knex')
50
+ await import('knex')
52
51
  } catch (e) {
53
52
  requirePackage('knex', 'rest-api-knex',
54
53
  'Knex.js is required for database operations. This is a peer dependency that allows you to control the version.')
@@ -100,6 +99,7 @@ export const RestApiKnexPlugin = {
100
99
 
101
100
  return {
102
101
  structure: filteredSchema,
102
+ storage: vars.schemaInfo?.storage,
103
103
  indexes: vars.schemaInfo?.indexes || [],
104
104
  foreignKeys: vars.schemaInfo?.foreignKeys || [],
105
105
  checkConstraints: vars.schemaInfo?.checkConstraints || []
@@ -457,7 +457,14 @@ export const RestApiKnexPlugin = {
457
457
  * @param {Object} params.context.db - Database connection (knex instance or transaction)
458
458
  * @returns {Promise<Object|null>} JSON:API formatted resource with belongsTo relationships, or null if not found
459
459
  */
460
- helpers.dataGetMinimal = async ({ scopeName, context, runHooks, applyQueryFilters }) => {
460
+ helpers.dataGetMinimal = async ({
461
+ scopeName,
462
+ context,
463
+ runHooks,
464
+ applyQueryFilters,
465
+ filters = context.queryParams?.filters,
466
+ queryPurpose = 'single'
467
+ }) => {
461
468
  const storageAdapter = getScopeStorageAdapter(scopeName)
462
469
  if (storageAdapter) {
463
470
  context.storageAdapter = storageAdapter
@@ -483,29 +490,37 @@ export const RestApiKnexPlugin = {
483
490
  if (typeof applyQueryFilters === 'function') {
484
491
  const scopedQueryState = await applyQueryFilters({
485
492
  query,
486
- filters: context.queryParams?.filters,
493
+ filters,
487
494
  tableName,
488
495
  db,
489
496
  scopeName,
497
+ queryPurpose,
490
498
  storageAdapter
491
499
  })
492
500
  query = unwrapQueryBuilderState(scopedQueryState, query)
493
501
  } else if (runHooks) {
502
+ const previousKnexQuery = context.knexQuery
494
503
  context.knexQuery = {
495
504
  query,
496
- filters: context.queryParams?.filters,
505
+ filters,
497
506
  schemaInfo: context.schemaInfo,
498
507
  scopeName,
499
508
  tableName,
500
509
  db,
510
+ queryPurpose,
501
511
  adapter: storageAdapter,
502
512
  storageAdapter,
503
513
  }
504
514
 
505
- await runHooks('knexQueryFiltering')
506
-
507
- if (context.knexQuery) {
508
- delete context.knexQuery
515
+ try {
516
+ await runHooks('knexQueryFiltering')
517
+ query = context.knexQuery?.query || query
518
+ } finally {
519
+ if (previousKnexQuery === undefined) {
520
+ delete context.knexQuery
521
+ } else {
522
+ context.knexQuery = previousKnexQuery
523
+ }
509
524
  }
510
525
  }
511
526
 
@@ -621,7 +636,9 @@ export const RestApiKnexPlugin = {
621
636
  scopeName,
622
637
  tableName,
623
638
  db,
639
+ queryPurpose: 'collection',
624
640
  adapter: storageAdapter,
641
+ storageAdapter,
625
642
  }
626
643
 
627
644
  log.trace('[DATA-QUERY] Stored data in context', { hasStoredData: !!context.knexQuery, filters: queryParams.filters })
@@ -682,9 +699,7 @@ export const RestApiKnexPlugin = {
682
699
  query
683
700
  .limit(pageSize)
684
701
  .offset((pageNumber - 1) * pageSize)
685
- }
686
- // Cursor-based pagination
687
- else if (queryParams.page.after || queryParams.page.before) {
702
+ } else if (queryParams.page.after || queryParams.page.before) {
688
703
  // Fetch one extra record to determine if there are more
689
704
  query.limit(pageSize + 1)
690
705
 
@@ -711,7 +726,13 @@ export const RestApiKnexPlugin = {
711
726
  sortDescriptors,
712
727
  cursorData,
713
728
  (direction) => (direction === 'DESC' ? '<' : '>'),
714
- (builder, descriptor, operator, value) => builder.where(descriptor.column, operator, value),
729
+ (builder, descriptor, operator, value) => builder.where(
730
+ descriptor.column,
731
+ operator,
732
+ storageAdapter?.translateFilterValue
733
+ ? storageAdapter.translateFilterValue(descriptor.field, value)
734
+ : value
735
+ ),
715
736
  {
716
737
  onMissingValue: (fieldName) => log.warn(`Cursor missing value for sort field: ${fieldName}`)
717
738
  }
@@ -739,15 +760,19 @@ export const RestApiKnexPlugin = {
739
760
  sortDescriptors,
740
761
  cursorData,
741
762
  (direction) => (direction === 'DESC' ? '>' : '<'),
742
- (builder, descriptor, operator, value) => builder.where(descriptor.column, operator, value),
763
+ (builder, descriptor, operator, value) => builder.where(
764
+ descriptor.column,
765
+ operator,
766
+ storageAdapter?.translateFilterValue
767
+ ? storageAdapter.translateFilterValue(descriptor.field, value)
768
+ : value
769
+ ),
743
770
  {
744
771
  onMissingValue: (fieldName) => log.warn(`Cursor missing value for sort field: ${fieldName}`)
745
772
  }
746
773
  )
747
774
  }
748
- }
749
- // Default pagination if only size is specified - treat as cursor-based for "load more"
750
- else {
775
+ } else {
751
776
  // Fetch one extra to detect hasMore
752
777
  query.limit(pageSize + 1)
753
778
  }
@@ -760,38 +785,10 @@ export const RestApiKnexPlugin = {
760
785
  // Execute query
761
786
  const records = await query
762
787
 
763
- // Store query string for response building
764
- const queryParts = []
765
- Object.entries(queryParams).forEach(([key, value]) => {
766
- if (Array.isArray(value)) {
767
- // Handle arrays (like sort, include)
768
- if (value.length > 0) {
769
- queryParts.push(`${key}=${value.map(v => encodeURIComponent(v)).join(',')}`)
770
- }
771
- } else if (typeof value === 'object' && value !== null) {
772
- // Handle nested objects (like filters, fields, page)
773
- Object.entries(value).forEach(([subKey, subValue]) => {
774
- if (subValue !== undefined && subValue !== null) {
775
- if (typeof subValue === 'object' && !Array.isArray(subValue)) {
776
- // Handle deeply nested objects (like filters[country][code])
777
- Object.entries(subValue).forEach(([subSubKey, subSubValue]) => {
778
- if (subSubValue !== undefined && subSubValue !== null) {
779
- queryParts.push(`${key}[${subKey}][${subSubKey}]=${encodeURIComponent(subSubValue)}`)
780
- }
781
- })
782
- } else {
783
- queryParts.push(`${key}[${subKey}]=${encodeURIComponent(subValue)}`)
784
- }
785
- }
786
- })
787
- } else if (value !== undefined && value !== null) {
788
- queryParts.push(`${key}=${encodeURIComponent(value)}`)
789
- }
790
- })
791
-
792
788
  // Initialize returnMeta namespace for thread-safe metadata
793
789
  context.returnMeta = context.returnMeta || {}
794
- context.returnMeta.queryString = queryParts.length > 0 ? `?${queryParts.join('&')}` : ''
790
+ const queryString = serializeJsonApiQuery(queryParams)
791
+ context.returnMeta.queryString = queryString ? `?${queryString}` : ''
795
792
 
796
793
  // Execute count query for pagination if offset-based pagination is used
797
794
  if (queryParams.page?.number !== undefined || (queryParams.page?.size !== undefined && !queryParams.page?.after && !queryParams.page?.before)) {
@@ -801,28 +798,31 @@ export const RestApiKnexPlugin = {
801
798
  // Only execute count query if enabled
802
799
  if (scope.vars.enablePaginationCounts) {
803
800
  // Build count query with same filters as main query
804
- const countQuery = db(tableName)
805
-
806
- // Apply filters through hooks (same as main query)
807
- if (queryParams.filters && Object.keys(queryParams.filters).length > 0) {
808
- // Store query data in context for hooks
809
- if (context) {
810
- context.knexQuery = {
811
- query: countQuery,
812
- filters: queryParams.filters,
813
- scopeName,
814
- tableName,
815
- schemaInfo,
816
- db
817
- }
818
- }
801
+ let countQuery = db(tableName)
802
+
803
+ // Mandatory server-side filters must also run when the client supplied no filters.
804
+ // Otherwise pagination metadata can disclose or count rows excluded from the page.
805
+ const previousKnexQuery = context.knexQuery
806
+ context.knexQuery = {
807
+ query: countQuery,
808
+ filters: queryParams.filters,
809
+ scopeName,
810
+ tableName,
811
+ schemaInfo,
812
+ db,
813
+ queryPurpose: 'count',
814
+ adapter: storageAdapter,
815
+ storageAdapter
816
+ }
819
817
 
820
- // Run the same filtering hooks
818
+ try {
821
819
  await runHooks('knexQueryFiltering')
822
-
823
- // Clean up
824
- if (context && context.knexQuery) {
820
+ countQuery = context.knexQuery?.query || countQuery
821
+ } finally {
822
+ if (previousKnexQuery === undefined) {
825
823
  delete context.knexQuery
824
+ } else {
825
+ context.knexQuery = previousKnexQuery
826
826
  }
827
827
  }
828
828
 
@@ -868,7 +868,9 @@ export const RestApiKnexPlugin = {
868
868
 
869
869
  const sortFields = sortDescriptors.map((descriptor) => descriptor.field)
870
870
 
871
- context.returnMeta.paginationMeta = buildCursorMeta(records, pageSize, hasMore, sortFields)
871
+ context.returnMeta.paginationMeta = buildCursorMeta(records, pageSize, hasMore, sortFields, {
872
+ schemaInfo
873
+ })
872
874
  const urlPrefix = getUrlPrefix(context, scope)
873
875
  context.returnMeta.paginationLinks = generateCursorPaginationLinks(
874
876
  urlPrefix,
@@ -877,7 +879,10 @@ export const RestApiKnexPlugin = {
877
879
  records,
878
880
  pageSize,
879
881
  hasMore,
880
- sortFields
882
+ sortFields,
883
+ {
884
+ schemaInfo
885
+ }
881
886
  )
882
887
  }
883
888
 
@@ -918,7 +923,6 @@ export const RestApiKnexPlugin = {
918
923
  if (storageAdapter) {
919
924
  context.storageAdapter = storageAdapter
920
925
  }
921
- const scope = api.resources[scopeName]
922
926
  const tableName = storageAdapter?.getTableName?.() || context.schemaInfo.tableName
923
927
  const idProperty = storageAdapter?.getIdColumn?.() || context.schemaInfo.idProperty
924
928
  const db = context.db || api.knex.instance
@@ -943,12 +947,26 @@ export const RestApiKnexPlugin = {
943
947
  })
944
948
  : attributes
945
949
 
950
+ const explicitId = dbAttributes[idProperty] ?? inputRecord.data.id
951
+
946
952
  // Insert and get the new ID
947
953
  const result = await db(tableName).insert(dbAttributes).returning(idProperty)
948
954
 
949
- // Extract the ID value (SQLite returns array of objects)
950
- const id = result[0]?.[idProperty] || result[0]
951
- return id
955
+ // Extract the ID value. Some dialects ignore .returning() for inserts and can
956
+ // yield 0 even when the caller explicitly supplied the primary key.
957
+ const returnedId = Array.isArray(result)
958
+ ? (result[0]?.[idProperty] ?? result[0])
959
+ : (result?.[idProperty] ?? result)
960
+
961
+ if (
962
+ explicitId !== undefined &&
963
+ explicitId !== null &&
964
+ (returnedId === undefined || returnedId === null || returnedId === 0 || returnedId === '0')
965
+ ) {
966
+ return explicitId
967
+ }
968
+
969
+ return returnedId
952
970
  }
953
971
 
954
972
  /**
@@ -1135,7 +1153,6 @@ export const RestApiKnexPlugin = {
1135
1153
  if (storageAdapter) {
1136
1154
  context.storageAdapter = storageAdapter
1137
1155
  }
1138
- const scope = api.resources[scopeName]
1139
1156
  const id = context.id
1140
1157
 
1141
1158
  const tableName = storageAdapter?.getTableName?.() || context.schemaInfo.tableName
@@ -1,3 +1,5 @@
1
+ import { normalizeReturnRecordSetting } from '../lib/querying-writing/return-record-settings.js'
2
+
1
3
  export default async function turnScopeInitIntoVars ({ context, scopes, vars: apiVars }) {
2
4
  // Refer to the scope's vars
3
5
  const scope = scopes[context.scopeName]
@@ -19,11 +21,16 @@ export default async function turnScopeInitIntoVars ({ context, scopes, vars: ap
19
21
  if (typeof scopeOptions.simplifiedTransport !== 'undefined') vars.simplifiedTransport = scopeOptions.simplifiedTransport
20
22
 
21
23
  // Set returnRecord settings as scope vars
22
- if (typeof scopeOptions.returnRecordApi !== 'undefined') vars.returnRecordApi = scopeOptions.returnRecordApi
23
- if (typeof scopeOptions.returnRecordTransport !== 'undefined') vars.returnRecordTransport = scopeOptions.returnRecordTransport
24
+ if (typeof scopeOptions.returnRecordApi !== 'undefined') {
25
+ vars.returnRecordApi = normalizeReturnRecordSetting(scopeOptions.returnRecordApi, vars.returnRecordApi || 'full')
26
+ }
27
+ if (typeof scopeOptions.returnRecordTransport !== 'undefined') {
28
+ vars.returnRecordTransport = normalizeReturnRecordSetting(scopeOptions.returnRecordTransport, vars.returnRecordTransport || 'no')
29
+ }
24
30
 
25
31
  // Set idProperty as scope var
26
32
  if (typeof scopeOptions.idProperty !== 'undefined') vars.idProperty = scopeOptions.idProperty
33
+ if (typeof scopeOptions.normalizeId === 'function') vars.normalizeId = scopeOptions.normalizeId
27
34
 
28
35
  // Add validation for query limits
29
36
  if (vars.queryDefaultLimit && vars.queryMaxLimit) {
@@ -3,8 +3,18 @@ import {
3
3
  RestApiValidationError
4
4
  } from '../../../lib/rest-api-errors.js'
5
5
  import { transformSimplifiedToJsonApi } from '../lib/querying-writing/simplified-helpers.js'
6
+ import { normalizeRelationshipIdentifiers } from '../lib/querying-writing/resource-id-normalization.js'
6
7
  import { createEnhancedLogger } from '../../../lib/enhanced-logger.js'
7
- import { unwrapQueryBuilderState } from '../lib/querying/query-builder-utils.js'
8
+ import {
9
+ normalizeReturnRecordMode,
10
+ normalizeReturnRecordSetting
11
+ } from '../lib/querying-writing/return-record-settings.js'
12
+ import {
13
+ getRequestContracts,
14
+ validateRequestContractOrThrow
15
+ } from '../lib/querying-writing/request-contracts.js'
16
+
17
+ export { normalizeReturnRecordMode as normalizeReturnValue }
8
18
 
9
19
  /**
10
20
  * Gets an enhanced logger instance with full error details and stack traces
@@ -28,16 +38,6 @@ export const getEnhancedLogger = (log) => {
28
38
  export const cascadeConfig = (settingName, sources, defaultValue) =>
29
39
  sources.find(source => source?.[settingName] !== undefined)?.[settingName] ?? defaultValue
30
40
 
31
- /**
32
- * Normalizes return record settings to valid values
33
- * @param {string|boolean} value - The value to normalize
34
- * @returns {string} Normalized value: 'no', 'minimal', or 'full'
35
- */
36
- export function normalizeReturnValue (value) {
37
- if (['no', 'minimal', 'full'].includes(value)) return value
38
- return 'no' // default
39
- }
40
-
41
41
  /**
42
42
  * Sets up common request context for REST API methods
43
43
  * Handles simplified mode, transaction setup, and initial validation
@@ -75,7 +75,7 @@ export async function setupCommonRequest ({ params, context, vars, scopes, scope
75
75
  } else {
76
76
  context.inputRecord = params
77
77
  // Preserve returnFullRecord if specified in params
78
- context.params = params.returnFullRecord ? { returnFullRecord: params.returnFullRecord } : {}
78
+ context.params = params.returnFullRecord !== undefined ? { returnFullRecord: params.returnFullRecord } : {}
79
79
  }
80
80
  } else {
81
81
  context.inputRecord = params.inputRecord
@@ -89,33 +89,10 @@ export async function setupCommonRequest ({ params, context, vars, scopes, scope
89
89
  const defaultReturnFullRecord = isTransport ? vars.returnRecordTransport : vars.returnRecordApi
90
90
 
91
91
  // Get return record setting - from params only (per-call override) or use default
92
- const returnFullRecordRaw = context.params.returnFullRecord !== undefined
93
- ? context.params.returnFullRecord
94
- : defaultReturnFullRecord
95
-
96
- // Normalize return record setting to always be an object with method keys
97
- if (typeof returnFullRecordRaw === 'object' && returnFullRecordRaw !== null) {
98
- // It's already an object, normalize the values
99
- context.returnRecordSetting = {
100
- post: normalizeReturnValue(returnFullRecordRaw.post),
101
- put: normalizeReturnValue(returnFullRecordRaw.put),
102
- patch: normalizeReturnValue(returnFullRecordRaw.patch)
103
- }
104
- } else {
105
- // It's a single value (string or boolean), apply to all methods
106
- const normalized = normalizeReturnValue(returnFullRecordRaw)
107
- context.returnRecordSetting = {
108
- post: normalized,
109
- put: normalized,
110
- patch: normalized
111
- }
112
- }
113
-
114
- // Helper function to normalize return values (same as above)
115
- function normalizeReturnValue (value) {
116
- if (['no', 'minimal', 'full'].includes(value)) return value
117
- return 'no' // default
118
- }
92
+ context.returnRecordSetting = normalizeReturnRecordSetting(
93
+ context.params.returnFullRecord,
94
+ defaultReturnFullRecord
95
+ )
119
96
 
120
97
  // These only make sense as parameter per query, not in vars etc.
121
98
  context.queryParams = params.queryParams || {}
@@ -160,7 +137,7 @@ export async function setupCommonRequest ({ params, context, vars, scopes, scope
160
137
  */
161
138
  export const handleWriteMethodError = async (error, context, method, scopeName, log, runHooks) => {
162
139
  // Rollback transaction if we created it
163
- if (context.shouldCommit) {
140
+ if (context.shouldCommit && !context.transactionCommitted) {
164
141
  await context.transaction.rollback()
165
142
  await runHooks('afterRollback')
166
143
  }
@@ -178,6 +155,39 @@ export const handleWriteMethodError = async (error, context, method, scopeName,
178
155
  throw error
179
156
  }
180
157
 
158
+ export const commitOwnedTransaction = async (context, runHooks) => {
159
+ if (!context.shouldCommit) {
160
+ return
161
+ }
162
+
163
+ await context.transaction.commit()
164
+ context.transactionCommitted = true
165
+ await runHooks('afterCommit')
166
+ }
167
+
168
+ export const validateRelationshipRoutePayload = ({
169
+ context,
170
+ vars,
171
+ scopeName,
172
+ operation,
173
+ relationshipData
174
+ }) => {
175
+ const contracts = getRequestContracts({
176
+ scopeName,
177
+ schemaInfo: context.schemaInfo,
178
+ includeDepthLimit: vars.includeDepthLimit,
179
+ sortableFields: vars.sortableFields
180
+ })
181
+
182
+ const validated = validateRequestContractOrThrow(
183
+ contracts[operation],
184
+ { data: relationshipData },
185
+ `${operation} relationship request body is invalid`
186
+ )
187
+
188
+ return validated.data
189
+ }
190
+
181
191
  /**
182
192
  * Validates that a pivot resource exists for many-to-many relationships
183
193
  *
@@ -296,16 +306,20 @@ export const validateResourceAttributesBeforeWrite = async ({
296
306
  context.originalInputAttributes = { ...(context.inputRecord.data.attributes || {}) }
297
307
  }
298
308
 
309
+ const schemaStructure = context.schemaInfo.schemaStructure || {}
310
+ const hasLogicalIdField = Object.hasOwn(schemaStructure, 'id')
311
+ const resourceId = context.inputRecord?.data?.id
312
+
299
313
  // Merge belongsTo updates with attributes for validation
300
314
  const attributesToValidate = {
301
315
  ...context.inputRecord.data.attributes,
302
- ...belongsToUpdates
316
+ ...belongsToUpdates,
317
+ ...(hasLogicalIdField && resourceId !== undefined ? { id: resourceId } : {})
303
318
  }
304
319
 
305
320
  // Extract computed fields and foreign key fields before validation
306
321
  const computedFields = {}
307
322
  const foreignKeyFields = {}
308
- const schemaStructure = context.schemaInfo.schemaStructure || {}
309
323
  Object.entries(attributesToValidate).forEach(([key, value]) => {
310
324
  const fieldDef = schemaStructure[key]
311
325
  // Computed fields should be stripped from input
@@ -375,12 +389,13 @@ export const validateResourceAttributesBeforeWrite = async ({
375
389
  const { validatedObject, errors } = await schema[validationMethod](attributesForValidation)
376
390
 
377
391
  if (Object.keys(errors).length > 0) {
378
- // --- START OF MODIFICATION ---
379
- const schemaStructure = context.schemaInfo.schemaInstance.structure // Get the schema structure for lookup
380
-
381
392
  const violations = Object.entries(errors).map(([field, error]) => {
382
393
  let fieldPath = `data.attributes.${field}` // Default path for attributes
383
394
 
395
+ if (field === 'id' && hasLogicalIdField) {
396
+ fieldPath = 'data.id'
397
+ }
398
+
384
399
  // Check if this field is a foreign key that has an 'as' alias
385
400
  const fieldDef = schemaStructure[field]
386
401
  if (fieldDef && fieldDef.belongsTo && fieldDef.as) {
@@ -400,7 +415,6 @@ export const validateResourceAttributesBeforeWrite = async ({
400
415
  message: error.message
401
416
  }
402
417
  })
403
- // --- END OF MODIFICATION ---
404
418
 
405
419
  throw new RestApiValidationError(
406
420
  'Schema validation failed for resource attributes',
@@ -411,9 +425,14 @@ export const validateResourceAttributesBeforeWrite = async ({
411
425
  )
412
426
  }
413
427
 
428
+ if (Object.hasOwn(validatedObject, 'id')) {
429
+ context.inputRecord.data.id = validatedObject.id
430
+ }
431
+ const { id: _validatedId, ...validatedAttributes } = validatedObject
432
+
414
433
  // Update attributes with validated values
415
434
  // Virtual fields have now been validated and cast properly
416
- context.inputRecord.data.attributes = validatedObject
435
+ context.inputRecord.data.attributes = validatedAttributes
417
436
 
418
437
  await runHooks(`afterSchemaValidate${methodSpecificHookSuffix}`)
419
438
  await runHooks('afterSchemaValidate')
@@ -434,8 +453,16 @@ export const validateRelationshipAccess = async (context, inputRecord, helpers,
434
453
  for (const [relName, relData] of Object.entries(inputRecord.data.relationships)) {
435
454
  if (!relData?.data) continue
436
455
 
456
+ const normalizedRelationshipData = normalizeRelationshipIdentifiers(relData.data, { api })
457
+ inputRecord.data.relationships[relName] = {
458
+ ...relData,
459
+ data: normalizedRelationshipData
460
+ }
461
+
437
462
  // Handle both single and array relationships
438
- const relatedItems = Array.isArray(relData.data) ? relData.data : [relData.data]
463
+ const relatedItems = Array.isArray(normalizedRelationshipData)
464
+ ? normalizedRelationshipData
465
+ : [normalizedRelationshipData]
439
466
 
440
467
  for (const item of relatedItems) {
441
468
  // Get the scope for the related resource
@@ -455,31 +482,18 @@ export const validateRelationshipAccess = async (context, inputRecord, helpers,
455
482
  isUpdate: false
456
483
  }
457
484
 
458
- const targetScope = api.resources?.[item.type]
459
- const applyQueryFilters = targetScope?.applyQueryFilters
460
- ? async ({ query, filters, tableName, db, scopeName, storageAdapter, isAnyApi }) => {
461
- const queryState = await targetScope.applyQueryFilters({
462
- query,
463
- filters,
464
- schemaInfo: getContext.schemaInfo,
465
- scopeName,
466
- tableName,
467
- db,
468
- isAnyApi,
469
- storageAdapter
470
- }, getContext)
471
-
472
- return {
473
- query: unwrapQueryBuilderState(queryState, query)
474
- }
475
- }
476
- : undefined
477
-
478
485
  // Get the minimal record
479
486
  const record = await helpers.dataGetMinimal({
480
487
  scopeName: item.type,
481
488
  context: getContext,
482
- applyQueryFilters
489
+ applyQueryFilters: relatedScope.applyQueryFilters
490
+ ? (filterParams) => relatedScope.applyQueryFilters({
491
+ ...filterParams,
492
+ schemaInfo: getContext.schemaInfo
493
+ }, getContext)
494
+ : undefined,
495
+ filters: {},
496
+ queryPurpose: 'relationship-validation'
483
497
  })
484
498
 
485
499
  if (!record) {
@@ -506,6 +520,22 @@ export const validateRelationshipAccess = async (context, inputRecord, helpers,
506
520
  }
507
521
  }
508
522
 
523
+ export const getVisibleRelationshipParent = async ({
524
+ context,
525
+ helpers,
526
+ scopeName,
527
+ runHooks
528
+ }) => {
529
+ // Related-route filters belong to the target resource, not its parent.
530
+ return helpers.dataGetMinimal({
531
+ scopeName,
532
+ context,
533
+ runHooks,
534
+ filters: {},
535
+ queryPurpose: 'relationship-parent'
536
+ })
537
+ }
538
+
509
539
  /**
510
540
  * Applies field setters to transform attribute values before storage
511
541
  * Setters are applied in dependency order to handle interdependent fields
@@ -552,8 +582,18 @@ export const applyFieldSetters = async (attributes, schemaInfo, context, api, he
552
582
  setterContext
553
583
  )
554
584
  } catch (error) {
555
- console.warn(`Setter for field '${fieldName}' failed:`, error)
556
- // Keep original value if setter fails
585
+ const message = error instanceof Error ? error.message : String(error)
586
+ throw new RestApiValidationError(
587
+ `Setter for field '${fieldName}' failed: ${message}`,
588
+ {
589
+ fields: [`data.attributes.${fieldName}`],
590
+ violations: [{
591
+ field: `data.attributes.${fieldName}`,
592
+ rule: 'setter_failed',
593
+ message
594
+ }]
595
+ }
596
+ )
557
597
  }
558
598
  }
559
599
 
@@ -667,7 +707,7 @@ export const findRelationshipDefinition = (schemaInfo, relationshipName) => {
667
707
  }
668
708
 
669
709
  // Then check schema fields for belongsTo relationships with matching 'as' property
670
- for (const [fieldName, fieldDef] of Object.entries(schemaInfo.schemaStructure || {})) {
710
+ for (const [, fieldDef] of Object.entries(schemaInfo.schemaStructure || {})) {
671
711
  if (fieldDef.as === relationshipName && fieldDef.belongsTo) {
672
712
  return fieldDef
673
713
  }