json-rest-api 1.0.29 → 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 (292) 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 +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} +74 -30
  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 -177
  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 +48 -317
  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 +134 -37
  91. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  92. package/plugins/core/lib/querying/sort-helpers.js +13 -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 +179 -194
  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 +100 -290
  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 +93 -38
  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 +128 -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 -2485
  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 -1077
  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 -395
  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 -148
  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 +36 -68
  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 +37 -81
  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 -1247
  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 -148
  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 -155
  251. package/tests/fixtures/api-configs.js +0 -2566
  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-boundaries.test.js +0 -154
  264. package/tests/pagination-contract.test.js +0 -122
  265. package/tests/pagination-cursor-multifield.test.js +0 -641
  266. package/tests/pagination-enhanced.test.js +0 -563
  267. package/tests/polymorphic-relationships.test.js +0 -347
  268. package/tests/polymorphic-simple.test.js +0 -148
  269. package/tests/positioning-advanced.test.js +0 -692
  270. package/tests/positioning-extended.test.js +0 -915
  271. package/tests/positioning-multifilter.test.js +0 -699
  272. package/tests/positioning.test.js +0 -560
  273. package/tests/post-commit-hooks.test.js +0 -120
  274. package/tests/projected-fields.test.js +0 -319
  275. package/tests/public-exports.test.js +0 -15
  276. package/tests/put-create-parity.test.js +0 -216
  277. package/tests/queries.test.js +0 -521
  278. package/tests/query-limits.test.js +0 -584
  279. package/tests/relationship-endpoints.test.js +0 -620
  280. package/tests/relationship-include-storage-adapter.test.js +0 -56
  281. package/tests/relationships.test.js +0 -681
  282. package/tests/rest-api.test.js +0 -337
  283. package/tests/return-record-settings.test.js +0 -184
  284. package/tests/row-policy.test.js +0 -489
  285. package/tests/s3-storage.test.js +0 -38
  286. package/tests/searchschema-knex-behavior.test.js +0 -187
  287. package/tests/searchschema-merge.test.js +0 -151
  288. package/tests/socketio.test.js +0 -860
  289. package/tests/storage-mapping.test.js +0 -362
  290. package/tests/temporal-boundaries.test.js +0 -208
  291. package/tests/test-development-guide.md +0 -370
  292. 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
- })