json-rest-api 1.0.7 → 1.0.8

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 (146) hide show
  1. package/.claude/agents/library-guide-expert.md +29 -0
  2. package/.claude/agents/rest-api-internals-expert.md +53 -0
  3. package/CLAUDE.md +139 -1
  4. package/README.md +2 -5
  5. package/docTest.js +238 -0
  6. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
  7. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
  8. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
  9. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
  10. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
  11. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
  12. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
  13. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
  14. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
  15. package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
  16. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
  17. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
  18. package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
  19. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
  20. package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
  21. package/docs/GUIDE/index.md +58 -0
  22. package/docs/GUIDE.md +61 -2238
  23. package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
  24. package/docs/README.md +57 -2239
  25. package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
  26. package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
  27. package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
  28. package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
  29. package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
  30. package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
  31. package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
  32. package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
  33. package/docs/assets/css/style.scss +78 -5
  34. package/docs/index.md +2 -12
  35. package/helper-reorganization-plan.md +85 -0
  36. package/helper-usage-analysis.md +94 -0
  37. package/package.json +5 -4
  38. package/plugins/core/bulk-operations-plugin.js +2 -2
  39. package/plugins/core/connectors/express-plugin.js +90 -32
  40. package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
  41. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
  42. package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
  43. package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
  44. package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
  45. package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
  46. package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
  47. package/plugins/core/lib/querying/knex-window-queries.js +328 -0
  48. package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
  49. package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
  50. package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
  51. package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
  52. package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
  53. package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
  54. package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
  55. package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
  56. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
  57. package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
  58. package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
  59. package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
  60. package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
  61. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
  62. package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
  63. package/plugins/core/lib/writing/relationship-processor.js +252 -0
  64. package/plugins/core/rest-api-knex-plugin.js +183 -62
  65. package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
  66. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
  67. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
  68. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
  69. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
  70. package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
  71. package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
  72. package/plugins/core/rest-api-plugin-methods/common.js +605 -0
  73. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
  74. package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
  75. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
  76. package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
  77. package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
  78. package/plugins/core/rest-api-plugin-methods/get.js +187 -0
  79. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
  80. package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
  81. package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
  82. package/plugins/core/rest-api-plugin-methods/post.js +157 -0
  83. package/plugins/core/rest-api-plugin-methods/put.js +257 -0
  84. package/plugins/core/rest-api-plugin-methods/query.js +208 -0
  85. package/plugins/core/rest-api-plugin.js +108 -2498
  86. package/plugins/core/socketio-plugin.js +28 -5
  87. package/plugins/storage/local-storage.js +2 -2
  88. package/tests/computed-fields.test.js +483 -0
  89. package/tests/cors.test.js +4 -4
  90. package/tests/curl.test.js +5 -4
  91. package/tests/custom-idproperty-relationships.test.js +891 -0
  92. package/tests/field-getters.test.js +353 -0
  93. package/tests/field-setters.test.js +310 -0
  94. package/tests/fixtures/api-configs.js +800 -28
  95. package/tests/jwt-auth.test.js +0 -2
  96. package/tests/nested-includes.test.js +1 -1
  97. package/tests/pagination-cursor-multifield.test.js +514 -0
  98. package/tests/pagination-enhanced.test.js +12 -9
  99. package/tests/polymorphic-relationships.test.js +10 -8
  100. package/tests/polymorphic-simple.test.js +6 -4
  101. package/tests/positioning-advanced.test.js +12 -12
  102. package/tests/positioning-extended.test.js +25 -25
  103. package/tests/positioning-multifilter.test.js +24 -24
  104. package/tests/positioning.test.js +3 -3
  105. package/tests/query-limits.test.js +24 -8
  106. package/tests/relationship-endpoints.test.js +4 -5
  107. package/tests/relationships.test.js +0 -1
  108. package/tests/socketio.test.js +12 -1
  109. package/tests/virtual-fields.test.js +13 -13
  110. package/PLAN_FOR_WEBSOCKETS.md +0 -398
  111. package/README_1_Initial_Setup.md +0 -393
  112. package/README_X_Hooks.md +0 -647
  113. package/TODO_rest-api-plugin-extras.todo +0 -23
  114. package/connectors_ideas/express-plugin.js +0 -120
  115. package/connectors_ideas/http-plugin.js +0 -138
  116. package/docs/examples/express-plugin.js +0 -366
  117. package/example.js +0 -301
  118. package/plugins/core/HELPER_MODULES.md +0 -285
  119. package/plugins/core/lib/compile-schemas.js +0 -245
  120. package/plugins/core/lib/database-value-normalizers.js +0 -192
  121. package/plugins/core/lib/default-data-helpers.js +0 -100
  122. package/plugins/core/lib/knex-cross-table-search.js +0 -442
  123. package/plugins/core/lib/knex-json-api-helpers.js +0 -84
  124. package/plugins/core/lib/knex-json-api-transformers.js +0 -449
  125. package/plugins/core/lib/knex-pagination-helpers.js +0 -193
  126. package/plugins/core/lib/knex-window-queries.js +0 -142
  127. package/plugins/core/lib/many-to-many-manipulations.js +0 -343
  128. package/plugins/core/lib/relationship-processor.js +0 -278
  129. package/plugins/core/lib/schema-helpers.js +0 -343
  130. package/plugins/core/lib/scope-validations.js +0 -252
  131. package/plugins/core/rest-api-relationships-plugin.js +0 -700
  132. package/plugins/core/utils/knex-query-helpers-base.js +0 -56
  133. /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
  134. /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
  135. /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
  136. /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
  137. /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
  138. /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
  139. /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
  140. /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
  141. /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
  142. /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
  143. /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
  144. /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
  145. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
  146. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
@@ -1,38 +1,51 @@
1
1
  import { requirePackage } from 'hooked-api';
2
2
  import { createSchema, createKnexTable } from 'json-rest-schema';
3
- import { createCrossTableSearchHelpers } from './lib/knex-cross-table-search.js';
4
- import { getForeignKeyFields, buildFieldSelection, isNonDatabaseField } from './lib/knex-field-helpers.js';
5
- import { buildQuerySelection } from './utils/knex-query-helpers-base.js';
6
- import { toJsonApiRecord, buildJsonApiResponse, processBelongsToRelationships, toJsonApiRecordWithBelongsTo } from './lib/knex-json-api-transformers.js';
7
- import { processIncludes } from './lib/knex-process-includes.js';
3
+ import { createCrossTableSearchHelpers } from './lib/querying/knex-cross-table-search.js';
4
+ import { buildFieldSelection, isNonDatabaseField } from './lib/querying-writing/knex-field-helpers.js';
5
+ import { getForeignKeyFields } from './lib/querying-writing/field-utils.js';
6
+ import { buildQuerySelection } from './lib/querying/knex-query-helpers-base.js';
7
+ import { toJsonApiRecord, buildJsonApiResponse } from './lib/querying/knex-json-api-transformers-querying.js';
8
+ import { processBelongsToRelationships } from './lib/writing/knex-json-api-transformers-writing.js';
9
+ import { toJsonApiRecordWithBelongsTo } from './lib/querying-writing/knex-json-api-transformers.js';
10
+ import { processIncludes } from './lib/querying/knex-process-includes.js';
11
+ import { loadRelationshipIdentifiers } from './lib/querying/knex-relationship-includes.js';
8
12
  import {
9
13
  polymorphicFiltersHook,
10
14
  crossTableFiltersHook,
11
15
  basicFiltersHook
12
- } from './lib/knex-query-helpers.js';
16
+ } from './lib/querying/knex-query-helpers.js';
13
17
  import { RestApiResourceError, RestApiValidationError } from '../../lib/rest-api-errors.js';
14
- import { supportsWindowFunctions, getDatabaseInfo } from './lib/database-capabilities.js';
15
- import { ERROR_SUBTYPES, DEFAULT_QUERY_LIMIT, DEFAULT_MAX_QUERY_LIMIT } from './utils/knex-constants.js';
18
+ import { supportsWindowFunctions, getDatabaseInfo } from './lib/querying-writing/database-capabilities.js';
19
+ import { ERROR_SUBTYPES, DEFAULT_QUERY_LIMIT, DEFAULT_MAX_QUERY_LIMIT } from './lib/querying-writing/knex-constants.js';
16
20
  import {
17
21
  calculatePaginationMeta,
18
22
  generatePaginationLinks,
19
23
  generateCursorPaginationLinks,
20
24
  buildCursorMeta,
21
25
  parseCursor
22
- } from './lib/knex-pagination-helpers.js';
26
+ } from './lib/querying/knex-pagination-helpers.js';
23
27
 
24
28
  /**
25
29
  * Strips non-database fields (computed and virtual) from attributes before database operations
26
30
  * @param {Object} attributes - The attributes object
27
- * @param {Object} schemaInfo - The schema info containing computed and virtual field definitions
31
+ * @param {Object} schemaInfo - The schema info containing computed field definitions and schema structure
28
32
  * @returns {Object} Attributes with computed and virtual fields removed
29
33
  */
30
34
  const stripNonDatabaseFields = (attributes, schemaInfo) => {
31
35
  if (!attributes || !schemaInfo) return attributes || {};
32
36
 
33
- const { computed = {}, virtual = {} } = schemaInfo;
37
+ const { computed = {}, schemaStructure = {} } = schemaInfo;
34
38
  return Object.entries(attributes)
35
- .filter(([key]) => !(key in computed || key in virtual))
39
+ .filter(([key]) => {
40
+ // Remove computed fields
41
+ if (key in computed) return false;
42
+ // Remove fields marked as virtual in the schema
43
+ const fieldDef = schemaStructure[key];
44
+ if (fieldDef && fieldDef.virtual === true) {
45
+ return false;
46
+ }
47
+ return true;
48
+ })
36
49
  .reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {});
37
50
  };
38
51
 
@@ -113,8 +126,22 @@ export const RestApiKnexPlugin = {
113
126
  api.knex.helpers.crossTableSearch = crossTableSearchHelpers;
114
127
 
115
128
  // Helper scope method to get all schema-related information
116
- addScopeMethod('createKnexTable', async ({ vars, scope, scopeName, scopeOptions, runHooks }) => {
117
- await createKnexTable(api.knex.instance, vars.schemaInfo.tableName, vars.schemaInfo.schema)
129
+ addScopeMethod('createKnexTable', async ({ vars, scope, scopeName, scopeOptions, runHooks }) => {
130
+ // Create a filtered schema that excludes virtual fields
131
+ const schemaStructure = vars.schemaInfo.schemaStructure || {};
132
+ const filteredSchema = {};
133
+
134
+ // Copy only non-virtual fields to the filtered schema
135
+ Object.entries(schemaStructure).forEach(([fieldName, fieldDef]) => {
136
+ if (!fieldDef.virtual) {
137
+ filteredSchema[fieldName] = fieldDef;
138
+ }
139
+ });
140
+
141
+ // Create schema object from filtered fields
142
+ const tableSchema = createSchema(filteredSchema);
143
+
144
+ await createKnexTable(api.knex.instance, vars.schemaInfo.tableName, tableSchema, vars.schemaInfo.idProperty)
118
145
  })
119
146
 
120
147
  helpers.newTransaction = async () => {
@@ -149,9 +176,12 @@ export const RestApiKnexPlugin = {
149
176
 
150
177
  log.debug(`[Knex] EXISTS ${tableName}/${id}`);
151
178
 
179
+ // Select with alias if idProperty is not 'id'
180
+ const selectClause = idProperty !== 'id' ? `${idProperty} as id` : 'id';
181
+
152
182
  const record = await db(tableName)
153
183
  .where(idProperty, id)
154
- .select(idProperty)
184
+ .select(selectClause)
155
185
  .first();
156
186
 
157
187
  return !!record;
@@ -231,8 +261,11 @@ export const RestApiKnexPlugin = {
231
261
  );
232
262
  }
233
263
 
234
- // Process includes
264
+ // Load relationship identifiers for all hasMany relationships
235
265
  const records = [record]; // Wrap in array for processing
266
+ await loadRelationshipIdentifiers(records, scopeName, scopes, db);
267
+
268
+ // Process includes
236
269
  const included = await processIncludes(scope, records, {
237
270
  log,
238
271
  scopes,
@@ -241,7 +274,7 @@ export const RestApiKnexPlugin = {
241
274
  });
242
275
 
243
276
  // Build and return response
244
- return buildJsonApiResponse(scope, records, included, true, scopeName);
277
+ return buildJsonApiResponse(scope, records, included, true, scopeName, context);
245
278
  };
246
279
 
247
280
  /**
@@ -268,7 +301,12 @@ export const RestApiKnexPlugin = {
268
301
 
269
302
  // Build query - no filtering hooks for single records
270
303
  // Permission checks will handle access control
271
- const query = db(tableName).where(idProperty, id);
304
+ let query = db(tableName).where(idProperty, id);
305
+
306
+ // Add alias if idProperty is not 'id'
307
+ if (idProperty !== 'id') {
308
+ query = query.select('*', `${idProperty} as id`);
309
+ }
272
310
 
273
311
  // Execute query
274
312
  const record = await query.first();
@@ -337,6 +375,7 @@ export const RestApiKnexPlugin = {
337
375
 
338
376
  // Start building query with table prefix (for JOIN support)
339
377
  let query = db(tableName);
378
+
340
379
  query = buildQuerySelection(query, tableName, fieldSelectionInfo.fieldsToSelect, true);
341
380
 
342
381
  /* ═══════════════════════════════════════════════════════════════════
@@ -430,6 +469,9 @@ export const RestApiKnexPlugin = {
430
469
  // Fetch one extra record to determine if there are more
431
470
  query.limit(pageSize + 1);
432
471
 
472
+ // Get the configured ID property
473
+ const idProperty = context.schemaInfo.idProperty || 'id';
474
+
433
475
  if (queryParams.page.after) {
434
476
  let cursorData;
435
477
  try {
@@ -447,28 +489,58 @@ export const RestApiKnexPlugin = {
447
489
  }
448
490
  );
449
491
  }
450
- // Add where conditions for cursor
451
- // Use the actual sort from query params or default
452
- let sortField = 'id';
453
- let sortDirection = 'ASC';
454
492
 
493
+ // Build sort fields array with directions
494
+ const sortFields = [];
455
495
  if (queryParams.sort && queryParams.sort.length > 0) {
456
- const firstSort = queryParams.sort[0];
457
- sortDirection = firstSort.startsWith('-') ? 'DESC' : 'ASC';
458
- sortField = firstSort.startsWith('-') ? firstSort.substring(1) : firstSort;
496
+ queryParams.sort.forEach(sortField => {
497
+ const desc = sortField.startsWith('-');
498
+ const field = desc ? sortField.substring(1) : sortField;
499
+ sortFields.push({ field, direction: desc ? 'DESC' : 'ASC' });
500
+ });
459
501
  } else if (scope.vars.defaultSort) {
460
- sortField = scope.vars.defaultSort.field || 'id';
461
- sortDirection = scope.vars.defaultSort.direction || 'ASC';
502
+ sortFields.push({
503
+ field: scope.vars.defaultSort.field || idProperty,
504
+ direction: scope.vars.defaultSort.direction || 'ASC'
505
+ });
506
+ } else {
507
+ sortFields.push({ field: idProperty, direction: 'ASC' });
462
508
  }
463
509
 
464
- // Ensure the field is properly prefixed when JOINs might be present
465
- const qualifiedField = `${tableName}.${sortField}`;
510
+ // Build compound WHERE clause for multi-field cursor
511
+ query.where(function() {
512
+ sortFields.forEach((sortInfo, index) => {
513
+ const { field, direction } = sortInfo;
514
+ const qualifiedField = `${tableName}.${field}`;
515
+ const cursorValue = cursorData[field];
516
+
517
+ if (cursorValue === undefined) {
518
+ log.warn(`Cursor missing value for sort field: ${field}`);
519
+ return;
520
+ }
521
+
522
+ // Build condition for this level and all previous levels
523
+ this.orWhere(function() {
524
+ // All previous fields must be equal
525
+ for (let i = 0; i < index; i++) {
526
+ const prevField = sortFields[i].field;
527
+ const prevQualifiedField = `${tableName}.${prevField}`;
528
+ const prevValue = cursorData[prevField];
529
+ if (prevValue !== undefined) {
530
+ this.where(prevQualifiedField, '=', prevValue);
531
+ }
532
+ }
533
+
534
+ // Current field must be greater/less than cursor value
535
+ if (direction === 'DESC') {
536
+ this.where(qualifiedField, '<', cursorValue);
537
+ } else {
538
+ this.where(qualifiedField, '>', cursorValue);
539
+ }
540
+ });
541
+ });
542
+ });
466
543
 
467
- if (sortDirection === 'DESC') {
468
- query.where(qualifiedField, '<', cursorData[sortField]);
469
- } else {
470
- query.where(qualifiedField, '>', cursorData[sortField]);
471
- }
472
544
  } else if (queryParams.page.before) {
473
545
  let cursorData;
474
546
  try {
@@ -486,27 +558,57 @@ export const RestApiKnexPlugin = {
486
558
  }
487
559
  );
488
560
  }
489
- // Use the actual sort from query params or default
490
- let sortField = 'id';
491
- let sortDirection = 'ASC';
492
561
 
562
+ // Build sort fields array with directions
563
+ const sortFields = [];
493
564
  if (queryParams.sort && queryParams.sort.length > 0) {
494
- const firstSort = queryParams.sort[0];
495
- sortDirection = firstSort.startsWith('-') ? 'DESC' : 'ASC';
496
- sortField = firstSort.startsWith('-') ? firstSort.substring(1) : firstSort;
565
+ queryParams.sort.forEach(sortField => {
566
+ const desc = sortField.startsWith('-');
567
+ const field = desc ? sortField.substring(1) : sortField;
568
+ sortFields.push({ field, direction: desc ? 'DESC' : 'ASC' });
569
+ });
497
570
  } else if (scope.vars.defaultSort) {
498
- sortField = scope.vars.defaultSort.field || 'id';
499
- sortDirection = scope.vars.defaultSort.direction || 'ASC';
500
- }
501
-
502
- // Ensure the field is properly prefixed when JOINs might be present
503
- const qualifiedField = `${tableName}.${sortField}`;
504
-
505
- if (sortDirection === 'DESC') {
506
- query.where(qualifiedField, '>', cursorData[sortField]);
571
+ sortFields.push({
572
+ field: scope.vars.defaultSort.field || idProperty,
573
+ direction: scope.vars.defaultSort.direction || 'ASC'
574
+ });
507
575
  } else {
508
- query.where(qualifiedField, '<', cursorData[sortField]);
576
+ sortFields.push({ field: idProperty, direction: 'ASC' });
509
577
  }
578
+
579
+ // Build compound WHERE clause for multi-field cursor (reversed for before)
580
+ query.where(function() {
581
+ sortFields.forEach((sortInfo, index) => {
582
+ const { field, direction } = sortInfo;
583
+ const qualifiedField = `${tableName}.${field}`;
584
+ const cursorValue = cursorData[field];
585
+
586
+ if (cursorValue === undefined) {
587
+ log.warn(`Cursor missing value for sort field: ${field}`);
588
+ return;
589
+ }
590
+
591
+ // Build condition for this level and all previous levels
592
+ this.orWhere(function() {
593
+ // All previous fields must be equal
594
+ for (let i = 0; i < index; i++) {
595
+ const prevField = sortFields[i].field;
596
+ const prevQualifiedField = `${tableName}.${prevField}`;
597
+ const prevValue = cursorData[prevField];
598
+ if (prevValue !== undefined) {
599
+ this.where(prevQualifiedField, '=', prevValue);
600
+ }
601
+ }
602
+
603
+ // Current field must be less/greater than cursor value (reversed)
604
+ if (direction === 'DESC') {
605
+ this.where(qualifiedField, '>', cursorValue);
606
+ } else {
607
+ this.where(qualifiedField, '<', cursorValue);
608
+ }
609
+ });
610
+ });
611
+ });
510
612
  }
511
613
  }
512
614
  // Default pagination if only size is specified - treat as cursor-based for "load more"
@@ -552,7 +654,9 @@ export const RestApiKnexPlugin = {
552
654
  }
553
655
  });
554
656
 
555
- scope.vars.queryString = queryParts.length > 0 ? `?${queryParts.join('&')}` : '';
657
+ // Initialize returnMeta namespace for thread-safe metadata
658
+ context.returnMeta = context.returnMeta || {};
659
+ context.returnMeta.queryString = queryParts.length > 0 ? `?${queryParts.join('&')}` : '';
556
660
 
557
661
  // Execute count query for pagination if offset-based pagination is used
558
662
  if (queryParams.page?.number !== undefined || (queryParams.page?.size !== undefined && !queryParams.page?.after && !queryParams.page?.before)) {
@@ -585,10 +689,10 @@ export const RestApiKnexPlugin = {
585
689
  const total = parseInt(countResult.total);
586
690
 
587
691
  // Calculate pagination metadata with total
588
- scope.vars.paginationMeta = calculatePaginationMeta(total, page, pageSize);
692
+ context.returnMeta.paginationMeta = calculatePaginationMeta(total, page, pageSize);
589
693
  } else {
590
694
  // Without count, we can still provide basic pagination info
591
- scope.vars.paginationMeta = {
695
+ context.returnMeta.paginationMeta = {
592
696
  page,
593
697
  pageSize
594
698
  // No total, pageCount, or hasMore when counts are disabled
@@ -596,11 +700,12 @@ export const RestApiKnexPlugin = {
596
700
  }
597
701
 
598
702
  // Generate links
599
- scope.vars.paginationLinks = generatePaginationLinks(
600
- scope.vars.resourceUrlPrefix,
703
+ const urlPrefix = scope.vars.publicBaseUrl || scope.vars.transport?.mountPath || '';
704
+ context.returnMeta.paginationLinks = generatePaginationLinks(
705
+ urlPrefix,
601
706
  scopeName,
602
707
  queryParams,
603
- scope.vars.paginationMeta
708
+ context.returnMeta.paginationMeta
604
709
  );
605
710
  }
606
711
 
@@ -620,14 +725,16 @@ export const RestApiKnexPlugin = {
620
725
  }
621
726
 
622
727
  // Determine sort fields for cursor
623
- let sortFields = ['id'];
728
+ const idProperty = context.schemaInfo.idProperty || 'id';
729
+ let sortFields = [idProperty];
624
730
  if (queryParams.sort && queryParams.sort.length > 0) {
625
731
  sortFields = queryParams.sort.map(s => s.startsWith('-') ? s.substring(1) : s);
626
732
  }
627
733
 
628
- scope.vars.paginationMeta = buildCursorMeta(records, pageSize, hasMore, sortFields);
629
- scope.vars.paginationLinks = generateCursorPaginationLinks(
630
- scope.vars.resourceUrlPrefix,
734
+ context.returnMeta.paginationMeta = buildCursorMeta(records, pageSize, hasMore, sortFields);
735
+ const urlPrefix = scope.vars.publicBaseUrl || scope.vars.transport?.mountPath || '';
736
+ context.returnMeta.paginationLinks = generateCursorPaginationLinks(
737
+ urlPrefix,
631
738
  scopeName,
632
739
  queryParams,
633
740
  records,
@@ -637,6 +744,9 @@ export const RestApiKnexPlugin = {
637
744
  );
638
745
  }
639
746
 
747
+ // Load relationship identifiers for all hasMany relationships
748
+ await loadRelationshipIdentifiers(records, scopeName, scopes, db);
749
+
640
750
  // Process includes
641
751
  const included = await processIncludes(scope, records, {
642
752
  log,
@@ -648,7 +758,7 @@ export const RestApiKnexPlugin = {
648
758
 
649
759
 
650
760
  // Build and return response
651
- return buildJsonApiResponse(scope, records, included, false, scopeName);
761
+ return buildJsonApiResponse(scope, records, included, false, scopeName, context);
652
762
  };
653
763
 
654
764
  /**
@@ -730,7 +840,12 @@ export const RestApiKnexPlugin = {
730
840
 
731
841
  // Strip non-database fields (computed and virtual) before database operation
732
842
  const finalAttributes = stripNonDatabaseFields(mergedAttributes, context.schemaInfo);
733
-
843
+
844
+ // Map 'id' to actual idProperty if needed (for PUT with specific ID)
845
+ if (idProperty !== 'id' && inputRecord.data.id) {
846
+ finalAttributes[idProperty] = inputRecord.data.id;
847
+ }
848
+
734
849
  if (isCreate) {
735
850
  // Create mode - insert new record with specified ID
736
851
  const recordData = {
@@ -756,6 +871,9 @@ export const RestApiKnexPlugin = {
756
871
  );
757
872
  }
758
873
 
874
+ // Remove the idProperty from attributes to prevent updating the primary key
875
+ delete finalAttributes[idProperty];
876
+
759
877
  // Update the record (replace all fields)
760
878
  if (Object.keys(finalAttributes).length > 0) {
761
879
  await db(tableName)
@@ -823,6 +941,9 @@ export const RestApiKnexPlugin = {
823
941
 
824
942
  log.debug(`[Knex] PATCH finalAttributes:`, finalAttributes);
825
943
 
944
+ // Remove the idProperty from attributes to prevent updating the primary key
945
+ delete finalAttributes[idProperty];
946
+
826
947
  // Update only if there are changes
827
948
  if (Object.keys(finalAttributes).length > 0) {
828
949
  await db(tableName)
@@ -0,0 +1,7 @@
1
+ import { compileSchemas } from '../lib/querying-writing/compile-schemas.js';
2
+
3
+ export default async function compileResourceSchemas({ context, scopes, runHooks }) {
4
+ const scope = scopes[context.scopeName];
5
+ // Pass scopeOptions and vars from context since scope object structure is different
6
+ return compileSchemas({ ...scope, scopeOptions: context.scopeOptions, vars: context.vars }, { context: { scopeName: context.scopeName }, runHooks });
7
+ }
@@ -0,0 +1,72 @@
1
+
2
+ import { parseJsonApiQuery } from "../lib/querying-writing/connectors-query-parser.js";
3
+
4
+ export default async function registerRelationshipRoutes ({ context, api, log }) {
5
+ const { scopeName } = context;
6
+ const basePath = api.scopes[scopeName].vars.transport?.mountPath || '';
7
+ const scopePath = `${basePath}/${scopeName}`;
8
+
9
+ // Helper to create route handlers
10
+ const createRouteHandler = (methodName) => {
11
+ return async ({ params, body, queryString }) => {
12
+ const scope = api.scopes[scopeName];
13
+
14
+ const methodParams = {
15
+ id: params.id,
16
+ relationshipName: params.relationshipName,
17
+ isTransport: true
18
+ };
19
+
20
+ // Add query params for getRelated
21
+ if (methodName === 'getRelated' && queryString) {
22
+ methodParams.queryParams = parseJsonApiQuery(queryString);
23
+ }
24
+
25
+ // Add body data for write operations
26
+ if (body && body.data !== undefined) {
27
+ methodParams.relationshipData = body.data;
28
+ }
29
+
30
+ return await scope[methodName](methodParams);
31
+ };
32
+ };
33
+
34
+ // Register relationship routes
35
+
36
+ // GET /api/{scope}/{id}/relationships/{relationshipName}
37
+ await api.addRoute({
38
+ method: 'GET',
39
+ path: `${scopePath}/:id/relationships/:relationshipName`,
40
+ handler: createRouteHandler('getRelationship')
41
+ });
42
+
43
+ // GET /api/{scope}/{id}/{relationshipName}
44
+ await api.addRoute({
45
+ method: 'GET',
46
+ path: `${scopePath}/:id/:relationshipName`,
47
+ handler: createRouteHandler('getRelated')
48
+ });
49
+
50
+ // POST /api/{scope}/{id}/relationships/{relationshipName}
51
+ await api.addRoute({
52
+ method: 'POST',
53
+ path: `${scopePath}/:id/relationships/:relationshipName`,
54
+ handler: createRouteHandler('postRelationship')
55
+ });
56
+
57
+ // PATCH /api/{scope}/{id}/relationships/{relationshipName}
58
+ await api.addRoute({
59
+ method: 'PATCH',
60
+ path: `${scopePath}/:id/relationships/:relationshipName`,
61
+ handler: createRouteHandler('patchRelationship')
62
+ });
63
+
64
+ // DELETE /api/{scope}/{id}/relationships/{relationshipName}
65
+ await api.addRoute({
66
+ method: 'DELETE',
67
+ path: `${scopePath}/:id/relationships/:relationshipName`,
68
+ handler: createRouteHandler('deleteRelationship')
69
+ });
70
+
71
+ log.trace(`Registered relationship routes for scope: ${scopeName}`);
72
+ }
@@ -0,0 +1,102 @@
1
+ import { RestApiResourceError } from '../../../lib/rest-api-errors.js';
2
+ import { parseJsonApiQuery } from '../lib/querying-writing/connectors-query-parser.js';
3
+
4
+ export default async function registerScopeRoutes({ context, api, vars, log }) {
5
+ const { scopeName } = context;
6
+ const basePath = vars.transport?.mountPath || '';
7
+
8
+ // Helper to create route handlers
9
+ const createRouteHandler = (scopeName, methodName) => {
10
+ return async ({ queryString, headers, params, body, context }) => {
11
+ const scope = api.scopes[scopeName];
12
+ if (!scope) {
13
+ throw new RestApiResourceError(
14
+ `Scope '${scopeName}' not found`,
15
+ {
16
+ subtype: 'not_found',
17
+ resourceType: 'scope',
18
+ resourceId: scopeName
19
+ }
20
+ );
21
+ }
22
+
23
+ // Build parameters for the scope method
24
+ const methodParams = {};
25
+
26
+ // Add ID for single-resource operations
27
+ if (['get', 'put', 'patch', 'delete'].includes(methodName)) {
28
+ methodParams.id = params.id;
29
+ }
30
+
31
+ // Parse query parameters for read operations
32
+ if (['query', 'get'].includes(methodName)) {
33
+ methodParams.queryParams = parseJsonApiQuery(queryString);
34
+ methodParams.isTransport = true;
35
+ }
36
+
37
+ // Add body for write operations
38
+ if (['post', 'put', 'patch'].includes(methodName)) {
39
+ methodParams.inputRecord = body;
40
+ methodParams.isTransport = true;
41
+
42
+ // Add query params for includes/fields on write operations
43
+ if (queryString) {
44
+ methodParams.queryParams = parseJsonApiQuery(queryString);
45
+ }
46
+ }
47
+
48
+ // Call the scope method
49
+ const result = await scope[methodName](methodParams, context);
50
+
51
+ // Return the result (transport plugin handles response formatting)
52
+ return result;
53
+ };
54
+ };
55
+
56
+ const scopePath = `${basePath}/${scopeName}`;
57
+
58
+ // Register routes for each HTTP method
59
+ // GET /api/{scope} - Query collection
60
+ await api.addRoute({
61
+ method: 'GET',
62
+ path: scopePath,
63
+ handler: createRouteHandler(scopeName, 'query')
64
+ });
65
+
66
+ // GET /api/{scope}/{id} - Get single resource
67
+ await api.addRoute({
68
+ method: 'GET',
69
+ path: `${scopePath}/:id`,
70
+ handler: createRouteHandler(scopeName, 'get')
71
+ });
72
+
73
+ // POST /api/{scope} - Create resource
74
+ await api.addRoute({
75
+ method: 'POST',
76
+ path: scopePath,
77
+ handler: createRouteHandler(scopeName, 'post')
78
+ });
79
+
80
+ // PUT /api/{scope}/{id} - Replace resource
81
+ await api.addRoute({
82
+ method: 'PUT',
83
+ path: `${scopePath}/:id`,
84
+ handler: createRouteHandler(scopeName, 'put')
85
+ });
86
+
87
+ // PATCH /api/{scope}/{id} - Update resource
88
+ await api.addRoute({
89
+ method: 'PATCH',
90
+ path: `${scopePath}/:id`,
91
+ handler: createRouteHandler(scopeName, 'patch')
92
+ });
93
+
94
+ // DELETE /api/{scope}/{id} - Delete resource
95
+ await api.addRoute({
96
+ method: 'DELETE',
97
+ path: `${scopePath}/:id`,
98
+ handler: createRouteHandler(scopeName, 'delete')
99
+ });
100
+
101
+ log.info(`Routes registered for scope '${scopeName}'`);
102
+ }
@@ -0,0 +1,50 @@
1
+ export default async function turnScopeInitIntoVars({ context, scopes, vars: apiVars }) {
2
+ // Refer to the scope's vars
3
+ const scope = scopes[context.scopeName];
4
+ const scopeOptions = scope?.scopeOptions || context.scopeOptions || {};
5
+ const vars = scope?.vars || apiVars;
6
+
7
+ // The scope-specific ones
8
+ vars.sortableFields = scopeOptions.sortableFields || [];
9
+ vars.defaultSort = scopeOptions.defaultSort || null;
10
+
11
+ // The general ones that are also set at api level, but overrideable
12
+ if (typeof scopeOptions.queryDefaultLimit !== 'undefined') vars.queryDefaultLimit = scopeOptions.queryDefaultLimit;
13
+ if (typeof scopeOptions.queryMaxLimit !== 'undefined') vars.queryMaxLimit = scopeOptions.queryMaxLimit;
14
+ if (typeof scopeOptions.includeDepthLimit !== 'undefined') vars.includeDepthLimit = scopeOptions.includeDepthLimit;
15
+ if (typeof scopeOptions.publicBaseUrl !== 'undefined') vars.publicBaseUrl = scopeOptions.publicBaseUrl;
16
+ if (typeof scopeOptions.enablePaginationCounts !== 'undefined') vars.enablePaginationCounts = scopeOptions.enablePaginationCounts;
17
+
18
+ // Set simplified settings as scope vars
19
+ if (typeof scopeOptions.simplifiedApi !== 'undefined') vars.simplifiedApi = scopeOptions.simplifiedApi;
20
+ if (typeof scopeOptions.simplifiedTransport !== 'undefined') vars.simplifiedTransport = scopeOptions.simplifiedTransport;
21
+
22
+ // Set returnRecord settings as scope vars
23
+ if (typeof scopeOptions.returnRecordApi !== 'undefined') vars.returnRecordApi = scopeOptions.returnRecordApi;
24
+ if (typeof scopeOptions.returnRecordTransport !== 'undefined') vars.returnRecordTransport = scopeOptions.returnRecordTransport;
25
+
26
+ // Set idProperty as scope var
27
+ if (typeof scopeOptions.idProperty !== 'undefined') vars.idProperty = scopeOptions.idProperty;
28
+
29
+ // Add validation for query limits
30
+ if (vars.queryDefaultLimit && vars.queryMaxLimit) {
31
+ if (vars.queryDefaultLimit > vars.queryMaxLimit) {
32
+ throw new Error(
33
+ `Invalid scope '${context.scopeName}' configuration: ` +
34
+ `queryDefaultLimit (${vars.queryDefaultLimit}) cannot exceed queryMaxLimit (${vars.queryMaxLimit})`
35
+ );
36
+ }
37
+ }
38
+
39
+ // Validate relationship include limits at scope creation time
40
+ Object.entries(scopeOptions.relationships || {}).forEach(([relName, relDef]) => {
41
+ if (relDef.include?.limit && vars.queryMaxLimit) {
42
+ if (relDef.include.limit > vars.queryMaxLimit) {
43
+ throw new Error(
44
+ `Invalid relationship '${context.scopeName}.${relName}' configuration: ` +
45
+ `include.limit (${relDef.include.limit}) cannot exceed queryMaxLimit (${vars.queryMaxLimit})`
46
+ );
47
+ }
48
+ }
49
+ });
50
+ }