json-rest-api 1.0.10 → 1.0.13

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 (117) hide show
  1. package/.claude/settings.local.json +9 -1
  2. package/AGENTS.md +34 -0
  3. package/CLAUDE.md +26 -1
  4. package/CRUSH.md +55 -0
  5. package/README.md +3 -3
  6. package/TODO.todo +8 -0
  7. package/docs/API.md +17 -18
  8. package/docs/COMPARISON.md +18 -13
  9. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +17 -21
  10. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +33 -2
  11. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +61 -7
  12. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +58 -61
  13. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +37 -37
  14. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +103 -87
  15. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +37 -34
  16. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +45 -42
  17. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +3 -3
  18. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +1 -1
  19. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -5
  20. package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +498 -755
  21. package/docs/GUIDE/GUIDE_6_Positioning.md +1 -3
  22. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +4 -5
  23. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +15 -6
  24. package/docs/GUIDE/GUIDE_X_Cors.md +2 -5
  25. package/docs/GUIDE/GUIDE_X_File_Uploads.md +2 -3
  26. package/docs/GUIDE/GUIDE_X_Multihome.md +1 -4
  27. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -2
  28. package/docs/GUIDE/GUIDE_X_URL_Management.md +317 -0
  29. package/docs/GUIDE/index.md +3 -0
  30. package/docs/QUICKSTART.md +12 -12
  31. package/docs/index.md +2 -2
  32. package/docs/mini-guides/file-uploads-complete.md +0 -1
  33. package/examples/url-override-example.js +137 -0
  34. package/index.js +7 -1
  35. package/package.json +7 -11
  36. package/plugins/core/bulk-operations-plugin.js +2 -0
  37. package/plugins/core/connectors/express-plugin.js +43 -24
  38. package/plugins/core/connectors/lib/request-helpers.js +2 -2
  39. package/plugins/core/lib/anyapi/anyapi-registry.js +821 -0
  40. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +647 -0
  41. package/plugins/core/lib/anyapi/schema-utils.js +186 -0
  42. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +113 -0
  43. package/plugins/core/lib/dbTablesOperations.js +305 -0
  44. package/plugins/core/lib/querying/knex-cross-table-search.js +64 -108
  45. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +21 -27
  46. package/plugins/core/lib/querying/knex-pagination-helpers.js +36 -20
  47. package/plugins/core/lib/querying/knex-query-helpers-base.js +49 -14
  48. package/plugins/core/lib/querying/knex-query-helpers.js +378 -222
  49. package/plugins/core/lib/querying/knex-relationship-includes.js +262 -87
  50. package/plugins/core/lib/querying/url-helpers.js +119 -0
  51. package/plugins/core/lib/querying-writing/compile-schemas.js +36 -28
  52. package/plugins/core/lib/querying-writing/database-capabilities.js +10 -9
  53. package/plugins/core/lib/querying-writing/knex-field-helpers.js +9 -3
  54. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +2 -2
  55. package/plugins/core/lib/querying-writing/payload-validators.js +10 -16
  56. package/plugins/core/lib/querying-writing/schema-helpers.js +52 -60
  57. package/plugins/core/lib/querying-writing/scope-validations.js +78 -22
  58. package/plugins/core/lib/querying-writing/simplified-helpers.js +208 -61
  59. package/plugins/core/lib/storage/storage-adapter.js +160 -0
  60. package/plugins/core/lib/writing/relationship-processor.js +4 -11
  61. package/plugins/core/rest-api-access.js +456 -0
  62. package/plugins/core/rest-api-anyapi-knex-plugin.js +2285 -0
  63. package/plugins/core/rest-api-knex-plugin.js +226 -87
  64. package/plugins/core/rest-api-multihome-plugin.js +89 -27
  65. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +0 -1
  66. package/plugins/core/rest-api-plugin-methods/check-permissions.js +1 -5
  67. package/plugins/core/rest-api-plugin-methods/common.js +46 -10
  68. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +31 -27
  69. package/plugins/core/rest-api-plugin-methods/delete.js +1 -4
  70. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +13 -1
  71. package/plugins/core/rest-api-plugin-methods/get-related.js +114 -56
  72. package/plugins/core/rest-api-plugin-methods/get-relationship.js +4 -3
  73. package/plugins/core/rest-api-plugin-methods/get.js +2 -5
  74. package/plugins/core/rest-api-plugin-methods/patch.js +15 -16
  75. package/plugins/core/rest-api-plugin-methods/post-relationship.js +21 -14
  76. package/plugins/core/rest-api-plugin-methods/post.js +23 -11
  77. package/plugins/core/rest-api-plugin-methods/put.js +18 -21
  78. package/plugins/core/rest-api-plugin-methods/query.js +3 -4
  79. package/plugins/core/rest-api-plugin-methods/release.js +13 -0
  80. package/plugins/core/rest-api-plugin.js +3 -4
  81. package/plugins/core/rest-api-positioning-plugin.js +253 -76
  82. package/plugins/core/socketio-plugin.js +436 -502
  83. package/quickTest.js +2 -2
  84. package/scripts/debug-anyapi-review-filter.mjs +63 -0
  85. package/scripts/inspect-anyapi-descriptor-country.mjs +19 -0
  86. package/scripts/inspect-anyapi-descriptor.mjs +19 -0
  87. package/test-debug.js +59 -0
  88. package/tests/README.md +7 -1
  89. package/tests/anyapi-basic.test.js +102 -0
  90. package/tests/anyapi-cursor-pagination.test.js +144 -0
  91. package/tests/computed-fields.test.js +27 -12
  92. package/tests/cors.test.js +5 -6
  93. package/tests/curl.test.js +1 -1
  94. package/tests/custom-idproperty-relationships.test.js +37 -29
  95. package/tests/field-getters.test.js +25 -7
  96. package/tests/fixtures/api-configs.js +576 -150
  97. package/tests/helpers/storage-mode.js +65 -0
  98. package/tests/helpers/test-utils.js +85 -3
  99. package/tests/include-depth-validation.test.js +35 -1
  100. package/tests/nested-includes.test.js +130 -103
  101. package/tests/pagination-cursor-multifield.test.js +17 -13
  102. package/tests/pagination-enhanced.test.js +19 -24
  103. package/tests/polymorphic-simple.test.js +9 -9
  104. package/tests/positioning-advanced.test.js +19 -19
  105. package/tests/positioning-extended.test.js +25 -25
  106. package/tests/positioning-multifilter.test.js +62 -62
  107. package/tests/positioning.test.js +7 -7
  108. package/tests/queries.test.js +5 -5
  109. package/tests/query-limits.test.js +77 -92
  110. package/tests/rest-api-access.test.js +206 -0
  111. package/tests/searchschema-merge.test.js +182 -0
  112. package/tests/socketio.test.js +33 -14
  113. package/tests/virtual-fields.test.js +0 -1
  114. package/docs/DOCS_ALL.md +0 -21894
  115. package/plugins/core/jwt-auth-plugin.js +0 -897
  116. package/plugins/core/lib/jwt-auth-helpers.js +0 -130
  117. package/tests/jwt-auth.test.js +0 -692
@@ -81,6 +81,28 @@ import { buildWindowedIncludeQuery, applyStandardIncludeConfig, buildOrderByClau
81
81
  import { RELATIONSHIPS_KEY, RELATIONSHIP_METADATA_KEY, ROW_NUMBER_KEY, COMPUTED_DEPENDENCIES_KEY, DEFAULT_QUERY_LIMIT } from '../querying-writing/knex-constants.js';
82
82
  import { RestApiResourceError } from '../../../../lib/rest-api-errors.js';
83
83
 
84
+ const translateSelectFieldsForAdapter = (fields, adapter) => {
85
+ if (!adapter || !fields) return fields;
86
+
87
+ const translateField = (field) => {
88
+ if (typeof field !== 'string') return field;
89
+
90
+ if (field === '*') return '*';
91
+
92
+ const aliasMatch = field.match(/\s+as\s+/i);
93
+ if (aliasMatch) {
94
+ const [source, alias] = field.split(/\s+as\s+/i);
95
+ const translatedSource = adapter.translateColumn(source.trim()) || source.trim();
96
+ return `${translatedSource} as ${alias.trim()}`;
97
+ }
98
+
99
+ const translated = adapter.translateColumn(field);
100
+ return translated || field;
101
+ };
102
+
103
+ return fields.map(translateField);
104
+ };
105
+
84
106
  /**
85
107
  * Groups records by their polymorphic type for efficient batch loading
86
108
  *
@@ -263,7 +285,7 @@ const loadRelationshipMetadata = async (scopes, records, scopeName) => {
263
285
  const schemaInfo = scopes[scopeName]?.vars?.schemaInfo;
264
286
  if (!schemaInfo) return;
265
287
 
266
- const schema = schemaInfo.schema.structure || schemaInfo.schema;
288
+ const schema = schemaInfo.schemaInstance.structure || schemaInfo.schemaInstance;
267
289
  const relationships = schemaInfo.schemaRelationships || {};
268
290
 
269
291
  // Process each record
@@ -287,11 +309,14 @@ const loadRelationshipMetadata = async (scopes, records, scopeName) => {
287
309
  }
288
310
  }
289
311
 
290
- // Process hasMany relationships
312
+ // Process hasMany, hasOne and manyToMany relationships
291
313
  for (const [relName, relDef] of Object.entries(relationships)) {
292
- if (relDef.hasMany) {
314
+ if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
293
315
  // Add empty relationship data - will be populated if explicitly included
294
316
  record[RELATIONSHIP_METADATA_KEY][relName] = { data: [] };
317
+ } else if (relDef.type === 'hasOne') {
318
+ // hasOne expects a single object or null
319
+ record[RELATIONSHIP_METADATA_KEY][relName] = { data: null };
295
320
  } else if (relDef.belongsToPolymorphic) {
296
321
  // Process polymorphic relationships defined in relationships section
297
322
  const { typeField, idField } = relDef.belongsToPolymorphic;
@@ -379,7 +404,7 @@ export const loadBelongsTo = async (scope, deps) => {
379
404
  const relationshipObject = { data: null };
380
405
 
381
406
  // Add links if urlPrefix is configured
382
- const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
407
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
383
408
  if (scopeName) {
384
409
  relationshipObject.links = {
385
410
  self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
@@ -395,16 +420,18 @@ export const loadBelongsTo = async (scope, deps) => {
395
420
  // Get target table info
396
421
  const targetTableName = scopes[targetScope].vars.schemaInfo.tableName
397
422
  const targetIdProperty = scopes[targetScope].vars.schemaInfo.idProperty
398
- const targetSchema = scopes[targetScope].vars.schemaInfo.schema;
423
+ const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance;
399
424
 
400
425
  // Build field selection for sparse fieldsets
401
426
  const targetScopeObject = scopes[targetScope];
427
+ const storageAdapter = targetScopeObject.vars.storageAdapter;
402
428
  const fieldSelectionInfo = fields?.[targetScope] ?
403
429
  await buildFieldSelection(targetScopeObject, {
404
430
  context: {
405
431
  scopeName: targetScope,
406
432
  queryParams: { fields: { [targetScope]: fields[targetScope] } },
407
- schemaInfo: targetScopeObject.vars.schemaInfo
433
+ schemaInfo: targetScopeObject.vars.schemaInfo,
434
+ storageAdapter,
408
435
  }
409
436
  }) :
410
437
  null;
@@ -412,7 +439,8 @@ export const loadBelongsTo = async (scope, deps) => {
412
439
  // Load the target records
413
440
  let query = knex(targetTableName).whereIn(targetIdProperty, uniqueIds);
414
441
  if (fieldSelectionInfo) {
415
- query = query.select(fieldSelectionInfo.fieldsToSelect);
442
+ const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter);
443
+ query = query.select(selectFields);
416
444
  } else if (targetIdProperty !== 'id') {
417
445
  // If no field selection but custom idProperty, we need to alias it
418
446
  query = query.select('*', `${targetIdProperty} as id`);
@@ -460,7 +488,7 @@ export const loadBelongsTo = async (scope, deps) => {
460
488
  const relationshipObject = { data: { type: targetScope, id: String(targetId) } };
461
489
 
462
490
  // Add links if urlPrefix is configured
463
- const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
491
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
464
492
  if (scopeName) {
465
493
  relationshipObject.links = {
466
494
  self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
@@ -484,7 +512,7 @@ export const loadBelongsTo = async (scope, deps) => {
484
512
  const relationshipObject = { data: null };
485
513
 
486
514
  // Add links if urlPrefix is configured
487
- const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
515
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
488
516
  if (scopeName) {
489
517
  relationshipObject.links = {
490
518
  self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
@@ -554,11 +582,13 @@ export const loadHasMany = async (scope, deps) => {
554
582
  const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope;
555
583
  const { scopes, log, knex, capabilities } = deps.context;
556
584
  try {
557
- log.trace('[INCLUDE] Loading hasMany relationship:', {
585
+ log.trace('[INCLUDE] Loading hasMany/manyToMany relationship:', {
558
586
  scopeName,
559
587
  includeName,
560
588
  recordCount: records.length,
561
- hasThrough: !!relDef.through
589
+ relDef,
590
+ isManyToMany: relDef.type === 'manyToMany',
591
+ hasMany: relDef.type === 'hasMany' ? relDef.target : undefined
562
592
  });
563
593
 
564
594
  // Collect all parent IDs
@@ -570,18 +600,20 @@ export const loadHasMany = async (scope, deps) => {
570
600
  }
571
601
 
572
602
 
573
- // Check if this is a many-to-many relationship (has through property)
574
- if (relDef.through) {
603
+ // Check if this is a many-to-many relationship
604
+ if (relDef.type === 'manyToMany') {
575
605
 
576
606
  // Handle many-to-many relationship
577
- const pivotTable = scopes[relDef.through].vars.schemaInfo.tableName
607
+ const pivotTable = scopes[relDef.through].vars.schemaInfo.tableName;
578
608
  const foreignKey = relDef.foreignKey;
579
609
  const otherKey = relDef.otherKey;
580
610
 
581
611
  if (!foreignKey || !otherKey) {
582
612
  throw new Error(`Missing foreignKey or otherKey in many-to-many relationship '${relName}' for scope '${scopeName}'`);
583
613
  }
584
- const targetScope = relDef.hasMany;
614
+
615
+ // For manyToMany, the target scope is the relationship name itself
616
+ const targetScope = includeName;
585
617
  const targetTable = scopes[targetScope].vars.schemaInfo.tableName
586
618
 
587
619
  log.debug(`[INCLUDE] Loading pivot records from ${pivotTable}:`, {
@@ -602,7 +634,7 @@ export const loadHasMany = async (scope, deps) => {
602
634
  const relationshipObject = { data: [] };
603
635
 
604
636
  // Add links if urlPrefix is configured
605
- const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
637
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
606
638
  if (scopeName) {
607
639
  relationshipObject.links = {
608
640
  self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
@@ -624,15 +656,17 @@ export const loadHasMany = async (scope, deps) => {
624
656
  });
625
657
 
626
658
  // Step 3: Build field selection for sparse fieldsets
627
- const targetSchema = scopes[targetScope].vars.schemaInfo.schema;
659
+ const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance;
628
660
  const targetScopeObject = scopes[targetScope];
661
+ const storageAdapter = targetScopeObject.vars.storageAdapter;
629
662
  const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
630
663
  const fieldSelectionInfo = fields?.[targetScope] ?
631
664
  await buildFieldSelection(targetScopeObject, {
632
665
  context: {
633
666
  scopeName: targetScope,
634
667
  queryParams: { fields: { [targetScope]: fields[targetScope] } },
635
- schemaInfo: targetScopeObject.vars.schemaInfo
668
+ schemaInfo: targetScopeObject.vars.schemaInfo,
669
+ storageAdapter,
636
670
  }
637
671
  }) :
638
672
  null;
@@ -698,7 +732,8 @@ export const loadHasMany = async (scope, deps) => {
698
732
  let query = knex(targetTable).whereIn(targetIdProperty, targetIds);
699
733
 
700
734
  if (fieldSelectionInfo) {
701
- query = query.select(fieldSelectionInfo.fieldsToSelect);
735
+ const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter);
736
+ query = query.select(selectFields);
702
737
  } else if (targetIdProperty !== 'id') {
703
738
  // If no field selection but custom idProperty, we need to alias it
704
739
  query = query.select('*', `${targetIdProperty} as id`);
@@ -775,7 +810,7 @@ export const loadHasMany = async (scope, deps) => {
775
810
  const relationshipObject = { data: relData };
776
811
 
777
812
  // Add links if urlPrefix is configured
778
- const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
813
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
779
814
  if (scopeName) {
780
815
  relationshipObject.links = {
781
816
  self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
@@ -807,9 +842,9 @@ export const loadHasMany = async (scope, deps) => {
807
842
  }
808
843
  }
809
844
 
810
- } else {
811
- // Handle regular one-to-many relationship
812
- const targetScope = relDef.hasMany;
845
+ } else if (relDef.type === 'hasMany') {
846
+ // Handle one-to-many relationship
847
+ const targetScope = relDef.target;
813
848
  const targetTable = scopes[targetScope].vars.schemaInfo.tableName;
814
849
  const foreignKey = relDef.foreignKey;
815
850
 
@@ -823,15 +858,17 @@ export const loadHasMany = async (scope, deps) => {
823
858
  });
824
859
 
825
860
  // Build field selection for sparse fieldsets
826
- const targetSchema = scopes[targetScope].vars.schemaInfo.schema;
861
+ const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance;
827
862
  const targetScopeObject = scopes[targetScope];
863
+ const storageAdapter = targetScopeObject.vars.storageAdapter;
828
864
  const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
829
865
  const fieldSelectionInfo = fields?.[targetScope] ?
830
866
  await buildFieldSelection(targetScopeObject, {
831
867
  context: {
832
868
  scopeName: targetScope,
833
869
  queryParams: { fields: { [targetScope]: fields[targetScope] } },
834
- schemaInfo: targetScopeObject.vars.schemaInfo
870
+ schemaInfo: targetScopeObject.vars.schemaInfo,
871
+ storageAdapter,
835
872
  }
836
873
  }) :
837
874
  null;
@@ -843,12 +880,16 @@ export const loadHasMany = async (scope, deps) => {
843
880
  if (relDef.include?.strategy === 'window') {
844
881
  try {
845
882
  // Try to build window function query
883
+ const selectFields = fieldSelectionInfo
884
+ ? translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
885
+ : null;
886
+
846
887
  query = buildWindowedIncludeQuery(
847
888
  knex,
848
889
  targetTable,
849
890
  foreignKey,
850
891
  mainIds,
851
- fieldSelectionInfo ? fieldSelectionInfo.fieldsToSelect : null,
892
+ selectFields,
852
893
  relDef.include || {},
853
894
  capabilities,
854
895
  targetScopeObject.vars // Pass target scope vars
@@ -874,7 +915,8 @@ export const loadHasMany = async (scope, deps) => {
874
915
  query = knex(targetTable).whereIn(foreignKey, mainIds);
875
916
 
876
917
  if (fieldSelectionInfo) {
877
- query = query.select(fieldSelectionInfo.fieldsToSelect);
918
+ const selectFieldsStd = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter);
919
+ query = query.select(selectFieldsStd);
878
920
  }
879
921
 
880
922
  // Apply standard config with defaults
@@ -948,7 +990,7 @@ export const loadHasMany = async (scope, deps) => {
948
990
  const relationshipObject = { data: relData };
949
991
 
950
992
  // Add links if urlPrefix is configured
951
- const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
993
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
952
994
  if (scopeName) {
953
995
  relationshipObject.links = {
954
996
  self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
@@ -1001,6 +1043,134 @@ export const loadHasMany = async (scope, deps) => {
1001
1043
  }
1002
1044
  };
1003
1045
 
1046
+ /**
1047
+ * Loads hasOne relationships (one-to-one)
1048
+ *
1049
+ * Similar to hasMany but expects a single related record.
1050
+ * For example, a user that has one profile.
1051
+ *
1052
+ * @param {Object} scope - The hooked-api scope object
1053
+ * @param {Object} deps - Dependencies object
1054
+ */
1055
+ export const loadHasOne = async (scope, deps) => {
1056
+ const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope;
1057
+ const { context: { scopes, log, knex, capabilities } } = deps;
1058
+
1059
+ log = log?.child ? log.child({ method: 'loadHasOne' }) : console;
1060
+
1061
+ if (!records || records.length === 0) {
1062
+ log.trace('[INCLUDE] No records to load hasOne for');
1063
+ return;
1064
+ }
1065
+
1066
+ // Get parent IDs
1067
+ const idProperty = scopes[scopeName]?.vars?.schemaInfo?.idProperty || 'id';
1068
+ const mainIds = [...new Set(records.map(r => r[idProperty]).filter(Boolean))];
1069
+
1070
+ if (mainIds.length === 0) {
1071
+ log.trace('[INCLUDE] No parent IDs found, skipping hasOne load');
1072
+ return;
1073
+ }
1074
+
1075
+ const targetScope = relDef.target;
1076
+ const targetTable = scopes[targetScope].vars.schemaInfo.tableName;
1077
+ const foreignKey = relDef.foreignKey;
1078
+
1079
+ if (!foreignKey) {
1080
+ throw new Error(`Missing foreignKey in hasOne relationship '${includeName}' for scope '${scopeName}'`);
1081
+ }
1082
+
1083
+ log.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
1084
+ whereIn: mainIds
1085
+ });
1086
+
1087
+ // Build query for hasOne - expects single result per parent
1088
+ const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance;
1089
+ const targetScopeObject = scopes[targetScope];
1090
+ const storageAdapter = targetScopeObject.vars.storageAdapter;
1091
+ const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
1092
+
1093
+ // Build field selection for sparse fieldsets
1094
+ const fieldSelectionInfo = fields?.[targetScope] ?
1095
+ await buildFieldSelection(targetScopeObject, {
1096
+ context: {
1097
+ scopeName: targetScope,
1098
+ queryParams: { fields: { [targetScope]: fields[targetScope] } },
1099
+ schemaInfo: targetScopeObject.vars.schemaInfo,
1100
+ storageAdapter,
1101
+ }
1102
+ }) :
1103
+ null;
1104
+
1105
+ // Query related records
1106
+ let query = knex(targetTable).whereIn(foreignKey, mainIds);
1107
+
1108
+ if (fieldSelectionInfo?.fieldsToSelect) {
1109
+ const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter);
1110
+ query = query.select(selectFields);
1111
+ }
1112
+
1113
+ const relatedRecords = await query;
1114
+
1115
+ // Create a map of parent ID to related record (should be one-to-one)
1116
+ const relatedByParentId = {};
1117
+ for (const related of relatedRecords) {
1118
+ const parentId = related[foreignKey];
1119
+ if (relatedByParentId[parentId]) {
1120
+ log.warn(`[INCLUDE] Multiple records found for hasOne relationship '${includeName}' with ${foreignKey}=${parentId}`);
1121
+ }
1122
+ relatedByParentId[parentId] = related;
1123
+ }
1124
+
1125
+ // Process each parent record
1126
+ for (const record of records) {
1127
+ const recordId = record[idProperty];
1128
+ const relatedRecord = relatedByParentId[recordId];
1129
+
1130
+ if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {};
1131
+
1132
+ if (relatedRecord) {
1133
+ // Transform to JSON:API format
1134
+ const transformed = await toJsonApiRecord(
1135
+ targetScopeObject,
1136
+ {
1137
+ context: {
1138
+ record: relatedRecord,
1139
+ scopeName: targetScope,
1140
+ schemaInfo: targetScopeObject.vars.schemaInfo
1141
+ }
1142
+ }
1143
+ );
1144
+
1145
+ // Set single object (not array) for hasOne
1146
+ record[RELATIONSHIPS_KEY][includeName] = {
1147
+ data: { type: targetScope, id: String(relatedRecord[targetIdProperty]) }
1148
+ };
1149
+
1150
+ // Add to included if not already there
1151
+ const key = `${targetScope}:${relatedRecord[targetIdProperty]}`;
1152
+ if (!included.has(key)) {
1153
+ included.set(key, transformed);
1154
+ }
1155
+
1156
+ // Process nested includes if any
1157
+ if (subIncludes && Object.keys(subIncludes).length > 0) {
1158
+ const newPath = `${currentPath}.${includeName}`;
1159
+ if (!processedPaths.has(newPath)) {
1160
+ processedPaths.add(newPath);
1161
+ await processIncludes(
1162
+ { records: [relatedRecord], scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: newPath, fields },
1163
+ { context: { scopes, log, knex, capabilities } }
1164
+ );
1165
+ }
1166
+ }
1167
+ } else {
1168
+ // No related record found
1169
+ record[RELATIONSHIPS_KEY][includeName] = { data: null };
1170
+ }
1171
+ }
1172
+ };
1173
+
1004
1174
  /**
1005
1175
  * Loads polymorphic belongsTo relationships
1006
1176
  *
@@ -1062,12 +1232,14 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
1062
1232
  // Build field selection for sparse fieldsets
1063
1233
  const targetScopeObject = scopes[targetType];
1064
1234
  // const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
1235
+ const storageAdapter = targetScopeObject.vars.storageAdapter;
1065
1236
  const fieldSelectionInfo = fields?.[targetType] ?
1066
1237
  await buildFieldSelection(targetScopeObject, {
1067
1238
  context: {
1068
1239
  scopeName: targetType,
1069
1240
  queryParams: { fields: { [targetType]: fields[targetType] } },
1070
- schemaInfo: targetScopeObject.vars.schemaInfo
1241
+ schemaInfo: targetScopeObject.vars.schemaInfo,
1242
+ storageAdapter,
1071
1243
  }
1072
1244
  }) :
1073
1245
  null;
@@ -1081,7 +1253,8 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
1081
1253
  const targetIdProperty = scopes[targetType]?.vars?.schemaInfo?.idProperty || 'id';
1082
1254
  let query = knex(targetTable).whereIn(targetIdProperty, targetIds);
1083
1255
  if (fieldSelectionInfo) {
1084
- query = query.select(fieldSelectionInfo.fieldsToSelect);
1256
+ const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter);
1257
+ query = query.select(selectFields);
1085
1258
  } else if (targetIdProperty !== 'id') {
1086
1259
  // If no field selection but custom idProperty, we need to alias it
1087
1260
  query = query.select('*', `${targetIdProperty} as id`);
@@ -1135,7 +1308,7 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
1135
1308
  };
1136
1309
 
1137
1310
  // Add links if urlPrefix is configured
1138
- const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
1311
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
1139
1312
  if (scopeName) {
1140
1313
  relationshipObject.links = {
1141
1314
  self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
@@ -1148,7 +1321,7 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
1148
1321
  const relationshipObject = { data: null };
1149
1322
 
1150
1323
  // Add links if urlPrefix is configured
1151
- const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
1324
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
1152
1325
  if (scopeName) {
1153
1326
  relationshipObject.links = {
1154
1327
  self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
@@ -1180,7 +1353,7 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
1180
1353
  const relationshipObject = { data: null };
1181
1354
 
1182
1355
  // Add links if urlPrefix is configured
1183
- const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
1356
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
1184
1357
  if (scopeName) {
1185
1358
  relationshipObject.links = {
1186
1359
  self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
@@ -1244,7 +1417,7 @@ export const loadReversePolymorphic = async (scope, deps) => {
1244
1417
  recordCount: records.length
1245
1418
  });
1246
1419
 
1247
- const targetScope = relDef.hasMany;
1420
+ const targetScope = relDef.target;
1248
1421
  const viaRelName = relDef.via;
1249
1422
 
1250
1423
  // Get the polymorphic field info from target scope
@@ -1274,14 +1447,19 @@ export const loadReversePolymorphic = async (scope, deps) => {
1274
1447
  });
1275
1448
 
1276
1449
  // Build field selection for sparse fieldsets
1277
- const targetSchema = scopes[targetScope].vars.schemaInfo.schema;
1450
+ const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance;
1278
1451
  const targetScopeObject = scopes[targetScope];
1452
+ const storageAdapter = targetScopeObject.vars.storageAdapter;
1279
1453
  const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
1280
1454
  const fieldSelectionInfo = fields?.[targetScope] ?
1281
- await buildFieldSelection(
1282
- { scopeName: targetScope, queryParams: { fields: { [targetScope]: fields[targetScope] } }, schemaInfo: targetScopeObject.vars.schemaInfo },
1283
- { context: { scopes, log, knex } }
1284
- ) :
1455
+ await buildFieldSelection(targetScopeObject, {
1456
+ context: {
1457
+ scopeName: targetScope,
1458
+ queryParams: { fields: { [targetScope]: fields[targetScope] } },
1459
+ schemaInfo: targetScopeObject.vars.schemaInfo,
1460
+ storageAdapter,
1461
+ }
1462
+ }) :
1285
1463
  null;
1286
1464
 
1287
1465
  // Query for records pointing back to our scope
@@ -1290,7 +1468,8 @@ export const loadReversePolymorphic = async (scope, deps) => {
1290
1468
  .whereIn(idField, parentIds);
1291
1469
 
1292
1470
  if (fieldSelectionInfo) {
1293
- query = query.select(fieldSelectionInfo.fieldsToSelect);
1471
+ const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter);
1472
+ query = query.select(selectFields);
1294
1473
  } else if (targetIdProperty !== 'id') {
1295
1474
  // If no field selection but custom idProperty, we need to alias it
1296
1475
  query = query.select('*', `${targetIdProperty} as id`);
@@ -1359,7 +1538,7 @@ export const loadReversePolymorphic = async (scope, deps) => {
1359
1538
  const relationshipObject = { data: relData };
1360
1539
 
1361
1540
  // Add links if urlPrefix is configured
1362
- const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
1541
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
1363
1542
  if (scopeName) {
1364
1543
  relationshipObject.links = {
1365
1544
  self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
@@ -1386,7 +1565,7 @@ export const loadReversePolymorphic = async (scope, deps) => {
1386
1565
  scopeName,
1387
1566
  includeName,
1388
1567
  via: relDef?.via,
1389
- targetScope: relDef?.hasMany,
1568
+ targetScope: relDef?.target,
1390
1569
  recordCount: records?.length || 0,
1391
1570
  error: error.message,
1392
1571
  stack: error.stack
@@ -1397,7 +1576,7 @@ export const loadReversePolymorphic = async (scope, deps) => {
1397
1576
  `Failed to load reverse polymorphic relationship '${includeName}' via '${relDef?.via}' for scope '${scopeName}': ${error.message}`
1398
1577
  );
1399
1578
  enhancedError.originalError = error;
1400
- enhancedError.context = { scopeName, includeName, via: relDef?.via, targetScope: relDef?.hasMany };
1579
+ enhancedError.context = { scopeName, includeName, via: relDef?.via, targetScope: relDef?.target };
1401
1580
  throw enhancedError;
1402
1581
  }
1403
1582
  };
@@ -1443,7 +1622,7 @@ export const processIncludes = async (scope, deps) => {
1443
1622
  return;
1444
1623
  }
1445
1624
 
1446
- const { schema, schemaRelationships } = schemaInfo;
1625
+ const { schemaInstance: schemaInstance, schemaRelationships } = schemaInfo;
1447
1626
 
1448
1627
  // Process each include
1449
1628
  for (const [includeName, subIncludes] of Object.entries(includeTree)) {
@@ -1461,7 +1640,7 @@ export const processIncludes = async (scope, deps) => {
1461
1640
 
1462
1641
  try {
1463
1642
  // Look for belongsTo relationships in schema fields
1464
- for (const [fieldName, fieldDef] of Object.entries(schema.structure || {})) {
1643
+ for (const [fieldName, fieldDef] of Object.entries(schemaInstance.structure || {})) {
1465
1644
  if (fieldDef.as === includeName && fieldDef.belongsTo) {
1466
1645
  await loadBelongsTo(
1467
1646
  { records, scopeName, fieldName, fieldDef, includeName, subIncludes, included, processedPaths, currentPath, fields, idProperty },
@@ -1477,7 +1656,19 @@ export const processIncludes = async (scope, deps) => {
1477
1656
  const relDef = schemaRelationships[includeName];
1478
1657
 
1479
1658
  if (relDef) {
1480
- if (relDef.hasMany) {
1659
+ if (relDef.type === 'hasOne') {
1660
+ // Handle hasOne relationship
1661
+ log.debug('[INCLUDE] About to call loadHasOne:', {
1662
+ scopeName,
1663
+ includeName,
1664
+ target: relDef.target
1665
+ });
1666
+ await loadHasOne(
1667
+ { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields },
1668
+ { context: { scopes, log, knex, capabilities } }
1669
+ );
1670
+ handled = true;
1671
+ } else if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
1481
1672
  // Check if it's a reverse polymorphic (via)
1482
1673
  if (relDef.via) {
1483
1674
  await loadReversePolymorphic(
@@ -1485,6 +1676,13 @@ export const processIncludes = async (scope, deps) => {
1485
1676
  { context: { scopes, log, knex, capabilities } }
1486
1677
  );
1487
1678
  } else {
1679
+ log.debug('[INCLUDE] About to call loadHasMany:', {
1680
+ scopeName,
1681
+ includeName,
1682
+ relDefKeys: Object.keys(relDef || {}),
1683
+ hasManyToMany: relDef.type === 'manyToMany',
1684
+ hasMany: relDef.type === 'hasMany' ? relDef.target : undefined
1685
+ });
1488
1686
  await loadHasMany(
1489
1687
  { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields },
1490
1688
  { context: { scopes, log, knex, capabilities } }
@@ -1516,7 +1714,7 @@ export const processIncludes = async (scope, deps) => {
1516
1714
  log.warn('[INCLUDE] Unknown relationship:', {
1517
1715
  scopeName,
1518
1716
  includeName,
1519
- availableFields: Object.keys(schema.structure || {}).filter(k => schema.structure[k].as),
1717
+ availableFields: Object.keys(schemaInstance.structure || {}).filter(k => schemaInstance.structure[k].as),
1520
1718
  availableRelationships: Object.keys(schemaRelationships || {})
1521
1719
  });
1522
1720
  }
@@ -1659,9 +1857,9 @@ export const buildIncludedResources = async (scope, deps) => {
1659
1857
  * without including the full related resources. This provides a consistent API surface where
1660
1858
  * clients always know what relationships exist and their IDs.
1661
1859
  *
1662
- * 2. **Simplified Mode Support**: In simplified mode, relationship IDs are transformed into `_ids`
1663
- * arrays (e.g., `reviews_ids: ['1', '2', '3']`). Without this function, these arrays only
1664
- * appear when using ?include, creating an inconsistent API where fields appear/disappear.
1860
+ * 2. **Simplified Mode Support**: In simplified mode, relationship IDs are transformed into
1861
+ * minimal objects (e.g., `reviews: [{id: '1'}, {id: '2'}, {id: '3'}]`). Without this function,
1862
+ * these objects only appear when using ?include, creating an inconsistent API where fields appear/disappear.
1665
1863
  *
1666
1864
  * 3. **Performance Balance**: Loading just IDs is much cheaper than loading full records. This gives
1667
1865
  * clients the ability to know what relationships exist without the cost of fetching all data.
@@ -1696,7 +1894,7 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
1696
1894
  for (const [relName, relDef] of Object.entries(relationships)) {
1697
1895
  let idsMap = {};
1698
1896
 
1699
- if (relDef.hasMany && !relDef.through && !relDef.via) {
1897
+ if (relDef.type === 'hasMany' && !relDef.via) {
1700
1898
  // Regular one-to-many
1701
1899
  // Example: publisher hasMany authors
1702
1900
  const foreignKey = relDef.foreignKey;
@@ -1704,8 +1902,8 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
1704
1902
  if (!foreignKey) {
1705
1903
  throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`);
1706
1904
  }
1707
- const targetScope = scopes[relDef.hasMany];
1708
- const targetTable = targetScope?.vars?.schemaInfo?.tableName || relDef.hasMany;
1905
+ const targetScope = scopes[relDef.target];
1906
+ const targetTable = targetScope?.vars?.schemaInfo?.tableName || relDef.target;
1709
1907
  const targetIdProperty = targetScope?.vars?.schemaInfo?.idProperty || 'id';
1710
1908
 
1711
1909
  const results = await knex(targetTable)
@@ -1718,35 +1916,10 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
1718
1916
  idsMap[parentId].push(String(row.id));
1719
1917
  });
1720
1918
 
1721
- } else if (relDef.hasMany && relDef.through) {
1722
- // Many-to-many with through
1723
- // Example: authors hasMany books through book_authors
1724
- const foreignKey = relDef.foreignKey;
1725
-
1726
- if (!foreignKey) {
1727
- throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`);
1728
- }
1729
- const otherKey = relDef.otherKey || `${relDef.hasMany.slice(0, -1)}_id`;
1730
-
1731
- // Get the actual table name from the pivot scope
1732
- const pivotScope = scopes[relDef.through];
1733
- const pivotTable = pivotScope?.vars?.schemaInfo?.tableName || relDef.through;
1734
-
1735
- const results = await knex(pivotTable)
1736
- .whereIn(foreignKey, recordIds)
1737
- .select(foreignKey, otherKey);
1738
-
1739
- results.forEach(row => {
1740
- const parentId = String(row[foreignKey]);
1741
- const childId = String(row[otherKey]);
1742
- if (!idsMap[parentId]) idsMap[parentId] = [];
1743
- idsMap[parentId].push(childId);
1744
- });
1745
-
1746
- } else if (relDef.manyToMany) {
1747
- // Many-to-many (alternate syntax)
1748
- // Example: { manyToMany: { through: 'article_tags', foreignKey: 'article_id', otherKey: 'tag_id' } }
1749
- const { through, foreignKey, otherKey } = relDef.manyToMany;
1919
+ } else if (relDef.type === 'manyToMany') {
1920
+ // Many-to-many relationship
1921
+ // Example: { type: 'manyToMany', through: 'article_tags', foreignKey: 'article_id', otherKey: 'tag_id' }
1922
+ const { through, foreignKey, otherKey } = relDef;
1750
1923
  const fk = foreignKey;
1751
1924
  const ok = otherKey;
1752
1925
 
@@ -1769,10 +1942,10 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
1769
1942
  idsMap[parentId].push(childId);
1770
1943
  });
1771
1944
 
1772
- } else if (relDef.hasMany && relDef.via) {
1945
+ } else if (relDef.type === 'hasMany' && relDef.via) {
1773
1946
  // Polymorphic reverse (via)
1774
1947
  // Example: publishers hasMany reviews via reviewable (where reviews.reviewable_type = 'publishers')
1775
- const targetScope = relDef.hasMany;
1948
+ const targetScope = relDef.target;
1776
1949
  const targetRelationships = scopes[targetScope]?.vars?.schemaInfo?.schemaRelationships;
1777
1950
  const viaRel = targetRelationships?.[relDef.via];
1778
1951
 
@@ -1797,14 +1970,16 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
1797
1970
  }
1798
1971
 
1799
1972
  // Apply the collected IDs to all records
1800
- if (Object.keys(idsMap).length > 0 || relDef.hasMany || relDef.manyToMany) {
1973
+ if (Object.keys(idsMap).length > 0 || relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
1801
1974
  records.forEach(record => {
1802
1975
  if (!record[RELATIONSHIPS_KEY]) {
1803
1976
  record[RELATIONSHIPS_KEY] = {};
1804
1977
  }
1805
1978
 
1806
1979
  const ids = idsMap[String(record.id)] || [];
1807
- const targetType = relDef.hasMany || (relDef.manyToMany ? relName : null);
1980
+ // For manyToMany, the target type is the relationship name itself
1981
+ // For hasMany/hasOne, the target type is specified in the target property
1982
+ const targetType = (relDef.type === 'manyToMany') ? relName : relDef.target;
1808
1983
 
1809
1984
  record[RELATIONSHIPS_KEY][relName] = {
1810
1985
  data: ids.map(id => ({
@@ -1815,4 +1990,4 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
1815
1990
  });
1816
1991
  }
1817
1992
  }
1818
- };
1993
+ };