json-rest-api 1.0.21 → 1.0.23

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 (88) hide show
  1. package/CLAUDE.md +1 -2
  2. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +180 -0
  3. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +172 -0
  4. package/docs/API.md +51 -14
  5. package/docs/COMPARISON.md +2 -2
  6. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +28 -1
  7. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +5 -1
  8. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +3 -1
  9. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +12 -60
  10. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +2 -2
  11. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -1
  12. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +2 -0
  13. package/docs/GUIDE/GUIDE_X_Autofiltering.md +355 -0
  14. package/docs/GUIDE/GUIDE_X_Fastify.md +78 -0
  15. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +250 -0
  16. package/docs/GUIDE/GUIDE_X_Query_Projections.md +159 -0
  17. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +199 -0
  18. package/docs/GUIDE/index.md +18 -9
  19. package/docs/QUICKSTART.md +5 -1
  20. package/docs/index.md +1 -1
  21. package/index.js +3 -2
  22. package/package.json +4 -4
  23. package/plugins/core/connectors/express-plugin.js +97 -184
  24. package/plugins/core/connectors/fastify-plugin.js +206 -0
  25. package/plugins/core/connectors/lib/connector-core.js +195 -0
  26. package/plugins/core/connectors/lib/transport-http-helpers.js +122 -0
  27. package/plugins/core/connectors/lib/transport-route-schemas.js +92 -0
  28. package/plugins/core/lib/dbIntrospection.js +1078 -0
  29. package/plugins/core/lib/dbTablesOperations.js +1104 -160
  30. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +5 -2
  31. package/plugins/core/lib/querying/knex-process-includes.js +3 -4
  32. package/plugins/core/lib/querying/knex-query-helpers.js +17 -71
  33. package/plugins/core/lib/querying/knex-relationship-includes.js +466 -206
  34. package/plugins/core/lib/querying/query-builder-utils.js +19 -0
  35. package/plugins/core/lib/querying/query-field-sort-helpers.js +135 -0
  36. package/plugins/core/lib/querying/sort-helpers.js +33 -0
  37. package/plugins/core/lib/querying/storage-adapter-utils.js +51 -0
  38. package/plugins/core/lib/querying/url-helpers.js +8 -2
  39. package/plugins/core/lib/querying-writing/compile-schemas.js +12 -1
  40. package/plugins/core/lib/querying-writing/knex-field-helpers.js +83 -13
  41. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +13 -12
  42. package/plugins/core/lib/querying-writing/query-field-helpers.js +88 -0
  43. package/plugins/core/lib/querying-writing/request-contracts.js +590 -0
  44. package/plugins/core/lib/storage/canonical-storage-mapping.js +113 -0
  45. package/plugins/core/lib/storage/storage-adapter.js +79 -2
  46. package/plugins/core/lib/storage/storage-mapping.js +204 -0
  47. package/plugins/core/rest-api-anyapi-knex-plugin.js +355 -228
  48. package/plugins/core/rest-api-autofilter-plugin.js +414 -0
  49. package/plugins/core/rest-api-knex-plugin.js +249 -211
  50. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +10 -5
  51. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +12 -6
  52. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +41 -0
  53. package/plugins/core/rest-api-plugin-methods/common.js +121 -83
  54. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +6 -1
  55. package/plugins/core/rest-api-plugin-methods/get.js +23 -14
  56. package/plugins/core/rest-api-plugin-methods/patch.js +28 -12
  57. package/plugins/core/rest-api-plugin-methods/post.js +13 -8
  58. package/plugins/core/rest-api-plugin-methods/put.js +45 -21
  59. package/plugins/core/rest-api-plugin-methods/query.js +15 -53
  60. package/plugins/core/rest-api-plugin.js +2 -0
  61. package/plugins/core/rest-api-positioning-plugin.js +1 -1
  62. package/plugins/core/rest-api-query-projections-plugin.js +130 -0
  63. package/plugins/core/socketio-plugin.js +1 -3
  64. package/tests/autofilter.test.js +463 -0
  65. package/tests/custom-id-simplified-post.test.js +75 -0
  66. package/tests/custom-idproperty-relationships.test.js +33 -0
  67. package/tests/db-introspection.test.js +518 -0
  68. package/tests/db-table-operations.test.js +781 -0
  69. package/tests/fastify-plugin.test.js +141 -0
  70. package/tests/field-getters.test.js +1 -2
  71. package/tests/field-setters.test.js +0 -4
  72. package/tests/fixtures/api-configs.js +188 -219
  73. package/tests/helpers/fake-fastify.js +99 -0
  74. package/tests/http-connectors-parity.test.js +235 -0
  75. package/tests/projected-fields.test.js +319 -0
  76. package/tests/relationships.test.js +3 -3
  77. package/tests/searchschema-knex-behavior.test.js +187 -0
  78. package/tests/searchschema-merge.test.js +21 -69
  79. package/tests/storage-mapping.test.js +210 -0
  80. package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +0 -707
  81. package/docs/GUIDE/GUIDE_X_Multihome.md +0 -666
  82. package/plugins/core/lib/querying-writing/payload-validators.js +0 -1314
  83. package/plugins/core/rest-api-access.js +0 -459
  84. package/plugins/core/rest-api-multihome-plugin.js +0 -314
  85. package/tests/multihome.test.js +0 -553
  86. package/tests/multihome.test.js.bak +0 -550
  87. package/tests/ownership-optout.test.js +0 -76
  88. package/tests/rest-api-access.test.js +0 -206
@@ -1,9 +1,15 @@
1
1
  import { requirePackage } from 'hooked-api'
2
2
  import { createSchema } from 'json-rest-schema'
3
- import { createKnexTable, addKnexFields, alterKnexFields } from './lib/dbTablesOperations.js'
4
- import { buildFieldSelection, isNonDatabaseField } from './lib/querying-writing/knex-field-helpers.js'
3
+ import {
4
+ createKnexTable,
5
+ addKnexFields,
6
+ alterKnexFields,
7
+ generateKnexMigration,
8
+ generateKnexMigrationDiff
9
+ } from './lib/dbTablesOperations.js'
10
+ import { introspectKnexTableSnapshot } from './lib/dbIntrospection.js'
11
+ import { applyFieldSelectionToQuery, buildFieldSelection, isNonDatabaseField } from './lib/querying-writing/knex-field-helpers.js'
5
12
  import { getForeignKeyFields } from './lib/querying-writing/field-utils.js'
6
- import { buildQuerySelection } from './lib/querying/knex-query-helpers-base.js'
7
13
  import { toJsonApiRecord, buildJsonApiResponse } from './lib/querying/knex-json-api-transformers-querying.js'
8
14
  import { processBelongsToRelationships } from './lib/writing/knex-json-api-transformers-writing.js'
9
15
  import { toJsonApiRecordWithBelongsTo } from './lib/querying-writing/knex-json-api-transformers.js'
@@ -26,30 +32,13 @@ import {
26
32
  } from './lib/querying/knex-pagination-helpers.js'
27
33
  import { getUrlPrefix } from './lib/querying/url-helpers.js'
28
34
  import { createStorageAdapter } from './lib/storage/storage-adapter.js'
29
-
30
- /**
31
- * Strips non-database fields (computed and virtual) from attributes before database operations
32
- * @param {Object} attributes - The attributes object
33
- * @param {Object} schemaInfo - The schema info containing computed field definitions and schema structure
34
- * @returns {Object} Attributes with computed and virtual fields removed
35
- */
36
- const stripNonDatabaseFields = (attributes, schemaInfo) => {
37
- if (!attributes || !schemaInfo) return attributes || {}
38
-
39
- const { computed = {}, schemaStructure = {} } = schemaInfo
40
- return Object.entries(attributes)
41
- .filter(([key]) => {
42
- // Remove computed fields
43
- if (key in computed) return false
44
- // Remove fields marked as virtual in the schema
45
- const fieldDef = schemaStructure[key]
46
- if (fieldDef && fieldDef.virtual === true) {
47
- return false
48
- }
49
- return true
50
- })
51
- .reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
52
- }
35
+ import {
36
+ applyCursorPredicate,
37
+ applyQueryFieldOrder,
38
+ buildEffectiveSortList,
39
+ parseSortEntry
40
+ } from './lib/querying/query-field-sort-helpers.js'
41
+ import { unwrapQueryBuilderState } from './lib/querying/query-builder-utils.js'
53
42
 
54
43
  export const RestApiKnexPlugin = {
55
44
  name: 'rest-api-knex',
@@ -99,22 +88,89 @@ export const RestApiKnexPlugin = {
99
88
  api.knex.helpers.getStorageAdapter = getScopeStorageAdapter
100
89
  helpers.getStorageAdapter = getScopeStorageAdapter
101
90
 
102
- const createSelectTranslator = (adapter) => {
103
- if (!adapter) return null
104
- return (field, alias) => {
105
- if (field === '*') {
106
- return alias ? `${alias}.*` : '*'
91
+ const buildScopeTableSchema = (vars = {}) => {
92
+ const schemaStructure = vars.schemaInfo?.schemaStructure || {}
93
+ const filteredSchema = {}
94
+
95
+ Object.entries(schemaStructure).forEach(([fieldName, fieldDef]) => {
96
+ if (!fieldDef.virtual) {
97
+ filteredSchema[fieldName] = fieldDef
98
+ }
99
+ })
100
+
101
+ return {
102
+ structure: filteredSchema,
103
+ indexes: vars.schemaInfo?.indexes || [],
104
+ foreignKeys: vars.schemaInfo?.foreignKeys || [],
105
+ checkConstraints: vars.schemaInfo?.checkConstraints || []
106
+ }
107
+ }
108
+
109
+ const buildLegacySortDescriptors = ({
110
+ query,
111
+ sort,
112
+ schemaInfo,
113
+ sortableFields,
114
+ storageAdapter,
115
+ defaultSort,
116
+ queryFieldRuntimeByField = new Map()
117
+ }) => {
118
+ const effectiveSort = buildEffectiveSortList(sort, { defaultSort, idField: 'id' })
119
+ const descriptors = []
120
+
121
+ for (const sortEntry of effectiveSort) {
122
+ const { field, direction, sqlDirection } = parseSortEntry(sortEntry)
123
+
124
+ if (field !== 'id' && sortableFields?.length > 0 && !sortableFields.includes(field)) {
125
+ log.warn(`Ignoring non-sortable field: ${field}`)
126
+ continue
107
127
  }
108
- const translated = adapter.translateColumn(field)
109
- const result = translated || field
110
- if (!alias) {
111
- return result
128
+
129
+ const queryFieldRuntime = queryFieldRuntimeByField.get(field)
130
+ if (queryFieldRuntime) {
131
+ applyQueryFieldOrder(query, queryFieldRuntime, sqlDirection)
132
+ descriptors.push({
133
+ field,
134
+ direction: sqlDirection,
135
+ queryFieldRuntime,
136
+ definition: queryFieldRuntime.definition,
137
+ isRelationship: false
138
+ })
139
+ continue
112
140
  }
113
- if (result.includes('.')) {
114
- return result
141
+
142
+ let dbField = field
143
+ const searchField = schemaInfo.searchSchemaStructure?.[field]
144
+ if (searchField?.actualField) {
145
+ dbField = searchField.actualField
115
146
  }
116
- return `${alias}.${result}`
147
+
148
+ const translatedField = storageAdapter?.translateColumn
149
+ ? storageAdapter.translateColumn(dbField)
150
+ : dbField
151
+
152
+ query.orderBy(translatedField, direction)
153
+ descriptors.push({
154
+ field,
155
+ direction: sqlDirection,
156
+ column: translatedField,
157
+ definition: schemaInfo.schemaStructure?.[field] || { type: field === 'id' ? 'id' : undefined },
158
+ isRelationship: Boolean(searchField?.isRelationship)
159
+ })
160
+ }
161
+
162
+ if (descriptors.length === 0) {
163
+ query.orderBy('id', 'asc')
164
+ descriptors.push({
165
+ field: 'id',
166
+ direction: 'ASC',
167
+ column: 'id',
168
+ definition: { type: 'id' },
169
+ isRelationship: false
170
+ })
117
171
  }
172
+
173
+ return descriptors
118
174
  }
119
175
 
120
176
  // Check database capabilities
@@ -170,21 +226,45 @@ export const RestApiKnexPlugin = {
170
226
 
171
227
  // Helper scope method to get all schema-related information
172
228
  addScopeMethod('createKnexTable', async ({ vars, scope, scopeName, scopeOptions, runHooks }) => {
173
- // Create a filtered schema that excludes virtual fields
174
- const schemaStructure = vars.schemaInfo.schemaStructure || {}
175
- const filteredSchema = {}
229
+ const tableSchemaInstance = buildScopeTableSchema(vars)
230
+ await createKnexTable(api.knex.instance, vars.schemaInfo, tableSchemaInstance, scopeOptions)
231
+ })
176
232
 
177
- // Copy only non-virtual fields to the filtered schema
178
- Object.entries(schemaStructure).forEach(([fieldName, fieldDef]) => {
179
- if (!fieldDef.virtual) {
180
- filteredSchema[fieldName] = fieldDef
181
- }
233
+ addScopeMethod('introspectKnexTableSnapshot', async ({ vars }) => {
234
+ return introspectKnexTableSnapshot(api.knex.instance, {
235
+ tableName: vars.schemaInfo.tableName,
236
+ idColumn: vars.schemaInfo.idProperty
182
237
  })
238
+ })
183
239
 
184
- // Create schema object from filtered fields
185
- const tableSchemaInstance = createSchema(filteredSchema)
240
+ addScopeMethod('generateKnexMigration', async ({ vars, scopeOptions }) => {
241
+ return generateKnexMigration(
242
+ vars.schemaInfo.tableName,
243
+ buildScopeTableSchema(vars),
244
+ {
245
+ ...scopeOptions,
246
+ idProperty: vars.schemaInfo.idProperty,
247
+ dialect: api.knex.instance?.client?.config?.client
248
+ }
249
+ )
250
+ })
186
251
 
187
- await createKnexTable(api.knex.instance, vars.schemaInfo, tableSchemaInstance, scopeOptions)
252
+ addScopeMethod('generateKnexMigrationDiff', async ({ vars, params }) => {
253
+ const snapshot = await introspectKnexTableSnapshot(api.knex.instance, {
254
+ tableName: vars.schemaInfo.tableName,
255
+ idColumn: vars.schemaInfo.idProperty
256
+ })
257
+
258
+ return generateKnexMigrationDiff(
259
+ vars.schemaInfo.tableName,
260
+ snapshot,
261
+ buildScopeTableSchema(vars),
262
+ {
263
+ ...(params?.options || {}),
264
+ idProperty: vars.schemaInfo.idProperty,
265
+ dialect: snapshot.dialect || api.knex.instance?.client?.config?.client
266
+ }
267
+ )
188
268
  })
189
269
 
190
270
  // Helper scope method to alter existing fields in a table
@@ -198,7 +278,7 @@ export const RestApiKnexPlugin = {
198
278
  api.knex.instance,
199
279
  vars.schemaInfo.tableName,
200
280
  params.fields,
201
- params.options // Pass through any additional options
281
+ { ...params.options, idProperty: vars.schemaInfo.idProperty }
202
282
  )
203
283
  })
204
284
 
@@ -207,7 +287,12 @@ export const RestApiKnexPlugin = {
207
287
  // Create schema object from filtered fields
208
288
  const partialTableSchema = createSchema(params.fields)
209
289
 
210
- await addKnexFields(api.knex.instance, vars.schemaInfo.tableName, partialTableSchema)
290
+ await addKnexFields(
291
+ api.knex.instance,
292
+ vars.schemaInfo.tableName,
293
+ partialTableSchema,
294
+ { idProperty: vars.schemaInfo.idProperty }
295
+ )
211
296
  })
212
297
 
213
298
  helpers.newTransaction = async () => {
@@ -317,15 +402,18 @@ export const RestApiKnexPlugin = {
317
402
  // Permission checks will handle access control
318
403
  let query = db(tableName).where(idProperty, id)
319
404
 
320
- // Apply field selection
321
- const selectTranslator = createSelectTranslator(storageAdapter)
322
- query = buildQuerySelection(
405
+ const selectionState = await applyFieldSelectionToQuery({
323
406
  query,
407
+ scope,
408
+ fieldSelectionInfo,
324
409
  tableName,
325
- fieldSelectionInfo.fieldsToSelect,
326
- false,
327
- selectTranslator ? { translateColumn: selectTranslator } : undefined
328
- )
410
+ useTablePrefix: false,
411
+ storageAdapter,
412
+ db,
413
+ context,
414
+ scopeName
415
+ })
416
+ query = selectionState.query
329
417
 
330
418
  const record = await query.first()
331
419
 
@@ -342,7 +430,7 @@ export const RestApiKnexPlugin = {
342
430
 
343
431
  // Load relationship identifiers for all hasMany relationships
344
432
  const records = [record] // Wrap in array for processing
345
- await loadRelationshipIdentifiers(records, scopeName, scopes, db)
433
+ await loadRelationshipIdentifiers(records, scopeName, scopes, db, context)
346
434
 
347
435
  // Process includes
348
436
  const included = await processIncludes(scope, records, {
@@ -369,7 +457,7 @@ export const RestApiKnexPlugin = {
369
457
  * @param {Object} params.context.db - Database connection (knex instance or transaction)
370
458
  * @returns {Promise<Object|null>} JSON:API formatted resource with belongsTo relationships, or null if not found
371
459
  */
372
- helpers.dataGetMinimal = async ({ scopeName, context }) => {
460
+ helpers.dataGetMinimal = async ({ scopeName, context, runHooks, applyQueryFilters }) => {
373
461
  const storageAdapter = getScopeStorageAdapter(scopeName)
374
462
  if (storageAdapter) {
375
463
  context.storageAdapter = storageAdapter
@@ -382,8 +470,9 @@ export const RestApiKnexPlugin = {
382
470
 
383
471
  log.debug(`[Knex] GET_MINIMAL ${tableName}/${id}`)
384
472
 
385
- // Build query - no filtering hooks for single records
386
- // Permission checks will handle access control
473
+ // Build query.
474
+ // Single-record lookups still go through knexQueryFiltering when hooks are available,
475
+ // so resource scoping plugins can treat GET/PUT/PATCH/DELETE the same way as collections.
387
476
  let query = db(tableName).where(idProperty, id)
388
477
 
389
478
  // Add alias if idProperty is not 'id'
@@ -391,6 +480,35 @@ export const RestApiKnexPlugin = {
391
480
  query = query.select('*', `${idProperty} as id`)
392
481
  }
393
482
 
483
+ if (typeof applyQueryFilters === 'function') {
484
+ const scopedQueryState = await applyQueryFilters({
485
+ query,
486
+ filters: context.queryParams?.filters,
487
+ tableName,
488
+ db,
489
+ scopeName,
490
+ storageAdapter
491
+ })
492
+ query = unwrapQueryBuilderState(scopedQueryState, query)
493
+ } else if (runHooks) {
494
+ context.knexQuery = {
495
+ query,
496
+ filters: context.queryParams?.filters,
497
+ schemaInfo: context.schemaInfo,
498
+ scopeName,
499
+ tableName,
500
+ db,
501
+ adapter: storageAdapter,
502
+ storageAdapter,
503
+ }
504
+
505
+ await runHooks('knexQueryFiltering')
506
+
507
+ if (context.knexQuery) {
508
+ delete context.knexQuery
509
+ }
510
+ }
511
+
394
512
  // Execute query
395
513
  const record = await query.first()
396
514
 
@@ -461,14 +579,19 @@ export const RestApiKnexPlugin = {
461
579
  // Start building query with table prefix (for JOIN support)
462
580
  let query = db(tableName)
463
581
 
464
- const selectTranslator = createSelectTranslator(storageAdapter)
465
- query = buildQuerySelection(
582
+ const selectionState = await applyFieldSelectionToQuery({
466
583
  query,
584
+ scope,
585
+ fieldSelectionInfo,
467
586
  tableName,
468
- fieldSelectionInfo.fieldsToSelect,
469
- true,
470
- selectTranslator ? { translateColumn: selectTranslator } : undefined
471
- )
587
+ useTablePrefix: true,
588
+ storageAdapter,
589
+ db,
590
+ context,
591
+ scopeName
592
+ })
593
+ query = selectionState.query
594
+ const queryFieldRuntimeByField = selectionState.queryFieldRuntimeByField
472
595
 
473
596
  /* ═══════════════════════════════════════════════════════════════════
474
597
  * FILTERING HOOKS
@@ -513,33 +636,15 @@ export const RestApiKnexPlugin = {
513
636
 
514
637
  log.trace('[DATA-QUERY] Finished knexQueryFiltering hook')
515
638
 
516
- // Apply sorting directly (no hooks)
517
- if (queryParams.sort && queryParams.sort.length > 0) {
518
- queryParams.sort.forEach(sortField => {
519
- const desc = sortField.startsWith('-')
520
- const field = desc ? sortField.substring(1) : sortField
521
-
522
- // Check if field is sortable
523
- if (sortableFields && sortableFields.length > 0 && !sortableFields.includes(field)) {
524
- log.warn(`Ignoring non-sortable field: ${field}`)
525
- return // Skip non-sortable fields
526
- }
527
-
528
- // Map relationship names to actual database columns for sorting
529
- // Check if this is a relationship field that needs to be mapped
530
- let dbField = field
531
- const searchField = schemaInfo.searchSchemaStructure?.[field]
532
- if (searchField?.actualField) {
533
- // This is a relationship field, use the actual database column
534
- dbField = searchField.actualField
535
- }
536
- const translatedField = storageAdapter?.translateColumn
537
- ? storageAdapter.translateColumn(dbField)
538
- : dbField
539
-
540
- query.orderBy(translatedField, desc ? 'desc' : 'asc')
541
- })
542
- }
639
+ const sortDescriptors = buildLegacySortDescriptors({
640
+ query,
641
+ sort: queryParams.sort,
642
+ schemaInfo,
643
+ sortableFields,
644
+ defaultSort: scope.vars.defaultSort,
645
+ storageAdapter,
646
+ queryFieldRuntimeByField
647
+ })
543
648
 
544
649
  // Apply pagination
545
650
  // Check if page object has any actual pagination parameters
@@ -583,9 +688,6 @@ export const RestApiKnexPlugin = {
583
688
  // Fetch one extra record to determine if there are more
584
689
  query.limit(pageSize + 1)
585
690
 
586
- // Get the configured ID property
587
- const idProperty = context.schemaInfo.idProperty || 'id'
588
-
589
691
  if (queryParams.page.after) {
590
692
  let cursorData
591
693
  try {
@@ -604,56 +706,16 @@ export const RestApiKnexPlugin = {
604
706
  )
605
707
  }
606
708
 
607
- // Build sort fields array with directions
608
- const sortFields = []
609
- if (queryParams.sort && queryParams.sort.length > 0) {
610
- queryParams.sort.forEach(sortField => {
611
- const desc = sortField.startsWith('-')
612
- const field = desc ? sortField.substring(1) : sortField
613
- sortFields.push({ field, direction: desc ? 'DESC' : 'ASC' })
614
- })
615
- } else if (scope.vars.defaultSort) {
616
- sortFields.push({
617
- field: scope.vars.defaultSort.field || idProperty,
618
- direction: scope.vars.defaultSort.direction || 'ASC'
619
- })
620
- } else {
621
- sortFields.push({ field: idProperty, direction: 'ASC' })
622
- }
623
-
624
- // Build compound WHERE clause for multi-field cursor
625
- query.where(function () {
626
- sortFields.forEach((sortInfo, index) => {
627
- const { field, direction } = sortInfo
628
- const qualifiedField = `${tableName}.${field}`
629
- const cursorValue = cursorData[field]
630
-
631
- if (cursorValue === undefined) {
632
- log.warn(`Cursor missing value for sort field: ${field}`)
633
- return
634
- }
635
-
636
- // Build condition for this level and all previous levels
637
- this.orWhere(function () {
638
- // All previous fields must be equal
639
- for (let i = 0; i < index; i++) {
640
- const prevField = sortFields[i].field
641
- const prevQualifiedField = `${tableName}.${prevField}`
642
- const prevValue = cursorData[prevField]
643
- if (prevValue !== undefined) {
644
- this.where(prevQualifiedField, '=', prevValue)
645
- }
646
- }
647
-
648
- // Current field must be greater/less than cursor value
649
- if (direction === 'DESC') {
650
- this.where(qualifiedField, '<', cursorValue)
651
- } else {
652
- this.where(qualifiedField, '>', cursorValue)
653
- }
654
- })
655
- })
656
- })
709
+ applyCursorPredicate(
710
+ query,
711
+ sortDescriptors,
712
+ cursorData,
713
+ (direction) => (direction === 'DESC' ? '<' : '>'),
714
+ (builder, descriptor, operator, value) => builder.where(descriptor.column, operator, value),
715
+ {
716
+ onMissingValue: (fieldName) => log.warn(`Cursor missing value for sort field: ${fieldName}`)
717
+ }
718
+ )
657
719
  } else if (queryParams.page.before) {
658
720
  let cursorData
659
721
  try {
@@ -672,56 +734,16 @@ export const RestApiKnexPlugin = {
672
734
  )
673
735
  }
674
736
 
675
- // Build sort fields array with directions
676
- const sortFields = []
677
- if (queryParams.sort && queryParams.sort.length > 0) {
678
- queryParams.sort.forEach(sortField => {
679
- const desc = sortField.startsWith('-')
680
- const field = desc ? sortField.substring(1) : sortField
681
- sortFields.push({ field, direction: desc ? 'DESC' : 'ASC' })
682
- })
683
- } else if (scope.vars.defaultSort) {
684
- sortFields.push({
685
- field: scope.vars.defaultSort.field || idProperty,
686
- direction: scope.vars.defaultSort.direction || 'ASC'
687
- })
688
- } else {
689
- sortFields.push({ field: idProperty, direction: 'ASC' })
690
- }
691
-
692
- // Build compound WHERE clause for multi-field cursor (reversed for before)
693
- query.where(function () {
694
- sortFields.forEach((sortInfo, index) => {
695
- const { field, direction } = sortInfo
696
- const qualifiedField = `${tableName}.${field}`
697
- const cursorValue = cursorData[field]
698
-
699
- if (cursorValue === undefined) {
700
- log.warn(`Cursor missing value for sort field: ${field}`)
701
- return
702
- }
703
-
704
- // Build condition for this level and all previous levels
705
- this.orWhere(function () {
706
- // All previous fields must be equal
707
- for (let i = 0; i < index; i++) {
708
- const prevField = sortFields[i].field
709
- const prevQualifiedField = `${tableName}.${prevField}`
710
- const prevValue = cursorData[prevField]
711
- if (prevValue !== undefined) {
712
- this.where(prevQualifiedField, '=', prevValue)
713
- }
714
- }
715
-
716
- // Current field must be less/greater than cursor value (reversed)
717
- if (direction === 'DESC') {
718
- this.where(qualifiedField, '>', cursorValue)
719
- } else {
720
- this.where(qualifiedField, '<', cursorValue)
721
- }
722
- })
723
- })
724
- })
737
+ applyCursorPredicate(
738
+ query,
739
+ sortDescriptors,
740
+ cursorData,
741
+ (direction) => (direction === 'DESC' ? '>' : '<'),
742
+ (builder, descriptor, operator, value) => builder.where(descriptor.column, operator, value),
743
+ {
744
+ onMissingValue: (fieldName) => log.warn(`Cursor missing value for sort field: ${fieldName}`)
745
+ }
746
+ )
725
747
  }
726
748
  }
727
749
  // Default pagination if only size is specified - treat as cursor-based for "load more"
@@ -844,12 +866,7 @@ export const RestApiKnexPlugin = {
844
866
  records.pop()
845
867
  }
846
868
 
847
- // Determine sort fields for cursor
848
- const idProperty = context.schemaInfo.idProperty || 'id'
849
- let sortFields = [idProperty]
850
- if (queryParams.sort && queryParams.sort.length > 0) {
851
- sortFields = queryParams.sort.map(s => s.startsWith('-') ? s.substring(1) : s)
852
- }
869
+ const sortFields = sortDescriptors.map((descriptor) => descriptor.field)
853
870
 
854
871
  context.returnMeta.paginationMeta = buildCursorMeta(records, pageSize, hasMore, sortFields)
855
872
  const urlPrefix = getUrlPrefix(context, scope)
@@ -865,7 +882,7 @@ export const RestApiKnexPlugin = {
865
882
  }
866
883
 
867
884
  // Load relationship identifiers for all hasMany relationships
868
- await loadRelationshipIdentifiers(records, scopeName, scopes, db)
885
+ await loadRelationshipIdentifiers(records, scopeName, scopes, db, context)
869
886
 
870
887
  // Process includes
871
888
  const included = await processIncludes(scope, records, {
@@ -909,11 +926,22 @@ export const RestApiKnexPlugin = {
909
926
 
910
927
  log.debug(`[Knex] POST ${tableName}`, inputRecord)
911
928
 
912
- // Extract attributes from JSON:API format
913
- const attributes = inputRecord.data.attributes
929
+ // Extract attributes from JSON:API format.
930
+ // POST may legitimately provide a resource id when the table uses a custom primary key
931
+ // (for example, one-to-one rows keyed by user_id/workspace_id). Keep that id in the
932
+ // attribute bag so storage translation can write it to the configured id column.
933
+ const attributes = {
934
+ ...(inputRecord.data.attributes || {}),
935
+ ...(inputRecord.data.id !== undefined ? { id: inputRecord.data.id } : {})
936
+ }
914
937
 
915
938
  // Strip non-database fields (computed and virtual) before insert
916
- const dbAttributes = stripNonDatabaseFields(attributes, context.schemaInfo)
939
+ const dbAttributes = storageAdapter?.toStorageRow
940
+ ? storageAdapter.toStorageRow(attributes, {
941
+ context,
942
+ operation: 'post'
943
+ })
944
+ : attributes
917
945
 
918
946
  // Insert and get the new ID
919
947
  const result = await db(tableName).insert(dbAttributes).returning(idProperty)
@@ -964,7 +992,12 @@ export const RestApiKnexPlugin = {
964
992
  const mergedAttributes = { ...attributes, ...foreignKeyUpdates }
965
993
 
966
994
  // Strip non-database fields (computed and virtual) before database operation
967
- const finalAttributes = stripNonDatabaseFields(mergedAttributes, context.schemaInfo)
995
+ const finalAttributes = storageAdapter?.toStorageRow
996
+ ? storageAdapter.toStorageRow(mergedAttributes, {
997
+ context,
998
+ operation: 'put'
999
+ })
1000
+ : mergedAttributes
968
1001
 
969
1002
  // Map 'id' to actual idProperty if needed (for PUT with specific ID)
970
1003
  if (idProperty !== 'id' && inputRecord.data.id) {
@@ -1063,7 +1096,12 @@ export const RestApiKnexPlugin = {
1063
1096
  const mergedAttributes = { ...attributes, ...foreignKeyUpdates }
1064
1097
 
1065
1098
  // Strip non-database fields (computed and virtual) before database operation
1066
- const finalAttributes = stripNonDatabaseFields(mergedAttributes, context.schemaInfo)
1099
+ const finalAttributes = storageAdapter?.toStorageRow
1100
+ ? storageAdapter.toStorageRow(mergedAttributes, {
1101
+ context,
1102
+ operation: 'patch'
1103
+ })
1104
+ : mergedAttributes
1067
1105
 
1068
1106
  log.debug('[Knex] PATCH finalAttributes:', finalAttributes)
1069
1107
 
@@ -36,35 +36,40 @@ export default async function registerRelationshipRoutes ({ context, api, log })
36
36
  await api.addRoute({
37
37
  method: 'GET',
38
38
  path: `${scopePath}/:id/relationships/:relationshipName`,
39
- handler: createRouteHandler('getRelationship')
39
+ handler: createRouteHandler('getRelationship'),
40
+ routeMeta: { kind: 'relationship', scopeName, operation: 'getRelationship' }
40
41
  })
41
42
 
42
43
  // GET /api/{scope}/{id}/{relationshipName}
43
44
  await api.addRoute({
44
45
  method: 'GET',
45
46
  path: `${scopePath}/:id/:relationshipName`,
46
- handler: createRouteHandler('getRelated')
47
+ handler: createRouteHandler('getRelated'),
48
+ routeMeta: { kind: 'relationship', scopeName, operation: 'getRelated' }
47
49
  })
48
50
 
49
51
  // POST /api/{scope}/{id}/relationships/{relationshipName}
50
52
  await api.addRoute({
51
53
  method: 'POST',
52
54
  path: `${scopePath}/:id/relationships/:relationshipName`,
53
- handler: createRouteHandler('postRelationship')
55
+ handler: createRouteHandler('postRelationship'),
56
+ routeMeta: { kind: 'relationship', scopeName, operation: 'postRelationship' }
54
57
  })
55
58
 
56
59
  // PATCH /api/{scope}/{id}/relationships/{relationshipName}
57
60
  await api.addRoute({
58
61
  method: 'PATCH',
59
62
  path: `${scopePath}/:id/relationships/:relationshipName`,
60
- handler: createRouteHandler('patchRelationship')
63
+ handler: createRouteHandler('patchRelationship'),
64
+ routeMeta: { kind: 'relationship', scopeName, operation: 'patchRelationship' }
61
65
  })
62
66
 
63
67
  // DELETE /api/{scope}/{id}/relationships/{relationshipName}
64
68
  await api.addRoute({
65
69
  method: 'DELETE',
66
70
  path: `${scopePath}/:id/relationships/:relationshipName`,
67
- handler: createRouteHandler('deleteRelationship')
71
+ handler: createRouteHandler('deleteRelationship'),
72
+ routeMeta: { kind: 'relationship', scopeName, operation: 'deleteRelationship' }
68
73
  })
69
74
 
70
75
  log.trace(`Registered relationship routes for scope: ${scopeName}`)