json-rest-api 1.0.23 → 1.0.25

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 (106) hide show
  1. package/README.md +24 -2
  2. package/docs/API.md +157 -33
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
  4. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
  5. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
  6. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
  7. package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
  8. package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
  9. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
  10. package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
  11. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  12. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  13. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  14. package/docs/GUIDE/index.md +4 -1
  15. package/fixing_plan.md +112 -0
  16. package/index.js +2 -0
  17. package/lib/enhanced-logger.js +1 -1
  18. package/package.json +6 -2
  19. package/plugins/core/bulk-operations-plugin.js +31 -38
  20. package/plugins/core/connectors/express-plugin.js +6 -7
  21. package/plugins/core/connectors/fastify-plugin.js +5 -1
  22. package/plugins/core/connectors/lib/busboy-detector.js +4 -4
  23. package/plugins/core/connectors/lib/connector-core.js +17 -11
  24. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  25. package/plugins/core/file-handling-plugin.js +126 -96
  26. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  27. package/plugins/core/lib/anyapi/schema-utils.js +44 -4
  28. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
  29. package/plugins/core/lib/dbIntrospection.js +1 -1
  30. package/plugins/core/lib/dbTablesOperations.js +10 -3
  31. package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
  32. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
  33. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  34. package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
  35. package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
  36. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  37. package/plugins/core/lib/querying/url-helpers.js +9 -21
  38. package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
  39. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  40. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  41. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  42. package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -3
  43. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  44. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  45. package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
  46. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  47. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
  48. package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
  49. package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
  50. package/plugins/core/lib/storage/storage-adapter.js +76 -14
  51. package/plugins/core/lib/storage/storage-mapping.js +86 -16
  52. package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
  53. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  54. package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
  55. package/plugins/core/rest-api-knex-plugin.js +93 -76
  56. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
  57. package/plugins/core/rest-api-plugin-methods/common.js +112 -72
  58. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
  59. package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
  60. package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
  61. package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
  62. package/plugins/core/rest-api-plugin-methods/get.js +7 -1
  63. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
  64. package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
  65. package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
  66. package/plugins/core/rest-api-plugin-methods/post.js +12 -7
  67. package/plugins/core/rest-api-plugin-methods/put.js +35 -10
  68. package/plugins/core/rest-api-plugin-methods/release.js +0 -2
  69. package/plugins/core/rest-api-plugin.js +11 -37
  70. package/plugins/core/rest-api-positioning-plugin.js +1 -4
  71. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  72. package/plugins/core/socketio-plugin.js +0 -1
  73. package/plugins/storage/local-storage.js +45 -20
  74. package/plugins/storage/s3-storage.js +23 -54
  75. package/quickTest.js +1 -0
  76. package/scripts/run-docs.js +80 -84
  77. package/test-debug.js +1 -1
  78. package/tests/README.md +8 -1
  79. package/tests/anyapi-basic.test.js +3 -0
  80. package/tests/anyapi-cursor-pagination.test.js +7 -1
  81. package/tests/anyapi-custom-idproperty.test.js +163 -0
  82. package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
  83. package/tests/db-introspection.test.js +4 -1
  84. package/tests/db-table-operations.test.js +25 -20
  85. package/tests/fastify-plugin.test.js +49 -6
  86. package/tests/field-setters.test.js +10 -10
  87. package/tests/file-handling.test.js +118 -0
  88. package/tests/fixtures/api-configs.js +495 -4
  89. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  90. package/tests/http-connectors-parity.test.js +165 -5
  91. package/tests/id-normalization.test.js +444 -0
  92. package/tests/local-storage.test.js +96 -0
  93. package/tests/logical-id-write-validation.test.js +151 -0
  94. package/tests/pagination-cursor-multifield.test.js +132 -11
  95. package/tests/pagination-enhanced.test.js +66 -15
  96. package/tests/post-commit-hooks.test.js +120 -0
  97. package/tests/public-exports.test.js +15 -0
  98. package/tests/put-create-parity.test.js +216 -0
  99. package/tests/relationship-endpoints.test.js +259 -0
  100. package/tests/return-record-settings.test.js +184 -0
  101. package/tests/row-policy.test.js +433 -0
  102. package/tests/s3-storage.test.js +38 -0
  103. package/tests/searchschema-merge.test.js +17 -0
  104. package/tests/socketio.test.js +62 -0
  105. package/tests/storage-mapping.test.js +56 -6
  106. package/tests/test-development-guide.md +5 -5
@@ -9,6 +9,21 @@ const clone = (value) => {
9
9
 
10
10
  const SUPPORTED_TYPES = new Set([...TYPE_TO_POOL.keys(), 'id'])
11
11
 
12
+ const resolveIdProperty = (schema = {}, explicitIdProperty = null) => {
13
+ if (explicitIdProperty) return explicitIdProperty
14
+ if (schema.id) return 'id'
15
+
16
+ const idFields = Object.entries(schema)
17
+ .filter(([, fieldDef]) => (
18
+ fieldDef?.type === 'id' &&
19
+ !fieldDef.belongsTo &&
20
+ !fieldDef.belongsToPolymorphic
21
+ ))
22
+ .map(([fieldName]) => fieldName)
23
+
24
+ return idFields.length === 1 ? idFields[0] : 'id'
25
+ }
26
+
12
27
  const BELONGS_TO_ALIAS = (fieldName, fieldDef) => {
13
28
  if (fieldDef.as) return fieldDef.as
14
29
  if (fieldName.endsWith('_id')) {
@@ -305,6 +320,7 @@ export class AnyapiRegistry {
305
320
 
306
321
  async #register (definition, externalTrx) {
307
322
  const { tenant, resource, schema, relationships = {}, canonicalFieldMap = null } = definition
323
+ const idProperty = resolveIdProperty(schema, definition.idProperty)
308
324
  const trx = externalTrx || await this.knex.transaction()
309
325
  const managed = !externalTrx
310
326
 
@@ -520,7 +536,7 @@ export class AnyapiRegistry {
520
536
  await trx('any_relationship_configs').insert(relationshipInserts)
521
537
  }
522
538
 
523
- const descriptor = await this.#loadDescriptor(tenant, resource, trx)
539
+ const descriptor = await this.#loadDescriptor(tenant, resource, trx, { idProperty })
524
540
 
525
541
  if (managed) {
526
542
  await trx.commit()
@@ -535,7 +551,7 @@ export class AnyapiRegistry {
535
551
  }
536
552
  }
537
553
 
538
- async #loadDescriptor (tenant, resource, trx) {
554
+ async #loadDescriptor (tenant, resource, trx, options = {}) {
539
555
  const query = (trx || this.knex)('any_resource_configs')
540
556
  .where({ tenant_id: tenant, resource })
541
557
  .first()
@@ -568,6 +584,7 @@ export class AnyapiRegistry {
568
584
  slotState,
569
585
  fieldRows,
570
586
  relationshipRows,
587
+ idProperty: options.idProperty,
571
588
  })
572
589
 
573
590
  const canonicalFieldMap = {}
@@ -589,7 +606,7 @@ export class AnyapiRegistry {
589
606
  return descriptor
590
607
  }
591
608
 
592
- #buildDescriptor ({ tenant, resource, schema, relationships, slotState, fieldRows, relationshipRows }) {
609
+ #buildDescriptor ({ tenant, resource, schema, relationships, slotState, fieldRows, relationshipRows, idProperty }) {
593
610
  const fields = {}
594
611
  const reverseAttributes = {}
595
612
  const belongsTo = {}
@@ -671,6 +688,7 @@ export class AnyapiRegistry {
671
688
  return {
672
689
  tenant,
673
690
  resource,
691
+ idProperty: resolveIdProperty(schema, idProperty),
674
692
  schema,
675
693
  relationships,
676
694
  canonical: DEFAULT_CANONICAL_CONFIG,
@@ -18,6 +18,7 @@ export const DEFAULT_CANONICAL_CONFIG = {
18
18
  scopeName: 'anyapi_records',
19
19
  tableName: 'any_records',
20
20
  type: 'anyapi_records',
21
+ logicalIdColumn: 'logical_id',
21
22
  tenantColumn: 'tenant_id',
22
23
  resourceColumn: 'resource',
23
24
  relationshipColumn: 'relationship',
@@ -34,20 +35,37 @@ const ensureTable = async (knex, tableName, tableBuilder) => {
34
35
  }
35
36
  }
36
37
 
37
- const ensureIndex = async (knex, tableName, columns, indexName) => {
38
+ const ensureColumn = async (knex, tableName, columnName, columnBuilder) => {
39
+ const exists = await knex.schema.hasTable(tableName)
40
+ if (!exists) return
41
+
42
+ const hasColumn = await knex.schema.hasColumn(tableName, columnName)
43
+ if (!hasColumn) {
44
+ await knex.schema.table(tableName, columnBuilder)
45
+ }
46
+ }
47
+
48
+ const ensureUniqueIndex = async (knex, tableName, columns, indexName) => {
38
49
  const exists = await knex.schema.hasTable(tableName)
39
50
  if (!exists) return
40
51
  const hasIndex = await knex.schema.withSchema('main').hasIndex?.(tableName, indexName)
41
52
  if (!hasIndex) {
42
- await knex.schema.table(tableName, (table) => {
43
- table.index(columns, indexName)
44
- })
53
+ try {
54
+ await knex.schema.table(tableName, (table) => {
55
+ table.unique(columns, indexName)
56
+ })
57
+ } catch (error) {
58
+ if (!String(error?.message || '').includes('already exists')) {
59
+ throw error
60
+ }
61
+ }
45
62
  }
46
63
  }
47
64
 
48
65
  export const ensureAnyApiSchema = async (knex) => {
49
66
  await ensureTable(knex, 'any_records', (table) => {
50
67
  table.increments('id').primary()
68
+ table.string('logical_id')
51
69
  table.string('tenant_id').notNullable()
52
70
  table.string('resource').notNullable()
53
71
 
@@ -77,9 +95,30 @@ export const ensureAnyApiSchema = async (knex) => {
77
95
  table.dateTime('deleted_at')
78
96
 
79
97
  table.index(['tenant_id', 'resource'])
98
+ table.unique(['tenant_id', 'resource', 'logical_id'], 'any_records_tenant_resource_logical_id_unique')
80
99
  table.index(['resource'])
81
100
  })
82
101
 
102
+ await ensureColumn(knex, 'any_records', 'logical_id', (table) => {
103
+ table.string('logical_id')
104
+ })
105
+ await ensureUniqueIndex(
106
+ knex,
107
+ 'any_records',
108
+ ['tenant_id', 'resource', 'logical_id'],
109
+ 'any_records_tenant_resource_logical_id_unique'
110
+ )
111
+
112
+ const rowsMissingLogicalId = await knex('any_records')
113
+ .select('id')
114
+ .whereNull('logical_id')
115
+
116
+ for (const row of rowsMissingLogicalId) {
117
+ await knex('any_records')
118
+ .where({ id: row.id })
119
+ .update({ logical_id: String(row.id) })
120
+ }
121
+
83
122
  await ensureTable(knex, 'any_links', (table) => {
84
123
  table.increments('id').primary()
85
124
  table.string('tenant_id').notNullable()
@@ -171,6 +210,7 @@ export const TYPE_TO_POOL = new Map([
171
210
  ['text', 'string'],
172
211
  ['uuid', 'string'],
173
212
  ['email', 'string'],
213
+ ['file', 'string'],
174
214
  ['number', 'number'],
175
215
  ['integer', 'number'],
176
216
  ['float', 'number'],
@@ -1,4 +1,5 @@
1
1
  import { RestApiValidationError } from '../../../../../lib/rest-api-errors.js'
2
+ import { normalizeDateValue } from '../../querying-writing/database-value-normalizers.js'
2
3
 
3
4
  export const normalizeId = (value) => (value === null || value === undefined ? null : String(value))
4
5
 
@@ -21,8 +22,12 @@ export const findSchemaFieldByAlias = (descriptor, alias) => {
21
22
 
22
23
  export const resolveFieldInfo = (descriptor, field) => {
23
24
  if (!descriptor) return null
24
- if (field === 'id') {
25
- return { column: 'id', definition: { type: 'id' } }
25
+ const idProperty = descriptor.idProperty || 'id'
26
+ if (field === 'id' || field === idProperty) {
27
+ return {
28
+ column: descriptor?.canonical?.logicalIdColumn || 'logical_id',
29
+ definition: { type: 'id' }
30
+ }
26
31
  }
27
32
 
28
33
  const directField = descriptor.fields?.[field]
@@ -87,6 +92,10 @@ export const coerceValueForDefinition = (value, definition, { isRelationship } =
87
92
  return String(value)
88
93
  }
89
94
 
95
+ if (['date', 'dateTime', 'time'].includes(type)) {
96
+ return normalizeDateValue(value, type)
97
+ }
98
+
90
99
  return value
91
100
  }
92
101
 
@@ -763,7 +763,7 @@ function deriveSqliteColumnNumbers (dataType, args = '') {
763
763
  async function introspectSqliteTableSnapshot (knex, { tableName, idColumn }) {
764
764
  const quotedTableName = quoteSqliteIdentifier(tableName)
765
765
  const tableRows = normalizeRows(await knex.raw(
766
- `SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`,
766
+ 'SELECT name, sql FROM sqlite_master WHERE type = \'table\' AND name = ? LIMIT 1',
767
767
  [tableName]
768
768
  ))
769
769
  const firstTableRow = Array.isArray(tableRows) ? tableRows[0] : null
@@ -103,13 +103,14 @@ function buildSetColumnType (values = []) {
103
103
  return `set(${normalized.map((value) => quoteJsString(value)).join(', ')})`
104
104
  }
105
105
 
106
- function createTableBuilderContext (schemaStructure = {}, idColumn = 'id') {
106
+ function createTableBuilderContext (schemaStructure = {}, idColumn = 'id', storage = undefined) {
107
107
  return {
108
108
  schemaStructure,
109
109
  idProperty: idColumn,
110
110
  storageInfo: buildStorageInfo({
111
111
  schemaStructure,
112
- idProperty: idColumn
112
+ idProperty: idColumn,
113
+ storage
113
114
  })
114
115
  }
115
116
  }
@@ -400,7 +401,8 @@ function buildImplicitIdDefinition (idColumn) {
400
401
  function resolveTableSchemaContext (schemaLike, options = {}) {
401
402
  const schemaStructure = schemaLike?.structure || schemaLike || {}
402
403
  const configuredIdColumn = options.idProperty || schemaStructure.id?.storage?.column || 'id'
403
- const tableContext = createTableBuilderContext(schemaStructure, configuredIdColumn)
404
+ const storage = options.storage ?? schemaLike?.storage
405
+ const tableContext = createTableBuilderContext(schemaStructure, configuredIdColumn, storage)
404
406
 
405
407
  let resourceIdField = null
406
408
  for (const [fieldName, definition] of Object.entries(schemaStructure)) {
@@ -888,6 +890,7 @@ export async function createKnexTable (knex, schemaInfo, tableSchemaInstance, op
888
890
  const dialect = detectKnexDialect(knex)
889
891
  const tableSchemaContext = resolveTableSchemaContext(tableSchemaInstance, {
890
892
  idProperty: schemaInfo.idProperty,
893
+ storage: schemaInfo.storage,
891
894
  tableName
892
895
  })
893
896
 
@@ -918,6 +921,7 @@ export async function addKnexFields (knex, tableName, schema, options = {}) {
918
921
  assertNoTopLevelTableMetadata(schema, 'addKnexFields')
919
922
  const dialect = detectKnexDialect(knex)
920
923
  const tableSchemaContext = resolveTableSchemaContext(schema, {
924
+ storage: options.storage,
921
925
  ...options,
922
926
  tableName
923
927
  })
@@ -936,6 +940,7 @@ export async function alterKnexFields (knex, tableName, fields, options = {}) {
936
940
  assertNoTopLevelTableMetadata(fields, 'alterKnexFields')
937
941
  const dialect = detectKnexDialect(knex)
938
942
  const tableSchemaContext = resolveTableSchemaContext(fields, {
943
+ storage: options.storage,
939
944
  ...options,
940
945
  tableName
941
946
  })
@@ -961,6 +966,7 @@ export function generateKnexMigration (tableName, schema, options = {}) {
961
966
  const { autoIncrement = true, timestamps = false, dialect = '' } = options
962
967
  const tableSchemaContext = resolveTableSchemaContext(schema, {
963
968
  ...options,
969
+ storage: options.storage,
964
970
  tableName
965
971
  })
966
972
 
@@ -1028,6 +1034,7 @@ export function generateKnexMigrationDiff (tableName, currentSnapshot, schema, o
1028
1034
  const resolvedDialect = normalizeText(dialect || currentSnapshot?.dialect).toLowerCase()
1029
1035
  const tableSchemaContext = resolveTableSchemaContext(schema, {
1030
1036
  ...options,
1037
+ storage: options.storage,
1031
1038
  tableName
1032
1039
  })
1033
1040
  const desiredColumns = buildDesiredColumnsMap(tableSchemaContext, { autoIncrement })
@@ -208,7 +208,6 @@ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, sea
208
208
  let foundRelationship = null
209
209
  let relationshipType = null
210
210
  let relationshipField = null
211
- let relationshipDef = null
212
211
 
213
212
  if (currentRelationships) {
214
213
  for (const [relName, relDef] of Object.entries(currentRelationships)) {
@@ -222,7 +221,6 @@ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, sea
222
221
  foundRelationship = targetScope
223
222
  relationshipType = 'hasManyPolymorphic'
224
223
  relationshipField = { typeField, idField, via: relDef.via }
225
- relationshipDef = relDef
226
224
  log.trace('[BUILD-JOIN] Found polymorphic hasMany relationship:', {
227
225
  relName, targetScope, via: relDef.via, typeField, idField
228
226
  })
@@ -238,7 +236,6 @@ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, sea
238
236
  foreignKey: relDef.foreignKey,
239
237
  otherKey: relDef.otherKey
240
238
  }
241
- relationshipDef = relDef
242
239
  log.trace('[BUILD-JOIN] Found many-to-many relationship:', {
243
240
  relName,
244
241
  targetScope,
@@ -256,7 +253,6 @@ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, sea
256
253
  log.error('[BUILD-JOIN] Missing foreignKey in hasMany relationship:', { relName, currentScope })
257
254
  throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${currentScope}'`)
258
255
  }
259
- relationshipDef = relDef
260
256
  log.trace('[BUILD-JOIN] Found hasMany relationship:', { relName, targetScope, foreignKey: relationshipField })
261
257
  break
262
258
  }
@@ -269,7 +265,6 @@ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, sea
269
265
  foundRelationship = targetScope
270
266
  relationshipType = 'belongsTo'
271
267
  relationshipField = fieldName
272
- relationshipDef = fieldDef
273
268
  log.trace('[BUILD-JOIN] Found belongsTo relationship:', { fieldName, targetScope })
274
269
  break
275
270
  }
@@ -282,9 +277,7 @@ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, sea
282
277
  )
283
278
  }
284
279
 
285
- const sourceSchema = scopes[currentScope].vars.schemaInfo.schemaInstance
286
280
  const sourceTableName = scopes[currentScope].vars.schemaInfo.tableName
287
- const targetSchemaInstance = scopes[targetScope].vars.schemaInfo.schemaInstance
288
281
  const targetTableName = scopes[targetScope].vars.schemaInfo.tableName
289
282
 
290
283
  if (relationshipType === 'manyToMany') {
@@ -350,37 +350,42 @@ export const toJsonApiRecord = (scope, record, scopeName) => {
350
350
  export const buildJsonApiResponse = async (scope, records, included = [], isSingle = false, scopeName, context) => {
351
351
  const {
352
352
  vars: {
353
- schemaInfo: { schemaInstance, schemaRelationships: relationships, idProperty }
353
+ schemaInfo
354
354
  }
355
355
  } = scope
356
356
 
357
- const idField = idProperty || 'id'
357
+ const {
358
+ schemaInstance,
359
+ schemaRelationships: relationships
360
+ } = schemaInfo
358
361
 
359
362
  const schemaStructure = getSchemaStructure(schemaInstance)
360
363
 
361
364
  const processedRecords = records.map(record => {
362
365
  const { [RELATIONSHIPS_KEY]: _relationships, ...cleanRecord } = record
366
+ const logicalRecord = translateRecordFromStorage(cleanRecord, schemaInfo)
363
367
  const jsonApiRecord = toJsonApiRecord(scope, cleanRecord, scopeName)
368
+ const resourceId = jsonApiRecord.id
364
369
 
365
370
  if (_relationships) {
366
371
  jsonApiRecord.relationships = _relationships
367
372
  }
368
373
 
369
374
  for (const [fieldName, fieldDef] of Object.entries(schemaStructure)) {
370
- if (fieldDef.belongsTo && fieldDef.as && fieldName in cleanRecord) {
375
+ if (fieldDef.belongsTo && fieldDef.as && fieldName in logicalRecord) {
371
376
  jsonApiRecord.relationships = jsonApiRecord.relationships || {}
372
377
  if (!jsonApiRecord.relationships[fieldDef.as]) {
373
- if (cleanRecord[fieldName] !== null && cleanRecord[fieldName] !== undefined) {
378
+ if (logicalRecord[fieldName] !== null && logicalRecord[fieldName] !== undefined) {
374
379
  const relationshipObject = {
375
380
  data: {
376
381
  type: fieldDef.belongsTo,
377
- id: String(cleanRecord[fieldName])
382
+ id: String(logicalRecord[fieldName])
378
383
  }
379
384
  }
380
385
 
381
386
  relationshipObject.links = {
382
- self: buildRelationshipUrl(context, scope, scopeName, record[idField], fieldDef.as, true),
383
- related: buildRelationshipUrl(context, scope, scopeName, record[idField], fieldDef.as, false)
387
+ self: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, true),
388
+ related: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, false)
384
389
  }
385
390
 
386
391
  jsonApiRecord.relationships[fieldDef.as] = relationshipObject
@@ -390,8 +395,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
390
395
  }
391
396
 
392
397
  relationshipObject.links = {
393
- self: buildRelationshipUrl(context, scope, scopeName, record[idField], fieldDef.as, true),
394
- related: buildRelationshipUrl(context, scope, scopeName, record[idField], fieldDef.as, false)
398
+ self: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, true),
399
+ related: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, false)
395
400
  }
396
401
 
397
402
  jsonApiRecord.relationships[fieldDef.as] = relationshipObject
@@ -402,8 +407,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
402
407
 
403
408
  Object.entries(relationships || {}).forEach(([relName, relDef]) => {
404
409
  if (relDef.belongsToPolymorphic) {
405
- const typeValue = cleanRecord[relDef.belongsToPolymorphic.typeField]
406
- const idValue = cleanRecord[relDef.belongsToPolymorphic.idField]
410
+ const typeValue = logicalRecord[relDef.belongsToPolymorphic.typeField]
411
+ const idValue = logicalRecord[relDef.belongsToPolymorphic.idField]
407
412
 
408
413
  if (typeValue && idValue) {
409
414
  jsonApiRecord.relationships = jsonApiRecord.relationships || {}
@@ -415,8 +420,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
415
420
  }
416
421
 
417
422
  relationshipObject.links = {
418
- self: buildRelationshipUrl(context, scope, scopeName, record[idField], relName, true),
419
- related: buildRelationshipUrl(context, scope, scopeName, record[idField], relName, false)
423
+ self: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, true),
424
+ related: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, false)
420
425
  }
421
426
 
422
427
  jsonApiRecord.relationships[relName] = relationshipObject
@@ -427,8 +432,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
427
432
  }
428
433
 
429
434
  relationshipObject.links = {
430
- self: buildRelationshipUrl(context, scope, scopeName, record[idField], relName, true),
431
- related: buildRelationshipUrl(context, scope, scopeName, record[idField], relName, false)
435
+ self: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, true),
436
+ related: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, false)
432
437
  }
433
438
 
434
439
  jsonApiRecord.relationships[relName] = relationshipObject
@@ -439,7 +444,6 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
439
444
  return jsonApiRecord
440
445
  })
441
446
 
442
- const urlPrefix = context.urlPrefix || scope.vars.transport?.mountPath || ''
443
447
  const normalizedData = isSingle ? processedRecords[0] : processedRecords
444
448
 
445
449
  if (normalizedData) {
@@ -1,3 +1,11 @@
1
+ import { getFieldValue } from '../storage/storage-mapping.js'
2
+ import { serializeJsonApiQuery } from '../querying-writing/connectors-query-parser.js'
3
+
4
+ const buildJsonApiLink = (baseUrl, queryParams, page) => {
5
+ const queryString = serializeJsonApiQuery(queryParams, { page })
6
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
7
+ }
8
+
1
9
  /**
2
10
  * Calculates pagination metadata from query results
3
11
  *
@@ -127,7 +135,7 @@ export const calculatePaginationMeta = (total, page, pageSize) => {
127
135
  * Purpose:
128
136
  * - JSON:API spec requires pagination links for easy navigation
129
137
  * - Preserves all other query parameters (filters, sorts, includes)
130
- * - Handles complex nested parameters like filter[author][name]=John
138
+ * - Uses the shared JSON:API query serializer for transport-supported params
131
139
  * - Only includes prev/next when applicable
132
140
  *
133
141
  * Data flow:
@@ -143,52 +151,20 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
143
151
  const { page, pageCount, pageSize } = paginationMeta
144
152
  const links = {}
145
153
 
146
- const otherParams = Object.entries(queryParams)
147
- .filter(([key]) => key !== 'page')
148
- .flatMap(([key, value]) => {
149
- if (Array.isArray(value)) {
150
- return value.length === 0
151
- ? []
152
- : value.map((v) => `${key}=${encodeURIComponent(v)}`)
153
- }
154
- if (typeof value === 'object' && value !== null) {
155
- const parts = []
156
- const processObject = (obj, prefix) => {
157
- Object.entries(obj).forEach(([k, v]) => {
158
- const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`
159
- if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
160
- processObject(v, newKey)
161
- } else if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
162
- parts.push(`${newKey}=${encodeURIComponent(v)}`)
163
- }
164
- })
165
- }
166
- processObject(value, key)
167
- return parts
168
- }
169
- if (value === undefined || value === null) {
170
- return []
171
- }
172
- return [`${key}=${encodeURIComponent(value)}`]
173
- })
174
- .filter(Boolean)
175
- .join('&')
176
-
177
154
  const baseUrl = `${urlPrefix}/${scopeName}`
178
- const queryPrefix = otherParams ? `?${otherParams}&` : '?'
179
155
 
180
- links.self = `${baseUrl}${queryPrefix}page[number]=${page}&page[size]=${pageSize}`
156
+ links.self = buildJsonApiLink(baseUrl, queryParams, { number: page, size: pageSize })
181
157
 
182
158
  if (pageCount !== undefined) {
183
- links.first = `${baseUrl}${queryPrefix}page[number]=1&page[size]=${pageSize}`
184
- links.last = `${baseUrl}${queryPrefix}page[number]=${pageCount}&page[size]=${pageSize}`
159
+ links.first = buildJsonApiLink(baseUrl, queryParams, { number: 1, size: pageSize })
160
+ links.last = buildJsonApiLink(baseUrl, queryParams, { number: pageCount, size: pageSize })
185
161
 
186
162
  if (page > 1) {
187
- links.prev = `${baseUrl}${queryPrefix}page[number]=${page - 1}&page[size]=${pageSize}`
163
+ links.prev = buildJsonApiLink(baseUrl, queryParams, { number: page - 1, size: pageSize })
188
164
  }
189
165
 
190
166
  if (page < pageCount) {
191
- links.next = `${baseUrl}${queryPrefix}page[number]=${page + 1}&page[size]=${pageSize}`
167
+ links.next = buildJsonApiLink(baseUrl, queryParams, { number: page + 1, size: pageSize })
192
168
  }
193
169
  }
194
170
 
@@ -257,11 +233,13 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
257
233
  * 4. Cursor used in 'next' link for fetching subsequent pages
258
234
  * 5. Enables efficient "WHERE (field1, field2) > (val1, val2)" queries
259
235
  */
260
- export const createCursor = (record, sortFields = ['id']) => {
236
+ export const createCursor = (record, sortFields = ['id'], { schemaInfo = null } = {}) => {
261
237
  const parts = []
262
238
  sortFields.forEach(field => {
263
- if (record[field] !== undefined) {
264
- const value = record[field]
239
+ const value = schemaInfo
240
+ ? getFieldValue(record, schemaInfo, field)
241
+ : record[field]
242
+ if (value !== undefined) {
265
243
  const stringValue = value instanceof Date ? value.toISOString() : String(value)
266
244
  parts.push(`${field}:${encodeURIComponent(stringValue)}`)
267
245
  }
@@ -427,60 +405,36 @@ export const parseCursor = (cursor) => {
427
405
  * 4. Added to response.links for navigation
428
406
  * 5. Client uses next link to fetch subsequent pages
429
407
  */
430
- export const generateCursorPaginationLinks = (urlPrefix, scopeName, queryParams, records, pageSize, hasMore, sortFields = ['id']) => {
408
+ export const generateCursorPaginationLinks = (
409
+ urlPrefix,
410
+ scopeName,
411
+ queryParams,
412
+ records,
413
+ pageSize,
414
+ hasMore,
415
+ sortFields = ['id'],
416
+ { schemaInfo = null } = {}
417
+ ) => {
431
418
  // Allow empty urlPrefix to generate relative links
432
419
  if (!records.length) return null
433
420
 
434
421
  const links = {}
435
422
  const baseUrl = `${urlPrefix}/${scopeName}`
436
-
437
- const otherParams = Object.entries(queryParams)
438
- .filter(([key]) => key !== 'page')
439
- .flatMap(([key, value]) => {
440
- if (Array.isArray(value)) {
441
- return value.length === 0
442
- ? []
443
- : value.map((v) => `${key}=${encodeURIComponent(v)}`)
444
- }
445
- if (typeof value === 'object' && value !== null) {
446
- const parts = []
447
- const processObject = (obj, prefix) => {
448
- Object.entries(obj).forEach(([k, v]) => {
449
- const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`
450
- if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
451
- processObject(v, newKey)
452
- } else if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
453
- parts.push(`${newKey}=${encodeURIComponent(v)}`)
454
- }
455
- })
456
- }
457
- processObject(value, key)
458
- return parts
459
- }
460
- if (value === undefined || value === null) {
461
- return []
462
- }
463
- return [`${key}=${encodeURIComponent(value)}`]
464
- })
465
- .filter(Boolean)
466
- .join('&')
467
-
468
- const queryPrefix = otherParams ? `?${otherParams}&` : '?'
423
+ const selfPage = { size: pageSize }
469
424
 
470
425
  if (queryParams.page?.after) {
471
- links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[after]=${queryParams.page.after}`
426
+ selfPage.after = queryParams.page.after
472
427
  } else if (queryParams.page?.before) {
473
- links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[before]=${queryParams.page.before}`
474
- } else {
475
- links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}`
428
+ selfPage.before = queryParams.page.before
476
429
  }
477
430
 
478
- links.first = `${baseUrl}${queryPrefix}page[size]=${pageSize}`
431
+ links.self = buildJsonApiLink(baseUrl, queryParams, selfPage)
432
+ links.first = buildJsonApiLink(baseUrl, queryParams, { size: pageSize })
479
433
 
480
434
  if (hasMore && records.length > 0) {
481
435
  const lastRecord = records[records.length - 1]
482
- const nextCursor = createCursor(lastRecord, sortFields)
483
- links.next = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[after]=${nextCursor}`
436
+ const nextCursor = createCursor(lastRecord, sortFields, { schemaInfo })
437
+ links.next = buildJsonApiLink(baseUrl, queryParams, { size: pageSize, after: nextCursor })
484
438
  }
485
439
 
486
440
  return links
@@ -543,7 +497,7 @@ export const generateCursorPaginationLinks = (urlPrefix, scopeName, queryParams,
543
497
  * 3. Added to response.meta.pagination
544
498
  * 4. Clients can use cursor directly or use the next link
545
499
  */
546
- export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id']) => {
500
+ export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'], { schemaInfo = null } = {}) => {
547
501
  const meta = {
548
502
  pageSize,
549
503
  hasMore
@@ -552,7 +506,7 @@ export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'])
552
506
  if (hasMore && records.length > 0) {
553
507
  const lastRecord = records[records.length - 1]
554
508
  meta.cursor = {
555
- next: createCursor(lastRecord, sortFields)
509
+ next: createCursor(lastRecord, sortFields, { schemaInfo })
556
510
  }
557
511
  }
558
512
 
@@ -191,7 +191,7 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
191
191
  // Step 2: Build polymorphic JOINs
192
192
  log.trace('[POLYMORPHIC-SEARCH] Building JOINs for polymorphic searches')
193
193
 
194
- for (const [filterKey, searchInfo] of polymorphicSearches) {
194
+ for (const [, searchInfo] of polymorphicSearches) {
195
195
  const { fieldDef, polymorphicField } = searchInfo
196
196
 
197
197
  // Get the relationship definition
@@ -485,7 +485,6 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
485
485
  }
486
486
 
487
487
  const schemaInfo = scopes[scopeName].vars.schemaInfo
488
- const tableName = schemaInfo.tableName
489
488
  const tableAlias = adapterUtils.defaultAliasForScope(scopeName)
490
489
  const aliasScopeMap = new Map()
491
490
  aliasScopeMap.set(tableAlias, scopeName)
@@ -730,7 +729,7 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
730
729
  fieldDef.applyFilter.call(this, this, filterValue)
731
730
  break
732
731
 
733
- default:
732
+ default: {
734
733
  const targetField = fieldDef.actualField || filterKey
735
734
  const columnRef = resolveFieldColumn(targetField)
736
735
  const operator = resolveSearchOperator(fieldDef)
@@ -738,6 +737,7 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
738
737
  applyWhereForOperator({ builder: this, columnRef, operator, value: normalized, knex })
739
738
  break
740
739
  }
740
+ }
741
741
  }
742
742
  })
743
743
  }
@@ -891,8 +891,6 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
891
891
  const scopeName = hookParams.context?.knexQuery?.scopeName
892
892
  const filters = hookParams.context?.knexQuery?.filters
893
893
  const query = hookParams.context?.knexQuery?.query
894
- const db = hookParams.context?.knexQuery?.db || knex
895
-
896
894
  if (!scopeName || !filters || !scopes[scopeName]) {
897
895
  return
898
896
  }
@@ -993,7 +991,7 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
993
991
  fieldDef.applyFilter.call(this, this, filterValue)
994
992
  break
995
993
 
996
- default:
994
+ default: {
997
995
  // Standard filtering
998
996
  const actualField = fieldDef.actualField || filterKey
999
997
  // Always qualify field names
@@ -1005,6 +1003,7 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
1005
1003
  applyWhereForOperator({ builder: this, columnRef: dbField, operator, value: normalized, knex })
1006
1004
  break
1007
1005
  }
1006
+ }
1008
1007
  }
1009
1008
  })
1010
1009
  }