json-rest-api 1.0.28 → 2.0.0

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 (289) hide show
  1. package/LICENSE +10 -670
  2. package/LICENSE-GPL-3.0 +674 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +39 -30
  5. package/docs/API.md +746 -1273
  6. package/docs/GUIDE/01-getting-started.md +338 -0
  7. package/docs/GUIDE/02-resources-and-relationships.md +33 -0
  8. package/docs/GUIDE/03-running-example.md +87 -0
  9. package/docs/GUIDE/04-creating-and-querying.md +181 -0
  10. package/docs/GUIDE/05-belongs-to.md +177 -0
  11. package/docs/GUIDE/06-has-many.md +184 -0
  12. package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
  13. package/docs/GUIDE/08-many-to-many.md +184 -0
  14. package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
  15. package/docs/GUIDE/10-put-and-patch.md +174 -0
  16. package/docs/GUIDE/11-relationship-endpoints.md +174 -0
  17. package/docs/GUIDE/12-field-transformations.md +287 -0
  18. package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
  19. package/docs/GUIDE/14-custom-resource-methods.md +125 -0
  20. package/docs/GUIDE/15-query-projections.md +266 -0
  21. package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
  22. package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
  23. package/docs/GUIDE/18-labels.md +86 -0
  24. package/docs/GUIDE/19-managed-transactions.md +179 -0
  25. package/docs/GUIDE/20-transaction-outcomes.md +207 -0
  26. package/docs/GUIDE/21-schema-and-migrations.md +634 -0
  27. package/docs/GUIDE/22-version-field-migrations.md +208 -0
  28. package/docs/GUIDE/23-fastify.md +179 -0
  29. package/docs/GUIDE/24-cors.md +172 -0
  30. package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
  31. package/docs/GUIDE/26-file-uploads.md +278 -0
  32. package/docs/GUIDE/27-bulk-operations.md +277 -0
  33. package/docs/GUIDE/28-socketio.md +325 -0
  34. package/docs/GUIDE/29-writing-plugins.md +278 -0
  35. package/docs/GUIDE/30-backend-capabilities.md +100 -0
  36. package/docs/GUIDE/31-positioning.md +181 -0
  37. package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +90 -31
  38. package/docs/GUIDE/33-migrating-to-v2.md +3827 -0
  39. package/docs/GUIDE/index.md +51 -59
  40. package/docs/QUICKSTART.md +120 -287
  41. package/docs/architecture.md +75 -0
  42. package/docs/contributing.md +178 -0
  43. package/index.d.ts +36 -0
  44. package/index.js +1 -22
  45. package/lib/enhanced-logger.js +91 -85
  46. package/lib/error-context.js +445 -5
  47. package/lib/error-formatter.js +219 -59
  48. package/lib/knex-transaction.js +64 -0
  49. package/lib/logger-types.d.ts +22 -0
  50. package/lib/missing-package.js +4 -0
  51. package/lib/rest-api-errors.js +103 -8
  52. package/lib/runtime/json-rest-api.js +172 -0
  53. package/lib/transaction-types.d.ts +59 -0
  54. package/package.json +97 -27
  55. package/plugins/core/bulk-operations-plugin.js +191 -184
  56. package/plugins/core/connectors/express-plugin.js +148 -219
  57. package/plugins/core/connectors/fastify-plugin.js +125 -82
  58. package/plugins/core/connectors/lib/busboy-detector.js +74 -156
  59. package/plugins/core/connectors/lib/connector-core.js +144 -20
  60. package/plugins/core/connectors/lib/formidable-detector.js +63 -120
  61. package/plugins/core/connectors/lib/http-validators.js +77 -0
  62. package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
  63. package/plugins/core/connectors/lib/request-helpers.js +10 -1
  64. package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
  65. package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
  66. package/plugins/core/file-handling-plugin.js +87 -174
  67. package/plugins/core/lib/anyapi/anyapi-registry.js +213 -164
  68. package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
  69. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
  70. package/plugins/core/lib/anyapi/schema-utils.js +25 -16
  71. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
  72. package/plugins/core/lib/dbIntrospection.js +228 -21
  73. package/plugins/core/lib/dbTablesOperations.js +409 -92
  74. package/plugins/core/lib/fractional-positioning.js +12 -48
  75. package/plugins/core/lib/querying/identifier-query.js +19 -0
  76. package/plugins/core/lib/querying/include-query-helpers.js +364 -0
  77. package/plugins/core/lib/querying/include-to-many.js +428 -0
  78. package/plugins/core/lib/querying/include-to-one.js +579 -0
  79. package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
  80. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
  81. package/plugins/core/lib/querying/knex-pagination-helpers.js +67 -330
  82. package/plugins/core/lib/querying/knex-process-includes.js +5 -110
  83. package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
  84. package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
  85. package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
  86. package/plugins/core/lib/querying/knex-window-queries.js +62 -253
  87. package/plugins/core/lib/querying/query-builder-utils.js +44 -4
  88. package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
  89. package/plugins/core/lib/querying/query-constraint.js +27 -0
  90. package/plugins/core/lib/querying/query-field-sort-helpers.js +205 -30
  91. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  92. package/plugins/core/lib/querying/sort-helpers.js +20 -1
  93. package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
  94. package/plugins/core/lib/querying/url-helpers.js +11 -4
  95. package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
  96. package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
  97. package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
  98. package/plugins/core/lib/querying-writing/database-value-normalizers.js +238 -187
  99. package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
  100. package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
  101. package/plugins/core/lib/querying-writing/field-utils.js +113 -92
  102. package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
  103. package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
  104. package/plugins/core/lib/querying-writing/knex-field-helpers.js +111 -287
  105. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
  106. package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
  107. package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
  108. package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
  109. package/plugins/core/lib/querying-writing/request-contracts.js +109 -44
  110. package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
  111. package/plugins/core/lib/querying-writing/response-options.js +35 -0
  112. package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
  113. package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
  114. package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
  115. package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
  116. package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
  117. package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
  118. package/plugins/core/lib/storage/storage-adapter.js +142 -62
  119. package/plugins/core/lib/storage/storage-mapping.js +78 -28
  120. package/plugins/core/lib/storage/storage-types.d.ts +502 -0
  121. package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
  122. package/plugins/core/lib/writing/relationship-processor.js +94 -158
  123. package/plugins/core/lib/writing/resource-version.js +172 -0
  124. package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
  125. package/plugins/core/rest-api-anyapi-knex-plugin.js +116 -2509
  126. package/plugins/core/rest-api-autofilter-plugin.js +17 -19
  127. package/plugins/core/rest-api-cors-plugin.js +52 -205
  128. package/plugins/core/rest-api-knex-plugin.js +45 -1069
  129. package/plugins/core/rest-api-label-plugin.js +25 -38
  130. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
  131. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
  132. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
  133. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
  134. package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
  135. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
  136. package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
  137. package/plugins/core/rest-api-plugin-methods/common.js +291 -385
  138. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
  139. package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
  140. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -145
  141. package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
  142. package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
  143. package/plugins/core/rest-api-plugin-methods/get.js +38 -69
  144. package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
  145. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
  146. package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
  147. package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
  148. package/plugins/core/rest-api-plugin-methods/post.js +51 -51
  149. package/plugins/core/rest-api-plugin-methods/put.js +69 -148
  150. package/plugins/core/rest-api-plugin-methods/query.js +39 -82
  151. package/plugins/core/rest-api-plugin-methods/release.js +2 -1
  152. package/plugins/core/rest-api-plugin.js +32 -46
  153. package/plugins/core/rest-api-positioning-plugin.js +183 -628
  154. package/plugins/core/rest-api-query-projections-plugin.js +19 -102
  155. package/plugins/core/rest-api-row-policy-plugin.js +3 -3
  156. package/plugins/core/socketio-plugin.js +364 -344
  157. package/plugins/storage/local-storage.js +40 -36
  158. package/types/autofilter.d.ts +57 -0
  159. package/types/bulk-methods.d.ts +63 -0
  160. package/types/errors.d.ts +118 -0
  161. package/types/fastify-options.d.ts +19 -0
  162. package/types/file-detectors.d.ts +33 -0
  163. package/types/file-storage.d.ts +48 -0
  164. package/types/hook-context.d.ts +180 -0
  165. package/types/plugin-options.d.ts +53 -0
  166. package/types/relationship-methods.d.ts +74 -0
  167. package/types/representations.d.ts +50 -0
  168. package/types/resource-methods.d.ts +95 -0
  169. package/types/resource-schema.d.ts +157 -0
  170. package/types/row-policy.d.ts +31 -0
  171. package/types/runtime.d.ts +132 -0
  172. package/types/transactions.d.ts +21 -0
  173. package/.claude/agents/library-guide-expert.md +0 -30
  174. package/.claude/agents/rest-api-internals-expert.md +0 -54
  175. package/.claude/settings.local.json +0 -70
  176. package/AGENTS.md +0 -34
  177. package/CLAUDE.md +0 -160
  178. package/CRUSH.md +0 -55
  179. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
  180. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
  181. package/TODO.todo +0 -13
  182. package/connectors_ideas/fastify.js +0 -217
  183. package/connectors_ideas/koa-plugin.js +0 -112
  184. package/docs/.bundle/config +0 -2
  185. package/docs/COMPARISON.md +0 -433
  186. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
  187. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
  188. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
  189. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
  190. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
  191. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
  192. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
  193. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1238
  194. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
  195. package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
  196. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
  197. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
  198. package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
  199. package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
  200. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
  201. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
  202. package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
  203. package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
  204. package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
  205. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
  206. package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
  207. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
  208. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
  209. package/docs/Gemfile +0 -6
  210. package/docs/Gemfile.lock +0 -308
  211. package/docs/ONBOARDING.md +0 -315
  212. package/docs/_config.yml +0 -36
  213. package/docs/_layouts/default.html +0 -123
  214. package/docs/apple-touch-icon.svg +0 -4
  215. package/docs/assets/css/style.scss +0 -392
  216. package/docs/favicon.svg +0 -4
  217. package/docs/index.md +0 -164
  218. package/docs/mini-guides/file-uploads-complete.md +0 -161
  219. package/eslint.config.js +0 -13
  220. package/examples/url-override-example.js +0 -137
  221. package/fixing_plan.md +0 -112
  222. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
  223. package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
  224. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
  225. package/plugins/storage/s3-storage.js +0 -93
  226. package/quickTest.js +0 -136
  227. package/scripts/debug-anyapi-review-filter.mjs +0 -63
  228. package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
  229. package/scripts/inspect-anyapi-descriptor.mjs +0 -19
  230. package/scripts/run-docs.js +0 -133
  231. package/test-debug.js +0 -59
  232. package/tests/README.md +0 -202
  233. package/tests/TEST_TEMPLATE.test.js +0 -178
  234. package/tests/anyapi-basic.test.js +0 -105
  235. package/tests/anyapi-cursor-pagination.test.js +0 -150
  236. package/tests/anyapi-custom-idproperty.test.js +0 -163
  237. package/tests/autofilter.test.js +0 -463
  238. package/tests/bulk-operations.test.js +0 -657
  239. package/tests/computed-fields.test.js +0 -586
  240. package/tests/cors.test.js +0 -834
  241. package/tests/curl.test.js +0 -467
  242. package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
  243. package/tests/custom-id-simplified-post.test.js +0 -75
  244. package/tests/custom-idproperty-relationships.test.js +0 -932
  245. package/tests/db-introspection.test.js +0 -521
  246. package/tests/db-table-operations.test.js +0 -798
  247. package/tests/fastify-plugin.test.js +0 -184
  248. package/tests/field-getters.test.js +0 -370
  249. package/tests/field-setters.test.js +0 -306
  250. package/tests/file-handling.test.js +0 -118
  251. package/tests/fixtures/api-configs.js +0 -2509
  252. package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
  253. package/tests/helpers/fake-fastify.js +0 -99
  254. package/tests/helpers/storage-mode.js +0 -65
  255. package/tests/helpers/test-utils.js +0 -203
  256. package/tests/http-connectors-parity.test.js +0 -395
  257. package/tests/id-normalization.test.js +0 -444
  258. package/tests/include-depth-validation.test.js +0 -331
  259. package/tests/includes.test.js +0 -427
  260. package/tests/local-storage.test.js +0 -96
  261. package/tests/logical-id-write-validation.test.js +0 -151
  262. package/tests/nested-includes.test.js +0 -617
  263. package/tests/pagination-cursor-multifield.test.js +0 -643
  264. package/tests/pagination-enhanced.test.js +0 -563
  265. package/tests/polymorphic-relationships.test.js +0 -347
  266. package/tests/polymorphic-simple.test.js +0 -148
  267. package/tests/positioning-advanced.test.js +0 -692
  268. package/tests/positioning-extended.test.js +0 -915
  269. package/tests/positioning-multifilter.test.js +0 -699
  270. package/tests/positioning.test.js +0 -560
  271. package/tests/post-commit-hooks.test.js +0 -120
  272. package/tests/projected-fields.test.js +0 -319
  273. package/tests/public-exports.test.js +0 -15
  274. package/tests/put-create-parity.test.js +0 -216
  275. package/tests/queries.test.js +0 -521
  276. package/tests/query-limits.test.js +0 -584
  277. package/tests/relationship-endpoints.test.js +0 -620
  278. package/tests/relationship-include-storage-adapter.test.js +0 -56
  279. package/tests/relationships.test.js +0 -681
  280. package/tests/rest-api.test.js +0 -337
  281. package/tests/return-record-settings.test.js +0 -184
  282. package/tests/row-policy.test.js +0 -489
  283. package/tests/s3-storage.test.js +0 -38
  284. package/tests/searchschema-knex-behavior.test.js +0 -187
  285. package/tests/searchschema-merge.test.js +0 -151
  286. package/tests/socketio.test.js +0 -860
  287. package/tests/storage-mapping.test.js +0 -362
  288. package/tests/test-development-guide.md +0 -370
  289. package/tests/virtual-fields.test.js +0 -404
@@ -1,643 +0,0 @@
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
- import { storageMode } from './helpers/storage-mode.js'
11
-
12
- // Create Knex instance for tests
13
- const knex = knexLib({
14
- client: 'better-sqlite3',
15
- connection: {
16
- filename: ':memory:'
17
- },
18
- useNullAsDefault: true
19
- })
20
-
21
- // API instance that persists across tests
22
- let api
23
- const anyapiIt = storageMode.isAnyApi() ? it : it.skip
24
-
25
- async function countAnyRecords (resource) {
26
- const result = await knex('any_records')
27
- .where({
28
- tenant_id: api.anyapi.tenantId,
29
- resource
30
- })
31
- .count('* as count')
32
- .first()
33
-
34
- return Number(result?.count ?? 0)
35
- }
36
-
37
- describe('Multi-field Cursor Pagination', () => {
38
- before(async () => {
39
- // Initialize API once with cursor pagination configuration
40
- api = await createCursorPaginationApi(knex)
41
- })
42
-
43
- after(async () => {
44
- // Close database connection
45
- await knex.destroy()
46
- })
47
-
48
- anyapiIt('cleans cursor fixture records from AnyAPI canonical storage', async () => {
49
- await cleanTables(knex, ['cursor_products', 'cursor_items'])
50
-
51
- await api.resources.products.post({
52
- inputRecord: createJsonApiDocument('products', {
53
- name: 'Cleanup Product',
54
- category: 'Cleanup',
55
- brand: 'Fixture',
56
- price: 10,
57
- sku: 'CLEAN-001'
58
- }),
59
- simplified: false
60
- })
61
-
62
- await api.resources.items.post({
63
- inputRecord: createJsonApiDocument('items', {
64
- code: 'CLEAN',
65
- name: 'Cleanup Item',
66
- category: 'Cleanup',
67
- type: 'fixture'
68
- }),
69
- simplified: false
70
- })
71
-
72
- assert.equal(await countAnyRecords('products'), 1)
73
- assert.equal(await countAnyRecords('items'), 1)
74
-
75
- await cleanTables(knex, ['cursor_products'])
76
-
77
- assert.equal(await countAnyRecords('products'), 0)
78
- assert.equal(await countAnyRecords('items'), 1)
79
-
80
- await cleanTables(knex, ['cursor_items'])
81
-
82
- assert.equal(await countAnyRecords('items'), 0)
83
- })
84
-
85
- describe('Basic Multi-field Cursor Pagination', () => {
86
- beforeEach(async () => {
87
- await cleanTables(knex, ['cursor_products'])
88
-
89
- // Create test data with some duplicate categories and brands
90
- const products = [
91
- { name: 'Apple', category: 'Fruit', brand: 'Fresh Farm', price: 2.50, sku: 'FF-001' },
92
- { name: 'Banana', category: 'Fruit', brand: 'Fresh Farm', price: 1.50, sku: 'FF-002' },
93
- { name: 'Cherry', category: 'Fruit', brand: 'Fresh Farm', price: 3.00, sku: 'FF-003' },
94
- { name: 'Date', category: 'Fruit', brand: 'Desert Delights', price: 4.00, sku: 'DD-001' },
95
- { name: 'Elderberry', category: 'Fruit', brand: 'Wild Harvest', price: 5.00, sku: 'WH-001' },
96
- { name: 'Asparagus', category: 'Vegetable', brand: 'Green Gardens', price: 3.50, sku: 'GG-001' },
97
- { name: 'Broccoli', category: 'Vegetable', brand: 'Green Gardens', price: 2.00, sku: 'GG-002' },
98
- { name: 'Carrot', category: 'Vegetable', brand: 'Root Ranch', price: 1.00, sku: 'RR-001' }
99
- ]
100
-
101
- for (const product of products) {
102
- await api.resources.products.post({
103
- inputRecord: createJsonApiDocument('products', product),
104
- simplified: false
105
- })
106
- }
107
- })
108
-
109
- it('should handle cursor pagination with multi-field sorting without skipping records', async () => {
110
- // Sort by category first (which has duplicates), then by name
111
- const pageSize = 3
112
- const allProducts = []
113
- let cursor = null
114
- let pageCount = 0
115
-
116
- // Collect all products through cursor pagination
117
- while (pageCount < 10) { // Safety limit
118
- const result = await api.resources.products.query({
119
- queryParams: {
120
- page: {
121
- size: pageSize,
122
- ...(cursor && { after: cursor })
123
- },
124
- sort: ['category', 'name']
125
- },
126
- simplified: false
127
- })
128
-
129
- validateJsonApiStructure(result, true)
130
- allProducts.push(...result.data)
131
- pageCount++
132
-
133
- if (!result.meta.pagination.hasMore) {
134
- break
135
- }
136
- cursor = result.meta.pagination.cursor.next
137
- }
138
-
139
- // Verify we got all 8 products
140
- assert.equal(allProducts.length, 8, 'Should retrieve all 8 products')
141
-
142
- // Verify the order is correct
143
- const productNames = allProducts.map(p => p.attributes.name)
144
- const expectedOrder = [
145
- 'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', // All Fruits in alphabetical order
146
- 'Asparagus', 'Broccoli', 'Carrot' // All Vegetables in alphabetical order
147
- ]
148
- assert.deepEqual(productNames, expectedOrder, 'Products should be in correct order')
149
-
150
- // Verify no duplicates
151
- const ids = allProducts.map(p => p.id)
152
- const uniqueIds = [...new Set(ids)]
153
- assert.equal(ids.length, uniqueIds.length, 'Should have no duplicate products')
154
- })
155
-
156
- it('should handle DESC sorting with duplicates correctly', async () => {
157
- // Sort by category DESC, then name ASC
158
- const firstPage = await api.resources.products.query({
159
- queryParams: {
160
- page: { size: 3 },
161
- sort: ['-category', 'name']
162
- },
163
- simplified: false
164
- })
165
-
166
- const secondPage = await api.resources.products.query({
167
- queryParams: {
168
- page: {
169
- size: 3,
170
- after: firstPage.meta.pagination.cursor.next
171
- },
172
- sort: ['-category', 'name']
173
- },
174
- simplified: false
175
- })
176
-
177
- // First page should have vegetables (category DESC)
178
- const firstPageCategories = firstPage.data.map(p => p.attributes.category)
179
- assert(firstPageCategories.every(c => c === 'Vegetable'), 'First page should only have Vegetables')
180
-
181
- // Second page should start with remaining vegetables or fruits
182
- const secondPageNames = secondPage.data.map(p => p.attributes.name)
183
- assert(secondPageNames.includes('Apple') || secondPageNames.includes('Banana'),
184
- 'Second page should include fruits that come after vegetables')
185
- })
186
-
187
- it('should handle three-field sorting with multiple duplicate values', async () => {
188
- // Add more products with duplicate category AND brand
189
- await api.resources.products.post({
190
- inputRecord: createJsonApiDocument('products', {
191
- name: 'Zucchini',
192
- category: 'Vegetable',
193
- brand: 'Green Gardens',
194
- price: 2.50,
195
- sku: 'GG-003'
196
- }),
197
- simplified: false
198
- })
199
-
200
- const pageSize = 2
201
- const allProducts = []
202
- let cursor = null
203
- let iterations = 0
204
-
205
- // Sort by category, brand, then name
206
- while (iterations < 20) {
207
- const result = await api.resources.products.query({
208
- queryParams: {
209
- page: {
210
- size: pageSize,
211
- ...(cursor && { after: cursor })
212
- },
213
- sort: ['category', 'brand', 'name']
214
- },
215
- simplified: false
216
- })
217
-
218
- allProducts.push(...result.data)
219
-
220
- if (!result.meta.pagination.hasMore) {
221
- break
222
- }
223
- cursor = result.meta.pagination.cursor.next
224
- iterations += 1
225
- }
226
-
227
- assert(iterations < 20, 'Cursor pagination should converge within expected page count')
228
-
229
- // Find all Green Gardens vegetables
230
- const greenGardensVeggies = allProducts.filter(p =>
231
- p.attributes.category === 'Vegetable' &&
232
- p.attributes.brand === 'Green Gardens'
233
- )
234
-
235
- assert.equal(greenGardensVeggies.length, 3, 'Should have all 3 Green Gardens vegetables')
236
-
237
- // Verify they're in name order
238
- const ggNames = greenGardensVeggies.map(p => p.attributes.name)
239
- assert.deepEqual(ggNames, ['Asparagus', 'Broccoli', 'Zucchini'],
240
- 'Green Gardens vegetables should be in alphabetical order by name')
241
- })
242
-
243
- it('should handle cursor pagination with numeric and text fields', async () => {
244
- // Sort by price (numeric), then category (text), then name (text)
245
- const firstPage = await api.resources.products.query({
246
- queryParams: {
247
- page: { size: 3 },
248
- sort: ['price', 'category', 'name']
249
- },
250
- simplified: false
251
- })
252
-
253
- // The cheapest item is Carrot at 1.00
254
- assert.equal(firstPage.data[0].attributes.name, 'Carrot')
255
- assert.equal(firstPage.data[0].attributes.price, 1.00)
256
-
257
- // Get second page
258
- const secondPage = await api.resources.products.query({
259
- queryParams: {
260
- page: {
261
- size: 3,
262
- after: firstPage.meta.pagination.cursor.next
263
- },
264
- sort: ['price', 'category', 'name']
265
- },
266
- simplified: false
267
- })
268
-
269
- // Verify prices are non-decreasing across pages
270
- const lastPriceFirstPage = firstPage.data[firstPage.data.length - 1].attributes.price
271
- const firstPriceSecondPage = secondPage.data[0].attributes.price
272
- assert(firstPriceSecondPage >= lastPriceFirstPage,
273
- 'Prices should be non-decreasing across pages')
274
- })
275
-
276
- it('should advance when sorting by logical camelCase fields backed by snake_case storage columns', async () => {
277
- await cleanTables(knex, ['cursor_products'])
278
-
279
- const products = [
280
- {
281
- name: 'Newest Product',
282
- category: 'Catalog',
283
- brand: 'Acme',
284
- price: 10,
285
- sku: 'CAMEL-001',
286
- createdAt: '2026-05-03T10:00:00.000Z'
287
- },
288
- {
289
- name: 'Recent Product',
290
- category: 'Catalog',
291
- brand: 'Acme',
292
- price: 20,
293
- sku: 'CAMEL-002',
294
- createdAt: '2026-05-03T09:00:00.000Z'
295
- },
296
- {
297
- name: 'Older Product',
298
- category: 'Catalog',
299
- brand: 'Acme',
300
- price: 30,
301
- sku: 'CAMEL-003',
302
- createdAt: '2026-05-03T08:00:00.000Z'
303
- }
304
- ]
305
-
306
- for (const product of products) {
307
- await api.resources.products.post({
308
- inputRecord: createJsonApiDocument('products', product),
309
- simplified: false
310
- })
311
- }
312
-
313
- const firstPage = await api.resources.products.query({
314
- queryParams: {
315
- page: { size: 2 },
316
- sort: ['-createdAt']
317
- },
318
- simplified: false
319
- })
320
-
321
- assert.equal(firstPage.data.length, 2)
322
- assert.equal(firstPage.meta.pagination.cursor?.next.includes('createdAt:'), true,
323
- 'Cursor should include the logical createdAt field')
324
-
325
- const secondPage = await api.resources.products.query({
326
- queryParams: {
327
- page: {
328
- size: 2,
329
- after: firstPage.meta.pagination.cursor.next
330
- },
331
- sort: ['-createdAt']
332
- },
333
- simplified: false
334
- })
335
-
336
- assert.deepEqual(
337
- firstPage.data.map((record) => record.attributes.name),
338
- ['Newest Product', 'Recent Product']
339
- )
340
- assert.deepEqual(
341
- firstPage.data.map((record) => record.attributes.createdAt),
342
- ['2026-05-03T10:00:00.000Z', '2026-05-03T09:00:00.000Z']
343
- )
344
- assert.deepEqual(
345
- secondPage.data.map((record) => record.attributes.name),
346
- ['Older Product']
347
- )
348
- })
349
-
350
- it('should generate correct cursor values for multi-field sorting', async () => {
351
- const result = await api.resources.products.query({
352
- queryParams: {
353
- page: { size: 3 },
354
- sort: ['category', 'name']
355
- },
356
- simplified: false
357
- })
358
-
359
- // Check that cursor is present
360
- assert(result.meta.pagination.cursor?.next, 'Should have next cursor')
361
-
362
- // The cursor should be parseable (not throwing an error)
363
- const cursor = result.meta.pagination.cursor.next
364
- assert(typeof cursor === 'string', 'Cursor should be a string')
365
-
366
- // Cursor should encode multiple field values
367
- // Since we're using the simplified format, it should contain field:value pairs
368
- assert(cursor.includes('category:'), 'Cursor should include category field')
369
- assert(cursor.includes('name:'), 'Cursor should include name field')
370
- })
371
-
372
- it('should handle page[before] with multi-field sorting', async () => {
373
- // First, get to the middle of the dataset
374
- const firstPage = await api.resources.products.query({
375
- queryParams: {
376
- page: { size: 4 },
377
- sort: ['category', 'name']
378
- },
379
- simplified: false
380
- })
381
-
382
- const secondPage = await api.resources.products.query({
383
- queryParams: {
384
- page: {
385
- size: 4,
386
- after: firstPage.meta.pagination.cursor.next
387
- },
388
- sort: ['category', 'name']
389
- },
390
- simplified: false
391
- })
392
-
393
- // Now go backwards from the second page
394
- const previousPage = await api.resources.products.query({
395
- queryParams: {
396
- page: {
397
- size: 4,
398
- before: secondPage.meta.pagination.cursor?.next ||
399
- // If no next cursor, create one from the last record
400
- `category:${encodeURIComponent(secondPage.data[0].attributes.category)},name:${encodeURIComponent(secondPage.data[0].attributes.name)},id:${encodeURIComponent(secondPage.data[0].id)}`
401
- },
402
- sort: ['category', 'name']
403
- },
404
- simplified: false
405
- })
406
-
407
- // Verify we got different records going backwards
408
- const secondPageIds = secondPage.data.map(p => p.id)
409
- const previousPageIds = previousPage.data.map(p => p.id)
410
- assert(!secondPageIds.some(id => previousPageIds.includes(id)),
411
- 'Previous page should have different records than second page')
412
- })
413
- })
414
-
415
- describe('Edge Cases and Special Scenarios', () => {
416
- beforeEach(async () => {
417
- await cleanTables(knex, ['cursor_products', 'cursor_items'])
418
- })
419
-
420
- it('should handle all records having the same value in first sort field', async () => {
421
- // Create products all in the same category
422
- const products = [
423
- { name: 'Alpha', category: 'Electronics', brand: 'TechCo', price: 100, sku: 'TC-001' },
424
- { name: 'Beta', category: 'Electronics', brand: 'TechCo', price: 200, sku: 'TC-002' },
425
- { name: 'Gamma', category: 'Electronics', brand: 'GadgetInc', price: 150, sku: 'GI-001' },
426
- { name: 'Delta', category: 'Electronics', brand: 'GadgetInc', price: 250, sku: 'GI-002' },
427
- { name: 'Epsilon', category: 'Electronics', brand: 'TechCo', price: 300, sku: 'TC-003' }
428
- ]
429
-
430
- for (const product of products) {
431
- await api.resources.products.post({
432
- inputRecord: createJsonApiDocument('products', product),
433
- simplified: false
434
- })
435
- }
436
-
437
- // Sort by category (all same), then brand, then name
438
- const pageSize = 2
439
- const allProducts = []
440
- let cursor = null
441
-
442
- while (true) {
443
- const result = await api.resources.products.query({
444
- queryParams: {
445
- page: {
446
- size: pageSize,
447
- ...(cursor && { after: cursor })
448
- },
449
- sort: ['category', 'brand', 'name']
450
- },
451
- simplified: false
452
- })
453
-
454
- allProducts.push(...result.data)
455
-
456
- if (!result.meta.pagination.hasMore) {
457
- break
458
- }
459
- cursor = result.meta.pagination.cursor.next
460
- }
461
-
462
- assert.equal(allProducts.length, 5, 'Should get all 5 products')
463
-
464
- // Check order: should be grouped by brand, then by name
465
- const productInfo = allProducts.map(p => ({
466
- brand: p.attributes.brand,
467
- name: p.attributes.name
468
- }))
469
-
470
- // GadgetInc should come before TechCo
471
- const gadgetIncIndex = productInfo.findIndex(p => p.brand === 'GadgetInc')
472
- const firstTechCoIndex = productInfo.findIndex(p => p.brand === 'TechCo')
473
- assert(gadgetIncIndex < firstTechCoIndex, 'GadgetInc products should come before TechCo')
474
- })
475
-
476
- it('should handle empty results with multi-field cursor', async () => {
477
- // Create one product
478
- await api.resources.products.post({
479
- inputRecord: createJsonApiDocument('products', {
480
- name: 'Single Product',
481
- category: 'Misc',
482
- brand: 'Generic',
483
- price: 10,
484
- sku: 'GEN-001'
485
- }),
486
- simplified: false
487
- })
488
-
489
- // Get the first (and only) page
490
- const firstPage = await api.resources.products.query({
491
- queryParams: {
492
- page: { size: 5 },
493
- sort: ['category', 'name']
494
- },
495
- simplified: false
496
- })
497
-
498
- assert.equal(firstPage.data.length, 1)
499
- assert.equal(firstPage.meta.pagination.hasMore, false)
500
- assert(!firstPage.meta.pagination.cursor?.next, 'Should not have next cursor on last page')
501
- })
502
-
503
- it('should handle special characters in multi-field cursors', async () => {
504
- // Create products with special characters
505
- const products = [
506
- { name: 'Product: Special', category: 'Cat:1', brand: 'Brand,A', price: 10, sku: 'SP-001' },
507
- { name: 'Product: Extra', category: 'Cat:1', brand: 'Brand,B', price: 20, sku: 'SP-002' },
508
- { name: 'Product: Normal', category: 'Cat:2', brand: 'Brand,A', price: 15, sku: 'SP-003' }
509
- ]
510
-
511
- for (const product of products) {
512
- await api.resources.products.post({
513
- inputRecord: createJsonApiDocument('products', product),
514
- simplified: false
515
- })
516
- }
517
-
518
- // Navigate through pages
519
- const firstPage = await api.resources.products.query({
520
- queryParams: {
521
- page: { size: 1 },
522
- sort: ['category', 'brand', 'name']
523
- },
524
- simplified: false
525
- })
526
-
527
- assert(firstPage.meta.pagination.cursor?.next, 'Should have cursor')
528
-
529
- // Use the cursor to get next page - should not throw
530
- const secondPage = await api.resources.products.query({
531
- queryParams: {
532
- page: {
533
- size: 1,
534
- after: firstPage.meta.pagination.cursor.next
535
- },
536
- sort: ['category', 'brand', 'name']
537
- },
538
- simplified: false
539
- })
540
-
541
- assert.equal(secondPage.data.length, 1)
542
- assert.notEqual(secondPage.data[0].id, firstPage.data[0].id,
543
- 'Should get different record on second page')
544
- })
545
-
546
- it('should maintain stable sorting with custom id property', async () => {
547
- // Create items using the resource with custom ID property
548
- const items = [
549
- { code: 'A001', name: 'Apple', category: 'Fruit', type: 'Fresh' },
550
- { code: 'A002', name: 'Apricot', category: 'Fruit', type: 'Fresh' },
551
- { code: 'B001', name: 'Banana', category: 'Fruit', type: 'Fresh' },
552
- { code: 'C001', name: 'Carrot', category: 'Vegetable', type: 'Root' }
553
- ]
554
-
555
- for (const item of items) {
556
- await api.resources.items.post({
557
- inputRecord: createJsonApiDocument('items', item),
558
- simplified: false
559
- })
560
- }
561
-
562
- // Query with multi-field sort
563
- const result = await api.resources.items.query({
564
- queryParams: {
565
- page: { size: 2 },
566
- sort: ['category', 'name']
567
- },
568
- simplified: false
569
- })
570
-
571
- // Should use 'item_id' as the ID field in responses
572
- assert(result.data[0].id, 'Should have id field')
573
- assert(typeof result.data[0].id === 'string', 'ID should be a string')
574
-
575
- // Cursor should work with custom ID
576
- if (result.meta.pagination.cursor?.next) {
577
- const secondPage = await api.resources.items.query({
578
- queryParams: {
579
- page: {
580
- size: 2,
581
- after: result.meta.pagination.cursor.next
582
- },
583
- sort: ['category', 'name']
584
- },
585
- simplified: false
586
- })
587
-
588
- assert(secondPage.data.length > 0, 'Should get results on second page')
589
- }
590
- })
591
-
592
- it('should handle the exact bug scenario - multiple fruits with same category', async () => {
593
- // This is the exact scenario from the bug report
594
- await cleanTables(knex, ['cursor_products'])
595
-
596
- const products = [
597
- { name: 'Apple', category: 'Fruit', brand: 'Farm Fresh', price: 2.00, sku: 'FRUIT-001' },
598
- { name: 'Banana', category: 'Fruit', brand: 'Farm Fresh', price: 1.50, sku: 'FRUIT-002' },
599
- { name: 'Cherry', category: 'Fruit', brand: 'Farm Fresh', price: 3.00, sku: 'FRUIT-003' },
600
- { name: 'Date', category: 'Fruit', brand: 'Desert Farm', price: 4.00, sku: 'FRUIT-004' },
601
- { name: 'Asparagus', category: 'Vegetable', brand: 'Green Farm', price: 2.50, sku: 'VEG-001' }
602
- ]
603
-
604
- for (const product of products) {
605
- await api.resources.products.post({
606
- inputRecord: createJsonApiDocument('products', product),
607
- simplified: false
608
- })
609
- }
610
-
611
- // Get first page with size 3 (should get Apple, Banana, Cherry)
612
- const firstPage = await api.resources.products.query({
613
- queryParams: {
614
- page: { size: 3 },
615
- sort: ['category', 'name']
616
- },
617
- simplified: false
618
- })
619
-
620
- assert.equal(firstPage.data.length, 3)
621
- const firstPageNames = firstPage.data.map(p => p.attributes.name)
622
- assert.deepEqual(firstPageNames, ['Apple', 'Banana', 'Cherry'])
623
-
624
- // Get second page - MUST include Date (the bug would skip it)
625
- const secondPage = await api.resources.products.query({
626
- queryParams: {
627
- page: {
628
- size: 3,
629
- after: firstPage.meta.pagination.cursor.next
630
- },
631
- sort: ['category', 'name']
632
- },
633
- simplified: false
634
- })
635
-
636
- const secondPageNames = secondPage.data.map(p => p.attributes.name)
637
- assert(secondPageNames.includes('Date'),
638
- 'Second page MUST include Date - this was the bug where it would be skipped')
639
- assert.deepEqual(secondPageNames, ['Date', 'Asparagus'],
640
- 'Second page should have Date and Asparagus in that order')
641
- })
642
- })
643
- })