json-rest-api 1.0.24 → 1.0.26

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 (84) 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 +605 -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/query/anyapi-query-adapter.js +36 -26
  25. package/plugins/core/lib/anyapi/schema-utils.js +1 -0
  26. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
  27. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
  28. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  29. package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
  30. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  31. package/plugins/core/lib/querying/storage-adapter-utils.js +8 -8
  32. package/plugins/core/lib/querying/url-helpers.js +9 -21
  33. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  34. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  35. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  36. package/plugins/core/lib/querying-writing/knex-field-helpers.js +6 -1
  37. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  38. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  39. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  40. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
  41. package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
  42. package/plugins/core/lib/storage/storage-adapter.js +74 -13
  43. package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
  44. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  45. package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
  46. package/plugins/core/rest-api-knex-plugin.js +70 -60
  47. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
  48. package/plugins/core/rest-api-plugin-methods/common.js +85 -63
  49. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
  50. package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
  51. package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
  52. package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
  53. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
  54. package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
  55. package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
  56. package/plugins/core/rest-api-plugin-methods/post.js +4 -7
  57. package/plugins/core/rest-api-plugin-methods/put.js +4 -7
  58. package/plugins/core/rest-api-plugin.js +6 -36
  59. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  60. package/plugins/storage/local-storage.js +44 -19
  61. package/plugins/storage/s3-storage.js +21 -55
  62. package/tests/README.md +1 -1
  63. package/tests/anyapi-cursor-pagination.test.js +7 -1
  64. package/tests/anyapi-custom-idproperty.test.js +163 -0
  65. package/tests/fastify-plugin.test.js +28 -0
  66. package/tests/field-setters.test.js +10 -10
  67. package/tests/file-handling.test.js +118 -0
  68. package/tests/fixtures/api-configs.js +383 -4
  69. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  70. package/tests/http-connectors-parity.test.js +165 -5
  71. package/tests/local-storage.test.js +96 -0
  72. package/tests/pagination-cursor-multifield.test.js +121 -0
  73. package/tests/pagination-enhanced.test.js +66 -15
  74. package/tests/post-commit-hooks.test.js +120 -0
  75. package/tests/public-exports.test.js +15 -0
  76. package/tests/put-create-parity.test.js +216 -0
  77. package/tests/relationship-endpoints.test.js +259 -0
  78. package/tests/return-record-settings.test.js +184 -0
  79. package/tests/row-policy.test.js +489 -0
  80. package/tests/s3-storage.test.js +38 -0
  81. package/tests/searchschema-merge.test.js +17 -0
  82. package/tests/socketio.test.js +62 -0
  83. package/tests/storage-mapping.test.js +1 -0
  84. package/tests/test-development-guide.md +5 -5
@@ -38,6 +38,7 @@ import {
38
38
  parseSortEntry
39
39
  } from './lib/querying/query-field-sort-helpers.js'
40
40
  import { unwrapQueryBuilderState } from './lib/querying/query-builder-utils.js'
41
+ import { serializeJsonApiQuery } from './lib/querying-writing/connectors-query-parser.js'
41
42
 
42
43
  export const RestApiKnexPlugin = {
43
44
  name: 'rest-api-knex',
@@ -456,7 +457,14 @@ export const RestApiKnexPlugin = {
456
457
  * @param {Object} params.context.db - Database connection (knex instance or transaction)
457
458
  * @returns {Promise<Object|null>} JSON:API formatted resource with belongsTo relationships, or null if not found
458
459
  */
459
- 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
+ }) => {
460
468
  const storageAdapter = getScopeStorageAdapter(scopeName)
461
469
  if (storageAdapter) {
462
470
  context.storageAdapter = storageAdapter
@@ -482,29 +490,37 @@ export const RestApiKnexPlugin = {
482
490
  if (typeof applyQueryFilters === 'function') {
483
491
  const scopedQueryState = await applyQueryFilters({
484
492
  query,
485
- filters: context.queryParams?.filters,
493
+ filters,
486
494
  tableName,
487
495
  db,
488
496
  scopeName,
497
+ queryPurpose,
489
498
  storageAdapter
490
499
  })
491
500
  query = unwrapQueryBuilderState(scopedQueryState, query)
492
501
  } else if (runHooks) {
502
+ const previousKnexQuery = context.knexQuery
493
503
  context.knexQuery = {
494
504
  query,
495
- filters: context.queryParams?.filters,
505
+ filters,
496
506
  schemaInfo: context.schemaInfo,
497
507
  scopeName,
498
508
  tableName,
499
509
  db,
510
+ queryPurpose,
500
511
  adapter: storageAdapter,
501
512
  storageAdapter,
502
513
  }
503
514
 
504
- await runHooks('knexQueryFiltering')
505
-
506
- if (context.knexQuery) {
507
- 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
+ }
508
524
  }
509
525
  }
510
526
 
@@ -620,7 +636,9 @@ export const RestApiKnexPlugin = {
620
636
  scopeName,
621
637
  tableName,
622
638
  db,
639
+ queryPurpose: 'collection',
623
640
  adapter: storageAdapter,
641
+ storageAdapter,
624
642
  }
625
643
 
626
644
  log.trace('[DATA-QUERY] Stored data in context', { hasStoredData: !!context.knexQuery, filters: queryParams.filters })
@@ -708,7 +726,13 @@ export const RestApiKnexPlugin = {
708
726
  sortDescriptors,
709
727
  cursorData,
710
728
  (direction) => (direction === 'DESC' ? '<' : '>'),
711
- (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
+ ),
712
736
  {
713
737
  onMissingValue: (fieldName) => log.warn(`Cursor missing value for sort field: ${fieldName}`)
714
738
  }
@@ -736,7 +760,13 @@ export const RestApiKnexPlugin = {
736
760
  sortDescriptors,
737
761
  cursorData,
738
762
  (direction) => (direction === 'DESC' ? '>' : '<'),
739
- (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
+ ),
740
770
  {
741
771
  onMissingValue: (fieldName) => log.warn(`Cursor missing value for sort field: ${fieldName}`)
742
772
  }
@@ -755,38 +785,10 @@ export const RestApiKnexPlugin = {
755
785
  // Execute query
756
786
  const records = await query
757
787
 
758
- // Store query string for response building
759
- const queryParts = []
760
- Object.entries(queryParams).forEach(([key, value]) => {
761
- if (Array.isArray(value)) {
762
- // Handle arrays (like sort, include)
763
- if (value.length > 0) {
764
- queryParts.push(`${key}=${value.map(v => encodeURIComponent(v)).join(',')}`)
765
- }
766
- } else if (typeof value === 'object' && value !== null) {
767
- // Handle nested objects (like filters, fields, page)
768
- Object.entries(value).forEach(([subKey, subValue]) => {
769
- if (subValue !== undefined && subValue !== null) {
770
- if (typeof subValue === 'object' && !Array.isArray(subValue)) {
771
- // Handle deeply nested objects (like filters[country][code])
772
- Object.entries(subValue).forEach(([subSubKey, subSubValue]) => {
773
- if (subSubValue !== undefined && subSubValue !== null) {
774
- queryParts.push(`${key}[${subKey}][${subSubKey}]=${encodeURIComponent(subSubValue)}`)
775
- }
776
- })
777
- } else {
778
- queryParts.push(`${key}[${subKey}]=${encodeURIComponent(subValue)}`)
779
- }
780
- }
781
- })
782
- } else if (value !== undefined && value !== null) {
783
- queryParts.push(`${key}=${encodeURIComponent(value)}`)
784
- }
785
- })
786
-
787
788
  // Initialize returnMeta namespace for thread-safe metadata
788
789
  context.returnMeta = context.returnMeta || {}
789
- context.returnMeta.queryString = queryParts.length > 0 ? `?${queryParts.join('&')}` : ''
790
+ const queryString = serializeJsonApiQuery(queryParams)
791
+ context.returnMeta.queryString = queryString ? `?${queryString}` : ''
790
792
 
791
793
  // Execute count query for pagination if offset-based pagination is used
792
794
  if (queryParams.page?.number !== undefined || (queryParams.page?.size !== undefined && !queryParams.page?.after && !queryParams.page?.before)) {
@@ -796,28 +798,31 @@ export const RestApiKnexPlugin = {
796
798
  // Only execute count query if enabled
797
799
  if (scope.vars.enablePaginationCounts) {
798
800
  // Build count query with same filters as main query
799
- const countQuery = db(tableName)
800
-
801
- // Apply filters through hooks (same as main query)
802
- if (queryParams.filters && Object.keys(queryParams.filters).length > 0) {
803
- // Store query data in context for hooks
804
- if (context) {
805
- context.knexQuery = {
806
- query: countQuery,
807
- filters: queryParams.filters,
808
- scopeName,
809
- tableName,
810
- schemaInfo,
811
- db
812
- }
813
- }
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
+ }
814
817
 
815
- // Run the same filtering hooks
818
+ try {
816
819
  await runHooks('knexQueryFiltering')
817
-
818
- // Clean up
819
- if (context && context.knexQuery) {
820
+ countQuery = context.knexQuery?.query || countQuery
821
+ } finally {
822
+ if (previousKnexQuery === undefined) {
820
823
  delete context.knexQuery
824
+ } else {
825
+ context.knexQuery = previousKnexQuery
821
826
  }
822
827
  }
823
828
 
@@ -863,7 +868,9 @@ export const RestApiKnexPlugin = {
863
868
 
864
869
  const sortFields = sortDescriptors.map((descriptor) => descriptor.field)
865
870
 
866
- context.returnMeta.paginationMeta = buildCursorMeta(records, pageSize, hasMore, sortFields)
871
+ context.returnMeta.paginationMeta = buildCursorMeta(records, pageSize, hasMore, sortFields, {
872
+ schemaInfo
873
+ })
867
874
  const urlPrefix = getUrlPrefix(context, scope)
868
875
  context.returnMeta.paginationLinks = generateCursorPaginationLinks(
869
876
  urlPrefix,
@@ -872,7 +879,10 @@ export const RestApiKnexPlugin = {
872
879
  records,
873
880
  pageSize,
874
881
  hasMore,
875
- sortFields
882
+ sortFields,
883
+ {
884
+ schemaInfo
885
+ }
876
886
  )
877
887
  }
878
888
 
@@ -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,8 +21,12 @@ 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
@@ -5,7 +5,16 @@ import {
5
5
  import { transformSimplifiedToJsonApi } from '../lib/querying-writing/simplified-helpers.js'
6
6
  import { normalizeRelationshipIdentifiers } from '../lib/querying-writing/resource-id-normalization.js'
7
7
  import { createEnhancedLogger } from '../../../lib/enhanced-logger.js'
8
- 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 }
9
18
 
10
19
  /**
11
20
  * Gets an enhanced logger instance with full error details and stack traces
@@ -29,16 +38,6 @@ export const getEnhancedLogger = (log) => {
29
38
  export const cascadeConfig = (settingName, sources, defaultValue) =>
30
39
  sources.find(source => source?.[settingName] !== undefined)?.[settingName] ?? defaultValue
31
40
 
32
- /**
33
- * Normalizes return record settings to valid values
34
- * @param {string|boolean} value - The value to normalize
35
- * @returns {string} Normalized value: 'no', 'minimal', or 'full'
36
- */
37
- export function normalizeReturnValue (value) {
38
- if (['no', 'minimal', 'full'].includes(value)) return value
39
- return 'no' // default
40
- }
41
-
42
41
  /**
43
42
  * Sets up common request context for REST API methods
44
43
  * Handles simplified mode, transaction setup, and initial validation
@@ -76,7 +75,7 @@ export async function setupCommonRequest ({ params, context, vars, scopes, scope
76
75
  } else {
77
76
  context.inputRecord = params
78
77
  // Preserve returnFullRecord if specified in params
79
- context.params = params.returnFullRecord ? { returnFullRecord: params.returnFullRecord } : {}
78
+ context.params = params.returnFullRecord !== undefined ? { returnFullRecord: params.returnFullRecord } : {}
80
79
  }
81
80
  } else {
82
81
  context.inputRecord = params.inputRecord
@@ -90,33 +89,10 @@ export async function setupCommonRequest ({ params, context, vars, scopes, scope
90
89
  const defaultReturnFullRecord = isTransport ? vars.returnRecordTransport : vars.returnRecordApi
91
90
 
92
91
  // Get return record setting - from params only (per-call override) or use default
93
- const returnFullRecordRaw = context.params.returnFullRecord !== undefined
94
- ? context.params.returnFullRecord
95
- : defaultReturnFullRecord
96
-
97
- // Normalize return record setting to always be an object with method keys
98
- if (typeof returnFullRecordRaw === 'object' && returnFullRecordRaw !== null) {
99
- // It's already an object, normalize the values
100
- context.returnRecordSetting = {
101
- post: normalizeReturnValue(returnFullRecordRaw.post),
102
- put: normalizeReturnValue(returnFullRecordRaw.put),
103
- patch: normalizeReturnValue(returnFullRecordRaw.patch)
104
- }
105
- } else {
106
- // It's a single value (string or boolean), apply to all methods
107
- const normalized = normalizeReturnValue(returnFullRecordRaw)
108
- context.returnRecordSetting = {
109
- post: normalized,
110
- put: normalized,
111
- patch: normalized
112
- }
113
- }
114
-
115
- // Helper function to normalize return values (same as above)
116
- function normalizeReturnValue (value) {
117
- if (['no', 'minimal', 'full'].includes(value)) return value
118
- return 'no' // default
119
- }
92
+ context.returnRecordSetting = normalizeReturnRecordSetting(
93
+ context.params.returnFullRecord,
94
+ defaultReturnFullRecord
95
+ )
120
96
 
121
97
  // These only make sense as parameter per query, not in vars etc.
122
98
  context.queryParams = params.queryParams || {}
@@ -161,7 +137,7 @@ export async function setupCommonRequest ({ params, context, vars, scopes, scope
161
137
  */
162
138
  export const handleWriteMethodError = async (error, context, method, scopeName, log, runHooks) => {
163
139
  // Rollback transaction if we created it
164
- if (context.shouldCommit) {
140
+ if (context.shouldCommit && !context.transactionCommitted) {
165
141
  await context.transaction.rollback()
166
142
  await runHooks('afterRollback')
167
143
  }
@@ -179,6 +155,39 @@ export const handleWriteMethodError = async (error, context, method, scopeName,
179
155
  throw error
180
156
  }
181
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
+
182
191
  /**
183
192
  * Validates that a pivot resource exists for many-to-many relationships
184
193
  *
@@ -473,31 +482,18 @@ export const validateRelationshipAccess = async (context, inputRecord, helpers,
473
482
  isUpdate: false
474
483
  }
475
484
 
476
- const targetScope = api.resources?.[item.type]
477
- const applyQueryFilters = targetScope?.applyQueryFilters
478
- ? async ({ query, filters, tableName, db, scopeName, storageAdapter, isAnyApi }) => {
479
- const queryState = await targetScope.applyQueryFilters({
480
- query,
481
- filters,
482
- schemaInfo: getContext.schemaInfo,
483
- scopeName,
484
- tableName,
485
- db,
486
- isAnyApi,
487
- storageAdapter
488
- }, getContext)
489
-
490
- return {
491
- query: unwrapQueryBuilderState(queryState, query)
492
- }
493
- }
494
- : undefined
495
-
496
485
  // Get the minimal record
497
486
  const record = await helpers.dataGetMinimal({
498
487
  scopeName: item.type,
499
488
  context: getContext,
500
- 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'
501
497
  })
502
498
 
503
499
  if (!record) {
@@ -524,6 +520,22 @@ export const validateRelationshipAccess = async (context, inputRecord, helpers,
524
520
  }
525
521
  }
526
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
+
527
539
  /**
528
540
  * Applies field setters to transform attribute values before storage
529
541
  * Setters are applied in dependency order to handle interdependent fields
@@ -570,8 +582,18 @@ export const applyFieldSetters = async (attributes, schemaInfo, context, api, he
570
582
  setterContext
571
583
  )
572
584
  } catch (error) {
573
- console.warn(`Setter for field '${fieldName}' failed:`, error)
574
- // 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
+ )
575
597
  }
576
598
  }
577
599
 
@@ -1,5 +1,11 @@
1
- import { RestApiResourceError, RestApiValidationError, RestApiPayloadError } from '../../../lib/rest-api-errors.js'
2
- import { findRelationshipDefinition, handleWriteMethodError } from './common.js'
1
+ import { RestApiResourceError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
2
+ import {
3
+ commitOwnedTransaction,
4
+ findRelationshipDefinition,
5
+ getVisibleRelationshipParent,
6
+ handleWriteMethodError,
7
+ validateRelationshipRoutePayload
8
+ } from './common.js'
3
9
  import {
4
10
  normalizeRelationshipIdentifiers,
5
11
  requireExistingResourceId
@@ -48,22 +54,28 @@ export default async function deleteRelationshipMethod ({ params, context, vars,
48
54
  )
49
55
  }
50
56
 
51
- if (!Array.isArray(params.relationshipData)) {
52
- throw new RestApiPayloadError('DELETE from relationship requires array of resource identifiers')
53
- }
57
+ params.relationshipData = validateRelationshipRoutePayload({
58
+ context,
59
+ vars,
60
+ scopeName,
61
+ operation: 'deleteRelationship',
62
+ relationshipData: params.relationshipData
63
+ })
54
64
  params.relationshipData = normalizeRelationshipIdentifiers(params.relationshipData, { api })
55
65
 
56
66
  // Check permissions
57
67
  await runHooks('checkPermissions')
58
68
  await runHooks('checkPermissionsDeleteRelationship')
59
69
 
60
- // Verify parent exists
61
- const exists = await helpers.dataExists({
70
+ // Verify the parent exists in the caller's visible dataset.
71
+ const parentRecord = await getVisibleRelationshipParent({
72
+ context,
73
+ helpers,
62
74
  scopeName,
63
- context: { db: context.db, id: context.id, schemaInfo: context.schemaInfo }
75
+ runHooks
64
76
  })
65
77
 
66
- if (!exists) {
78
+ if (!parentRecord) {
67
79
  throw new RestApiResourceError('Resource not found', { subtype: 'not_found' })
68
80
  }
69
81
 
@@ -108,16 +120,14 @@ export default async function deleteRelationshipMethod ({ params, context, vars,
108
120
  },
109
121
  transaction: context.transaction,
110
122
  simplified: false
111
- })
123
+ }, { ...context })
112
124
  }
113
125
  }
114
126
 
115
127
  await runHooks('finish')
116
128
  await runHooks('finishDeleteRelationship')
117
129
 
118
- if (context.shouldCommit) {
119
- await context.transaction.commit()
120
- }
130
+ await commitOwnedTransaction(context, runHooks)
121
131
 
122
132
  // 204 No Content
123
133
  } catch (error) {
@@ -1,5 +1,5 @@
1
1
  import { RestApiResourceError } from '../../../lib/rest-api-errors.js'
2
- import { handleWriteMethodError } from './common.js'
2
+ import { commitOwnedTransaction, handleWriteMethodError } from './common.js'
3
3
  import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
4
4
 
5
5
  /**
@@ -96,11 +96,7 @@ export default async function deleteMethod ({
96
96
  await runHooks('finish')
97
97
  await runHooks('finishDelete')
98
98
 
99
- // Commit transaction if we created it
100
- if (context.shouldCommit) {
101
- await context.transaction.commit()
102
- await runHooks('afterCommit')
103
- }
99
+ await commitOwnedTransaction(context, runHooks)
104
100
 
105
101
  // DELETE typically returns void/undefined (204 No Content)
106
102
  } catch (error) {
@@ -1,5 +1,5 @@
1
1
  import { RestApiResourceError } from '../../../lib/rest-api-errors.js'
2
- import { findRelationshipDefinition } from './common.js'
2
+ import { findRelationshipDefinition, getVisibleRelationshipParent } from './common.js'
3
3
  import { buildRelationshipUrl } from '../lib/querying/url-helpers.js'
4
4
  import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
5
5
 
@@ -57,13 +57,15 @@ export default async function getRelatedMethod ({ params, context, vars, helpers
57
57
  await runHooks('checkPermissions')
58
58
  await runHooks('checkPermissionsGetRelated')
59
59
 
60
- // Verify parent exists
61
- const exists = await helpers.dataExists({
60
+ // Verify the parent exists in the caller's visible dataset.
61
+ const parentRecord = await getVisibleRelationshipParent({
62
+ context,
63
+ helpers,
62
64
  scopeName,
63
- context: { db: context.db, id: context.id, schemaInfo: context.schemaInfo }
65
+ runHooks
64
66
  })
65
67
 
66
- if (!exists) {
68
+ if (!parentRecord) {
67
69
  throw new RestApiResourceError('Resource not found', { subtype: 'not_found' })
68
70
  }
69
71
 
@@ -102,7 +104,7 @@ export default async function getRelatedMethod ({ params, context, vars, helpers
102
104
  transaction: context.transaction,
103
105
  simplified: false,
104
106
  isTransport: params.isTransport
105
- })
107
+ }, { ...context })
106
108
 
107
109
  const relatedId = parent.data.relationships?.[context.relationshipName]?.data?.id
108
110
  if (!relatedId) {
@@ -119,7 +121,7 @@ export default async function getRelatedMethod ({ params, context, vars, helpers
119
121
  transaction: context.transaction,
120
122
  simplified: false,
121
123
  isTransport: params.isTransport
122
- })
124
+ }, { ...context })
123
125
 
124
126
  return {
125
127
  links: { self: buildRelationshipUrl(context, scope, scopeName, context.id, context.relationshipName, false) },
@@ -140,7 +142,7 @@ export default async function getRelatedMethod ({ params, context, vars, helpers
140
142
  transaction: context.transaction,
141
143
  simplified: false,
142
144
  isTransport: params.isTransport
143
- })
145
+ }, { ...context })
144
146
 
145
147
  // Extract the related resource from the parent's relationships
146
148
  const relatedId = parent.data.relationships?.[context.relationshipName]?.data?.id
@@ -194,7 +196,7 @@ export default async function getRelatedMethod ({ params, context, vars, helpers
194
196
  transaction: context.transaction,
195
197
  simplified: false,
196
198
  isTransport: params.isTransport
197
- })
199
+ }, { ...context })
198
200
 
199
201
  if (!result.links) {
200
202
  result.links = {}
@@ -228,7 +230,7 @@ export default async function getRelatedMethod ({ params, context, vars, helpers
228
230
  transaction: context.transaction,
229
231
  simplified: false,
230
232
  isTransport: params.isTransport
231
- })
233
+ }, { ...context })
232
234
 
233
235
  if (!result.links) {
234
236
  result.links = {}
@@ -256,7 +258,7 @@ export default async function getRelatedMethod ({ params, context, vars, helpers
256
258
  transaction: context.transaction,
257
259
  simplified: false,
258
260
  isTransport: params.isTransport,
259
- })
261
+ }, { ...context })
260
262
  if (related?.data) {
261
263
  results.push(related.data)
262
264
  }
@@ -307,7 +309,7 @@ export default async function getRelatedMethod ({ params, context, vars, helpers
307
309
  transaction: context.transaction,
308
310
  simplified: false,
309
311
  isTransport: params.isTransport
310
- })
312
+ }, { ...context })
311
313
  let includedResources = pivotResult.included?.filter((r) => r.type === targetType) || []
312
314
 
313
315
  if (includedResources.length === 0) {
@@ -339,7 +341,7 @@ export default async function getRelatedMethod ({ params, context, vars, helpers
339
341
  transaction: context.transaction,
340
342
  simplified: false,
341
343
  isTransport: params.isTransport,
342
- })
344
+ }, { ...context })
343
345
  if (related?.data) {
344
346
  includedResources.push(related.data)
345
347
  }
@@ -46,7 +46,7 @@ export default async function getRelationshipMethod ({ params, context, vars, he
46
46
  transaction: context.transaction,
47
47
  simplified: false,
48
48
  isTransport: params.isTransport
49
- })
49
+ }, { ...context })
50
50
 
51
51
  if (!fullRecord || !fullRecord.data) {
52
52
  throw new RestApiResourceError('Resource not found', { subtype: 'not_found' })