json-rest-api 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.claude/agents/library-guide-expert.md +29 -0
  2. package/.claude/agents/rest-api-internals-expert.md +53 -0
  3. package/CLAUDE.md +139 -1
  4. package/README.md +2 -5
  5. package/docTest.js +238 -0
  6. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
  7. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
  8. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
  9. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
  10. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
  11. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
  12. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
  13. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
  14. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
  15. package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
  16. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
  17. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
  18. package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
  19. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
  20. package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
  21. package/docs/GUIDE/index.md +58 -0
  22. package/docs/GUIDE.md +61 -2238
  23. package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
  24. package/docs/README.md +57 -2239
  25. package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
  26. package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
  27. package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
  28. package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
  29. package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
  30. package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
  31. package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
  32. package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
  33. package/docs/assets/css/style.scss +78 -5
  34. package/docs/index.md +2 -12
  35. package/helper-reorganization-plan.md +85 -0
  36. package/helper-usage-analysis.md +94 -0
  37. package/package.json +5 -4
  38. package/plugins/core/bulk-operations-plugin.js +2 -2
  39. package/plugins/core/connectors/express-plugin.js +99 -33
  40. package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
  41. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
  42. package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
  43. package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
  44. package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
  45. package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
  46. package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
  47. package/plugins/core/lib/querying/knex-window-queries.js +328 -0
  48. package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
  49. package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
  50. package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
  51. package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
  52. package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
  53. package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
  54. package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
  55. package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
  56. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
  57. package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
  58. package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
  59. package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
  60. package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
  61. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
  62. package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
  63. package/plugins/core/lib/writing/relationship-processor.js +252 -0
  64. package/plugins/core/rest-api-knex-plugin.js +183 -62
  65. package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
  66. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
  67. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
  68. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
  69. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
  70. package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
  71. package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
  72. package/plugins/core/rest-api-plugin-methods/common.js +605 -0
  73. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
  74. package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
  75. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
  76. package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
  77. package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
  78. package/plugins/core/rest-api-plugin-methods/get.js +187 -0
  79. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
  80. package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
  81. package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
  82. package/plugins/core/rest-api-plugin-methods/post.js +157 -0
  83. package/plugins/core/rest-api-plugin-methods/put.js +257 -0
  84. package/plugins/core/rest-api-plugin-methods/query.js +208 -0
  85. package/plugins/core/rest-api-plugin.js +108 -2498
  86. package/plugins/core/socketio-plugin.js +28 -5
  87. package/plugins/storage/local-storage.js +2 -2
  88. package/tests/computed-fields.test.js +483 -0
  89. package/tests/cors.test.js +4 -4
  90. package/tests/curl.test.js +5 -4
  91. package/tests/custom-idproperty-relationships.test.js +891 -0
  92. package/tests/field-getters.test.js +353 -0
  93. package/tests/field-setters.test.js +310 -0
  94. package/tests/fixtures/api-configs.js +800 -28
  95. package/tests/jwt-auth.test.js +0 -2
  96. package/tests/nested-includes.test.js +1 -1
  97. package/tests/pagination-cursor-multifield.test.js +514 -0
  98. package/tests/pagination-enhanced.test.js +12 -9
  99. package/tests/polymorphic-relationships.test.js +10 -8
  100. package/tests/polymorphic-simple.test.js +6 -4
  101. package/tests/positioning-advanced.test.js +12 -12
  102. package/tests/positioning-extended.test.js +25 -25
  103. package/tests/positioning-multifilter.test.js +24 -24
  104. package/tests/positioning.test.js +3 -3
  105. package/tests/query-limits.test.js +24 -8
  106. package/tests/relationship-endpoints.test.js +4 -5
  107. package/tests/relationships.test.js +0 -1
  108. package/tests/socketio.test.js +12 -1
  109. package/tests/virtual-fields.test.js +13 -13
  110. package/PLAN_FOR_WEBSOCKETS.md +0 -398
  111. package/README_1_Initial_Setup.md +0 -393
  112. package/README_X_Hooks.md +0 -647
  113. package/TODO_rest-api-plugin-extras.todo +0 -23
  114. package/connectors_ideas/express-plugin.js +0 -120
  115. package/connectors_ideas/http-plugin.js +0 -138
  116. package/docs/examples/express-plugin.js +0 -366
  117. package/example.js +0 -301
  118. package/plugins/core/HELPER_MODULES.md +0 -285
  119. package/plugins/core/lib/compile-schemas.js +0 -245
  120. package/plugins/core/lib/database-value-normalizers.js +0 -192
  121. package/plugins/core/lib/default-data-helpers.js +0 -100
  122. package/plugins/core/lib/knex-cross-table-search.js +0 -442
  123. package/plugins/core/lib/knex-json-api-helpers.js +0 -84
  124. package/plugins/core/lib/knex-json-api-transformers.js +0 -449
  125. package/plugins/core/lib/knex-pagination-helpers.js +0 -193
  126. package/plugins/core/lib/knex-window-queries.js +0 -142
  127. package/plugins/core/lib/many-to-many-manipulations.js +0 -343
  128. package/plugins/core/lib/relationship-processor.js +0 -278
  129. package/plugins/core/lib/schema-helpers.js +0 -343
  130. package/plugins/core/lib/scope-validations.js +0 -252
  131. package/plugins/core/rest-api-relationships-plugin.js +0 -700
  132. package/plugins/core/utils/knex-query-helpers-base.js +0 -56
  133. /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
  134. /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
  135. /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
  136. /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
  137. /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
  138. /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
  139. /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
  140. /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
  141. /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
  142. /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
  143. /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
  144. /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
  145. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
  146. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Calculates pagination metadata from query results
3
+ *
4
+ * @param {number} total - Total number of records in the dataset
5
+ * @param {number} page - Current page number (1-based)
6
+ * @param {number} pageSize - Number of records per page
7
+ * @returns {Object} Pagination metadata including page count and hasMore flag
8
+ * @throws {Error} If pageSize <= 0 or page < 1
9
+ *
10
+ * @example
11
+ * // Input: 100 total records, viewing page 2 of 20 records each
12
+ * const meta = calculatePaginationMeta(100, 2, 20);
13
+ * // Output:
14
+ * // {
15
+ * // page: 2, // current page
16
+ * // pageSize: 20, // records per page
17
+ * // pageCount: 5, // total pages (100/20)
18
+ * // total: 100, // total records
19
+ * // hasMore: true // page 2 < 5, so more pages exist
20
+ * // }
21
+ *
22
+ * @example
23
+ * // Input: Last page scenario
24
+ * const meta = calculatePaginationMeta(45, 5, 10);
25
+ * // Output:
26
+ * // {
27
+ * // page: 5,
28
+ * // pageSize: 10,
29
+ * // pageCount: 5, // 45/10 = 4.5, rounds up to 5
30
+ * // total: 45,
31
+ * // hasMore: false // on last page
32
+ * // }
33
+ *
34
+ * @description
35
+ * Used by:
36
+ * - rest-api-knex-plugin's dataQuery method after counting total records
37
+ * - generatePaginationLinks to build navigation links
38
+ * - Applied when offset-based pagination is enabled
39
+ *
40
+ * Purpose:
41
+ * - Provides consistent pagination metadata across all collection responses
42
+ * - Calculates derived values like pageCount and hasMore flag
43
+ * - Validates pagination parameters to prevent invalid states
44
+ *
45
+ * Data flow:
46
+ * 1. Query method counts total records when pagination.counts is enabled
47
+ * 2. calculatePaginationMeta processes the count with current page/size
48
+ * 3. Returns metadata that goes into response.meta.pagination
49
+ * 4. Used by generatePaginationLinks to determine which links to include
50
+ */
51
+ export const calculatePaginationMeta = (total, page, pageSize) => {
52
+ if (pageSize <= 0) {
53
+ throw new Error('Page size must be greater than 0');
54
+ }
55
+ if (page < 1) {
56
+ throw new Error('Page number must be greater than 0');
57
+ }
58
+
59
+ const pageCount = Math.ceil(total / pageSize);
60
+ const currentPage = page || 1;
61
+ const hasMore = currentPage < pageCount;
62
+
63
+ return {
64
+ page: currentPage,
65
+ pageSize,
66
+ pageCount,
67
+ total,
68
+ hasMore
69
+ };
70
+ };
71
+
72
+ /**
73
+ * Generates JSON:API compliant pagination links for offset-based pagination
74
+ *
75
+ * @param {string} urlPrefix - Base URL prefix for the API
76
+ * @param {string} scopeName - Resource type name
77
+ * @param {Object} queryParams - Current query parameters
78
+ * @param {Object} paginationMeta - Pagination metadata from calculatePaginationMeta
79
+ * @returns {Object|null} Links object with self, first, last, prev, next
80
+ *
81
+ * @example
82
+ * // Input data:
83
+ * const urlPrefix = '/api/v1';
84
+ * const scopeName = 'articles';
85
+ * const queryParams = {
86
+ * filter: { status: 'published' },
87
+ * sort: ['-created_at'],
88
+ * page: { number: 2, size: 20 }
89
+ * };
90
+ * const paginationMeta = {
91
+ * page: 2,
92
+ * pageSize: 20,
93
+ * pageCount: 5,
94
+ * total: 100,
95
+ * hasMore: true
96
+ * };
97
+ *
98
+ * const links = generatePaginationLinks(urlPrefix, scopeName, queryParams, paginationMeta);
99
+ *
100
+ * // Output - complete URLs with all parameters preserved:
101
+ * // {
102
+ * // self: '/api/v1/articles?filter[status]=published&sort=-created_at&page[number]=2&page[size]=20',
103
+ * // first: '/api/v1/articles?filter[status]=published&sort=-created_at&page[number]=1&page[size]=20',
104
+ * // last: '/api/v1/articles?filter[status]=published&sort=-created_at&page[number]=5&page[size]=20',
105
+ * // prev: '/api/v1/articles?filter[status]=published&sort=-created_at&page[number]=1&page[size]=20',
106
+ * // next: '/api/v1/articles?filter[status]=published&sort=-created_at&page[number]=3&page[size]=20'
107
+ * // }
108
+ *
109
+ * @example
110
+ * // Input: First page (no prev link)
111
+ * const paginationMeta = { page: 1, pageSize: 10, pageCount: 3 };
112
+ * const links = generatePaginationLinks('/api', 'users', {}, paginationMeta);
113
+ *
114
+ * // Output - notice no 'prev' property:
115
+ * // {
116
+ * // self: '/api/users?page[number]=1&page[size]=10',
117
+ * // first: '/api/users?page[number]=1&page[size]=10',
118
+ * // last: '/api/users?page[number]=3&page[size]=10',
119
+ * // next: '/api/users?page[number]=2&page[size]=10'
120
+ * // }
121
+ *
122
+ * @description
123
+ * Used by:
124
+ * - rest-api-knex-plugin's dataQuery adds these links to response.links
125
+ * - Called when offset-based pagination is used (not cursor-based)
126
+ *
127
+ * Purpose:
128
+ * - JSON:API spec requires pagination links for easy navigation
129
+ * - Preserves all other query parameters (filters, sorts, includes)
130
+ * - Handles complex nested parameters like filter[author][name]=John
131
+ * - Only includes prev/next when applicable
132
+ *
133
+ * Data flow:
134
+ * 1. After records are fetched and pagination calculated
135
+ * 2. Builds complete URLs preserving all query parameters
136
+ * 3. Conditionally includes prev (not on page 1) and next (not on last page)
137
+ * 4. Added to response.links for client navigation
138
+ */
139
+ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, paginationMeta) => {
140
+ if (!urlPrefix) return null;
141
+
142
+ const { page, pageCount, pageSize } = paginationMeta;
143
+ const links = {};
144
+
145
+ const otherParams = Object.entries(queryParams)
146
+ .filter(([key]) => key !== 'page')
147
+ .map(([key, value]) => {
148
+ if (Array.isArray(value)) {
149
+ return value.map(v => `${key}=${encodeURIComponent(v)}`).join('&');
150
+ } else if (typeof value === 'object' && value !== null) {
151
+ const parts = [];
152
+ const processObject = (obj, prefix) => {
153
+ Object.entries(obj).forEach(([k, v]) => {
154
+ const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`;
155
+ if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
156
+ processObject(v, newKey);
157
+ } else {
158
+ parts.push(`${newKey}=${encodeURIComponent(v)}`);
159
+ }
160
+ });
161
+ };
162
+ processObject(value, key);
163
+ return parts.join('&');
164
+ }
165
+ return `${key}=${encodeURIComponent(value)}`;
166
+ })
167
+ .join('&');
168
+
169
+ const baseUrl = `${urlPrefix}/${scopeName}`;
170
+ const queryPrefix = otherParams ? `?${otherParams}&` : '?';
171
+
172
+ links.self = `${baseUrl}${queryPrefix}page[number]=${page}&page[size]=${pageSize}`;
173
+
174
+ if (pageCount !== undefined) {
175
+ links.first = `${baseUrl}${queryPrefix}page[number]=1&page[size]=${pageSize}`;
176
+ links.last = `${baseUrl}${queryPrefix}page[number]=${pageCount}&page[size]=${pageSize}`;
177
+
178
+ if (page > 1) {
179
+ links.prev = `${baseUrl}${queryPrefix}page[number]=${page - 1}&page[size]=${pageSize}`;
180
+ }
181
+
182
+ if (page < pageCount) {
183
+ links.next = `${baseUrl}${queryPrefix}page[number]=${page + 1}&page[size]=${pageSize}`;
184
+ }
185
+ }
186
+
187
+ return links;
188
+ };
189
+
190
+ /**
191
+ * Creates an opaque cursor string from record data for cursor-based pagination
192
+ *
193
+ * @param {Object} record - Database record to create cursor from
194
+ * @param {Array<string>} sortFields - Fields to include in cursor (default: ['id'])
195
+ * @returns {string} URL-safe cursor string
196
+ *
197
+ * @example
198
+ * // Input: Simple ID-based cursor
199
+ * const record = {
200
+ * id: 123,
201
+ * title: 'My Article',
202
+ * created_at: '2024-01-01T10:00:00Z'
203
+ * };
204
+ * const cursor = createCursor(record, ['id']);
205
+ *
206
+ * // Output: "id:123"
207
+ * // This cursor marks position at record with id=123
208
+ *
209
+ * @example
210
+ * // Input: Multi-field cursor for complex sorting
211
+ * const record = {
212
+ * id: 5,
213
+ * created_at: new Date('2024-01-15T08:30:00Z'),
214
+ * title: 'Article: Part 2'
215
+ * };
216
+ * const cursor = createCursor(record, ['created_at', 'id']);
217
+ *
218
+ * // Output: "created_at:2024-01-15T08%3A30%3A00.000Z,id:5"
219
+ * // URL-encoded to handle the colons in timestamp
220
+ * // Used for queries like: WHERE (created_at, id) > ('2024-01-15T08:30:00.000Z', 5)
221
+ *
222
+ * @example
223
+ * // Input: Handling special characters
224
+ * const record = {
225
+ * category: 'Tech & Science',
226
+ * title: 'AI: The Future?',
227
+ * id: 42
228
+ * };
229
+ * const cursor = createCursor(record, ['category', 'title', 'id']);
230
+ *
231
+ * // Output: "category:Tech%20%26%20Science,title:AI%3A%20The%20Future%3F,id:42"
232
+ * // Spaces encoded as %20, & as %26, : as %3A, ? as %3F
233
+ *
234
+ * @description
235
+ * Used by:
236
+ * - generateCursorPaginationLinks to create next/prev cursors
237
+ * - buildCursorMeta to provide cursor in response metadata
238
+ *
239
+ * Purpose:
240
+ * - Cursor-based pagination is more stable than offset for changing data
241
+ * - Encodes multiple sort fields to maintain stable ordering
242
+ * - URL-encodes values to handle special characters safely
243
+ * - Simple format that's easy to parse back
244
+ *
245
+ * Data flow:
246
+ * 1. After fetching records, takes the last record
247
+ * 2. Extracts values for all sort fields
248
+ * 3. Creates cursor encoding those values
249
+ * 4. Cursor used in 'next' link for fetching subsequent pages
250
+ * 5. Enables efficient "WHERE (field1, field2) > (val1, val2)" queries
251
+ */
252
+ export const createCursor = (record, sortFields = ['id']) => {
253
+ const parts = [];
254
+ sortFields.forEach(field => {
255
+ if (record[field] !== undefined) {
256
+ const value = record[field];
257
+ const stringValue = value instanceof Date ? value.toISOString() : String(value);
258
+ parts.push(`${field}:${encodeURIComponent(stringValue)}`);
259
+ }
260
+ });
261
+ return parts.join(',');
262
+ };
263
+
264
+ /**
265
+ * Parses a cursor string back into field/value pairs
266
+ *
267
+ * @param {string} cursor - Cursor string to parse
268
+ * @returns {Object} Object with field names as keys and decoded values
269
+ * @throws {Error} If cursor format is invalid
270
+ *
271
+ * @example
272
+ * // Input: Simple cursor
273
+ * const cursor = "id:123";
274
+ * const data = parseCursor(cursor);
275
+ *
276
+ * // Output: { id: "123" }
277
+ * // Ready to use in SQL: WHERE id > '123'
278
+ *
279
+ * @example
280
+ * // Input: Multi-field cursor with URL-encoded values
281
+ * const cursor = "created_at:2024-01-15T08%3A30%3A00.000Z,id:5";
282
+ * const data = parseCursor(cursor);
283
+ *
284
+ * // Output:
285
+ * // {
286
+ * // created_at: "2024-01-15T08:30:00.000Z", // Decoded
287
+ * // id: "5"
288
+ * // }
289
+ * // Used for: WHERE (created_at, id) > ('2024-01-15T08:30:00.000Z', '5')
290
+ *
291
+ * @example
292
+ * // Input: Invalid cursor (throws error)
293
+ * try {
294
+ * const data = parseCursor("invalid-no-colon");
295
+ * } catch (e) {
296
+ * console.log(e.message);
297
+ * // "Invalid cursor format: Invalid cursor format: missing colon separator"
298
+ * }
299
+ *
300
+ * @description
301
+ * Used by:
302
+ * - rest-api-knex-plugin's dataQuery when processing page[after] parameter
303
+ * - Used to build WHERE clause for cursor-based queries
304
+ *
305
+ * Purpose:
306
+ * - Decodes cursor back to usable values for SQL queries
307
+ * - Handles URL-encoded special characters
308
+ * - Validates cursor format to prevent injection attacks
309
+ *
310
+ * Data flow:
311
+ * 1. Client sends page[after]=cursor parameter
312
+ * 2. parseCursor extracts field values from cursor
313
+ * 3. Values used to build WHERE clause like "WHERE id > 123"
314
+ * 4. Ensures pagination continues from exact position
315
+ */
316
+ export const parseCursor = (cursor) => {
317
+ try {
318
+ const data = {};
319
+ if (!cursor || cursor.trim() === '') {
320
+ throw new Error('Empty cursor');
321
+ }
322
+
323
+ const pairs = cursor.split(',');
324
+
325
+ for (const pair of pairs) {
326
+ const colonIndex = pair.indexOf(':');
327
+ if (colonIndex === -1) {
328
+ throw new Error('Invalid cursor format: missing colon separator');
329
+ }
330
+
331
+ const field = pair.substring(0, colonIndex);
332
+ const encodedValue = pair.substring(colonIndex + 1);
333
+
334
+ if (!field) {
335
+ throw new Error('Invalid cursor format: empty field name');
336
+ }
337
+
338
+ data[field] = decodeURIComponent(encodedValue);
339
+ }
340
+
341
+ return data;
342
+ } catch (e) {
343
+ throw new Error(`Invalid cursor format: ${e.message}`);
344
+ }
345
+ };
346
+
347
+ /**
348
+ * Generates pagination links for cursor-based pagination
349
+ *
350
+ * @param {string} urlPrefix - Base URL prefix
351
+ * @param {string} scopeName - Resource type name
352
+ * @param {Object} queryParams - Current query parameters
353
+ * @param {Array<Object>} records - Current page records
354
+ * @param {number} pageSize - Records per page
355
+ * @param {boolean} hasMore - Whether more records exist
356
+ * @param {Array<string>} sortFields - Fields used for cursor
357
+ * @returns {Object|null} Links object with self, first, next
358
+ *
359
+ * @example
360
+ * // Input: First page of results
361
+ * const urlPrefix = '/api/v1';
362
+ * const scopeName = 'articles';
363
+ * const queryParams = {
364
+ * filter: { status: 'published' },
365
+ * page: { size: 20 }
366
+ * };
367
+ * const records = [
368
+ * { id: 1, created_at: '2024-01-01', title: 'First' },
369
+ * { id: 2, created_at: '2024-01-02', title: 'Second' },
370
+ * { id: 3, created_at: '2024-01-03', title: 'Third' }
371
+ * ];
372
+ * const hasMore = true; // More records exist
373
+ *
374
+ * const links = generateCursorPaginationLinks(
375
+ * urlPrefix, scopeName, queryParams, records, 20, hasMore, ['created_at', 'id']
376
+ * );
377
+ *
378
+ * // Output:
379
+ * // {
380
+ * // self: '/api/v1/articles?filter[status]=published&page[size]=20',
381
+ * // first: '/api/v1/articles?filter[status]=published&page[size]=20',
382
+ * // next: '/api/v1/articles?filter[status]=published&page[size]=20&page[after]=created_at:2024-01-03,id:3'
383
+ * // }
384
+ * // The 'next' cursor points after the last record (id:3)
385
+ *
386
+ * @example
387
+ * // Input: Last page (no more records)
388
+ * const records = [
389
+ * { id: 98, created_at: '2024-03-01' },
390
+ * { id: 99, created_at: '2024-03-02' }
391
+ * ];
392
+ * const hasMore = false; // No more records
393
+ *
394
+ * const links = generateCursorPaginationLinks(
395
+ * '/api', 'articles', {}, records, 20, hasMore, ['id']
396
+ * );
397
+ *
398
+ * // Output - no 'next' link:
399
+ * // {
400
+ * // self: '/api/articles?page[size]=20',
401
+ * // first: '/api/articles?page[size]=20'
402
+ * // }
403
+ *
404
+ * @description
405
+ * Used by:
406
+ * - rest-api-knex-plugin when cursor pagination is enabled
407
+ * - Called after fetching records with cursor-based query
408
+ *
409
+ * Purpose:
410
+ * - Cursor pagination is more efficient for large datasets
411
+ * - Stable pagination when data is being added/removed
412
+ * - Only includes 'next' link when more data exists
413
+ * - Preserves all other query parameters
414
+ *
415
+ * Data flow:
416
+ * 1. After cursor-based query fetches records
417
+ * 2. If hasMore is true, creates cursor from last record
418
+ * 3. Builds next link with page[after] parameter
419
+ * 4. Added to response.links for navigation
420
+ * 5. Client uses next link to fetch subsequent pages
421
+ */
422
+ export const generateCursorPaginationLinks = (urlPrefix, scopeName, queryParams, records, pageSize, hasMore, sortFields = ['id']) => {
423
+ if (!urlPrefix || !records.length) return null;
424
+
425
+ const links = {};
426
+ const baseUrl = `${urlPrefix}/${scopeName}`;
427
+
428
+ const otherParams = Object.entries(queryParams)
429
+ .filter(([key]) => key !== 'page')
430
+ .map(([key, value]) => {
431
+ if (Array.isArray(value)) {
432
+ return value.map(v => `${key}=${encodeURIComponent(v)}`).join('&');
433
+ } else if (typeof value === 'object' && value !== null) {
434
+ const parts = [];
435
+ const processObject = (obj, prefix) => {
436
+ Object.entries(obj).forEach(([k, v]) => {
437
+ const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`;
438
+ if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
439
+ processObject(v, newKey);
440
+ } else {
441
+ parts.push(`${newKey}=${encodeURIComponent(v)}`);
442
+ }
443
+ });
444
+ };
445
+ processObject(value, key);
446
+ return parts.join('&');
447
+ }
448
+ return `${key}=${encodeURIComponent(value)}`;
449
+ })
450
+ .join('&');
451
+
452
+ const queryPrefix = otherParams ? `?${otherParams}&` : '?';
453
+
454
+ if (queryParams.page?.after) {
455
+ links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[after]=${queryParams.page.after}`;
456
+ } else if (queryParams.page?.before) {
457
+ links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[before]=${queryParams.page.before}`;
458
+ } else {
459
+ links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}`;
460
+ }
461
+
462
+ links.first = `${baseUrl}${queryPrefix}page[size]=${pageSize}`;
463
+
464
+ if (hasMore && records.length > 0) {
465
+ const lastRecord = records[records.length - 1];
466
+ const nextCursor = createCursor(lastRecord, sortFields);
467
+ links.next = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[after]=${nextCursor}`;
468
+ }
469
+
470
+ return links;
471
+ };
472
+
473
+ /**
474
+ * Builds cursor pagination metadata for the response
475
+ *
476
+ * @param {Array<Object>} records - Current page records
477
+ * @param {number} pageSize - Records per page
478
+ * @param {boolean} hasMore - Whether more records exist
479
+ * @param {Array<string>} sortFields - Fields used for cursor
480
+ * @returns {Object} Metadata with pageSize, hasMore, and optional cursor
481
+ *
482
+ * @example
483
+ * // Input: Page with more records available
484
+ * const records = [
485
+ * { id: 10, created_at: '2024-01-10', title: 'Article 10' },
486
+ * { id: 11, created_at: '2024-01-11', title: 'Article 11' },
487
+ * { id: 12, created_at: '2024-01-12', title: 'Article 12' }
488
+ * ];
489
+ * const meta = buildCursorMeta(records, 20, true, ['created_at', 'id']);
490
+ *
491
+ * // Output:
492
+ * // {
493
+ * // pageSize: 20,
494
+ * // hasMore: true,
495
+ * // cursor: {
496
+ * // next: 'created_at:2024-01-12,id:12' // Cursor from last record
497
+ * // }
498
+ * // }
499
+ *
500
+ * @example
501
+ * // Input: Last page (no more records)
502
+ * const records = [
503
+ * { id: 98, name: 'Last Item' }
504
+ * ];
505
+ * const meta = buildCursorMeta(records, 20, false, ['id']);
506
+ *
507
+ * // Output:
508
+ * // {
509
+ * // pageSize: 20,
510
+ * // hasMore: false
511
+ * // // No cursor property since hasMore is false
512
+ * // }
513
+ *
514
+ * @description
515
+ * Used by:
516
+ * - rest-api-knex-plugin adds this to response.meta for cursor pagination
517
+ * - Provides cursor that client can use directly if needed
518
+ *
519
+ * Purpose:
520
+ * - Gives clients direct access to cursor for custom pagination
521
+ * - Indicates whether more pages exist without counting
522
+ * - Simpler than offset pagination metadata (no total count)
523
+ *
524
+ * Data flow:
525
+ * 1. Called after fetching records with cursor query
526
+ * 2. Creates cursor from last record if more exist
527
+ * 3. Added to response.meta.pagination
528
+ * 4. Clients can use cursor directly or use the next link
529
+ */
530
+ export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id']) => {
531
+ const meta = {
532
+ pageSize,
533
+ hasMore
534
+ };
535
+
536
+ if (hasMore && records.length > 0) {
537
+ const lastRecord = records[records.length - 1];
538
+ meta.cursor = {
539
+ next: createCursor(lastRecord, sortFields)
540
+ };
541
+ }
542
+
543
+ return meta;
544
+ };