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,665 +1,220 @@
1
- import {
2
- calculatePosition,
3
- getInitialPosition,
4
- } from './lib/fractional-positioning.js'
1
+ import { createHash } from 'node:crypto'
2
+ import { calculatePosition, getInitialPosition, isValidPosition } from './lib/fractional-positioning.js'
5
3
  import { createStorageAdapter } from './lib/storage/storage-adapter.js'
4
+ import { hasKnexTableIndex } from './lib/dbIntrospection.js'
5
+ import { applyDatabaseReadOptions, databaseIdentityExpression } from './lib/querying-writing/database-value-normalizers.js'
6
+ import { RestApiValidationError } from '../../lib/rest-api-errors.js'
7
+ import { errorMessage, getOperationDiagnosticContext, isRestApiError } from '../../lib/error-context.js'
8
+ import { createEnhancedLogger } from '../../lib/enhanced-logger.js'
9
+
10
+ const lockTable = 'json_rest_api_positioning_locks'
11
+
12
+ async function createLockTable (knex) {
13
+ if (await knex.schema.hasTable(lockTable)) return
14
+ try {
15
+ await knex.schema.createTable(lockTable, table => table.string('resource', 64).primary())
16
+ } catch (error) {
17
+ // Another application process may have initialized the same database.
18
+ if (!await knex.schema.hasTable(lockTable)) throw error
19
+ }
20
+ }
21
+
22
+ function positionExpression (knex, column) {
23
+ const client = knex.client.config.client
24
+ if (client === 'pg' || client === 'postgresql') return knex.raw('?? COLLATE "C"', [column])
25
+ if (client === 'mysql2' || client === 'mysql') return knex.raw('BINARY ??', [column])
26
+ return knex.raw('?? COLLATE BINARY', [column])
27
+ }
6
28
 
7
29
  export const PositioningPlugin = {
8
30
  name: 'positioning',
9
- dependencies: ['rest-api', 'rest-api-knex|rest-api-anyapi-knex'],
10
-
11
- install ({ api, addHook, vars, helpers, log, scopes, pluginOptions }) {
12
- const installedPlugins = Array.from(api._installedPlugins || [])
13
- const legacyStorageInstalled = installedPlugins.includes('rest-api-knex')
14
- const canonicalStorageInstalled = installedPlugins.includes('rest-api-anyapi-knex')
15
- if (!legacyStorageInstalled && !canonicalStorageInstalled) {
16
- throw new Error(
17
- "Positioning plugin requires either 'rest-api-knex' or 'rest-api-anyapi-knex' to be installed before it."
18
- )
19
- }
20
-
21
- if (!api.knex?.instance) {
22
- throw new Error('Positioning plugin requires a storage plugin with knex support (rest-api-knex or rest-api-anyapi-knex)')
31
+ dependencies: ['rest-api', ['rest-api-knex', 'rest-api-anyapi-knex']],
32
+
33
+ async install ({ api, addHook, vars, log, scopes, pluginOptions = {} }) {
34
+ log = createEnhancedLogger(log)
35
+ const knex = api.knex?.instance
36
+ if (!knex) throw new Error('Positioning requires Knex storage')
37
+ const config = {
38
+ field: 'position',
39
+ filters: [],
40
+ excludeResources: ['system_migrations', 'system_logs'],
41
+ beforeIdField: 'beforeId',
42
+ defaultPosition: 'last',
43
+ strategy: 'fractional',
44
+ autoIndex: true,
45
+ ...pluginOptions
23
46
  }
24
-
25
- // Get configuration - hooked-api namespaces options by plugin name
26
- const positioningOptions = pluginOptions || {}
27
-
28
- // Store configuration in vars (data only) so hooks can use it without re-reading plugin options.
29
- vars.positioning = {
30
- field: positioningOptions.field || 'position',
31
- filters: positioningOptions.filters || [],
32
- excludeResources: positioningOptions.excludeResources || ['system_migrations', 'system_logs'],
33
- strategy: positioningOptions.strategy || 'fractional',
34
- beforeIdField: positioningOptions.beforeIdField || 'beforeId',
35
- defaultPosition: positioningOptions.defaultPosition || 'last',
36
- autoIndex: positioningOptions.autoIndex !== undefined ? positioningOptions.autoIndex : true,
37
- rebalanceThreshold: positioningOptions.rebalanceThreshold || 50 // Max position string length
47
+ if (config.strategy !== 'fractional') throw new Error("Positioning supports only the 'fractional' strategy")
48
+ if (!['first', 'last'].includes(config.defaultPosition)) throw new Error("defaultPosition must be 'first' or 'last'")
49
+ if (Object.hasOwn(config, 'rebalanceThreshold')) throw new Error('rebalanceThreshold is not supported; automatic rebalancing is not implemented')
50
+ vars.positioning = config
51
+ await createLockTable(knex)
52
+
53
+ function enabled (scopeName) {
54
+ return !config.excludeResources.includes(scopeName) && Boolean(scopes[scopeName]?.vars.schemaInfo?.schemaStructure?.[config.field])
38
55
  }
39
56
 
40
- const resolveStorageAdapter = (scopeName) => {
41
- if (!scopeName) return null
42
- const scope = scopes[scopeName] || api.resources?.[scopeName]
43
- const scopeVars = scope?.vars
44
- if (!scopeVars) return null
45
-
46
- if (scopeVars.storageAdapter) {
47
- return scopeVars.storageAdapter
48
- }
49
-
50
- if (scopeVars.schemaInfo && api.knex?.instance) {
51
- const adapter = createStorageAdapter({ knex: api.knex.instance, schemaInfo: scopeVars.schemaInfo })
52
- scopeVars.storageAdapter = adapter
53
- return adapter
54
- }
55
-
56
- return null
57
- }
58
-
59
- // Validate configuration
60
- if (!['fractional', 'integer'].includes(vars.positioning.strategy)) {
61
- throw new Error(`Invalid positioning strategy: ${vars.positioning.strategy}. Must be 'fractional' or 'integer'`)
62
- }
63
-
64
- if (vars.positioning.strategy === 'integer') {
65
- throw new Error("Integer positioning strategy not yet implemented. Please use 'fractional'")
66
- }
67
-
68
- // Helper to check if a resource should have positioning
69
- function shouldHavePositioning (scopeName) {
70
- return !vars.positioning.excludeResources.includes(scopeName)
57
+ function adapterFor (scopeName) {
58
+ const scope = scopes[scopeName]
59
+ scope.vars.storageAdapter ||= createStorageAdapter({ knex, schemaInfo: scope.vars.schemaInfo })
60
+ return scope.vars.storageAdapter
71
61
  }
72
62
 
73
- // Helper to build filter conditions for position groups
74
- function buildPositionFilterConditions (adapter, schemaInfo, {
75
- attributes = {},
76
- rawRecord = {},
77
- simplifiedRecord = {},
78
- inputRelationships = {},
79
- minimalRelationships = {},
80
- }) {
81
- const conditions = {}
82
-
83
- log.debug('*** buildPositionFilterConditions', {
84
- filters: vars.positioning.filters,
85
- attributeKeys: Object.keys(attributes || {}),
86
- rawKeys: Object.keys(rawRecord || {}),
87
- simplifiedKeys: Object.keys(simplifiedRecord || {}),
88
- })
89
-
90
- const tryAssign = (holder, field) => (holder && field in holder ? holder[field] : undefined)
91
-
92
- vars.positioning.filters.forEach((filterField) => {
93
- const searchField = schemaInfo.searchSchemaStructure?.[filterField] || null
94
- const schemaField = schemaInfo.schemaStructure?.[filterField] || null
95
- const isRelationship = Boolean(
96
- searchField?.isRelationship ||
97
- schemaField?.belongsTo ||
98
- schemaField?.belongsToPolymorphic
99
- )
100
-
101
- if (!searchField && !schemaField) {
102
- log.warn(`Filter field '${filterField}' not found in search schema or resource schema for positioning`)
103
- }
104
-
105
- const dbField = searchField?.actualField || filterField
106
- let value
107
-
108
- const attempt = (candidate) => {
109
- if (value === undefined && candidate !== undefined) {
110
- value = candidate
111
- }
112
- }
113
-
114
- attempt(tryAssign(rawRecord, filterField))
115
- attempt(tryAssign(simplifiedRecord, filterField))
116
- attempt(tryAssign(rawRecord, dbField))
117
- attempt(tryAssign(attributes, filterField))
118
- attempt(tryAssign(attributes, dbField))
119
-
120
- if (isRelationship) {
121
- attempt(inputRelationships?.[filterField]?.data?.id)
122
- attempt(minimalRelationships?.[filterField]?.data?.id)
123
- // Some contexts may expose relationship data under attributes
124
- attempt(tryAssign(rawRecord?.relationships, filterField)?.data?.id)
125
- }
126
-
127
- if (value !== undefined) {
128
- const column = adapter.translateColumn(dbField)
129
- const normalizedValue = value === null
130
- ? null
131
- : adapter.translateFilterValue(filterField, value)
132
- conditions[column] = normalizedValue
133
- }
63
+ function filterFields (schemaInfo) {
64
+ return config.filters.map(name => {
65
+ const field = schemaInfo.searchSchemaStructure?.[name]?.actualField ||
66
+ Object.keys(schemaInfo.schemaStructure).find(key => schemaInfo.schemaStructure[key].as === name) || name
67
+ if (!schemaInfo.schemaStructure[field]) throw new Error(`Unknown positioning group field '${name}'`)
68
+ return { name, field }
134
69
  })
135
-
136
- log.debug('*** Filter conditions built', { conditions })
137
- return conditions
138
70
  }
139
71
 
140
- // Validate that resources have position field when added
141
- addHook('scope:added', 'validate-position-field', {}, ({ context, vars }) => {
142
- const { scopeName, scopeOptions } = context
143
-
144
- // Skip excluded resources
145
- if (!shouldHavePositioning(scopeName)) {
146
- log.debug(`Resource ${scopeName} excluded from positioning`)
147
- return
148
- }
149
-
150
- // Check if schema has position field
151
- const schema = scopeOptions.schema
152
- if (schema && !schema[vars.positioning.field]) {
153
- throw new Error(
154
- `Resource '${scopeName}' must have '${vars.positioning.field}' field in schema to use positioning plugin`
155
- )
72
+ addHook('resource:added', 'validate-position-field', {}, ({ context }) => {
73
+ if (config.excludeResources.includes(context.scopeName)) return
74
+ const schemaInfo = scopes[context.scopeName].vars.schemaInfo
75
+ const definition = schemaInfo.schemaStructure[config.field]
76
+ if (!definition || definition.type !== 'string') {
77
+ throw new Error(`Resource '${context.scopeName}' must declare a string '${config.field}' field for positioning`)
156
78
  }
157
-
158
- // Validate filter fields exist (check both relationship names and actual field names)
159
- vars.positioning.filters.forEach(filterField => {
160
- // Check if it's a relationship field by looking for a field with matching 'as' property
161
- let fieldExists = false
162
-
163
- // First check if the field exists directly in the schema
164
- if (schema[filterField]) {
165
- fieldExists = true
166
- } else {
167
- // Check if it's a relationship name (as property)
168
- for (const [, fieldDef] of Object.entries(schema)) {
169
- if (fieldDef.as === filterField) {
170
- fieldExists = true
171
- break
172
- }
173
- }
174
- }
175
-
176
- if (!fieldExists) {
177
- throw new Error(
178
- `Resource '${scopeName}' must have '${filterField}' field or relationship in schema for position filtering`
179
- )
180
- }
181
- })
79
+ if (definition.setter) throw new Error(`Positioning field '${config.field}' cannot have a setter`)
80
+ filterFields(schemaInfo)
182
81
  })
183
82
 
184
- // Add index for position field if autoIndex is enabled
185
- addHook('scope:added', 'add-position-index', { afterFunction: 'validate-position-field' }, async ({ context, vars, scopes }) => {
186
- const { scopeName } = context
187
-
188
- if (!shouldHavePositioning(scopeName) || !vars.positioning.autoIndex) {
189
- return
190
- }
191
-
192
- const scope = scopes[scopeName]
193
- const schemaInfo = scope.vars.schemaInfo
194
-
195
- if (!schemaInfo || !api.knex?.instance) {
196
- return // Skip if no database connection
197
- }
198
-
83
+ addHook('resource:added', 'add-position-index', { afterFunction: 'validate-position-field' }, async ({ context }) => {
84
+ if (!enabled(context.scopeName) || !config.autoIndex) return
85
+ const adapter = adapterFor(context.scopeName)
86
+ if (adapter.isCanonical()) return
87
+ const tableName = adapter.getTableName()
88
+ if (!await knex.schema.hasTable(tableName)) return
89
+ const columns = [...new Set([
90
+ ...filterFields(scopes[context.scopeName].vars.schemaInfo).map(({ field }) => adapter.translateColumn(field)),
91
+ adapter.translateColumn(config.field)
92
+ ])]
199
93
  try {
200
- const adapter = resolveStorageAdapter(scopeName)
201
- if (adapter.isCanonical()) {
202
- log.debug(`Skipping positioning index for canonical storage on ${scopeName}`)
203
- return
204
- }
205
-
206
- const knex = api.knex.instance
207
- const tableName = adapter.getTableName()
208
-
209
- // Check if table exists first
210
- const tableExists = await knex.schema.hasTable(tableName)
211
- if (!tableExists) {
212
- log.debug(`Table ${tableName} doesn't exist yet, skipping index creation`)
213
- return
214
- }
215
-
216
- // Build index columns: map filter fields to actual database columns
217
- const indexColumns = new Set()
218
-
219
- // Map each filter field to its database column
220
- vars.positioning.filters.forEach(filterField => {
221
- const searchField = schemaInfo.searchSchemaStructure?.[filterField]
222
- if (searchField) {
223
- // Use actualField if it exists (for relationships)
224
- const dbField = searchField.actualField || filterField
225
- indexColumns.add(adapter.translateColumn(dbField))
226
- } else if (schemaInfo.schemaStructure[filterField]) {
227
- // Direct database field
228
- indexColumns.add(adapter.translateColumn(filterField))
229
- }
230
- })
231
-
232
- // Add the position field
233
- indexColumns.add(adapter.translateColumn(vars.positioning.field))
234
-
235
- const indexColumnList = [...indexColumns].filter(Boolean)
236
-
237
- const indexName = `idx_${tableName}_positioning`
238
-
239
- // Check if index already exists
240
- const hasIndex = await knex.schema.hasIndex(tableName, indexColumnList, indexName)
241
-
242
- if (!hasIndex) {
243
- await knex.schema.table(tableName, table => {
244
- table.index(indexColumnList, indexName)
245
- })
246
-
247
- log.info(`Created positioning index on ${tableName}`, { columns: indexColumnList })
94
+ const name = `idx_${tableName}_positioning`
95
+ if (!await hasKnexTableIndex(knex, tableName, name)) {
96
+ await knex.schema.table(tableName, table => table.index(columns, name))
248
97
  }
249
98
  } catch (error) {
250
- log.warn(`Could not create positioning index for ${scopeName}:`, error.message)
251
- }
252
- })
253
-
254
- // Process beforeId parameter in requests
255
- addHook('beforeSchemaValidate', 'process-beforeid', {}, async ({ context, scopeName, vars }) => {
256
- log.debug('*** beforeSchemaValidate positioning hook', {
257
- scopeName,
258
- method: context.method,
259
- shouldHavePositioning: shouldHavePositioning(scopeName)
260
- })
261
-
262
- // Skip excluded resources
263
- if (!shouldHavePositioning(scopeName)) {
264
- log.debug('*** Skipping beforeId processing - excluded resource')
265
- return
266
- }
267
-
268
- // Skip if no positioning context needed
269
- const method = context.method
270
- if (!['post', 'put', 'patch'].includes(method)) {
271
- log.debug('*** Skipping beforeId processing - wrong method', { method })
272
- return
273
- }
274
-
275
- // Check if resource has position field
276
- const scope = scopes[scopeName]
277
- const hasPositionField = scope?.vars?.schemaInfo?.schemaStructure?.[vars.positioning.field]
278
-
279
- if (!hasPositionField) {
280
- return
281
- }
282
-
283
- // Extract beforeId from JSON:API format attributes
284
- const attributes = context.inputRecord?.data?.attributes ||
285
- (context.simplified && context.inputRecord && typeof context.inputRecord === 'object'
286
- ? context.inputRecord
287
- : {})
288
- const beforeId = attributes[vars.positioning.beforeIdField]
289
-
290
- // Store beforeId in context for later use
291
- if (beforeId !== undefined) {
292
- context.positioningBeforeId = beforeId
293
-
294
- // Remove beforeId from attributes so it doesn't get stored
295
- delete attributes[vars.positioning.beforeIdField]
296
-
297
- if (context.simplified && context.inputRecord && typeof context.inputRecord === 'object') {
298
- delete context.inputRecord[vars.positioning.beforeIdField]
299
- }
300
- }
301
-
302
- // Remove any manually provided position field - position is managed by this plugin
303
- if (attributes[vars.positioning.field] !== undefined) {
304
- delete attributes[vars.positioning.field]
305
-
306
- log.debug('Positioning beforeId extracted', {
307
- scopeName,
308
- beforeId,
309
- method
310
- })
311
-
312
- if (context.simplified && context.inputRecord && typeof context.inputRecord === 'object') {
313
- delete context.inputRecord[vars.positioning.field]
314
- }
315
- }
316
-
317
- // For new records without explicit position, mark for positioning
318
- if (method === 'post' && !attributes[vars.positioning.field]) {
319
- context.needsPositioning = true
99
+ try {
100
+ await log.warn(`Could not create positioning index for ${context.scopeName}: ${errorMessage(error)}`, {
101
+ ...getOperationDiagnosticContext(context, { phase: 'positionIndex', method: 'addResource', backend: knex.client.config.client }), error
102
+ })
103
+ } catch { /* Index creation is best-effort, including its diagnostics. */ }
320
104
  }
321
105
  })
322
106
 
323
- // Calculate and set position before create/update
324
- addHook('beforeDataCallPost', 'calculate-position-post', {}, async ({ context, scopeName, vars, helpers }) => {
325
- log.debug('*** beforeDataCallPost hook fired for positioning', { scopeName, method: context.method })
326
- await calculateAndSetPosition(context, scopeName, vars, helpers, log, scopes, api)
327
- })
328
-
329
- addHook('beforeDataCallPut', 'calculate-position-put', {}, async ({ context, scopeName, vars, helpers }) => {
330
- log.debug('*** beforeDataCallPut hook fired for positioning', { scopeName, method: context.method })
331
- // Only process if beforeId was provided or it's a new record
332
- if (context.positioningBeforeId !== undefined || context.needsPositioning) {
333
- await calculateAndSetPosition(context, scopeName, vars, helpers, log, scopes, api)
107
+ addHook('beforeProcessing', 'lock-position-resource', {}, async ({ context, scopeName }) => {
108
+ if (!enabled(scopeName)) return
109
+ const adapter = adapterFor(scopeName)
110
+ const descriptor = context.schemaInfo.descriptor
111
+ const resource = createHash('sha256').update(JSON.stringify([
112
+ adapter.getTableName(), descriptor?.tenant, descriptor?.resource
113
+ ])).digest('hex')
114
+ const transaction = context.transaction
115
+ // A row write holds the resource lock until transaction completion, including
116
+ // empty groups, and rejects stale PostgreSQL repeatable-read snapshots.
117
+ await transaction(lockTable).insert({ resource }).onConflict('resource').merge(['resource'])
118
+ context.positioningBeforeId = undefined
119
+ const attributes = context.inputRecord?.data?.attributes
120
+ if (context.method === 'put' && attributes && typeof attributes === 'object' && !Array.isArray(attributes)) {
121
+ // PUT checks replacement completeness before schema hooks; position is
122
+ // managed here and replaced with its current/calculated value before storage.
123
+ attributes[config.field] = getInitialPosition()
334
124
  }
335
125
  })
336
126
 
337
- addHook('beforeDataCallPatch', 'calculate-position-patch', {}, async ({ context, scopeName, vars, helpers }) => {
338
- log.debug('*** beforeDataCallPatch hook fired for positioning', { scopeName, method: context.method })
339
- // Only process if beforeId was provided
340
- if (context.positioningBeforeId !== undefined) {
341
- await calculateAndSetPosition(context, scopeName, vars, helpers, log, scopes, api)
342
- }
127
+ addHook('beforeSchemaValidate', 'process-beforeid', {}, ({ context, scopeName }) => {
128
+ if (!enabled(scopeName)) return
129
+ const attributes = context.inputRecord.data.attributes || {}
130
+ context.positioningBeforeId = attributes[config.beforeIdField]
131
+ delete attributes[config.beforeIdField]
132
+ delete attributes[config.field]
343
133
  })
344
134
 
345
- // Helper function to calculate and set position
346
- async function calculateAndSetPosition (context, scopeName, vars, helpers, log, scopes, api) {
347
- log.debug('*** calculateAndSetPosition called', {
348
- scopeName,
349
- method: context.method,
350
- shouldHavePositioning: shouldHavePositioning(scopeName),
351
- beforeId: context.positioningBeforeId,
352
- needsPositioning: context.needsPositioning
353
- })
354
-
355
- // Skip if no positioning needed
356
- if (!shouldHavePositioning(scopeName)) {
357
- log.debug('*** Skipping - resource excluded from positioning')
358
- return
359
- }
360
-
361
- const beforeId = context.positioningBeforeId
362
- const needsPositioning = context.needsPositioning
363
-
364
- // Skip if no positioning action needed
365
- if (beforeId === undefined && !needsPositioning) {
366
- log.debug('*** Skipping - no positioning needed', { beforeId, needsPositioning })
367
- return
368
- }
369
-
370
- const recordData = (() => {
371
- if (context.inputRecord?.data?.attributes) {
372
- return { ...context.inputRecord.data.attributes }
373
- }
374
- if (context.simplified && context.inputRecord && typeof context.inputRecord === 'object') {
375
- return { ...context.inputRecord }
376
- }
377
- return {}
378
- })()
379
-
380
- log.debug('*** Record data for positioning', {
381
- recordData,
382
- simplified: context.simplified,
383
- inputRecord: context.inputRecord
384
- })
385
-
386
- const jsonApiRecord = context.inputRecord?.data || {}
387
- const inputRelationships = jsonApiRecord.relationships || {}
388
- const simplifiedRecord = context.simplified && context.inputRecord && typeof context.inputRecord === 'object'
389
- ? context.inputRecord
390
- : {}
391
-
392
- const minimalAttributes = context.minimalRecord?.attributes || {}
393
- const minimalRelationships = context.minimalRecord?.relationships || {}
394
-
395
- const combinedAttributes = {
396
- ...minimalAttributes,
397
- ...recordData,
398
- }
399
-
400
- // Build filter conditions for the position group
135
+ async function calculateAndSetPosition ({ context, scopeName }) {
136
+ if (!enabled(scopeName)) return
137
+ const adapter = adapterFor(scopeName)
401
138
  const schemaInfo = context.schemaInfo
402
- const adapter = context.storageAdapter || resolveStorageAdapter(scopeName)
403
-
404
- if (!adapter) {
405
- log.warn('*** Positioning skipped - no storage adapter available', { scopeName })
406
- return
407
- }
408
-
409
- const filterConditions = buildPositionFilterConditions(adapter, schemaInfo, {
410
- attributes: combinedAttributes,
411
- rawRecord: recordData,
412
- simplifiedRecord,
413
- inputRelationships,
414
- minimalRelationships,
415
- })
416
-
417
- log.debug('*** Position filter conditions', { scopeName, filterConditions })
418
-
419
- // Query existing items in the same position group
420
- const tableName = adapter.getTableName()
421
- const idProperty = schemaInfo.idProperty
139
+ const attributes = context.inputRecord.data.attributes ||= {}
140
+ const transaction = context.transaction
422
141
  const idColumn = adapter.getIdColumn()
423
- const positionColumn = adapter.translateColumn(vars.positioning.field)
424
-
425
- log.debug('*** Building position query', {
426
- tableName,
427
- idProperty,
428
- idColumn,
429
- positionField: vars.positioning.field,
430
- positionColumn,
431
- filterConditions
142
+ const positionColumn = adapter.translateColumn(config.field)
143
+ const position = positionExpression(knex, positionColumn)
144
+ const isCreate = context.method === 'post' || context.isCreate === true
145
+ const fields = filterFields(schemaInfo)
146
+ const columns = Object.fromEntries([config.field, ...fields.map(({ field }) => field)].map(field => {
147
+ const column = adapter.translateColumn(field)
148
+ return [column, databaseIdentityExpression(transaction, column)]
149
+ }))
150
+ const current = !isCreate && await applyDatabaseReadOptions(adapter.buildBaseQuery({ transaction })
151
+ .where(idColumn, adapter.translateFilterValue('id', context.id)).forUpdate()).select(columns).first()
152
+ let groupChanged = false
153
+ const conditions = fields.map(({ name, field }) => {
154
+ const oldValue = current ? adapter.getFieldValue(current, field) : null
155
+ const value = Object.hasOwn(attributes, field) ? attributes[field] : oldValue
156
+ const normalized = value == null ? null : adapter.translateFilterValue(name, value)
157
+ const oldNormalized = oldValue == null ? null : adapter.translateFilterValue(name, oldValue)
158
+ if (normalized !== oldNormalized) groupChanged = true
159
+ return [adapter.translateColumn(field), normalized]
432
160
  })
433
-
434
- // Check if we have a transaction to use
435
- // Build base query with filter conditions
436
- const baseQuery = adapter.buildBaseQuery({ transaction: context.transaction })
437
- Object.entries(filterConditions).forEach(([column, value]) => {
438
- if (value === null) {
439
- baseQuery.whereNull(column)
440
- } else {
441
- baseQuery.where(column, value)
442
- }
443
- })
444
-
445
- // For updates, exclude the current record
446
- if ((context.method === 'patch' || context.method === 'put') && context.id) {
447
- const translatedId = adapter.translateFilterValue(idProperty, context.id)
448
- baseQuery.whereNot(idColumn, translatedId)
449
- }
450
-
451
- // Calculate effective beforeId first
452
- let effectiveBeforeId = beforeId
453
-
454
- // Handle default positioning when no beforeId is provided
455
- if (beforeId === undefined) {
456
- if (vars.positioning.defaultPosition === 'last') {
457
- effectiveBeforeId = null // null means position at end
458
- } else if (vars.positioning.defaultPosition === 'first') {
459
- effectiveBeforeId = 'FIRST' // Special marker for first position
460
- }
461
- }
462
-
463
- let items = []
464
-
465
- const selectColumns = {
466
- [idProperty]: idColumn,
467
- [vars.positioning.field]: positionColumn,
468
- }
469
- const selectWithAliases = (builder) => adapter.selectColumns(builder, selectColumns)
470
-
471
- try {
472
- log.debug('*** Positioning query start', {
473
- effectiveBeforeId,
474
- selectColumns,
475
- })
476
- if (effectiveBeforeId === null) {
477
- // Positioning at end - only get the last item
478
- const lastItem = await selectWithAliases(baseQuery
479
- .clone())
480
- .orderBy(positionColumn, 'desc')
481
- .first()
482
-
483
- if (lastItem) {
484
- items = [lastItem]
485
- }
486
- } else if (effectiveBeforeId === 'FIRST') {
487
- // Positioning at beginning - get the first item
488
- const firstItem = await selectWithAliases(baseQuery
489
- .clone())
490
- .orderBy(positionColumn, 'asc')
491
- .first()
492
-
493
- if (firstItem) {
494
- items = [firstItem]
495
- effectiveBeforeId = firstItem[idProperty]
496
- } else {
497
- effectiveBeforeId = null
498
- }
499
- } else if (effectiveBeforeId) {
500
- // Positioning before a specific item - get that item and the one before it
501
- const normalizedTargetId = adapter.translateFilterValue(idProperty, effectiveBeforeId)
502
- const targetItem = await selectWithAliases(baseQuery
503
- .clone())
504
- .where(idColumn, normalizedTargetId)
505
- .first()
506
-
507
- if (targetItem) {
508
- // Get the item immediately before the target
509
- const prevItem = await selectWithAliases(baseQuery
510
- .clone())
511
- .where(positionColumn, '<', targetItem[vars.positioning.field])
512
- .orderBy(positionColumn, 'desc')
513
- .first()
514
-
515
- items = prevItem ? [prevItem, targetItem] : [targetItem]
516
- }
517
- } else {
518
- // First item in group - no items needed
519
- items = []
520
- }
521
-
522
- log.debug('*** Query completed', { itemCount: items.length, items })
523
- } catch (error) {
524
- log.error('*** Query failed', { error: error.message, stack: error.stack })
525
- throw error
526
- }
527
-
528
- // Calculate new position
529
- const newPosition = items.length === 0
530
- ? getInitialPosition()
531
- : calculatePosition(items, effectiveBeforeId, idProperty, vars.positioning.field)
532
-
533
- // Set the position on the incoming payload so downstream steps persist it
534
- if (context.inputRecord?.data) {
535
- context.inputRecord.data.attributes = context.inputRecord.data.attributes || {}
536
- context.inputRecord.data.attributes[vars.positioning.field] = newPosition
537
- }
538
-
539
- if (context.simplified && context.inputRecord && typeof context.inputRecord === 'object') {
540
- context.inputRecord[vars.positioning.field] = newPosition
541
- }
542
-
543
- log.debug('Position calculated', {
544
- scopeName,
545
- newPosition,
546
- beforeId: effectiveBeforeId,
547
- filterConditions,
548
- itemCount: items.length,
549
- method: context.method,
550
- id: context.id
551
- })
552
- }
553
-
554
- // Add positioning info back to response
555
- addHook('afterGet', 'add-beforeid-to-response', {}, async ({ context, vars }) => {
556
- // Only add if beforeId was in the original request
557
- if (context.positioningBeforeId !== undefined && context.record) {
558
- if (context.simplified) {
559
- context.record[vars.positioning.beforeIdField] = context.positioningBeforeId
560
- } else {
561
- // For JSON:API, add to meta
562
- context.meta = context.meta || {}
563
- context.meta.positioning = {
564
- [vars.positioning.beforeIdField]: context.positioningBeforeId
565
- }
566
- }
567
- }
568
- })
569
-
570
- // Apply default sort by position if no other sort specified
571
- addHook('beforeQuery', 'apply-position-sort', {}, async ({ context, scopeName, vars }) => {
572
- if (!shouldHavePositioning(scopeName)) {
161
+ const oldPosition = current && adapter.getFieldValue(current, config.field)
162
+ let beforeId = context.positioningBeforeId
163
+ if (!isCreate && beforeId === undefined && !groupChanged && isValidPosition(oldPosition)) {
164
+ attributes[config.field] = oldPosition
573
165
  return
574
166
  }
167
+ if (beforeId === undefined) beforeId = config.defaultPosition === 'first' ? 'FIRST' : null
168
+ if (!isCreate && beforeId !== null && String(beforeId) === String(context.id)) {
169
+ if (!groupChanged && isValidPosition(oldPosition)) { attributes[config.field] = oldPosition; return }
170
+ beforeId = null
171
+ }
172
+
173
+ const base = adapter.buildBaseQuery({ transaction }).forUpdate()
174
+ for (const [column, value] of conditions) {
175
+ if (value === null) base.whereNull(column)
176
+ else base.where(column, value)
177
+ }
178
+ if (!isCreate) base.whereNot(idColumn, adapter.translateFilterValue('id', context.id))
179
+ const select = query => adapter.selectColumns(query, { position: positionColumn })
180
+ let target
181
+ if (beforeId === 'FIRST') target = await select(base.clone()).orderBy(position, 'asc').first()
182
+ else if (beforeId !== null) {
183
+ try {
184
+ // Placement targets obey the same visibility and read authorization as GET.
185
+ await api.resources[scopeName].get({
186
+ id: beforeId, transaction, format: 'jsonapi', queryParams: { fields: { [scopeName]: 'id' } }
187
+ }, { ...context })
188
+ target = await select(base.clone()).where(idColumn, adapter.translateFilterValue('id', beforeId)).first()
189
+ } catch (error) {
190
+ if (!isRestApiError(error) || error.subtype !== 'not_found') throw error
191
+ }
192
+ }
193
+ const neighbor = target
194
+ ? await select(base.clone()).where(position, '<', target.position).orderBy(position, 'desc').first()
195
+ : await select(base.clone()).orderBy(position, 'desc').first()
196
+ const items = [neighbor, target].filter(Boolean)
197
+ if (items.some(item => !isValidPosition(item.position))) {
198
+ throw new RestApiValidationError('Existing positions must be valid fractional keys; migrate the list before positioning', { fields: [config.field] })
199
+ }
200
+ const newPosition = calculatePosition(neighbor?.position, target?.position)
201
+ const definition = schemaInfo.schemaStructure[config.field]
202
+ const maximum = Math.min(definition.max ?? Infinity, definition.maxLength || 255)
203
+ if (newPosition.length > maximum) {
204
+ throw new RestApiValidationError('Position key exceeds the field length; rebalance the list before retrying', { fields: [config.field] })
205
+ }
206
+ attributes[config.field] = newPosition
207
+ }
575
208
 
576
- // Check if sort is already specified
577
- const hasSort = context.queryParams?.sort && context.queryParams.sort.length > 0
578
-
579
- if (!hasSort) {
580
- // Apply position sort
581
- context.queryParams = context.queryParams || {}
582
- context.queryParams.sort = [vars.positioning.field]
583
-
584
- log.trace('Applied default position sort', { scopeName })
585
- }
209
+ for (const method of ['Post', 'Put', 'Patch']) {
210
+ addHook(`beforeDataCall${method}`, `calculate-position-${method.toLowerCase()}`, {}, calculateAndSetPosition)
211
+ }
212
+ addHook('beforeDataQuery', 'apply-position-sort', {}, ({ context, scopeName }) => {
213
+ if (enabled(scopeName) && !context.queryParams.sort?.length) context.queryParams.sort = [config.field]
586
214
  })
587
-
588
- // API methods for positioning operations
589
215
  api.positioning = {
590
- /**
591
- * Reorder items by providing new positions
592
- * @param {string} scopeName - Resource scope name
593
- * @param {Array} positions - Array of {id, position} or {id, beforeId}
594
- * @param {Object} filters - Filter conditions for position group
595
- */
596
- async reorder (scopeName, positions, filters = {}) {
597
- const scope = scopes[scopeName]
598
- if (!scope) {
599
- throw new Error(`Unknown resource: ${scopeName}`)
600
- }
601
-
602
- // Implementation would go here for bulk reordering
603
- log.info('Bulk reorder requested', { scopeName, count: positions.length })
604
- },
605
-
606
- /**
607
- * Get positioning configuration
608
- */
609
- getConfig () {
610
- return { ...vars.positioning }
611
- },
612
-
613
- /**
614
- * Check if a resource has positioning enabled
615
- */
616
- isEnabled (scopeName) {
617
- return shouldHavePositioning(scopeName)
618
- }
216
+ getConfig: () => ({ ...config, filters: [...config.filters], excludeResources: [...config.excludeResources] }),
217
+ isEnabled: enabled
619
218
  }
620
-
621
- log.info('Positioning plugin installed', {
622
- field: vars.positioning.field,
623
- strategy: vars.positioning.strategy,
624
- filters: vars.positioning.filters,
625
- excludedResources: vars.positioning.excludeResources
626
- })
627
219
  }
628
220
  }
629
-
630
- /*
631
- Usage examples:
632
-
633
- // Basic usage - positioning for all resources
634
- await api.use(PositioningPlugin);
635
-
636
- // With configuration
637
- await api.use(PositioningPlugin, {
638
- field: 'sortOrder',
639
- filters: ['status', 'projectId'],
640
- excludeResources: ['users', 'logs']
641
- });
642
-
643
- // Position grouping example
644
- await api.use(PositioningPlugin, {
645
- filters: ['boardId', 'listId'], // Separate positions per board/list combo
646
- defaultPosition: 'last'
647
- });
648
-
649
- // In requests:
650
- // POST /api/tasks
651
- {
652
- "title": "New Task",
653
- "boardId": 123,
654
- "listId": 456,
655
- "beforeId": "task-789" // Position before this task
656
- }
657
-
658
- // Or to add at end:
659
- {
660
- "title": "New Task",
661
- "boardId": 123,
662
- "listId": 456,
663
- "beforeId": null // Explicit last position
664
- }
665
- */