json-rest-api 1.0.14 → 1.0.15

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 (118) hide show
  1. package/connectors_ideas/fastify.js +66 -68
  2. package/connectors_ideas/koa-plugin.js +50 -51
  3. package/eslint.config.js +11 -2
  4. package/examples/url-override-example.js +63 -63
  5. package/index.js +13 -13
  6. package/lib/enhanced-logger.js +51 -51
  7. package/lib/error-formatter.js +61 -61
  8. package/lib/rest-api-errors.js +28 -28
  9. package/package.json +1 -1
  10. package/plugins/core/bulk-operations-plugin.js +142 -145
  11. package/plugins/core/connectors/express-plugin.js +210 -217
  12. package/plugins/core/connectors/lib/busboy-detector.js +84 -84
  13. package/plugins/core/connectors/lib/formidable-detector.js +50 -50
  14. package/plugins/core/connectors/lib/request-helpers.js +74 -74
  15. package/plugins/core/file-handling-plugin.js +125 -125
  16. package/plugins/core/lib/anyapi/anyapi-registry.js +254 -254
  17. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +287 -287
  18. package/plugins/core/lib/anyapi/schema-utils.js +101 -101
  19. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +41 -41
  20. package/plugins/core/lib/dbTablesOperations.js +134 -135
  21. package/plugins/core/lib/fractional-positioning.js +74 -76
  22. package/plugins/core/lib/querying/knex-cross-table-search.js +320 -320
  23. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +144 -144
  24. package/plugins/core/lib/querying/knex-pagination-helpers.js +164 -164
  25. package/plugins/core/lib/querying/knex-process-includes.js +50 -50
  26. package/plugins/core/lib/querying/knex-query-helpers-base.js +31 -31
  27. package/plugins/core/lib/querying/knex-query-helpers.js +451 -452
  28. package/plugins/core/lib/querying/knex-relationship-includes.js +1160 -1170
  29. package/plugins/core/lib/querying/knex-window-queries.js +89 -89
  30. package/plugins/core/lib/querying/url-helpers.js +35 -35
  31. package/plugins/core/lib/querying-writing/compile-schemas.js +97 -98
  32. package/plugins/core/lib/querying-writing/connectors-query-parser.js +31 -31
  33. package/plugins/core/lib/querying-writing/database-capabilities.js +94 -94
  34. package/plugins/core/lib/querying-writing/database-value-normalizers.js +76 -76
  35. package/plugins/core/lib/querying-writing/default-data-helpers.js +27 -27
  36. package/plugins/core/lib/querying-writing/field-utils.js +44 -42
  37. package/plugins/core/lib/querying-writing/knex-constants.js +15 -16
  38. package/plugins/core/lib/querying-writing/knex-field-helpers.js +117 -115
  39. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +41 -41
  40. package/plugins/core/lib/querying-writing/payload-validators.js +275 -275
  41. package/plugins/core/lib/querying-writing/schema-helpers.js +129 -129
  42. package/plugins/core/lib/querying-writing/scope-validations.js +143 -146
  43. package/plugins/core/lib/querying-writing/simplified-helpers.js +133 -129
  44. package/plugins/core/lib/storage/storage-adapter.js +68 -68
  45. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +31 -31
  46. package/plugins/core/lib/writing/many-to-many-manipulations.js +81 -81
  47. package/plugins/core/lib/writing/relationship-processor.js +58 -58
  48. package/plugins/core/rest-api-access.js +8 -8
  49. package/plugins/core/rest-api-anyapi-knex-plugin.js +920 -920
  50. package/plugins/core/rest-api-cors-plugin.js +170 -171
  51. package/plugins/core/rest-api-knex-plugin.js +482 -493
  52. package/plugins/core/rest-api-multihome-plugin.js +247 -251
  53. package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +5 -5
  54. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +20 -21
  55. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +44 -44
  56. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +23 -23
  57. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +15 -15
  58. package/plugins/core/rest-api-plugin-methods/add-route.js +9 -9
  59. package/plugins/core/rest-api-plugin-methods/check-permissions.js +5 -6
  60. package/plugins/core/rest-api-plugin-methods/common.js +295 -297
  61. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +35 -37
  62. package/plugins/core/rest-api-plugin-methods/delete.js +57 -59
  63. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +172 -171
  64. package/plugins/core/rest-api-plugin-methods/get-related.js +96 -96
  65. package/plugins/core/rest-api-plugin-methods/get-relationship.js +16 -18
  66. package/plugins/core/rest-api-plugin-methods/get.js +86 -86
  67. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +16 -17
  68. package/plugins/core/rest-api-plugin-methods/patch.js +63 -64
  69. package/plugins/core/rest-api-plugin-methods/post-relationship.js +54 -55
  70. package/plugins/core/rest-api-plugin-methods/post.js +61 -62
  71. package/plugins/core/rest-api-plugin-methods/put.js +86 -87
  72. package/plugins/core/rest-api-plugin-methods/query.js +88 -88
  73. package/plugins/core/rest-api-plugin-methods/release.js +5 -6
  74. package/plugins/core/rest-api-plugin.js +76 -78
  75. package/plugins/core/rest-api-positioning-plugin.js +251 -252
  76. package/plugins/core/socketio-plugin.js +240 -236
  77. package/plugins/storage/local-storage.js +103 -103
  78. package/plugins/storage/s3-storage.js +41 -42
  79. package/quickTest.js +37 -37
  80. package/scripts/debug-anyapi-review-filter.mjs +18 -18
  81. package/scripts/inspect-anyapi-descriptor-country.mjs +10 -10
  82. package/scripts/inspect-anyapi-descriptor.mjs +10 -10
  83. package/scripts/run-docs.js +112 -113
  84. package/test-debug.js +28 -28
  85. package/tests/TEST_TEMPLATE.test.js +51 -51
  86. package/tests/anyapi-basic.test.js +42 -42
  87. package/tests/anyapi-cursor-pagination.test.js +48 -48
  88. package/tests/bulk-operations.test.js +208 -208
  89. package/tests/computed-fields.test.js +204 -204
  90. package/tests/cors.test.js +412 -413
  91. package/tests/curl.test.js +275 -276
  92. package/tests/custom-idproperty-relationships.test.js +266 -266
  93. package/tests/field-getters.test.js +132 -132
  94. package/tests/field-setters.test.js +110 -110
  95. package/tests/fixtures/api-configs.js +699 -699
  96. package/tests/helpers/storage-mode.js +32 -32
  97. package/tests/helpers/test-utils.js +76 -76
  98. package/tests/include-depth-validation.test.js +76 -76
  99. package/tests/includes.test.js +145 -145
  100. package/tests/multihome.test.js +175 -175
  101. package/tests/nested-includes.test.js +255 -255
  102. package/tests/pagination-cursor-multifield.test.js +164 -164
  103. package/tests/pagination-enhanced.test.js +225 -225
  104. package/tests/polymorphic-relationships.test.js +96 -96
  105. package/tests/polymorphic-simple.test.js +44 -44
  106. package/tests/positioning-advanced.test.js +226 -226
  107. package/tests/positioning-extended.test.js +399 -399
  108. package/tests/positioning-multifilter.test.js +204 -204
  109. package/tests/positioning.test.js +266 -266
  110. package/tests/queries.test.js +184 -184
  111. package/tests/query-limits.test.js +154 -155
  112. package/tests/relationship-endpoints.test.js +127 -127
  113. package/tests/relationships.test.js +178 -179
  114. package/tests/rest-api-access.test.js +86 -86
  115. package/tests/rest-api.test.js +103 -103
  116. package/tests/searchschema-merge.test.js +93 -93
  117. package/tests/socketio.test.js +326 -326
  118. package/tests/virtual-fields.test.js +164 -164
@@ -3,30 +3,29 @@ import {
3
3
  getInitialPosition,
4
4
  isValidPosition,
5
5
  assignInitialPositions,
6
- } from './lib/fractional-positioning.js';
7
- import { createStorageAdapter } from './lib/storage/storage-adapter.js';
6
+ } from './lib/fractional-positioning.js'
7
+ import { createStorageAdapter } from './lib/storage/storage-adapter.js'
8
8
 
9
9
  export const PositioningPlugin = {
10
10
  name: 'positioning',
11
11
  dependencies: ['rest-api', 'rest-api-knex|rest-api-anyapi-knex'],
12
12
 
13
- install({ api, addHook, vars, helpers, log, scopes, pluginOptions }) {
14
-
15
- const installedPlugins = Array.from(api._installedPlugins || []);
16
- const legacyStorageInstalled = installedPlugins.includes('rest-api-knex');
17
- const canonicalStorageInstalled = installedPlugins.includes('rest-api-anyapi-knex');
13
+ install ({ api, addHook, vars, helpers, log, scopes, pluginOptions }) {
14
+ const installedPlugins = Array.from(api._installedPlugins || [])
15
+ const legacyStorageInstalled = installedPlugins.includes('rest-api-knex')
16
+ const canonicalStorageInstalled = installedPlugins.includes('rest-api-anyapi-knex')
18
17
  if (!legacyStorageInstalled && !canonicalStorageInstalled) {
19
18
  throw new Error(
20
19
  "Positioning plugin requires either 'rest-api-knex' or 'rest-api-anyapi-knex' to be installed before it."
21
- );
20
+ )
22
21
  }
23
22
 
24
23
  if (!api.knex?.instance) {
25
- throw new Error('Positioning plugin requires a storage plugin with knex support (rest-api-knex or rest-api-anyapi-knex)');
24
+ throw new Error('Positioning plugin requires a storage plugin with knex support (rest-api-knex or rest-api-anyapi-knex)')
26
25
  }
27
26
 
28
27
  // Get configuration - hooked-api namespaces options by plugin name
29
- const positioningOptions = pluginOptions || {};
28
+ const positioningOptions = pluginOptions || {}
30
29
 
31
30
  // Store configuration in vars (data only) - inspired by multihome pattern
32
31
  vars.positioning = {
@@ -38,394 +37,394 @@ export const PositioningPlugin = {
38
37
  defaultPosition: positioningOptions.defaultPosition || 'last',
39
38
  autoIndex: positioningOptions.autoIndex !== undefined ? positioningOptions.autoIndex : true,
40
39
  rebalanceThreshold: positioningOptions.rebalanceThreshold || 50 // Max position string length
41
- };
40
+ }
42
41
 
43
42
  const resolveStorageAdapter = (scopeName) => {
44
- if (!scopeName) return null;
45
- const scope = scopes[scopeName] || api.resources?.[scopeName];
46
- const scopeVars = scope?.vars;
47
- if (!scopeVars) return null;
43
+ if (!scopeName) return null
44
+ const scope = scopes[scopeName] || api.resources?.[scopeName]
45
+ const scopeVars = scope?.vars
46
+ if (!scopeVars) return null
48
47
 
49
48
  if (scopeVars.storageAdapter) {
50
- return scopeVars.storageAdapter;
49
+ return scopeVars.storageAdapter
51
50
  }
52
51
 
53
52
  if (scopeVars.schemaInfo && api.knex?.instance) {
54
- const adapter = createStorageAdapter({ knex: api.knex.instance, schemaInfo: scopeVars.schemaInfo });
55
- scopeVars.storageAdapter = adapter;
56
- return adapter;
53
+ const adapter = createStorageAdapter({ knex: api.knex.instance, schemaInfo: scopeVars.schemaInfo })
54
+ scopeVars.storageAdapter = adapter
55
+ return adapter
57
56
  }
58
57
 
59
- return null;
60
- };
58
+ return null
59
+ }
61
60
 
62
61
  // Validate configuration
63
62
  if (!['fractional', 'integer'].includes(vars.positioning.strategy)) {
64
- throw new Error(`Invalid positioning strategy: ${vars.positioning.strategy}. Must be 'fractional' or 'integer'`);
63
+ throw new Error(`Invalid positioning strategy: ${vars.positioning.strategy}. Must be 'fractional' or 'integer'`)
65
64
  }
66
65
 
67
66
  if (vars.positioning.strategy === 'integer') {
68
- throw new Error("Integer positioning strategy not yet implemented. Please use 'fractional'");
67
+ throw new Error("Integer positioning strategy not yet implemented. Please use 'fractional'")
69
68
  }
70
69
 
71
70
  // Helper to check if a resource should have positioning
72
- function shouldHavePositioning(scopeName) {
73
- return !vars.positioning.excludeResources.includes(scopeName);
71
+ function shouldHavePositioning (scopeName) {
72
+ return !vars.positioning.excludeResources.includes(scopeName)
74
73
  }
75
74
 
76
75
  // Helper to build filter conditions for position groups
77
- function buildPositionFilterConditions(adapter, schemaInfo, {
76
+ function buildPositionFilterConditions (adapter, schemaInfo, {
78
77
  attributes = {},
79
78
  rawRecord = {},
80
79
  simplifiedRecord = {},
81
80
  inputRelationships = {},
82
81
  minimalRelationships = {},
83
82
  }) {
84
- const conditions = {};
83
+ const conditions = {}
85
84
 
86
85
  log.debug('*** buildPositionFilterConditions', {
87
86
  filters: vars.positioning.filters,
88
87
  attributeKeys: Object.keys(attributes || {}),
89
88
  rawKeys: Object.keys(rawRecord || {}),
90
89
  simplifiedKeys: Object.keys(simplifiedRecord || {}),
91
- });
90
+ })
92
91
 
93
- const tryAssign = (holder, field) => (holder && field in holder ? holder[field] : undefined);
92
+ const tryAssign = (holder, field) => (holder && field in holder ? holder[field] : undefined)
94
93
 
95
94
  vars.positioning.filters.forEach((filterField) => {
96
- const searchField = schemaInfo.searchSchemaStructure?.[filterField] || null;
97
- const schemaField = schemaInfo.schemaStructure?.[filterField] || null;
95
+ const searchField = schemaInfo.searchSchemaStructure?.[filterField] || null
96
+ const schemaField = schemaInfo.schemaStructure?.[filterField] || null
98
97
  const isRelationship = Boolean(
99
- searchField?.isRelationship
100
- || schemaField?.belongsTo
101
- || schemaField?.belongsToPolymorphic
102
- );
98
+ searchField?.isRelationship ||
99
+ schemaField?.belongsTo ||
100
+ schemaField?.belongsToPolymorphic
101
+ )
103
102
 
104
103
  if (!searchField && !schemaField) {
105
- log.warn(`Filter field '${filterField}' not found in search schema or resource schema for positioning`);
104
+ log.warn(`Filter field '${filterField}' not found in search schema or resource schema for positioning`)
106
105
  }
107
106
 
108
- const dbField = searchField?.actualField || filterField;
109
- let value;
107
+ const dbField = searchField?.actualField || filterField
108
+ let value
110
109
 
111
110
  const attempt = (candidate) => {
112
111
  if (value === undefined && candidate !== undefined) {
113
- value = candidate;
112
+ value = candidate
114
113
  }
115
- };
114
+ }
116
115
 
117
- attempt(tryAssign(rawRecord, filterField));
118
- attempt(tryAssign(simplifiedRecord, filterField));
119
- attempt(tryAssign(rawRecord, dbField));
120
- attempt(tryAssign(attributes, filterField));
121
- attempt(tryAssign(attributes, dbField));
116
+ attempt(tryAssign(rawRecord, filterField))
117
+ attempt(tryAssign(simplifiedRecord, filterField))
118
+ attempt(tryAssign(rawRecord, dbField))
119
+ attempt(tryAssign(attributes, filterField))
120
+ attempt(tryAssign(attributes, dbField))
122
121
 
123
122
  if (isRelationship) {
124
- attempt(inputRelationships?.[filterField]?.data?.id);
125
- attempt(minimalRelationships?.[filterField]?.data?.id);
123
+ attempt(inputRelationships?.[filterField]?.data?.id)
124
+ attempt(minimalRelationships?.[filterField]?.data?.id)
126
125
  // Some contexts may expose relationship data under attributes
127
- attempt(tryAssign(rawRecord?.relationships, filterField)?.data?.id);
126
+ attempt(tryAssign(rawRecord?.relationships, filterField)?.data?.id)
128
127
  }
129
128
 
130
- if (value !== undefined) {
131
- const column = adapter.translateColumn(dbField);
129
+ if (value !== undefined) {
130
+ const column = adapter.translateColumn(dbField)
132
131
  const normalizedValue = value === null
133
132
  ? null
134
- : adapter.translateFilterValue(filterField, value);
135
- conditions[column] = normalizedValue;
133
+ : adapter.translateFilterValue(filterField, value)
134
+ conditions[column] = normalizedValue
136
135
  }
137
- });
136
+ })
138
137
 
139
- log.debug('*** Filter conditions built', { conditions });
140
- return conditions;
138
+ log.debug('*** Filter conditions built', { conditions })
139
+ return conditions
141
140
  }
142
141
 
143
142
  // Validate that resources have position field when added
144
143
  addHook('scope:added', 'validate-position-field', {}, ({ context, vars }) => {
145
- const { scopeName, scopeOptions } = context;
144
+ const { scopeName, scopeOptions } = context
146
145
 
147
146
  // Skip excluded resources
148
147
  if (!shouldHavePositioning(scopeName)) {
149
- log.debug(`Resource ${scopeName} excluded from positioning`);
150
- return;
148
+ log.debug(`Resource ${scopeName} excluded from positioning`)
149
+ return
151
150
  }
152
151
 
153
152
  // Check if schema has position field
154
- const schema = scopeOptions.schema;
153
+ const schema = scopeOptions.schema
155
154
  if (schema && !schema[vars.positioning.field]) {
156
155
  throw new Error(
157
156
  `Resource '${scopeName}' must have '${vars.positioning.field}' field in schema to use positioning plugin`
158
- );
157
+ )
159
158
  }
160
159
 
161
160
  // Validate filter fields exist (check both relationship names and actual field names)
162
161
  vars.positioning.filters.forEach(filterField => {
163
162
  // Check if it's a relationship field by looking for a field with matching 'as' property
164
- let fieldExists = false;
165
-
163
+ let fieldExists = false
164
+
166
165
  // First check if the field exists directly in the schema
167
166
  if (schema[filterField]) {
168
- fieldExists = true;
167
+ fieldExists = true
169
168
  } else {
170
169
  // Check if it's a relationship name (as property)
171
170
  for (const [fieldName, fieldDef] of Object.entries(schema)) {
172
171
  if (fieldDef.as === filterField) {
173
- fieldExists = true;
174
- break;
172
+ fieldExists = true
173
+ break
175
174
  }
176
175
  }
177
176
  }
178
-
177
+
179
178
  if (!fieldExists) {
180
179
  throw new Error(
181
180
  `Resource '${scopeName}' must have '${filterField}' field or relationship in schema for position filtering`
182
- );
181
+ )
183
182
  }
184
- });
185
- });
183
+ })
184
+ })
186
185
 
187
186
  // Add index for position field if autoIndex is enabled
188
187
  addHook('scope:added', 'add-position-index', { afterFunction: 'validate-position-field' }, async ({ context, vars, scopes }) => {
189
- const { scopeName } = context;
190
-
188
+ const { scopeName } = context
189
+
191
190
  if (!shouldHavePositioning(scopeName) || !vars.positioning.autoIndex) {
192
- return;
191
+ return
193
192
  }
194
193
 
195
- const scope = scopes[scopeName];
196
- const schemaInfo = scope.vars.schemaInfo;
197
-
194
+ const scope = scopes[scopeName]
195
+ const schemaInfo = scope.vars.schemaInfo
196
+
198
197
  if (!schemaInfo || !api.knex?.instance) {
199
- return; // Skip if no database connection
198
+ return // Skip if no database connection
200
199
  }
201
200
 
202
201
  try {
203
- const adapter = resolveStorageAdapter(scopeName);
202
+ const adapter = resolveStorageAdapter(scopeName)
204
203
  if (adapter.isCanonical()) {
205
- log.debug(`Skipping positioning index for canonical storage on ${scopeName}`);
206
- return;
204
+ log.debug(`Skipping positioning index for canonical storage on ${scopeName}`)
205
+ return
207
206
  }
208
207
 
209
- const knex = api.knex.instance;
210
- const tableName = adapter.getTableName();
211
-
208
+ const knex = api.knex.instance
209
+ const tableName = adapter.getTableName()
210
+
212
211
  // Check if table exists first
213
- const tableExists = await knex.schema.hasTable(tableName);
212
+ const tableExists = await knex.schema.hasTable(tableName)
214
213
  if (!tableExists) {
215
- log.debug(`Table ${tableName} doesn't exist yet, skipping index creation`);
216
- return;
214
+ log.debug(`Table ${tableName} doesn't exist yet, skipping index creation`)
215
+ return
217
216
  }
218
-
217
+
219
218
  // Build index columns: map filter fields to actual database columns
220
- const indexColumns = new Set();
221
-
219
+ const indexColumns = new Set()
220
+
222
221
  // Map each filter field to its database column
223
222
  vars.positioning.filters.forEach(filterField => {
224
- const searchField = schemaInfo.searchSchemaStructure?.[filterField];
223
+ const searchField = schemaInfo.searchSchemaStructure?.[filterField]
225
224
  if (searchField) {
226
225
  // Use actualField if it exists (for relationships)
227
- const dbField = searchField.actualField || filterField;
228
- indexColumns.add(adapter.translateColumn(dbField));
226
+ const dbField = searchField.actualField || filterField
227
+ indexColumns.add(adapter.translateColumn(dbField))
229
228
  } else if (schemaInfo.schemaStructure[filterField]) {
230
229
  // Direct database field
231
- indexColumns.add(adapter.translateColumn(filterField));
230
+ indexColumns.add(adapter.translateColumn(filterField))
232
231
  }
233
- });
234
-
232
+ })
233
+
235
234
  // Add the position field
236
- indexColumns.add(adapter.translateColumn(vars.positioning.field));
235
+ indexColumns.add(adapter.translateColumn(vars.positioning.field))
236
+
237
+ const indexColumnList = [...indexColumns].filter(Boolean)
238
+
239
+ const indexName = `idx_${tableName}_positioning`
237
240
 
238
- const indexColumnList = [...indexColumns].filter(Boolean);
239
-
240
- const indexName = `idx_${tableName}_positioning`;
241
-
242
241
  // Check if index already exists
243
- const hasIndex = await knex.schema.hasIndex(tableName, indexColumnList, indexName);
244
-
242
+ const hasIndex = await knex.schema.hasIndex(tableName, indexColumnList, indexName)
243
+
245
244
  if (!hasIndex) {
246
245
  await knex.schema.table(tableName, table => {
247
- table.index(indexColumnList, indexName);
248
- });
249
-
250
- log.info(`Created positioning index on ${tableName}`, { columns: indexColumnList });
246
+ table.index(indexColumnList, indexName)
247
+ })
248
+
249
+ log.info(`Created positioning index on ${tableName}`, { columns: indexColumnList })
251
250
  }
252
251
  } catch (error) {
253
- log.warn(`Could not create positioning index for ${scopeName}:`, error.message);
252
+ log.warn(`Could not create positioning index for ${scopeName}:`, error.message)
254
253
  }
255
- });
254
+ })
256
255
 
257
256
  // Process beforeId parameter in requests
258
257
  addHook('beforeSchemaValidate', 'process-beforeid', {}, async ({ context, scopeName, vars }) => {
259
- log.debug('*** beforeSchemaValidate positioning hook', {
260
- scopeName,
258
+ log.debug('*** beforeSchemaValidate positioning hook', {
259
+ scopeName,
261
260
  method: context.method,
262
261
  shouldHavePositioning: shouldHavePositioning(scopeName)
263
- });
264
-
262
+ })
263
+
265
264
  // Skip excluded resources
266
265
  if (!shouldHavePositioning(scopeName)) {
267
- log.debug('*** Skipping beforeId processing - excluded resource');
268
- return;
266
+ log.debug('*** Skipping beforeId processing - excluded resource')
267
+ return
269
268
  }
270
269
 
271
270
  // Skip if no positioning context needed
272
- const method = context.method;
271
+ const method = context.method
273
272
  if (!['post', 'put', 'patch'].includes(method)) {
274
- log.debug('*** Skipping beforeId processing - wrong method', { method });
275
- return;
273
+ log.debug('*** Skipping beforeId processing - wrong method', { method })
274
+ return
276
275
  }
277
276
 
278
277
  // Check if resource has position field
279
- const scope = scopes[scopeName];
280
- const hasPositionField = scope?.vars?.schemaInfo?.schemaStructure?.[vars.positioning.field];
281
-
278
+ const scope = scopes[scopeName]
279
+ const hasPositionField = scope?.vars?.schemaInfo?.schemaStructure?.[vars.positioning.field]
280
+
282
281
  if (!hasPositionField) {
283
- return;
282
+ return
284
283
  }
285
284
 
286
285
  // Extract beforeId from JSON:API format attributes
287
- const attributes = context.inputRecord?.data?.attributes
288
- || (context.simplified && context.inputRecord && typeof context.inputRecord === 'object'
286
+ const attributes = context.inputRecord?.data?.attributes ||
287
+ (context.simplified && context.inputRecord && typeof context.inputRecord === 'object'
289
288
  ? context.inputRecord
290
- : {});
291
- const beforeId = attributes[vars.positioning.beforeIdField];
289
+ : {})
290
+ const beforeId = attributes[vars.positioning.beforeIdField]
292
291
 
293
292
  // Store beforeId in context for later use
294
293
  if (beforeId !== undefined) {
295
- context.positioningBeforeId = beforeId;
294
+ context.positioningBeforeId = beforeId
296
295
 
297
296
  // Remove beforeId from attributes so it doesn't get stored
298
- delete attributes[vars.positioning.beforeIdField];
297
+ delete attributes[vars.positioning.beforeIdField]
299
298
 
300
299
  if (context.simplified && context.inputRecord && typeof context.inputRecord === 'object') {
301
- delete context.inputRecord[vars.positioning.beforeIdField];
300
+ delete context.inputRecord[vars.positioning.beforeIdField]
302
301
  }
303
302
  }
304
303
 
305
304
  // Remove any manually provided position field - position is managed by this plugin
306
305
  if (attributes[vars.positioning.field] !== undefined) {
307
- delete attributes[vars.positioning.field];
306
+ delete attributes[vars.positioning.field]
308
307
 
309
- log.debug('Positioning beforeId extracted', {
310
- scopeName,
308
+ log.debug('Positioning beforeId extracted', {
309
+ scopeName,
311
310
  beforeId,
312
311
  method
313
- });
312
+ })
314
313
 
315
314
  if (context.simplified && context.inputRecord && typeof context.inputRecord === 'object') {
316
- delete context.inputRecord[vars.positioning.field];
315
+ delete context.inputRecord[vars.positioning.field]
317
316
  }
318
317
  }
319
318
 
320
319
  // For new records without explicit position, mark for positioning
321
320
  if (method === 'post' && !attributes[vars.positioning.field]) {
322
- context.needsPositioning = true;
321
+ context.needsPositioning = true
323
322
  }
324
- });
323
+ })
325
324
 
326
325
  // Calculate and set position before create/update
327
326
  addHook('beforeDataCallPost', 'calculate-position-post', {}, async ({ context, scopeName, vars, helpers }) => {
328
- log.debug('*** beforeDataCallPost hook fired for positioning', { scopeName, method: context.method });
329
- await calculateAndSetPosition(context, scopeName, vars, helpers, log, scopes, api);
330
- });
327
+ log.debug('*** beforeDataCallPost hook fired for positioning', { scopeName, method: context.method })
328
+ await calculateAndSetPosition(context, scopeName, vars, helpers, log, scopes, api)
329
+ })
331
330
 
332
331
  addHook('beforeDataCallPut', 'calculate-position-put', {}, async ({ context, scopeName, vars, helpers }) => {
333
- log.debug('*** beforeDataCallPut hook fired for positioning', { scopeName, method: context.method });
332
+ log.debug('*** beforeDataCallPut hook fired for positioning', { scopeName, method: context.method })
334
333
  // Only process if beforeId was provided or it's a new record
335
334
  if (context.positioningBeforeId !== undefined || context.needsPositioning) {
336
- await calculateAndSetPosition(context, scopeName, vars, helpers, log, scopes, api);
335
+ await calculateAndSetPosition(context, scopeName, vars, helpers, log, scopes, api)
337
336
  }
338
- });
337
+ })
339
338
 
340
339
  addHook('beforeDataCallPatch', 'calculate-position-patch', {}, async ({ context, scopeName, vars, helpers }) => {
341
- log.debug('*** beforeDataCallPatch hook fired for positioning', { scopeName, method: context.method });
340
+ log.debug('*** beforeDataCallPatch hook fired for positioning', { scopeName, method: context.method })
342
341
  // Only process if beforeId was provided
343
342
  if (context.positioningBeforeId !== undefined) {
344
- await calculateAndSetPosition(context, scopeName, vars, helpers, log, scopes, api);
343
+ await calculateAndSetPosition(context, scopeName, vars, helpers, log, scopes, api)
345
344
  }
346
- });
345
+ })
347
346
 
348
347
  // Helper function to calculate and set position
349
- async function calculateAndSetPosition(context, scopeName, vars, helpers, log, scopes, api) {
350
- log.debug('*** calculateAndSetPosition called', {
351
- scopeName,
348
+ async function calculateAndSetPosition (context, scopeName, vars, helpers, log, scopes, api) {
349
+ log.debug('*** calculateAndSetPosition called', {
350
+ scopeName,
352
351
  method: context.method,
353
352
  shouldHavePositioning: shouldHavePositioning(scopeName),
354
353
  beforeId: context.positioningBeforeId,
355
354
  needsPositioning: context.needsPositioning
356
- });
357
-
355
+ })
356
+
358
357
  // Skip if no positioning needed
359
358
  if (!shouldHavePositioning(scopeName)) {
360
- log.debug('*** Skipping - resource excluded from positioning');
361
- return;
359
+ log.debug('*** Skipping - resource excluded from positioning')
360
+ return
362
361
  }
363
362
 
364
- const beforeId = context.positioningBeforeId;
365
- const needsPositioning = context.needsPositioning;
366
-
363
+ const beforeId = context.positioningBeforeId
364
+ const needsPositioning = context.needsPositioning
365
+
367
366
  // Skip if no positioning action needed
368
367
  if (beforeId === undefined && !needsPositioning) {
369
- log.debug('*** Skipping - no positioning needed', { beforeId, needsPositioning });
370
- return;
368
+ log.debug('*** Skipping - no positioning needed', { beforeId, needsPositioning })
369
+ return
371
370
  }
372
371
 
373
372
  const recordData = (() => {
374
373
  if (context.inputRecord?.data?.attributes) {
375
- return { ...context.inputRecord.data.attributes };
374
+ return { ...context.inputRecord.data.attributes }
376
375
  }
377
376
  if (context.simplified && context.inputRecord && typeof context.inputRecord === 'object') {
378
- return { ...context.inputRecord };
377
+ return { ...context.inputRecord }
379
378
  }
380
- return {};
381
- })();
382
-
383
- log.debug('*** Record data for positioning', {
379
+ return {}
380
+ })()
381
+
382
+ log.debug('*** Record data for positioning', {
384
383
  recordData,
385
384
  simplified: context.simplified,
386
385
  inputRecord: context.inputRecord
387
- });
386
+ })
388
387
 
389
- const jsonApiRecord = context.inputRecord?.data || {};
390
- const inputRelationships = jsonApiRecord.relationships || {};
388
+ const jsonApiRecord = context.inputRecord?.data || {}
389
+ const inputRelationships = jsonApiRecord.relationships || {}
391
390
  const simplifiedRecord = context.simplified && context.inputRecord && typeof context.inputRecord === 'object'
392
391
  ? context.inputRecord
393
- : {};
392
+ : {}
394
393
 
395
- const minimalAttributes = context.minimalRecord?.attributes || {};
396
- const minimalRelationships = context.minimalRecord?.relationships || {};
394
+ const minimalAttributes = context.minimalRecord?.attributes || {}
395
+ const minimalRelationships = context.minimalRecord?.relationships || {}
397
396
 
398
397
  const combinedAttributes = {
399
398
  ...minimalAttributes,
400
399
  ...recordData,
401
- };
400
+ }
402
401
 
403
402
  // Build filter conditions for the position group
404
- const schemaInfo = context.schemaInfo;
405
- const adapter = context.storageAdapter || resolveStorageAdapter(scopeName);
403
+ const schemaInfo = context.schemaInfo
404
+ const adapter = context.storageAdapter || resolveStorageAdapter(scopeName)
406
405
 
407
406
  if (!adapter) {
408
- log.warn('*** Positioning skipped - no storage adapter available', { scopeName });
409
- return;
407
+ log.warn('*** Positioning skipped - no storage adapter available', { scopeName })
408
+ return
410
409
  }
411
-
410
+
412
411
  const filterConditions = buildPositionFilterConditions(adapter, schemaInfo, {
413
412
  attributes: combinedAttributes,
414
413
  rawRecord: recordData,
415
414
  simplifiedRecord,
416
415
  inputRelationships,
417
416
  minimalRelationships,
418
- });
417
+ })
419
418
 
420
- log.debug('*** Position filter conditions', { scopeName, filterConditions });
419
+ log.debug('*** Position filter conditions', { scopeName, filterConditions })
421
420
 
422
421
  // Query existing items in the same position group
423
- const knex = api.knex.instance;
424
- const tableName = adapter.getTableName();
425
- const idProperty = schemaInfo.idProperty;
426
- const idColumn = adapter.getIdColumn();
427
- const positionColumn = adapter.translateColumn(vars.positioning.field);
428
-
422
+ const knex = api.knex.instance
423
+ const tableName = adapter.getTableName()
424
+ const idProperty = schemaInfo.idProperty
425
+ const idColumn = adapter.getIdColumn()
426
+ const positionColumn = adapter.translateColumn(vars.positioning.field)
427
+
429
428
  log.debug('*** Building position query', {
430
429
  tableName,
431
430
  idProperty,
@@ -433,117 +432,117 @@ export const PositioningPlugin = {
433
432
  positionField: vars.positioning.field,
434
433
  positionColumn,
435
434
  filterConditions
436
- });
437
-
435
+ })
436
+
438
437
  // Check if we have a transaction to use
439
438
  // Build base query with filter conditions
440
- const baseQuery = adapter.buildBaseQuery({ transaction: context.transaction });
439
+ const baseQuery = adapter.buildBaseQuery({ transaction: context.transaction })
441
440
  Object.entries(filterConditions).forEach(([column, value]) => {
442
441
  if (value === null) {
443
- baseQuery.whereNull(column);
442
+ baseQuery.whereNull(column)
444
443
  } else {
445
- baseQuery.where(column, value);
444
+ baseQuery.where(column, value)
446
445
  }
447
- });
446
+ })
448
447
 
449
448
  // For updates, exclude the current record
450
449
  if ((context.method === 'patch' || context.method === 'put') && context.id) {
451
- const translatedId = adapter.translateFilterValue(idProperty, context.id);
452
- baseQuery.whereNot(idColumn, translatedId);
450
+ const translatedId = adapter.translateFilterValue(idProperty, context.id)
451
+ baseQuery.whereNot(idColumn, translatedId)
453
452
  }
454
-
453
+
455
454
  // Calculate effective beforeId first
456
- let effectiveBeforeId = beforeId;
457
-
455
+ let effectiveBeforeId = beforeId
456
+
458
457
  // Handle default positioning when no beforeId is provided
459
458
  if (beforeId === undefined) {
460
459
  if (vars.positioning.defaultPosition === 'last') {
461
- effectiveBeforeId = null; // null means position at end
460
+ effectiveBeforeId = null // null means position at end
462
461
  } else if (vars.positioning.defaultPosition === 'first') {
463
- effectiveBeforeId = 'FIRST'; // Special marker for first position
462
+ effectiveBeforeId = 'FIRST' // Special marker for first position
464
463
  }
465
464
  }
466
-
467
- let items = [];
468
-
465
+
466
+ let items = []
467
+
469
468
  const selectColumns = {
470
469
  [idProperty]: idColumn,
471
470
  [vars.positioning.field]: positionColumn,
472
- };
473
- const selectWithAliases = (builder) => adapter.selectColumns(builder, selectColumns);
471
+ }
472
+ const selectWithAliases = (builder) => adapter.selectColumns(builder, selectColumns)
474
473
 
475
474
  try {
476
475
  log.debug('*** Positioning query start', {
477
476
  effectiveBeforeId,
478
477
  selectColumns,
479
- });
478
+ })
480
479
  if (effectiveBeforeId === null) {
481
480
  // Positioning at end - only get the last item
482
481
  const lastItem = await selectWithAliases(baseQuery
483
482
  .clone())
484
483
  .orderBy(positionColumn, 'desc')
485
- .first();
486
-
484
+ .first()
485
+
487
486
  if (lastItem) {
488
- items = [lastItem];
487
+ items = [lastItem]
489
488
  }
490
489
  } else if (effectiveBeforeId === 'FIRST') {
491
490
  // Positioning at beginning - get the first item
492
491
  const firstItem = await selectWithAliases(baseQuery
493
492
  .clone())
494
493
  .orderBy(positionColumn, 'asc')
495
- .first();
496
-
494
+ .first()
495
+
497
496
  if (firstItem) {
498
- items = [firstItem];
499
- effectiveBeforeId = firstItem[idProperty];
497
+ items = [firstItem]
498
+ effectiveBeforeId = firstItem[idProperty]
500
499
  } else {
501
- effectiveBeforeId = null;
500
+ effectiveBeforeId = null
502
501
  }
503
502
  } else if (effectiveBeforeId) {
504
503
  // Positioning before a specific item - get that item and the one before it
505
- const normalizedTargetId = adapter.translateFilterValue(idProperty, effectiveBeforeId);
504
+ const normalizedTargetId = adapter.translateFilterValue(idProperty, effectiveBeforeId)
506
505
  const targetItem = await selectWithAliases(baseQuery
507
506
  .clone())
508
507
  .where(idColumn, normalizedTargetId)
509
- .first();
510
-
508
+ .first()
509
+
511
510
  if (targetItem) {
512
511
  // Get the item immediately before the target
513
512
  const prevItem = await selectWithAliases(baseQuery
514
513
  .clone())
515
514
  .where(positionColumn, '<', targetItem[vars.positioning.field])
516
515
  .orderBy(positionColumn, 'desc')
517
- .first();
518
-
519
- items = prevItem ? [prevItem, targetItem] : [targetItem];
516
+ .first()
517
+
518
+ items = prevItem ? [prevItem, targetItem] : [targetItem]
520
519
  }
521
520
  } else {
522
521
  // First item in group - no items needed
523
- items = [];
522
+ items = []
524
523
  }
525
-
526
- log.debug('*** Query completed', { itemCount: items.length, items });
524
+
525
+ log.debug('*** Query completed', { itemCount: items.length, items })
527
526
  } catch (error) {
528
- log.error('*** Query failed', { error: error.message, stack: error.stack });
529
- throw error;
527
+ log.error('*** Query failed', { error: error.message, stack: error.stack })
528
+ throw error
530
529
  }
531
-
530
+
532
531
  // Calculate new position
533
- const newPosition = items.length === 0
532
+ const newPosition = items.length === 0
534
533
  ? getInitialPosition()
535
- : calculatePosition(items, effectiveBeforeId, idProperty, vars.positioning.field);
536
-
534
+ : calculatePosition(items, effectiveBeforeId, idProperty, vars.positioning.field)
535
+
537
536
  // Set the position on the incoming payload so downstream steps persist it
538
537
  if (context.inputRecord?.data) {
539
- context.inputRecord.data.attributes = context.inputRecord.data.attributes || {};
540
- context.inputRecord.data.attributes[vars.positioning.field] = newPosition;
538
+ context.inputRecord.data.attributes = context.inputRecord.data.attributes || {}
539
+ context.inputRecord.data.attributes[vars.positioning.field] = newPosition
541
540
  }
542
541
 
543
542
  if (context.simplified && context.inputRecord && typeof context.inputRecord === 'object') {
544
- context.inputRecord[vars.positioning.field] = newPosition;
543
+ context.inputRecord[vars.positioning.field] = newPosition
545
544
  }
546
-
545
+
547
546
  log.debug('Position calculated', {
548
547
  scopeName,
549
548
  newPosition,
@@ -552,7 +551,7 @@ export const PositioningPlugin = {
552
551
  itemCount: items.length,
553
552
  method: context.method,
554
553
  id: context.id
555
- });
554
+ })
556
555
  }
557
556
 
558
557
  // Add positioning info back to response
@@ -560,34 +559,34 @@ export const PositioningPlugin = {
560
559
  // Only add if beforeId was in the original request
561
560
  if (context.positioningBeforeId !== undefined && context.record) {
562
561
  if (context.simplified) {
563
- context.record[vars.positioning.beforeIdField] = context.positioningBeforeId;
562
+ context.record[vars.positioning.beforeIdField] = context.positioningBeforeId
564
563
  } else {
565
564
  // For JSON:API, add to meta
566
- context.meta = context.meta || {};
565
+ context.meta = context.meta || {}
567
566
  context.meta.positioning = {
568
567
  [vars.positioning.beforeIdField]: context.positioningBeforeId
569
- };
568
+ }
570
569
  }
571
570
  }
572
- });
571
+ })
573
572
 
574
573
  // Apply default sort by position if no other sort specified
575
574
  addHook('beforeQuery', 'apply-position-sort', {}, async ({ context, scopeName, vars }) => {
576
575
  if (!shouldHavePositioning(scopeName)) {
577
- return;
576
+ return
578
577
  }
579
578
 
580
579
  // Check if sort is already specified
581
- const hasSort = context.queryParams?.sort && context.queryParams.sort.length > 0;
582
-
580
+ const hasSort = context.queryParams?.sort && context.queryParams.sort.length > 0
581
+
583
582
  if (!hasSort) {
584
583
  // Apply position sort
585
- context.queryParams = context.queryParams || {};
586
- context.queryParams.sort = [vars.positioning.field];
587
-
588
- log.trace('Applied default position sort', { scopeName });
584
+ context.queryParams = context.queryParams || {}
585
+ context.queryParams.sort = [vars.positioning.field]
586
+
587
+ log.trace('Applied default position sort', { scopeName })
589
588
  }
590
- });
589
+ })
591
590
 
592
591
  // API methods for positioning operations
593
592
  api.positioning = {
@@ -597,41 +596,41 @@ export const PositioningPlugin = {
597
596
  * @param {Array} positions - Array of {id, position} or {id, beforeId}
598
597
  * @param {Object} filters - Filter conditions for position group
599
598
  */
600
- async reorder(scopeName, positions, filters = {}) {
601
- const scope = scopes[scopeName];
599
+ async reorder (scopeName, positions, filters = {}) {
600
+ const scope = scopes[scopeName]
602
601
  if (!scope) {
603
- throw new Error(`Unknown resource: ${scopeName}`);
602
+ throw new Error(`Unknown resource: ${scopeName}`)
604
603
  }
605
604
 
606
605
  // Implementation would go here for bulk reordering
607
- log.info('Bulk reorder requested', { scopeName, count: positions.length });
606
+ log.info('Bulk reorder requested', { scopeName, count: positions.length })
608
607
  },
609
608
 
610
609
  /**
611
610
  * Get positioning configuration
612
611
  */
613
- getConfig() {
614
- return { ...vars.positioning };
612
+ getConfig () {
613
+ return { ...vars.positioning }
615
614
  },
616
615
 
617
616
  /**
618
617
  * Check if a resource has positioning enabled
619
618
  */
620
- isEnabled(scopeName) {
621
- return shouldHavePositioning(scopeName);
619
+ isEnabled (scopeName) {
620
+ return shouldHavePositioning(scopeName)
622
621
  }
623
- };
622
+ }
624
623
 
625
624
  log.info('Positioning plugin installed', {
626
625
  field: vars.positioning.field,
627
626
  strategy: vars.positioning.strategy,
628
627
  filters: vars.positioning.filters,
629
628
  excludedResources: vars.positioning.excludeResources
630
- });
629
+ })
631
630
  }
632
- };
631
+ }
633
632
 
634
- /*
633
+ /*
635
634
  Usage examples:
636
635
 
637
636
  // Basic usage - positioning for all resources