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
@@ -341,7 +341,6 @@ describe('JWT Auth Plugin', () => {
341
341
  inputRecord: countryDoc,
342
342
  simplified: false
343
343
  });
344
- if (!countryResult.data) debugger
345
344
  countryId = countryResult.data.id;
346
345
  });
347
346
 
@@ -510,7 +509,6 @@ describe('JWT Auth Plugin', () => {
510
509
  });
511
510
 
512
511
  it('should deny non-owner non-admin from updating', async () => {
513
- debugger
514
512
  // Create post as user
515
513
  const postDoc = createJsonApiDocument('posts', {
516
514
  title: 'User Post',
@@ -292,7 +292,7 @@ describe('Nested Include Operations', () => {
292
292
  const result = await api.resources.authors.query({
293
293
  queryParams: {
294
294
  include: ['books', 'books.authors'],
295
- filter: { name: 'Author 1' }
295
+ filters: { name: 'Author 1' }
296
296
  },
297
297
  simplified: false
298
298
  });
@@ -0,0 +1,514 @@
1
+ import { describe, it, before, after, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import knexLib from 'knex';
4
+ import {
5
+ validateJsonApiStructure,
6
+ cleanTables,
7
+ createJsonApiDocument
8
+ } from './helpers/test-utils.js';
9
+ import { createCursorPaginationApi } from './fixtures/api-configs.js';
10
+
11
+ // Create Knex instance for tests
12
+ const knex = knexLib({
13
+ client: 'better-sqlite3',
14
+ connection: {
15
+ filename: ':memory:'
16
+ },
17
+ useNullAsDefault: true
18
+ });
19
+
20
+ // API instance that persists across tests
21
+ let api;
22
+
23
+ describe('Multi-field Cursor Pagination', () => {
24
+ before(async () => {
25
+ // Initialize API once with cursor pagination configuration
26
+ api = await createCursorPaginationApi(knex);
27
+ });
28
+
29
+ after(async () => {
30
+ // Close database connection
31
+ await knex.destroy();
32
+ });
33
+
34
+ describe('Basic Multi-field Cursor Pagination', () => {
35
+ beforeEach(async () => {
36
+ await cleanTables(knex, ['cursor_products']);
37
+
38
+ // Create test data with some duplicate categories and brands
39
+ const products = [
40
+ { name: 'Apple', category: 'Fruit', brand: 'Fresh Farm', price: 2.50, sku: 'FF-001' },
41
+ { name: 'Banana', category: 'Fruit', brand: 'Fresh Farm', price: 1.50, sku: 'FF-002' },
42
+ { name: 'Cherry', category: 'Fruit', brand: 'Fresh Farm', price: 3.00, sku: 'FF-003' },
43
+ { name: 'Date', category: 'Fruit', brand: 'Desert Delights', price: 4.00, sku: 'DD-001' },
44
+ { name: 'Elderberry', category: 'Fruit', brand: 'Wild Harvest', price: 5.00, sku: 'WH-001' },
45
+ { name: 'Asparagus', category: 'Vegetable', brand: 'Green Gardens', price: 3.50, sku: 'GG-001' },
46
+ { name: 'Broccoli', category: 'Vegetable', brand: 'Green Gardens', price: 2.00, sku: 'GG-002' },
47
+ { name: 'Carrot', category: 'Vegetable', brand: 'Root Ranch', price: 1.00, sku: 'RR-001' }
48
+ ];
49
+
50
+ for (const product of products) {
51
+ await api.resources.products.post({
52
+ inputRecord: createJsonApiDocument('products', product),
53
+ simplified: false
54
+ });
55
+ }
56
+ });
57
+
58
+ it('should handle cursor pagination with multi-field sorting without skipping records', async () => {
59
+ // Sort by category first (which has duplicates), then by name
60
+ const pageSize = 3;
61
+ const allProducts = [];
62
+ let cursor = null;
63
+ let pageCount = 0;
64
+
65
+ // Collect all products through cursor pagination
66
+ while (pageCount < 10) { // Safety limit
67
+ const result = await api.resources.products.query({
68
+ queryParams: {
69
+ page: {
70
+ size: pageSize,
71
+ ...(cursor && { after: cursor })
72
+ },
73
+ sort: ['category', 'name']
74
+ },
75
+ simplified: false
76
+ });
77
+
78
+ validateJsonApiStructure(result, true);
79
+ allProducts.push(...result.data);
80
+ pageCount++;
81
+
82
+ if (!result.meta.pagination.hasMore) {
83
+ break;
84
+ }
85
+ cursor = result.meta.pagination.cursor.next;
86
+ }
87
+
88
+ // Verify we got all 8 products
89
+ assert.equal(allProducts.length, 8, 'Should retrieve all 8 products');
90
+
91
+ // Verify the order is correct
92
+ const productNames = allProducts.map(p => p.attributes.name);
93
+ const expectedOrder = [
94
+ 'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', // All Fruits in alphabetical order
95
+ 'Asparagus', 'Broccoli', 'Carrot' // All Vegetables in alphabetical order
96
+ ];
97
+ assert.deepEqual(productNames, expectedOrder, 'Products should be in correct order');
98
+
99
+ // Verify no duplicates
100
+ const ids = allProducts.map(p => p.id);
101
+ const uniqueIds = [...new Set(ids)];
102
+ assert.equal(ids.length, uniqueIds.length, 'Should have no duplicate products');
103
+ });
104
+
105
+ it('should handle DESC sorting with duplicates correctly', async () => {
106
+ // Sort by category DESC, then name ASC
107
+ const firstPage = await api.resources.products.query({
108
+ queryParams: {
109
+ page: { size: 3 },
110
+ sort: ['-category', 'name']
111
+ },
112
+ simplified: false
113
+ });
114
+
115
+ const secondPage = await api.resources.products.query({
116
+ queryParams: {
117
+ page: {
118
+ size: 3,
119
+ after: firstPage.meta.pagination.cursor.next
120
+ },
121
+ sort: ['-category', 'name']
122
+ },
123
+ simplified: false
124
+ });
125
+
126
+ // First page should have vegetables (category DESC)
127
+ const firstPageCategories = firstPage.data.map(p => p.attributes.category);
128
+ assert(firstPageCategories.every(c => c === 'Vegetable'), 'First page should only have Vegetables');
129
+
130
+ // Second page should start with remaining vegetables or fruits
131
+ const secondPageNames = secondPage.data.map(p => p.attributes.name);
132
+ assert(secondPageNames.includes('Apple') || secondPageNames.includes('Banana'),
133
+ 'Second page should include fruits that come after vegetables');
134
+ });
135
+
136
+ it('should handle three-field sorting with multiple duplicate values', async () => {
137
+ // Add more products with duplicate category AND brand
138
+ await api.resources.products.post({
139
+ inputRecord: createJsonApiDocument('products', {
140
+ name: 'Zucchini',
141
+ category: 'Vegetable',
142
+ brand: 'Green Gardens',
143
+ price: 2.50,
144
+ sku: 'GG-003'
145
+ }),
146
+ simplified: false
147
+ });
148
+
149
+ const pageSize = 2;
150
+ const allProducts = [];
151
+ let cursor = null;
152
+
153
+ // Sort by category, brand, then name
154
+ while (true) {
155
+ const result = await api.resources.products.query({
156
+ queryParams: {
157
+ page: {
158
+ size: pageSize,
159
+ ...(cursor && { after: cursor })
160
+ },
161
+ sort: ['category', 'brand', 'name']
162
+ },
163
+ simplified: false
164
+ });
165
+
166
+ allProducts.push(...result.data);
167
+
168
+ if (!result.meta.pagination.hasMore) {
169
+ break;
170
+ }
171
+ cursor = result.meta.pagination.cursor.next;
172
+ }
173
+
174
+ // Find all Green Gardens vegetables
175
+ const greenGardensVeggies = allProducts.filter(p =>
176
+ p.attributes.category === 'Vegetable' &&
177
+ p.attributes.brand === 'Green Gardens'
178
+ );
179
+
180
+ assert.equal(greenGardensVeggies.length, 3, 'Should have all 3 Green Gardens vegetables');
181
+
182
+ // Verify they're in name order
183
+ const ggNames = greenGardensVeggies.map(p => p.attributes.name);
184
+ assert.deepEqual(ggNames, ['Asparagus', 'Broccoli', 'Zucchini'],
185
+ 'Green Gardens vegetables should be in alphabetical order by name');
186
+ });
187
+
188
+ it('should handle cursor pagination with numeric and text fields', async () => {
189
+ // Sort by price (numeric), then category (text), then name (text)
190
+ const firstPage = await api.resources.products.query({
191
+ queryParams: {
192
+ page: { size: 3 },
193
+ sort: ['price', 'category', 'name']
194
+ },
195
+ simplified: false
196
+ });
197
+
198
+ // The cheapest item is Carrot at 1.00
199
+ assert.equal(firstPage.data[0].attributes.name, 'Carrot');
200
+ assert.equal(firstPage.data[0].attributes.price, 1.00);
201
+
202
+ // Get second page
203
+ const secondPage = await api.resources.products.query({
204
+ queryParams: {
205
+ page: {
206
+ size: 3,
207
+ after: firstPage.meta.pagination.cursor.next
208
+ },
209
+ sort: ['price', 'category', 'name']
210
+ },
211
+ simplified: false
212
+ });
213
+
214
+ // Verify prices are non-decreasing across pages
215
+ const lastPriceFirstPage = firstPage.data[firstPage.data.length - 1].attributes.price;
216
+ const firstPriceSecondPage = secondPage.data[0].attributes.price;
217
+ assert(firstPriceSecondPage >= lastPriceFirstPage,
218
+ 'Prices should be non-decreasing across pages');
219
+ });
220
+
221
+ it('should generate correct cursor values for multi-field sorting', async () => {
222
+ const result = await api.resources.products.query({
223
+ queryParams: {
224
+ page: { size: 3 },
225
+ sort: ['category', 'name']
226
+ },
227
+ simplified: false
228
+ });
229
+
230
+ // Check that cursor is present
231
+ assert(result.meta.pagination.cursor?.next, 'Should have next cursor');
232
+
233
+ // The cursor should be parseable (not throwing an error)
234
+ const cursor = result.meta.pagination.cursor.next;
235
+ assert(typeof cursor === 'string', 'Cursor should be a string');
236
+
237
+ // Cursor should encode multiple field values
238
+ // Since we're using the simplified format, it should contain field:value pairs
239
+ assert(cursor.includes('category:'), 'Cursor should include category field');
240
+ assert(cursor.includes('name:'), 'Cursor should include name field');
241
+ });
242
+
243
+ it('should handle page[before] with multi-field sorting', async () => {
244
+ // First, get to the middle of the dataset
245
+ const firstPage = await api.resources.products.query({
246
+ queryParams: {
247
+ page: { size: 4 },
248
+ sort: ['category', 'name']
249
+ },
250
+ simplified: false
251
+ });
252
+
253
+ const secondPage = await api.resources.products.query({
254
+ queryParams: {
255
+ page: {
256
+ size: 4,
257
+ after: firstPage.meta.pagination.cursor.next
258
+ },
259
+ sort: ['category', 'name']
260
+ },
261
+ simplified: false
262
+ });
263
+
264
+ // Now go backwards from the second page
265
+ const previousPage = await api.resources.products.query({
266
+ queryParams: {
267
+ page: {
268
+ size: 4,
269
+ before: secondPage.meta.pagination.cursor?.next ||
270
+ // If no next cursor, create one from the last record
271
+ `category:${encodeURIComponent(secondPage.data[0].attributes.category)},name:${encodeURIComponent(secondPage.data[0].attributes.name)}`
272
+ },
273
+ sort: ['category', 'name']
274
+ },
275
+ simplified: false
276
+ });
277
+
278
+ // Verify we got different records going backwards
279
+ const secondPageIds = secondPage.data.map(p => p.id);
280
+ const previousPageIds = previousPage.data.map(p => p.id);
281
+ assert(!secondPageIds.some(id => previousPageIds.includes(id)),
282
+ 'Previous page should have different records than second page');
283
+ });
284
+ });
285
+
286
+ describe('Edge Cases and Special Scenarios', () => {
287
+ beforeEach(async () => {
288
+ await cleanTables(knex, ['cursor_products', 'cursor_items']);
289
+ });
290
+
291
+ it('should handle all records having the same value in first sort field', async () => {
292
+ // Create products all in the same category
293
+ const products = [
294
+ { name: 'Alpha', category: 'Electronics', brand: 'TechCo', price: 100, sku: 'TC-001' },
295
+ { name: 'Beta', category: 'Electronics', brand: 'TechCo', price: 200, sku: 'TC-002' },
296
+ { name: 'Gamma', category: 'Electronics', brand: 'GadgetInc', price: 150, sku: 'GI-001' },
297
+ { name: 'Delta', category: 'Electronics', brand: 'GadgetInc', price: 250, sku: 'GI-002' },
298
+ { name: 'Epsilon', category: 'Electronics', brand: 'TechCo', price: 300, sku: 'TC-003' }
299
+ ];
300
+
301
+ for (const product of products) {
302
+ await api.resources.products.post({
303
+ inputRecord: createJsonApiDocument('products', product),
304
+ simplified: false
305
+ });
306
+ }
307
+
308
+ // Sort by category (all same), then brand, then name
309
+ const pageSize = 2;
310
+ const allProducts = [];
311
+ let cursor = null;
312
+
313
+ while (true) {
314
+ const result = await api.resources.products.query({
315
+ queryParams: {
316
+ page: {
317
+ size: pageSize,
318
+ ...(cursor && { after: cursor })
319
+ },
320
+ sort: ['category', 'brand', 'name']
321
+ },
322
+ simplified: false
323
+ });
324
+
325
+ allProducts.push(...result.data);
326
+
327
+ if (!result.meta.pagination.hasMore) {
328
+ break;
329
+ }
330
+ cursor = result.meta.pagination.cursor.next;
331
+ }
332
+
333
+ assert.equal(allProducts.length, 5, 'Should get all 5 products');
334
+
335
+ // Check order: should be grouped by brand, then by name
336
+ const productInfo = allProducts.map(p => ({
337
+ brand: p.attributes.brand,
338
+ name: p.attributes.name
339
+ }));
340
+
341
+ // GadgetInc should come before TechCo
342
+ const gadgetIncIndex = productInfo.findIndex(p => p.brand === 'GadgetInc');
343
+ const firstTechCoIndex = productInfo.findIndex(p => p.brand === 'TechCo');
344
+ assert(gadgetIncIndex < firstTechCoIndex, 'GadgetInc products should come before TechCo');
345
+ });
346
+
347
+ it('should handle empty results with multi-field cursor', async () => {
348
+ // Create one product
349
+ await api.resources.products.post({
350
+ inputRecord: createJsonApiDocument('products', {
351
+ name: 'Single Product',
352
+ category: 'Misc',
353
+ brand: 'Generic',
354
+ price: 10,
355
+ sku: 'GEN-001'
356
+ }),
357
+ simplified: false
358
+ });
359
+
360
+ // Get the first (and only) page
361
+ const firstPage = await api.resources.products.query({
362
+ queryParams: {
363
+ page: { size: 5 },
364
+ sort: ['category', 'name']
365
+ },
366
+ simplified: false
367
+ });
368
+
369
+ assert.equal(firstPage.data.length, 1);
370
+ assert.equal(firstPage.meta.pagination.hasMore, false);
371
+ assert(!firstPage.meta.pagination.cursor?.next, 'Should not have next cursor on last page');
372
+ });
373
+
374
+ it('should handle special characters in multi-field cursors', async () => {
375
+ // Create products with special characters
376
+ const products = [
377
+ { name: 'Product: Special', category: 'Cat:1', brand: 'Brand,A', price: 10, sku: 'SP-001' },
378
+ { name: 'Product: Extra', category: 'Cat:1', brand: 'Brand,B', price: 20, sku: 'SP-002' },
379
+ { name: 'Product: Normal', category: 'Cat:2', brand: 'Brand,A', price: 15, sku: 'SP-003' }
380
+ ];
381
+
382
+ for (const product of products) {
383
+ await api.resources.products.post({
384
+ inputRecord: createJsonApiDocument('products', product),
385
+ simplified: false
386
+ });
387
+ }
388
+
389
+ // Navigate through pages
390
+ const firstPage = await api.resources.products.query({
391
+ queryParams: {
392
+ page: { size: 1 },
393
+ sort: ['category', 'brand', 'name']
394
+ },
395
+ simplified: false
396
+ });
397
+
398
+ assert(firstPage.meta.pagination.cursor?.next, 'Should have cursor');
399
+
400
+ // Use the cursor to get next page - should not throw
401
+ const secondPage = await api.resources.products.query({
402
+ queryParams: {
403
+ page: {
404
+ size: 1,
405
+ after: firstPage.meta.pagination.cursor.next
406
+ },
407
+ sort: ['category', 'brand', 'name']
408
+ },
409
+ simplified: false
410
+ });
411
+
412
+ assert.equal(secondPage.data.length, 1);
413
+ assert.notEqual(secondPage.data[0].id, firstPage.data[0].id,
414
+ 'Should get different record on second page');
415
+ });
416
+
417
+ it('should maintain stable sorting with custom id property', async () => {
418
+ // Create items using the resource with custom ID property
419
+ const items = [
420
+ { code: 'A001', name: 'Apple', category: 'Fruit', type: 'Fresh' },
421
+ { code: 'A002', name: 'Apricot', category: 'Fruit', type: 'Fresh' },
422
+ { code: 'B001', name: 'Banana', category: 'Fruit', type: 'Fresh' },
423
+ { code: 'C001', name: 'Carrot', category: 'Vegetable', type: 'Root' }
424
+ ];
425
+
426
+ for (const item of items) {
427
+ await api.resources.items.post({
428
+ inputRecord: createJsonApiDocument('items', item),
429
+ simplified: false
430
+ });
431
+ }
432
+
433
+ // Query with multi-field sort
434
+ const result = await api.resources.items.query({
435
+ queryParams: {
436
+ page: { size: 2 },
437
+ sort: ['category', 'name']
438
+ },
439
+ simplified: false
440
+ });
441
+
442
+ // Should use 'item_id' as the ID field in responses
443
+ assert(result.data[0].id, 'Should have id field');
444
+ assert(typeof result.data[0].id === 'string', 'ID should be a string');
445
+
446
+ // Cursor should work with custom ID
447
+ if (result.meta.pagination.cursor?.next) {
448
+ const secondPage = await api.resources.items.query({
449
+ queryParams: {
450
+ page: {
451
+ size: 2,
452
+ after: result.meta.pagination.cursor.next
453
+ },
454
+ sort: ['category', 'name']
455
+ },
456
+ simplified: false
457
+ });
458
+
459
+ assert(secondPage.data.length > 0, 'Should get results on second page');
460
+ }
461
+ });
462
+
463
+ it('should handle the exact bug scenario - multiple fruits with same category', async () => {
464
+ // This is the exact scenario from the bug report
465
+ await cleanTables(knex, ['cursor_products']);
466
+
467
+ const products = [
468
+ { name: 'Apple', category: 'Fruit', brand: 'Farm Fresh', price: 2.00, sku: 'FRUIT-001' },
469
+ { name: 'Banana', category: 'Fruit', brand: 'Farm Fresh', price: 1.50, sku: 'FRUIT-002' },
470
+ { name: 'Cherry', category: 'Fruit', brand: 'Farm Fresh', price: 3.00, sku: 'FRUIT-003' },
471
+ { name: 'Date', category: 'Fruit', brand: 'Desert Farm', price: 4.00, sku: 'FRUIT-004' },
472
+ { name: 'Asparagus', category: 'Vegetable', brand: 'Green Farm', price: 2.50, sku: 'VEG-001' }
473
+ ];
474
+
475
+ for (const product of products) {
476
+ await api.resources.products.post({
477
+ inputRecord: createJsonApiDocument('products', product),
478
+ simplified: false
479
+ });
480
+ }
481
+
482
+ // Get first page with size 3 (should get Apple, Banana, Cherry)
483
+ const firstPage = await api.resources.products.query({
484
+ queryParams: {
485
+ page: { size: 3 },
486
+ sort: ['category', 'name']
487
+ },
488
+ simplified: false
489
+ });
490
+
491
+ assert.equal(firstPage.data.length, 3);
492
+ const firstPageNames = firstPage.data.map(p => p.attributes.name);
493
+ assert.deepEqual(firstPageNames, ['Apple', 'Banana', 'Cherry']);
494
+
495
+ // Get second page - MUST include Date (the bug would skip it)
496
+ const secondPage = await api.resources.products.query({
497
+ queryParams: {
498
+ page: {
499
+ size: 3,
500
+ after: firstPage.meta.pagination.cursor.next
501
+ },
502
+ sort: ['category', 'name']
503
+ },
504
+ simplified: false
505
+ });
506
+
507
+ const secondPageNames = secondPage.data.map(p => p.attributes.name);
508
+ assert(secondPageNames.includes('Date'),
509
+ 'Second page MUST include Date - this was the bug where it would be skipped');
510
+ assert.deepEqual(secondPageNames, ['Date', 'Asparagus'],
511
+ 'Second page should have Date and Asparagus in that order');
512
+ });
513
+ });
514
+ });
@@ -26,7 +26,7 @@ describe('Enhanced Pagination Features', () => {
26
26
  before(async () => {
27
27
  // Initialize API once with pagination features
28
28
  api = await createPaginationApi(knex, {
29
- resourceUrlPrefix: 'https://api.example.com/v1'
29
+ publicBaseUrl: 'https://api.example.com/v1'
30
30
  });
31
31
  });
32
32
 
@@ -112,10 +112,10 @@ describe('Enhanced Pagination Features', () => {
112
112
  });
113
113
  });
114
114
 
115
- it('should not add self links when resourceUrlPrefix is not configured', async () => {
116
- // Temporarily clear the resourceUrlPrefix
117
- const originalPrefix = api.vars.resourceUrlPrefix;
118
- api.vars.resourceUrlPrefix = '';
115
+ it('should add relative self links when publicBaseUrl is not configured', async () => {
116
+ // Temporarily clear the publicBaseUrl
117
+ const originalPrefix = api.vars.publicBaseUrl;
118
+ api.vars.publicBaseUrl = '';
119
119
 
120
120
  const result = await api.resources.books.query({
121
121
  queryParams: {},
@@ -124,14 +124,17 @@ describe('Enhanced Pagination Features', () => {
124
124
 
125
125
  validateJsonApiStructure(result, true);
126
126
 
127
- // No links should be present
127
+ // Links should be present but relative
128
128
  result.data.forEach(book => {
129
- assert(!book.links, 'Resources should not have links without resourceUrlPrefix');
129
+ assert(book.links, 'Resources should have links even without publicBaseUrl');
130
+ assert(book.links.self, 'Resources should have self link');
131
+ assert(book.links.self.startsWith('/books/'), 'Self link should be relative');
130
132
  });
131
- assert(!result.links, 'Response should not have top-level links without resourceUrlPrefix');
133
+ assert(result.links, 'Response should have top-level links even without publicBaseUrl');
134
+ assert(result.links.self.startsWith('/books'), 'Top-level self link should be relative');
132
135
 
133
136
  // Restore the prefix
134
- api.vars.resourceUrlPrefix = originalPrefix;
137
+ api.vars.publicBaseUrl = originalPrefix;
135
138
  });
136
139
  });
137
140
 
@@ -74,8 +74,8 @@ describe('Polymorphic Relationship Operations', () => {
74
74
  validateJsonApiStructure(review);
75
75
  assert.equal(review.data.type, 'reviews');
76
76
  assert.equal(review.data.attributes.rating, 5);
77
- assert.equal(review.data.attributes.reviewable_type, 'books');
78
- assert.equal(String(review.data.attributes.reviewable_id), String(book.data.id));
77
+ // Since polymorphic fields are filtered out from POST responses,
78
+ // we need to GET the record to verify the relationship was saved correctly
79
79
  });
80
80
 
81
81
  it('should create a review for an author', async () => {
@@ -99,8 +99,9 @@ describe('Polymorphic Relationship Operations', () => {
99
99
  });
100
100
 
101
101
  validateJsonApiStructure(review);
102
- assert.equal(review.data.attributes.reviewable_type, 'authors');
103
- assert.equal(String(review.data.attributes.reviewable_id), String(author.data.id));
102
+ // Polymorphic fields are no longer in attributes, they're in relationships
103
+ assert.equal(review.data.relationships.reviewable.data.type, 'authors');
104
+ assert.equal(review.data.relationships.reviewable.data.id, String(author.data.id));
104
105
  });
105
106
 
106
107
  it('should create a review for a publisher', async () => {
@@ -134,8 +135,9 @@ describe('Polymorphic Relationship Operations', () => {
134
135
  });
135
136
 
136
137
  validateJsonApiStructure(review);
137
- assert.equal(review.data.attributes.reviewable_type, 'publishers');
138
- assert.equal(String(review.data.attributes.reviewable_id), String(publisher.data.id));
138
+ // Polymorphic fields are no longer in attributes, they're in relationships
139
+ assert.equal(review.data.relationships.reviewable.data.type, 'publishers');
140
+ assert.equal(review.data.relationships.reviewable.data.id, String(publisher.data.id));
139
141
  });
140
142
 
141
143
  it('should fail to create review with invalid reviewable_type', async () => {
@@ -337,8 +339,8 @@ describe('Polymorphic Relationship Operations', () => {
337
339
  assert.equal(result.included.length, 2);
338
340
  assert(result.included.every(r => r.type === 'reviews'));
339
341
  assert(result.included.every(r =>
340
- r.attributes.reviewable_type === 'books' &&
341
- String(r.attributes.reviewable_id) === String(book.data.id)
342
+ r.relationships.reviewable.data.type === 'books' &&
343
+ String(r.relationships.reviewable.data.id) === String(book.data.id)
342
344
  ));
343
345
  });
344
346
  });
@@ -80,8 +80,9 @@ describe('Polymorphic Relationships - Basic Tests', () => {
80
80
  });
81
81
 
82
82
  validateJsonApiStructure(retrievedReview);
83
- assert.equal(retrievedReview.data.attributes.reviewable_type, 'books');
84
- assert.equal(String(retrievedReview.data.attributes.reviewable_id), String(book.data.id));
83
+ // Polymorphic fields are no longer in attributes, they're in relationships
84
+ assert.equal(retrievedReview.data.relationships.reviewable.data.type, 'books');
85
+ assert.equal(retrievedReview.data.relationships.reviewable.data.id, String(book.data.id));
85
86
  });
86
87
 
87
88
  it('should query all reviews', async () => {
@@ -137,8 +138,9 @@ describe('Polymorphic Relationships - Basic Tests', () => {
137
138
  assert.equal(result.data.length, 2);
138
139
 
139
140
  // Check we have one of each type
140
- const bookReview = result.data.find(r => r.attributes.reviewable_type === 'books');
141
- const authorReview = result.data.find(r => r.attributes.reviewable_type === 'authors');
141
+ // Polymorphic fields are no longer in attributes, they're in relationships
142
+ const bookReview = result.data.find(r => r.relationships.reviewable.data.type === 'books');
143
+ const authorReview = result.data.find(r => r.relationships.reviewable.data.type === 'authors');
142
144
 
143
145
  assert(bookReview, 'Should have a book review');
144
146
  assert(authorReview, 'Should have an author review');