json-rest-api 1.0.29 → 2.1.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 (294) 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 +744 -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 +733 -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 +103 -0
  36. package/docs/GUIDE/31-positioning.md +181 -0
  37. package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +74 -30
  38. package/docs/GUIDE/33-migrating-to-v2.md +3838 -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 +37 -0
  44. package/index.js +3 -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 +70 -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 -177
  67. package/plugins/core/lib/anyapi/anyapi-registry.js +586 -418
  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 +120 -56
  71. package/plugins/core/lib/anyapi/stored-definition.js +109 -0
  72. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
  73. package/plugins/core/lib/dbIntrospection.js +228 -21
  74. package/plugins/core/lib/dbTablesOperations.js +409 -92
  75. package/plugins/core/lib/fractional-positioning.js +12 -48
  76. package/plugins/core/lib/querying/identifier-query.js +19 -0
  77. package/plugins/core/lib/querying/include-query-helpers.js +364 -0
  78. package/plugins/core/lib/querying/include-to-many.js +428 -0
  79. package/plugins/core/lib/querying/include-to-one.js +579 -0
  80. package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
  81. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
  82. package/plugins/core/lib/querying/knex-pagination-helpers.js +48 -317
  83. package/plugins/core/lib/querying/knex-process-includes.js +5 -110
  84. package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
  85. package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
  86. package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
  87. package/plugins/core/lib/querying/knex-window-queries.js +62 -253
  88. package/plugins/core/lib/querying/query-builder-utils.js +44 -4
  89. package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
  90. package/plugins/core/lib/querying/query-constraint.js +27 -0
  91. package/plugins/core/lib/querying/query-field-sort-helpers.js +134 -37
  92. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  93. package/plugins/core/lib/querying/sort-helpers.js +13 -1
  94. package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
  95. package/plugins/core/lib/querying/url-helpers.js +11 -4
  96. package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
  97. package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
  98. package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
  99. package/plugins/core/lib/querying-writing/database-value-normalizers.js +179 -194
  100. package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
  101. package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
  102. package/plugins/core/lib/querying-writing/field-utils.js +113 -92
  103. package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
  104. package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
  105. package/plugins/core/lib/querying-writing/knex-field-helpers.js +100 -290
  106. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
  107. package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
  108. package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
  109. package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
  110. package/plugins/core/lib/querying-writing/request-contracts.js +93 -38
  111. package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
  112. package/plugins/core/lib/querying-writing/response-options.js +35 -0
  113. package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
  114. package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
  115. package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
  116. package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
  117. package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
  118. package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
  119. package/plugins/core/lib/storage/storage-adapter.js +128 -62
  120. package/plugins/core/lib/storage/storage-mapping.js +78 -28
  121. package/plugins/core/lib/storage/storage-types.d.ts +502 -0
  122. package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
  123. package/plugins/core/lib/writing/relationship-processor.js +94 -158
  124. package/plugins/core/lib/writing/resource-version.js +172 -0
  125. package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
  126. package/plugins/core/rest-api-anyapi-knex-plugin.js +290 -2475
  127. package/plugins/core/rest-api-autofilter-plugin.js +17 -19
  128. package/plugins/core/rest-api-cors-plugin.js +52 -205
  129. package/plugins/core/rest-api-knex-plugin.js +45 -1077
  130. package/plugins/core/rest-api-label-plugin.js +25 -38
  131. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
  132. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
  133. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
  134. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
  135. package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
  136. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
  137. package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
  138. package/plugins/core/rest-api-plugin-methods/common.js +291 -395
  139. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
  140. package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
  141. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -148
  142. package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
  143. package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
  144. package/plugins/core/rest-api-plugin-methods/get.js +36 -68
  145. package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
  146. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
  147. package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
  148. package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
  149. package/plugins/core/rest-api-plugin-methods/post.js +51 -51
  150. package/plugins/core/rest-api-plugin-methods/put.js +69 -148
  151. package/plugins/core/rest-api-plugin-methods/query.js +37 -81
  152. package/plugins/core/rest-api-plugin-methods/release.js +2 -1
  153. package/plugins/core/rest-api-plugin.js +32 -46
  154. package/plugins/core/rest-api-positioning-plugin.js +183 -628
  155. package/plugins/core/rest-api-query-projections-plugin.js +19 -102
  156. package/plugins/core/rest-api-row-policy-plugin.js +3 -3
  157. package/plugins/core/socketio-plugin.js +364 -344
  158. package/plugins/storage/local-storage.js +40 -36
  159. package/types/anyapi.d.ts +30 -0
  160. package/types/autofilter.d.ts +57 -0
  161. package/types/bulk-methods.d.ts +63 -0
  162. package/types/errors.d.ts +118 -0
  163. package/types/fastify-options.d.ts +19 -0
  164. package/types/file-detectors.d.ts +33 -0
  165. package/types/file-storage.d.ts +48 -0
  166. package/types/hook-context.d.ts +180 -0
  167. package/types/plugin-options.d.ts +65 -0
  168. package/types/relationship-methods.d.ts +74 -0
  169. package/types/representations.d.ts +50 -0
  170. package/types/resource-methods.d.ts +95 -0
  171. package/types/resource-schema.d.ts +157 -0
  172. package/types/row-policy.d.ts +31 -0
  173. package/types/runtime.d.ts +132 -0
  174. package/types/transactions.d.ts +21 -0
  175. package/.claude/agents/library-guide-expert.md +0 -30
  176. package/.claude/agents/rest-api-internals-expert.md +0 -54
  177. package/.claude/settings.local.json +0 -70
  178. package/AGENTS.md +0 -34
  179. package/CLAUDE.md +0 -160
  180. package/CRUSH.md +0 -55
  181. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
  182. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
  183. package/TODO.todo +0 -13
  184. package/connectors_ideas/fastify.js +0 -217
  185. package/connectors_ideas/koa-plugin.js +0 -112
  186. package/docs/.bundle/config +0 -2
  187. package/docs/COMPARISON.md +0 -433
  188. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
  189. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
  190. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
  191. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
  192. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
  193. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
  194. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
  195. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1247
  196. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
  197. package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
  198. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
  199. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
  200. package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
  201. package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
  202. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
  203. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
  204. package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
  205. package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
  206. package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
  207. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
  208. package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
  209. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
  210. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
  211. package/docs/Gemfile +0 -6
  212. package/docs/Gemfile.lock +0 -308
  213. package/docs/ONBOARDING.md +0 -315
  214. package/docs/_config.yml +0 -36
  215. package/docs/_layouts/default.html +0 -123
  216. package/docs/apple-touch-icon.svg +0 -4
  217. package/docs/assets/css/style.scss +0 -392
  218. package/docs/favicon.svg +0 -4
  219. package/docs/index.md +0 -164
  220. package/docs/mini-guides/file-uploads-complete.md +0 -161
  221. package/eslint.config.js +0 -13
  222. package/examples/url-override-example.js +0 -137
  223. package/fixing_plan.md +0 -112
  224. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
  225. package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
  226. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
  227. package/plugins/storage/s3-storage.js +0 -93
  228. package/quickTest.js +0 -136
  229. package/scripts/debug-anyapi-review-filter.mjs +0 -63
  230. package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
  231. package/scripts/inspect-anyapi-descriptor.mjs +0 -19
  232. package/scripts/run-docs.js +0 -133
  233. package/test-debug.js +0 -59
  234. package/tests/README.md +0 -202
  235. package/tests/TEST_TEMPLATE.test.js +0 -178
  236. package/tests/anyapi-basic.test.js +0 -105
  237. package/tests/anyapi-cursor-pagination.test.js +0 -148
  238. package/tests/anyapi-custom-idproperty.test.js +0 -163
  239. package/tests/autofilter.test.js +0 -463
  240. package/tests/bulk-operations.test.js +0 -657
  241. package/tests/computed-fields.test.js +0 -586
  242. package/tests/cors.test.js +0 -834
  243. package/tests/curl.test.js +0 -467
  244. package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
  245. package/tests/custom-id-simplified-post.test.js +0 -75
  246. package/tests/custom-idproperty-relationships.test.js +0 -932
  247. package/tests/db-introspection.test.js +0 -521
  248. package/tests/db-table-operations.test.js +0 -798
  249. package/tests/fastify-plugin.test.js +0 -184
  250. package/tests/field-getters.test.js +0 -370
  251. package/tests/field-setters.test.js +0 -306
  252. package/tests/file-handling.test.js +0 -155
  253. package/tests/fixtures/api-configs.js +0 -2566
  254. package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
  255. package/tests/helpers/fake-fastify.js +0 -99
  256. package/tests/helpers/storage-mode.js +0 -65
  257. package/tests/helpers/test-utils.js +0 -203
  258. package/tests/http-connectors-parity.test.js +0 -395
  259. package/tests/id-normalization.test.js +0 -444
  260. package/tests/include-depth-validation.test.js +0 -331
  261. package/tests/includes.test.js +0 -427
  262. package/tests/local-storage.test.js +0 -96
  263. package/tests/logical-id-write-validation.test.js +0 -151
  264. package/tests/nested-includes.test.js +0 -617
  265. package/tests/pagination-boundaries.test.js +0 -154
  266. package/tests/pagination-contract.test.js +0 -122
  267. package/tests/pagination-cursor-multifield.test.js +0 -641
  268. package/tests/pagination-enhanced.test.js +0 -563
  269. package/tests/polymorphic-relationships.test.js +0 -347
  270. package/tests/polymorphic-simple.test.js +0 -148
  271. package/tests/positioning-advanced.test.js +0 -692
  272. package/tests/positioning-extended.test.js +0 -915
  273. package/tests/positioning-multifilter.test.js +0 -699
  274. package/tests/positioning.test.js +0 -560
  275. package/tests/post-commit-hooks.test.js +0 -120
  276. package/tests/projected-fields.test.js +0 -319
  277. package/tests/public-exports.test.js +0 -15
  278. package/tests/put-create-parity.test.js +0 -216
  279. package/tests/queries.test.js +0 -521
  280. package/tests/query-limits.test.js +0 -584
  281. package/tests/relationship-endpoints.test.js +0 -620
  282. package/tests/relationship-include-storage-adapter.test.js +0 -56
  283. package/tests/relationships.test.js +0 -681
  284. package/tests/rest-api.test.js +0 -337
  285. package/tests/return-record-settings.test.js +0 -184
  286. package/tests/row-policy.test.js +0 -489
  287. package/tests/s3-storage.test.js +0 -38
  288. package/tests/searchschema-knex-behavior.test.js +0 -187
  289. package/tests/searchschema-merge.test.js +0 -151
  290. package/tests/socketio.test.js +0 -860
  291. package/tests/storage-mapping.test.js +0 -362
  292. package/tests/temporal-boundaries.test.js +0 -208
  293. package/tests/test-development-guide.md +0 -370
  294. package/tests/virtual-fields.test.js +0 -404
@@ -1,641 +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: `category:${encodeURIComponent(secondPage.data[0].attributes.category)},name:${encodeURIComponent(secondPage.data[0].attributes.name)},id:${encodeURIComponent(secondPage.data[0].id)}`
399
- },
400
- sort: ['category', 'name']
401
- },
402
- simplified: false
403
- })
404
-
405
- // Verify we got different records going backwards
406
- const secondPageIds = secondPage.data.map(p => p.id)
407
- const previousPageIds = previousPage.data.map(p => p.id)
408
- assert(!secondPageIds.some(id => previousPageIds.includes(id)),
409
- 'Previous page should have different records than second page')
410
- })
411
- })
412
-
413
- describe('Edge Cases and Special Scenarios', () => {
414
- beforeEach(async () => {
415
- await cleanTables(knex, ['cursor_products', 'cursor_items'])
416
- })
417
-
418
- it('should handle all records having the same value in first sort field', async () => {
419
- // Create products all in the same category
420
- const products = [
421
- { name: 'Alpha', category: 'Electronics', brand: 'TechCo', price: 100, sku: 'TC-001' },
422
- { name: 'Beta', category: 'Electronics', brand: 'TechCo', price: 200, sku: 'TC-002' },
423
- { name: 'Gamma', category: 'Electronics', brand: 'GadgetInc', price: 150, sku: 'GI-001' },
424
- { name: 'Delta', category: 'Electronics', brand: 'GadgetInc', price: 250, sku: 'GI-002' },
425
- { name: 'Epsilon', category: 'Electronics', brand: 'TechCo', price: 300, sku: 'TC-003' }
426
- ]
427
-
428
- for (const product of products) {
429
- await api.resources.products.post({
430
- inputRecord: createJsonApiDocument('products', product),
431
- simplified: false
432
- })
433
- }
434
-
435
- // Sort by category (all same), then brand, then name
436
- const pageSize = 2
437
- const allProducts = []
438
- let cursor = null
439
-
440
- while (true) {
441
- const result = await api.resources.products.query({
442
- queryParams: {
443
- page: {
444
- size: pageSize,
445
- ...(cursor && { after: cursor })
446
- },
447
- sort: ['category', 'brand', 'name']
448
- },
449
- simplified: false
450
- })
451
-
452
- allProducts.push(...result.data)
453
-
454
- if (!result.meta.pagination.hasMore) {
455
- break
456
- }
457
- cursor = result.meta.pagination.cursor.next
458
- }
459
-
460
- assert.equal(allProducts.length, 5, 'Should get all 5 products')
461
-
462
- // Check order: should be grouped by brand, then by name
463
- const productInfo = allProducts.map(p => ({
464
- brand: p.attributes.brand,
465
- name: p.attributes.name
466
- }))
467
-
468
- // GadgetInc should come before TechCo
469
- const gadgetIncIndex = productInfo.findIndex(p => p.brand === 'GadgetInc')
470
- const firstTechCoIndex = productInfo.findIndex(p => p.brand === 'TechCo')
471
- assert(gadgetIncIndex < firstTechCoIndex, 'GadgetInc products should come before TechCo')
472
- })
473
-
474
- it('should handle empty results with multi-field cursor', async () => {
475
- // Create one product
476
- await api.resources.products.post({
477
- inputRecord: createJsonApiDocument('products', {
478
- name: 'Single Product',
479
- category: 'Misc',
480
- brand: 'Generic',
481
- price: 10,
482
- sku: 'GEN-001'
483
- }),
484
- simplified: false
485
- })
486
-
487
- // Get the first (and only) page
488
- const firstPage = await api.resources.products.query({
489
- queryParams: {
490
- page: { size: 5 },
491
- sort: ['category', 'name']
492
- },
493
- simplified: false
494
- })
495
-
496
- assert.equal(firstPage.data.length, 1)
497
- assert.equal(firstPage.meta.pagination.hasMore, false)
498
- assert(!firstPage.meta.pagination.cursor?.next, 'Should not have next cursor on last page')
499
- })
500
-
501
- it('should handle special characters in multi-field cursors', async () => {
502
- // Create products with special characters
503
- const products = [
504
- { name: 'Product: Special', category: 'Cat:1', brand: 'Brand,A', price: 10, sku: 'SP-001' },
505
- { name: 'Product: Extra', category: 'Cat:1', brand: 'Brand,B', price: 20, sku: 'SP-002' },
506
- { name: 'Product: Normal', category: 'Cat:2', brand: 'Brand,A', price: 15, sku: 'SP-003' }
507
- ]
508
-
509
- for (const product of products) {
510
- await api.resources.products.post({
511
- inputRecord: createJsonApiDocument('products', product),
512
- simplified: false
513
- })
514
- }
515
-
516
- // Navigate through pages
517
- const firstPage = await api.resources.products.query({
518
- queryParams: {
519
- page: { size: 1 },
520
- sort: ['category', 'brand', 'name']
521
- },
522
- simplified: false
523
- })
524
-
525
- assert(firstPage.meta.pagination.cursor?.next, 'Should have cursor')
526
-
527
- // Use the cursor to get next page - should not throw
528
- const secondPage = await api.resources.products.query({
529
- queryParams: {
530
- page: {
531
- size: 1,
532
- after: firstPage.meta.pagination.cursor.next
533
- },
534
- sort: ['category', 'brand', 'name']
535
- },
536
- simplified: false
537
- })
538
-
539
- assert.equal(secondPage.data.length, 1)
540
- assert.notEqual(secondPage.data[0].id, firstPage.data[0].id,
541
- 'Should get different record on second page')
542
- })
543
-
544
- it('should maintain stable sorting with custom id property', async () => {
545
- // Create items using the resource with custom ID property
546
- const items = [
547
- { code: 'A001', name: 'Apple', category: 'Fruit', type: 'Fresh' },
548
- { code: 'A002', name: 'Apricot', category: 'Fruit', type: 'Fresh' },
549
- { code: 'B001', name: 'Banana', category: 'Fruit', type: 'Fresh' },
550
- { code: 'C001', name: 'Carrot', category: 'Vegetable', type: 'Root' }
551
- ]
552
-
553
- for (const item of items) {
554
- await api.resources.items.post({
555
- inputRecord: createJsonApiDocument('items', item),
556
- simplified: false
557
- })
558
- }
559
-
560
- // Query with multi-field sort
561
- const result = await api.resources.items.query({
562
- queryParams: {
563
- page: { size: 2 },
564
- sort: ['category', 'name']
565
- },
566
- simplified: false
567
- })
568
-
569
- // Should use 'item_id' as the ID field in responses
570
- assert(result.data[0].id, 'Should have id field')
571
- assert(typeof result.data[0].id === 'string', 'ID should be a string')
572
-
573
- // Cursor should work with custom ID
574
- if (result.meta.pagination.cursor?.next) {
575
- const secondPage = await api.resources.items.query({
576
- queryParams: {
577
- page: {
578
- size: 2,
579
- after: result.meta.pagination.cursor.next
580
- },
581
- sort: ['category', 'name']
582
- },
583
- simplified: false
584
- })
585
-
586
- assert(secondPage.data.length > 0, 'Should get results on second page')
587
- }
588
- })
589
-
590
- it('should handle the exact bug scenario - multiple fruits with same category', async () => {
591
- // This is the exact scenario from the bug report
592
- await cleanTables(knex, ['cursor_products'])
593
-
594
- const products = [
595
- { name: 'Apple', category: 'Fruit', brand: 'Farm Fresh', price: 2.00, sku: 'FRUIT-001' },
596
- { name: 'Banana', category: 'Fruit', brand: 'Farm Fresh', price: 1.50, sku: 'FRUIT-002' },
597
- { name: 'Cherry', category: 'Fruit', brand: 'Farm Fresh', price: 3.00, sku: 'FRUIT-003' },
598
- { name: 'Date', category: 'Fruit', brand: 'Desert Farm', price: 4.00, sku: 'FRUIT-004' },
599
- { name: 'Asparagus', category: 'Vegetable', brand: 'Green Farm', price: 2.50, sku: 'VEG-001' }
600
- ]
601
-
602
- for (const product of products) {
603
- await api.resources.products.post({
604
- inputRecord: createJsonApiDocument('products', product),
605
- simplified: false
606
- })
607
- }
608
-
609
- // Get first page with size 3 (should get Apple, Banana, Cherry)
610
- const firstPage = await api.resources.products.query({
611
- queryParams: {
612
- page: { size: 3 },
613
- sort: ['category', 'name']
614
- },
615
- simplified: false
616
- })
617
-
618
- assert.equal(firstPage.data.length, 3)
619
- const firstPageNames = firstPage.data.map(p => p.attributes.name)
620
- assert.deepEqual(firstPageNames, ['Apple', 'Banana', 'Cherry'])
621
-
622
- // Get second page - MUST include Date (the bug would skip it)
623
- const secondPage = await api.resources.products.query({
624
- queryParams: {
625
- page: {
626
- size: 3,
627
- after: firstPage.meta.pagination.cursor.next
628
- },
629
- sort: ['category', 'name']
630
- },
631
- simplified: false
632
- })
633
-
634
- const secondPageNames = secondPage.data.map(p => p.attributes.name)
635
- assert(secondPageNames.includes('Date'),
636
- 'Second page MUST include Date - this was the bug where it would be skipped')
637
- assert.deepEqual(secondPageNames, ['Date', 'Asparagus'],
638
- 'Second page should have Date and Asparagus in that order')
639
- })
640
- })
641
- })