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.
- package/LICENSE +10 -670
- package/LICENSE-GPL-3.0 +674 -0
- package/LICENSE-MIT +21 -0
- package/README.md +39 -30
- package/docs/API.md +744 -1273
- package/docs/GUIDE/01-getting-started.md +338 -0
- package/docs/GUIDE/02-resources-and-relationships.md +33 -0
- package/docs/GUIDE/03-running-example.md +87 -0
- package/docs/GUIDE/04-creating-and-querying.md +181 -0
- package/docs/GUIDE/05-belongs-to.md +177 -0
- package/docs/GUIDE/06-has-many.md +184 -0
- package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
- package/docs/GUIDE/08-many-to-many.md +184 -0
- package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
- package/docs/GUIDE/10-put-and-patch.md +174 -0
- package/docs/GUIDE/11-relationship-endpoints.md +174 -0
- package/docs/GUIDE/12-field-transformations.md +287 -0
- package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
- package/docs/GUIDE/14-custom-resource-methods.md +125 -0
- package/docs/GUIDE/15-query-projections.md +266 -0
- package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
- package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
- package/docs/GUIDE/18-labels.md +86 -0
- package/docs/GUIDE/19-managed-transactions.md +179 -0
- package/docs/GUIDE/20-transaction-outcomes.md +207 -0
- package/docs/GUIDE/21-schema-and-migrations.md +634 -0
- package/docs/GUIDE/22-version-field-migrations.md +208 -0
- package/docs/GUIDE/23-fastify.md +179 -0
- package/docs/GUIDE/24-cors.md +172 -0
- package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
- package/docs/GUIDE/26-file-uploads.md +278 -0
- package/docs/GUIDE/27-bulk-operations.md +277 -0
- package/docs/GUIDE/28-socketio.md +325 -0
- package/docs/GUIDE/29-writing-plugins.md +278 -0
- package/docs/GUIDE/30-backend-capabilities.md +100 -0
- package/docs/GUIDE/31-positioning.md +181 -0
- package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +74 -30
- package/docs/GUIDE/33-migrating-to-v2.md +3827 -0
- package/docs/GUIDE/index.md +51 -59
- package/docs/QUICKSTART.md +120 -287
- package/docs/architecture.md +75 -0
- package/docs/contributing.md +178 -0
- package/index.d.ts +36 -0
- package/index.js +1 -22
- package/lib/enhanced-logger.js +91 -85
- package/lib/error-context.js +445 -5
- package/lib/error-formatter.js +219 -59
- package/lib/knex-transaction.js +64 -0
- package/lib/logger-types.d.ts +22 -0
- package/lib/missing-package.js +4 -0
- package/lib/rest-api-errors.js +103 -8
- package/lib/runtime/json-rest-api.js +172 -0
- package/lib/transaction-types.d.ts +59 -0
- package/package.json +97 -27
- package/plugins/core/bulk-operations-plugin.js +191 -184
- package/plugins/core/connectors/express-plugin.js +148 -219
- package/plugins/core/connectors/fastify-plugin.js +125 -82
- package/plugins/core/connectors/lib/busboy-detector.js +74 -156
- package/plugins/core/connectors/lib/connector-core.js +144 -20
- package/plugins/core/connectors/lib/formidable-detector.js +63 -120
- package/plugins/core/connectors/lib/http-validators.js +77 -0
- package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
- package/plugins/core/connectors/lib/request-helpers.js +10 -1
- package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
- package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
- package/plugins/core/file-handling-plugin.js +87 -177
- package/plugins/core/lib/anyapi/anyapi-registry.js +213 -164
- package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
- package/plugins/core/lib/anyapi/schema-utils.js +25 -16
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
- package/plugins/core/lib/dbIntrospection.js +228 -21
- package/plugins/core/lib/dbTablesOperations.js +409 -92
- package/plugins/core/lib/fractional-positioning.js +12 -48
- package/plugins/core/lib/querying/identifier-query.js +19 -0
- package/plugins/core/lib/querying/include-query-helpers.js +364 -0
- package/plugins/core/lib/querying/include-to-many.js +428 -0
- package/plugins/core/lib/querying/include-to-one.js +579 -0
- package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
- package/plugins/core/lib/querying/knex-pagination-helpers.js +48 -317
- package/plugins/core/lib/querying/knex-process-includes.js +5 -110
- package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
- package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
- package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
- package/plugins/core/lib/querying/knex-window-queries.js +62 -253
- package/plugins/core/lib/querying/query-builder-utils.js +44 -4
- package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
- package/plugins/core/lib/querying/query-constraint.js +27 -0
- package/plugins/core/lib/querying/query-field-sort-helpers.js +134 -37
- package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
- package/plugins/core/lib/querying/sort-helpers.js +13 -1
- package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
- package/plugins/core/lib/querying/url-helpers.js +11 -4
- package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
- package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +179 -194
- package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
- package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
- package/plugins/core/lib/querying-writing/field-utils.js +113 -92
- package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
- package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +100 -290
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
- package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
- package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
- package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
- package/plugins/core/lib/querying-writing/request-contracts.js +93 -38
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
- package/plugins/core/lib/querying-writing/response-options.js +35 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
- package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
- package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
- package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
- package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
- package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
- package/plugins/core/lib/storage/storage-adapter.js +128 -62
- package/plugins/core/lib/storage/storage-mapping.js +78 -28
- package/plugins/core/lib/storage/storage-types.d.ts +502 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
- package/plugins/core/lib/writing/relationship-processor.js +94 -158
- package/plugins/core/lib/writing/resource-version.js +172 -0
- package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +116 -2485
- package/plugins/core/rest-api-autofilter-plugin.js +17 -19
- package/plugins/core/rest-api-cors-plugin.js +52 -205
- package/plugins/core/rest-api-knex-plugin.js +45 -1077
- package/plugins/core/rest-api-label-plugin.js +25 -38
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
- package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
- package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
- package/plugins/core/rest-api-plugin-methods/common.js +291 -395
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
- package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -148
- package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
- package/plugins/core/rest-api-plugin-methods/get.js +36 -68
- package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
- package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
- package/plugins/core/rest-api-plugin-methods/post.js +51 -51
- package/plugins/core/rest-api-plugin-methods/put.js +69 -148
- package/plugins/core/rest-api-plugin-methods/query.js +37 -81
- package/plugins/core/rest-api-plugin-methods/release.js +2 -1
- package/plugins/core/rest-api-plugin.js +32 -46
- package/plugins/core/rest-api-positioning-plugin.js +183 -628
- package/plugins/core/rest-api-query-projections-plugin.js +19 -102
- package/plugins/core/rest-api-row-policy-plugin.js +3 -3
- package/plugins/core/socketio-plugin.js +364 -344
- package/plugins/storage/local-storage.js +40 -36
- package/types/autofilter.d.ts +57 -0
- package/types/bulk-methods.d.ts +63 -0
- package/types/errors.d.ts +118 -0
- package/types/fastify-options.d.ts +19 -0
- package/types/file-detectors.d.ts +33 -0
- package/types/file-storage.d.ts +48 -0
- package/types/hook-context.d.ts +180 -0
- package/types/plugin-options.d.ts +53 -0
- package/types/relationship-methods.d.ts +74 -0
- package/types/representations.d.ts +50 -0
- package/types/resource-methods.d.ts +95 -0
- package/types/resource-schema.d.ts +157 -0
- package/types/row-policy.d.ts +31 -0
- package/types/runtime.d.ts +132 -0
- package/types/transactions.d.ts +21 -0
- package/.claude/agents/library-guide-expert.md +0 -30
- package/.claude/agents/rest-api-internals-expert.md +0 -54
- package/.claude/settings.local.json +0 -70
- package/AGENTS.md +0 -34
- package/CLAUDE.md +0 -160
- package/CRUSH.md +0 -55
- package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
- package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
- package/TODO.todo +0 -13
- package/connectors_ideas/fastify.js +0 -217
- package/connectors_ideas/koa-plugin.js +0 -112
- package/docs/.bundle/config +0 -2
- package/docs/COMPARISON.md +0 -433
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1247
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
- package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
- package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
- package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
- package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
- package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
- package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
- package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
- package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
- package/docs/Gemfile +0 -6
- package/docs/Gemfile.lock +0 -308
- package/docs/ONBOARDING.md +0 -315
- package/docs/_config.yml +0 -36
- package/docs/_layouts/default.html +0 -123
- package/docs/apple-touch-icon.svg +0 -4
- package/docs/assets/css/style.scss +0 -392
- package/docs/favicon.svg +0 -4
- package/docs/index.md +0 -164
- package/docs/mini-guides/file-uploads-complete.md +0 -161
- package/eslint.config.js +0 -13
- package/examples/url-override-example.js +0 -137
- package/fixing_plan.md +0 -112
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
- package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
- package/plugins/storage/s3-storage.js +0 -93
- package/quickTest.js +0 -136
- package/scripts/debug-anyapi-review-filter.mjs +0 -63
- package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
- package/scripts/inspect-anyapi-descriptor.mjs +0 -19
- package/scripts/run-docs.js +0 -133
- package/test-debug.js +0 -59
- package/tests/README.md +0 -202
- package/tests/TEST_TEMPLATE.test.js +0 -178
- package/tests/anyapi-basic.test.js +0 -105
- package/tests/anyapi-cursor-pagination.test.js +0 -148
- package/tests/anyapi-custom-idproperty.test.js +0 -163
- package/tests/autofilter.test.js +0 -463
- package/tests/bulk-operations.test.js +0 -657
- package/tests/computed-fields.test.js +0 -586
- package/tests/cors.test.js +0 -834
- package/tests/curl.test.js +0 -467
- package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
- package/tests/custom-id-simplified-post.test.js +0 -75
- package/tests/custom-idproperty-relationships.test.js +0 -932
- package/tests/db-introspection.test.js +0 -521
- package/tests/db-table-operations.test.js +0 -798
- package/tests/fastify-plugin.test.js +0 -184
- package/tests/field-getters.test.js +0 -370
- package/tests/field-setters.test.js +0 -306
- package/tests/file-handling.test.js +0 -155
- package/tests/fixtures/api-configs.js +0 -2566
- package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
- package/tests/helpers/fake-fastify.js +0 -99
- package/tests/helpers/storage-mode.js +0 -65
- package/tests/helpers/test-utils.js +0 -203
- package/tests/http-connectors-parity.test.js +0 -395
- package/tests/id-normalization.test.js +0 -444
- package/tests/include-depth-validation.test.js +0 -331
- package/tests/includes.test.js +0 -427
- package/tests/local-storage.test.js +0 -96
- package/tests/logical-id-write-validation.test.js +0 -151
- package/tests/nested-includes.test.js +0 -617
- package/tests/pagination-boundaries.test.js +0 -154
- package/tests/pagination-contract.test.js +0 -122
- package/tests/pagination-cursor-multifield.test.js +0 -641
- package/tests/pagination-enhanced.test.js +0 -563
- package/tests/polymorphic-relationships.test.js +0 -347
- package/tests/polymorphic-simple.test.js +0 -148
- package/tests/positioning-advanced.test.js +0 -692
- package/tests/positioning-extended.test.js +0 -915
- package/tests/positioning-multifilter.test.js +0 -699
- package/tests/positioning.test.js +0 -560
- package/tests/post-commit-hooks.test.js +0 -120
- package/tests/projected-fields.test.js +0 -319
- package/tests/public-exports.test.js +0 -15
- package/tests/put-create-parity.test.js +0 -216
- package/tests/queries.test.js +0 -521
- package/tests/query-limits.test.js +0 -584
- package/tests/relationship-endpoints.test.js +0 -620
- package/tests/relationship-include-storage-adapter.test.js +0 -56
- package/tests/relationships.test.js +0 -681
- package/tests/rest-api.test.js +0 -337
- package/tests/return-record-settings.test.js +0 -184
- package/tests/row-policy.test.js +0 -489
- package/tests/s3-storage.test.js +0 -38
- package/tests/searchschema-knex-behavior.test.js +0 -187
- package/tests/searchschema-merge.test.js +0 -151
- package/tests/socketio.test.js +0 -860
- package/tests/storage-mapping.test.js +0 -362
- package/tests/temporal-boundaries.test.js +0 -208
- package/tests/test-development-guide.md +0 -370
- package/tests/virtual-fields.test.js +0 -404
|
@@ -1,665 +1,220 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
|
10
|
-
|
|
11
|
-
install ({ api, addHook, vars,
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
201
|
-
if (
|
|
202
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
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('
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
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(
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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
|
-
*/
|