json-rest-api 1.0.29 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/LICENSE +10 -670
  2. package/LICENSE-GPL-3.0 +674 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +39 -30
  5. package/docs/API.md +744 -1273
  6. package/docs/GUIDE/01-getting-started.md +338 -0
  7. package/docs/GUIDE/02-resources-and-relationships.md +33 -0
  8. package/docs/GUIDE/03-running-example.md +87 -0
  9. package/docs/GUIDE/04-creating-and-querying.md +181 -0
  10. package/docs/GUIDE/05-belongs-to.md +177 -0
  11. package/docs/GUIDE/06-has-many.md +184 -0
  12. package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
  13. package/docs/GUIDE/08-many-to-many.md +184 -0
  14. package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
  15. package/docs/GUIDE/10-put-and-patch.md +174 -0
  16. package/docs/GUIDE/11-relationship-endpoints.md +174 -0
  17. package/docs/GUIDE/12-field-transformations.md +287 -0
  18. package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
  19. package/docs/GUIDE/14-custom-resource-methods.md +125 -0
  20. package/docs/GUIDE/15-query-projections.md +266 -0
  21. package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
  22. package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
  23. package/docs/GUIDE/18-labels.md +86 -0
  24. package/docs/GUIDE/19-managed-transactions.md +179 -0
  25. package/docs/GUIDE/20-transaction-outcomes.md +207 -0
  26. package/docs/GUIDE/21-schema-and-migrations.md +733 -0
  27. package/docs/GUIDE/22-version-field-migrations.md +208 -0
  28. package/docs/GUIDE/23-fastify.md +179 -0
  29. package/docs/GUIDE/24-cors.md +172 -0
  30. package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
  31. package/docs/GUIDE/26-file-uploads.md +278 -0
  32. package/docs/GUIDE/27-bulk-operations.md +277 -0
  33. package/docs/GUIDE/28-socketio.md +325 -0
  34. package/docs/GUIDE/29-writing-plugins.md +278 -0
  35. package/docs/GUIDE/30-backend-capabilities.md +103 -0
  36. package/docs/GUIDE/31-positioning.md +181 -0
  37. package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +74 -30
  38. package/docs/GUIDE/33-migrating-to-v2.md +3838 -0
  39. package/docs/GUIDE/index.md +51 -59
  40. package/docs/QUICKSTART.md +120 -287
  41. package/docs/architecture.md +75 -0
  42. package/docs/contributing.md +178 -0
  43. package/index.d.ts +37 -0
  44. package/index.js +3 -22
  45. package/lib/enhanced-logger.js +91 -85
  46. package/lib/error-context.js +445 -5
  47. package/lib/error-formatter.js +219 -59
  48. package/lib/knex-transaction.js +70 -0
  49. package/lib/logger-types.d.ts +22 -0
  50. package/lib/missing-package.js +4 -0
  51. package/lib/rest-api-errors.js +103 -8
  52. package/lib/runtime/json-rest-api.js +172 -0
  53. package/lib/transaction-types.d.ts +59 -0
  54. package/package.json +97 -27
  55. package/plugins/core/bulk-operations-plugin.js +191 -184
  56. package/plugins/core/connectors/express-plugin.js +148 -219
  57. package/plugins/core/connectors/fastify-plugin.js +125 -82
  58. package/plugins/core/connectors/lib/busboy-detector.js +74 -156
  59. package/plugins/core/connectors/lib/connector-core.js +144 -20
  60. package/plugins/core/connectors/lib/formidable-detector.js +63 -120
  61. package/plugins/core/connectors/lib/http-validators.js +77 -0
  62. package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
  63. package/plugins/core/connectors/lib/request-helpers.js +10 -1
  64. package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
  65. package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
  66. package/plugins/core/file-handling-plugin.js +87 -177
  67. package/plugins/core/lib/anyapi/anyapi-registry.js +586 -418
  68. package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
  69. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
  70. package/plugins/core/lib/anyapi/schema-utils.js +120 -56
  71. package/plugins/core/lib/anyapi/stored-definition.js +109 -0
  72. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
  73. package/plugins/core/lib/dbIntrospection.js +228 -21
  74. package/plugins/core/lib/dbTablesOperations.js +409 -92
  75. package/plugins/core/lib/fractional-positioning.js +12 -48
  76. package/plugins/core/lib/querying/identifier-query.js +19 -0
  77. package/plugins/core/lib/querying/include-query-helpers.js +364 -0
  78. package/plugins/core/lib/querying/include-to-many.js +428 -0
  79. package/plugins/core/lib/querying/include-to-one.js +579 -0
  80. package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
  81. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
  82. package/plugins/core/lib/querying/knex-pagination-helpers.js +48 -317
  83. package/plugins/core/lib/querying/knex-process-includes.js +5 -110
  84. package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
  85. package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
  86. package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
  87. package/plugins/core/lib/querying/knex-window-queries.js +62 -253
  88. package/plugins/core/lib/querying/query-builder-utils.js +44 -4
  89. package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
  90. package/plugins/core/lib/querying/query-constraint.js +27 -0
  91. package/plugins/core/lib/querying/query-field-sort-helpers.js +134 -37
  92. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  93. package/plugins/core/lib/querying/sort-helpers.js +13 -1
  94. package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
  95. package/plugins/core/lib/querying/url-helpers.js +11 -4
  96. package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
  97. package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
  98. package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
  99. package/plugins/core/lib/querying-writing/database-value-normalizers.js +179 -194
  100. package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
  101. package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
  102. package/plugins/core/lib/querying-writing/field-utils.js +113 -92
  103. package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
  104. package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
  105. package/plugins/core/lib/querying-writing/knex-field-helpers.js +100 -290
  106. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
  107. package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
  108. package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
  109. package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
  110. package/plugins/core/lib/querying-writing/request-contracts.js +93 -38
  111. package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
  112. package/plugins/core/lib/querying-writing/response-options.js +35 -0
  113. package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
  114. package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
  115. package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
  116. package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
  117. package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
  118. package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
  119. package/plugins/core/lib/storage/storage-adapter.js +128 -62
  120. package/plugins/core/lib/storage/storage-mapping.js +78 -28
  121. package/plugins/core/lib/storage/storage-types.d.ts +502 -0
  122. package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
  123. package/plugins/core/lib/writing/relationship-processor.js +94 -158
  124. package/plugins/core/lib/writing/resource-version.js +172 -0
  125. package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
  126. package/plugins/core/rest-api-anyapi-knex-plugin.js +290 -2475
  127. package/plugins/core/rest-api-autofilter-plugin.js +17 -19
  128. package/plugins/core/rest-api-cors-plugin.js +52 -205
  129. package/plugins/core/rest-api-knex-plugin.js +45 -1077
  130. package/plugins/core/rest-api-label-plugin.js +25 -38
  131. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
  132. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
  133. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
  134. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
  135. package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
  136. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
  137. package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
  138. package/plugins/core/rest-api-plugin-methods/common.js +291 -395
  139. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
  140. package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
  141. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -148
  142. package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
  143. package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
  144. package/plugins/core/rest-api-plugin-methods/get.js +36 -68
  145. package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
  146. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
  147. package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
  148. package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
  149. package/plugins/core/rest-api-plugin-methods/post.js +51 -51
  150. package/plugins/core/rest-api-plugin-methods/put.js +69 -148
  151. package/plugins/core/rest-api-plugin-methods/query.js +37 -81
  152. package/plugins/core/rest-api-plugin-methods/release.js +2 -1
  153. package/plugins/core/rest-api-plugin.js +32 -46
  154. package/plugins/core/rest-api-positioning-plugin.js +183 -628
  155. package/plugins/core/rest-api-query-projections-plugin.js +19 -102
  156. package/plugins/core/rest-api-row-policy-plugin.js +3 -3
  157. package/plugins/core/socketio-plugin.js +364 -344
  158. package/plugins/storage/local-storage.js +40 -36
  159. package/types/anyapi.d.ts +30 -0
  160. package/types/autofilter.d.ts +57 -0
  161. package/types/bulk-methods.d.ts +63 -0
  162. package/types/errors.d.ts +118 -0
  163. package/types/fastify-options.d.ts +19 -0
  164. package/types/file-detectors.d.ts +33 -0
  165. package/types/file-storage.d.ts +48 -0
  166. package/types/hook-context.d.ts +180 -0
  167. package/types/plugin-options.d.ts +65 -0
  168. package/types/relationship-methods.d.ts +74 -0
  169. package/types/representations.d.ts +50 -0
  170. package/types/resource-methods.d.ts +95 -0
  171. package/types/resource-schema.d.ts +157 -0
  172. package/types/row-policy.d.ts +31 -0
  173. package/types/runtime.d.ts +132 -0
  174. package/types/transactions.d.ts +21 -0
  175. package/.claude/agents/library-guide-expert.md +0 -30
  176. package/.claude/agents/rest-api-internals-expert.md +0 -54
  177. package/.claude/settings.local.json +0 -70
  178. package/AGENTS.md +0 -34
  179. package/CLAUDE.md +0 -160
  180. package/CRUSH.md +0 -55
  181. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
  182. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
  183. package/TODO.todo +0 -13
  184. package/connectors_ideas/fastify.js +0 -217
  185. package/connectors_ideas/koa-plugin.js +0 -112
  186. package/docs/.bundle/config +0 -2
  187. package/docs/COMPARISON.md +0 -433
  188. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
  189. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
  190. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
  191. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
  192. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
  193. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
  194. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
  195. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1247
  196. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
  197. package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
  198. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
  199. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
  200. package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
  201. package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
  202. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
  203. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
  204. package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
  205. package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
  206. package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
  207. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
  208. package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
  209. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
  210. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
  211. package/docs/Gemfile +0 -6
  212. package/docs/Gemfile.lock +0 -308
  213. package/docs/ONBOARDING.md +0 -315
  214. package/docs/_config.yml +0 -36
  215. package/docs/_layouts/default.html +0 -123
  216. package/docs/apple-touch-icon.svg +0 -4
  217. package/docs/assets/css/style.scss +0 -392
  218. package/docs/favicon.svg +0 -4
  219. package/docs/index.md +0 -164
  220. package/docs/mini-guides/file-uploads-complete.md +0 -161
  221. package/eslint.config.js +0 -13
  222. package/examples/url-override-example.js +0 -137
  223. package/fixing_plan.md +0 -112
  224. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
  225. package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
  226. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
  227. package/plugins/storage/s3-storage.js +0 -93
  228. package/quickTest.js +0 -136
  229. package/scripts/debug-anyapi-review-filter.mjs +0 -63
  230. package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
  231. package/scripts/inspect-anyapi-descriptor.mjs +0 -19
  232. package/scripts/run-docs.js +0 -133
  233. package/test-debug.js +0 -59
  234. package/tests/README.md +0 -202
  235. package/tests/TEST_TEMPLATE.test.js +0 -178
  236. package/tests/anyapi-basic.test.js +0 -105
  237. package/tests/anyapi-cursor-pagination.test.js +0 -148
  238. package/tests/anyapi-custom-idproperty.test.js +0 -163
  239. package/tests/autofilter.test.js +0 -463
  240. package/tests/bulk-operations.test.js +0 -657
  241. package/tests/computed-fields.test.js +0 -586
  242. package/tests/cors.test.js +0 -834
  243. package/tests/curl.test.js +0 -467
  244. package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
  245. package/tests/custom-id-simplified-post.test.js +0 -75
  246. package/tests/custom-idproperty-relationships.test.js +0 -932
  247. package/tests/db-introspection.test.js +0 -521
  248. package/tests/db-table-operations.test.js +0 -798
  249. package/tests/fastify-plugin.test.js +0 -184
  250. package/tests/field-getters.test.js +0 -370
  251. package/tests/field-setters.test.js +0 -306
  252. package/tests/file-handling.test.js +0 -155
  253. package/tests/fixtures/api-configs.js +0 -2566
  254. package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
  255. package/tests/helpers/fake-fastify.js +0 -99
  256. package/tests/helpers/storage-mode.js +0 -65
  257. package/tests/helpers/test-utils.js +0 -203
  258. package/tests/http-connectors-parity.test.js +0 -395
  259. package/tests/id-normalization.test.js +0 -444
  260. package/tests/include-depth-validation.test.js +0 -331
  261. package/tests/includes.test.js +0 -427
  262. package/tests/local-storage.test.js +0 -96
  263. package/tests/logical-id-write-validation.test.js +0 -151
  264. package/tests/nested-includes.test.js +0 -617
  265. package/tests/pagination-boundaries.test.js +0 -154
  266. package/tests/pagination-contract.test.js +0 -122
  267. package/tests/pagination-cursor-multifield.test.js +0 -641
  268. package/tests/pagination-enhanced.test.js +0 -563
  269. package/tests/polymorphic-relationships.test.js +0 -347
  270. package/tests/polymorphic-simple.test.js +0 -148
  271. package/tests/positioning-advanced.test.js +0 -692
  272. package/tests/positioning-extended.test.js +0 -915
  273. package/tests/positioning-multifilter.test.js +0 -699
  274. package/tests/positioning.test.js +0 -560
  275. package/tests/post-commit-hooks.test.js +0 -120
  276. package/tests/projected-fields.test.js +0 -319
  277. package/tests/public-exports.test.js +0 -15
  278. package/tests/put-create-parity.test.js +0 -216
  279. package/tests/queries.test.js +0 -521
  280. package/tests/query-limits.test.js +0 -584
  281. package/tests/relationship-endpoints.test.js +0 -620
  282. package/tests/relationship-include-storage-adapter.test.js +0 -56
  283. package/tests/relationships.test.js +0 -681
  284. package/tests/rest-api.test.js +0 -337
  285. package/tests/return-record-settings.test.js +0 -184
  286. package/tests/row-policy.test.js +0 -489
  287. package/tests/s3-storage.test.js +0 -38
  288. package/tests/searchschema-knex-behavior.test.js +0 -187
  289. package/tests/searchschema-merge.test.js +0 -151
  290. package/tests/socketio.test.js +0 -860
  291. package/tests/storage-mapping.test.js +0 -362
  292. package/tests/temporal-boundaries.test.js +0 -208
  293. package/tests/test-development-guide.md +0 -370
  294. package/tests/virtual-fields.test.js +0 -404
@@ -1,2566 +0,0 @@
1
- import { Api } from 'hooked-api'
2
- import {
3
- AutoFilterPlugin,
4
- FileHandlingPlugin,
5
- QueryProjectionsPlugin,
6
- RestApiAnyapiKnexPlugin,
7
- RestApiKnexPlugin,
8
- RestApiPlugin,
9
- RowPolicyPlugin,
10
- SocketIOPlugin
11
- } from '../../index.js'
12
- import { ExpressPlugin } from '../../plugins/core/connectors/express-plugin.js'
13
- import express from 'express'
14
- import { createServer } from 'http'
15
- import { ensureAnyApiSchema } from '../../plugins/core/lib/anyapi/schema-utils.js'
16
- import { storageMode } from '../helpers/storage-mode.js'
17
-
18
- async function resetAnyApiTables (knex) {
19
- if (!storageMode.isAnyApi()) return
20
- await knex('any_links').delete().catch(() => {})
21
- await knex('any_records').delete().catch(() => {})
22
- await knex('any_relationship_configs').delete().catch(() => {})
23
- await knex('any_field_configs').delete().catch(() => {})
24
- await knex('any_resource_configs').delete().catch(() => {})
25
- }
26
-
27
- async function useStoragePlugin (api, knex, { tenantId } = {}) {
28
- if (storageMode.isAnyApi()) {
29
- await ensureAnyApiSchema(knex)
30
- const sanitizedName = api?.name ? api.name.replace(/[^A-Za-z0-9]+/g, '_').toLowerCase() : 'tenant'
31
- const effectiveTenantId = tenantId || api?.anyapi?.tenantId || `${sanitizedName}_tenant`
32
- api.anyapi = api.anyapi || {}
33
- api.anyapi.tenantId = effectiveTenantId
34
- await api.use(RestApiAnyapiKnexPlugin, { knex, tenantId: effectiveTenantId })
35
- storageMode.setCurrentTenant(effectiveTenantId)
36
- } else {
37
- await api.use(RestApiKnexPlugin, { knex })
38
- }
39
- }
40
-
41
- function mapTable (tableName, resourceName) {
42
- if (storageMode.isAnyApi()) {
43
- storageMode.registerTable(tableName, resourceName)
44
- }
45
- }
46
-
47
- async function withTenantContext (tenantId, fn) {
48
- if (!storageMode.isAnyApi()) {
49
- return await fn()
50
- }
51
-
52
- const previousTenant = storageMode.currentTenant
53
- storageMode.setCurrentTenant(tenantId || storageMode.defaultTenant)
54
- try {
55
- return await fn()
56
- } finally {
57
- storageMode.setCurrentTenant(previousTenant)
58
- }
59
- }
60
-
61
- export async function createTemporalBoundaryApi (knex) {
62
- const api = new Api({ name: 'temporal-boundaries', log: { level: 'error' } })
63
- await api.use(RestApiPlugin, {
64
- simplifiedApi: false,
65
- simplifiedTransport: false,
66
- returnRecordApi: { post: 'full', put: 'full', patch: 'full' },
67
- queryDefaultLimit: 2,
68
- queryMaxLimit: 3
69
- })
70
- await api.use(QueryProjectionsPlugin)
71
- await useStoragePlugin(api, knex)
72
- const producedAt = {
73
- type: 'dateTime', temporalPrecision: 0, computed: true,
74
- compute: () => new Date('2026-09-08T01:02:03.456Z')
75
- }
76
- await api.addResource('people', {
77
- schema: { id: { type: 'id' }, name: { type: 'string' }, producedAt },
78
- relationships: { events: { type: 'hasMany', target: 'events', foreignKey: 'personId' } },
79
- tableName: 'temporal_people'
80
- })
81
- await api.resources.people.createKnexTable()
82
- mapTable('temporal_people', 'people')
83
- await api.addResource('events', {
84
- sortableFields: ['id', 'name', 'occurredAt', 'day', 'atTime', 'observedAtMs'],
85
- schema: {
86
- id: { type: 'id' },
87
- name: { type: 'string', required: true },
88
- occurredAt: { type: 'dateTime', temporalPrecision: 6, nullable: true, search: true },
89
- day: { type: 'date', nullable: true, search: true },
90
- atTime: { type: 'time', temporalPrecision: 3, nullable: true, search: true },
91
- observedAtMs: { type: 'epochMilliseconds', nullable: true, search: true },
92
- observedAtSeconds: { type: 'epochSeconds', nullable: true, search: true },
93
- serializedAt: {
94
- type: 'dateTime', temporalPrecision: 6, nullable: true, search: true,
95
- storage: { serialize: value => value == null ? null : value.replace('T', ' ').replace(/Z$/, '') }
96
- },
97
- personId: { type: 'id', belongsTo: 'people', as: 'person', nullable: true },
98
- subjectId: { type: 'number', nullable: true },
99
- subjectType: { type: 'string', nullable: true },
100
- producedAt
101
- },
102
- queryFields: {
103
- projectedAt: {
104
- type: 'dateTime', temporalPrecision: 3, sortable: true,
105
- select: ({ knex, column }) => knex.raw('??', [column('occurredAt')])
106
- }
107
- },
108
- relationships: {
109
- subject: { belongsToPolymorphic: { types: ['people'], typeField: 'subjectType', idField: 'subjectId' } }
110
- },
111
- tableName: 'temporal_events'
112
- })
113
- await api.resources.events.createKnexTable()
114
- mapTable('temporal_events', 'events')
115
- return api
116
- }
117
-
118
- /**
119
- * Creates a basic API configuration with Countries, Publishers, Authors, Books
120
- */
121
- export async function createBasicApi (knex, pluginOptions = {}) {
122
- const apiName = pluginOptions.apiName || 'basic-test-api'
123
- const tablePrefix = pluginOptions.tablePrefix || 'basic'
124
- if (storageMode.isAnyApi()) {
125
- storageMode.clearRegistry()
126
- }
127
- const api = new Api({
128
- name: apiName,
129
- log: { level: process.env.LOG_LEVEL || 'info' }
130
- })
131
-
132
- const previousTenant = storageMode.currentTenant
133
- const tenantId = storageMode.isAnyApi()
134
- ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
135
- : storageMode.defaultTenant
136
- if (storageMode.isAnyApi()) {
137
- storageMode.setCurrentTenant(tenantId)
138
- }
139
-
140
- const restApiOptions = {
141
- simplifiedApi: false,
142
- simplifiedTransport: false,
143
- returnRecordApi: {
144
- post: true, // Need to return record to get ID for tests
145
- put: false,
146
- patch: false
147
- },
148
- returnRecordTransport: {
149
- post: 'full', // Tests expect full JSON:API response
150
- put: 'no',
151
- patch: 'minimal' // Tests expect minimal response with ID
152
- },
153
- sortableFields: ['id', 'title', 'country_id', 'publisher_id', 'name', 'code'],
154
- ...pluginOptions['rest-api'] // Merge any custom options for rest-api plugin
155
- }
156
-
157
- await api.use(RestApiPlugin, restApiOptions)
158
- await useStoragePlugin(api, knex, { tenantId })
159
- await resetAnyApiTables(knex)
160
-
161
- // Add Express plugin if requested
162
- if (pluginOptions.includeExpress) {
163
- await api.use(ExpressPlugin, {
164
- mountPath: '/api', // Default mount path for tests
165
- ...(pluginOptions.express || {})
166
- })
167
- }
168
-
169
- try {
170
- // Countries table
171
- await api.addResource('countries', {
172
- schema: {
173
- id: { type: 'id' },
174
- name: { type: 'string', required: true, max: 100, search: true },
175
- code: { type: 'string', max: 2, unique: true }
176
- },
177
- relationships: {
178
- publishers: { type: 'hasMany', target: 'publishers', foreignKey: 'country_id' },
179
- books: { type: 'hasMany', target: 'books', foreignKey: 'country_id' }
180
- },
181
- tableName: `${tablePrefix}_countries`
182
- })
183
- await api.resources.countries.createKnexTable()
184
- mapTable(`${tablePrefix}_countries`, 'countries')
185
-
186
- // Publishers table
187
- await api.addResource('publishers', {
188
- schema: {
189
- id: { type: 'id' },
190
- name: { type: 'string', required: true, max: 200 },
191
- country_id: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
192
- },
193
- relationships: {
194
- books: { type: 'hasMany', target: 'books', foreignKey: 'publisher_id' },
195
- authors: { type: 'hasMany', target: 'authors', foreignKey: 'publisher_id' }
196
- },
197
- tableName: `${tablePrefix}_publishers`
198
- })
199
- await api.resources.publishers.createKnexTable()
200
- mapTable(`${tablePrefix}_publishers`, 'publishers')
201
-
202
- // Authors table
203
- await api.addResource('authors', {
204
- schema: {
205
- id: { type: 'id' },
206
- name: { type: 'string', required: true, max: 200 },
207
- publisher_id: { type: 'number', nullable: true, belongsTo: 'publishers', as: 'publisher' }
208
- },
209
- relationships: {
210
- books: { type: 'manyToMany', through: 'book_authors', foreignKey: 'author_id', otherKey: 'book_id' }
211
- },
212
- tableName: `${tablePrefix}_authors`
213
- })
214
- await api.resources.authors.createKnexTable()
215
- mapTable(`${tablePrefix}_authors`, 'authors')
216
-
217
- // Books table
218
- await api.addResource('books', {
219
- schema: {
220
- id: { type: 'id' },
221
- title: { type: 'string', required: true, max: 300, search: true },
222
- country_id: { type: 'number', required: true, belongsTo: 'countries', as: 'country', search: true },
223
- publisher_id: { type: 'number', belongsTo: 'publishers', as: 'publisher', search: true }
224
- },
225
- relationships: {
226
- authors: { type: 'manyToMany', through: 'book_authors', foreignKey: 'book_id', otherKey: 'author_id' }
227
- },
228
- tableName: `${tablePrefix}_books`
229
- })
230
- await api.resources.books.createKnexTable()
231
- mapTable(`${tablePrefix}_books`, 'books')
232
-
233
- // Book-Authors pivot table
234
- await api.addResource('book_authors', {
235
- schema: {
236
- id: { type: 'id' },
237
- book_id: { type: 'number', required: true, belongsTo: 'books', as: 'book' },
238
- author_id: { type: 'number', required: true, belongsTo: 'authors', as: 'author' }
239
- },
240
- tableName: `${tablePrefix}_book_authors`
241
- })
242
- await api.resources.book_authors.createKnexTable()
243
- mapTable(`${tablePrefix}_book_authors`, 'book_authors')
244
- if (storageMode.isAnyApi()) {
245
- const descriptorTenant = api.anyapi?.tenantId || tenantId
246
- const booksDescriptor = await api.anyapi.registry.getDescriptor(descriptorTenant, 'books')
247
- const authorsDescriptor = await api.anyapi.registry.getDescriptor(descriptorTenant, 'authors')
248
- const relationshipKey = booksDescriptor?.manyToMany?.authors?.relationship
249
- const inverseRelationshipKey = authorsDescriptor?.manyToMany?.books?.relationship
250
- storageMode.registerLink(
251
- `${tablePrefix}_book_authors`,
252
- 'books',
253
- 'authors',
254
- relationshipKey,
255
- inverseRelationshipKey
256
- )
257
- }
258
-
259
- return api
260
- } finally {
261
- if (storageMode.isAnyApi()) {
262
- storageMode.setCurrentTenant(previousTenant)
263
- }
264
- }
265
- }
266
-
267
- export async function createReturnRecordApi (knex, pluginOptions = {}) {
268
- const apiName = pluginOptions.apiName || 'return-record-test-api'
269
- const tablePrefix = pluginOptions.tablePrefix || 'return'
270
- if (storageMode.isAnyApi()) {
271
- storageMode.clearRegistry()
272
- }
273
-
274
- const api = new Api({
275
- name: apiName,
276
- log: { level: process.env.LOG_LEVEL || 'info' }
277
- })
278
-
279
- const previousTenant = storageMode.currentTenant
280
- const tenantId = storageMode.isAnyApi()
281
- ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
282
- : storageMode.defaultTenant
283
- if (storageMode.isAnyApi()) {
284
- storageMode.setCurrentTenant(tenantId)
285
- }
286
-
287
- await api.use(RestApiPlugin, {
288
- simplifiedApi: false,
289
- simplifiedTransport: false,
290
- returnRecordApi: {
291
- post: true,
292
- put: false,
293
- patch: false
294
- },
295
- returnRecordTransport: {
296
- post: false,
297
- put: true,
298
- patch: 'minimal'
299
- },
300
- sortableFields: ['id', 'name'],
301
- ...pluginOptions['rest-api']
302
- })
303
- await useStoragePlugin(api, knex, { tenantId })
304
- await resetAnyApiTables(knex)
305
-
306
- try {
307
- await api.addResource('global_items', {
308
- schema: {
309
- id: { type: 'id' },
310
- name: { type: 'string', required: true, max: 100 }
311
- },
312
- tableName: `${tablePrefix}_global_items`
313
- })
314
- await api.resources.global_items.createKnexTable()
315
- mapTable(`${tablePrefix}_global_items`, 'global_items')
316
-
317
- await api.addResource('scope_items', {
318
- schema: {
319
- id: { type: 'id' },
320
- name: { type: 'string', required: true, max: 100 }
321
- },
322
- tableName: `${tablePrefix}_scope_items`,
323
- returnRecordApi: {
324
- post: false,
325
- put: true,
326
- patch: 'minimal'
327
- },
328
- returnRecordTransport: true
329
- })
330
- await api.resources.scope_items.createKnexTable()
331
- mapTable(`${tablePrefix}_scope_items`, 'scope_items')
332
- } finally {
333
- if (storageMode.isAnyApi()) {
334
- storageMode.setCurrentTenant(previousTenant)
335
- }
336
- }
337
-
338
- return api
339
- }
340
-
341
- /**
342
- * Creates a small API that uses logical camelCase belongsTo fields with default snake_case storage mapping.
343
- * This reproduces the full JSON:API linkage path where storage column names differ from logical field names.
344
- */
345
- export async function createCamelCaseBelongsToApi (knex, pluginOptions = {}) {
346
- const apiName = pluginOptions.apiName || 'camelcase-belongsto-test-api'
347
- const tablePrefix = pluginOptions.tablePrefix || 'camel_fk'
348
-
349
- const api = new Api({
350
- name: apiName,
351
- log: { level: process.env.LOG_LEVEL || 'info' }
352
- })
353
-
354
- await api.use(RestApiPlugin, {
355
- simplifiedApi: false,
356
- simplifiedTransport: false,
357
- returnRecordApi: {
358
- post: true,
359
- put: false,
360
- patch: false
361
- },
362
- returnRecordTransport: {
363
- post: 'full',
364
- put: 'no',
365
- patch: 'full'
366
- },
367
- ...(pluginOptions['rest-api'] || {})
368
- })
369
-
370
- await api.use(RestApiKnexPlugin, { knex })
371
-
372
- await api.addResource('countries', {
373
- schema: {
374
- id: { type: 'id' },
375
- name: { type: 'string', required: true, max: 100 }
376
- },
377
- tableName: `${tablePrefix}_countries`
378
- })
379
- await api.resources.countries.createKnexTable()
380
-
381
- await api.addResource('publishers', {
382
- schema: {
383
- id: { type: 'id' },
384
- name: { type: 'string', required: true, max: 200 },
385
- countryId: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
386
- },
387
- tableName: `${tablePrefix}_publishers`
388
- })
389
- await api.resources.publishers.createKnexTable()
390
-
391
- return api
392
- }
393
-
394
- export async function createRelationshipIncludeStorageAdapterApi (knex) {
395
- const api = new Api({
396
- name: 'relationship-include-storage-adapter-test',
397
- log: { level: process.env.LOG_LEVEL || 'info' }
398
- })
399
-
400
- await api.use(RestApiPlugin, {
401
- simplifiedApi: false,
402
- simplifiedTransport: false
403
- })
404
- await api.use(RestApiKnexPlugin, { knex })
405
-
406
- await knex.schema.createTable('uninitialized_pets', table => {
407
- table.increments('id').primary()
408
- table.string('first_name').notNullable()
409
- table.string('last_name').notNullable()
410
- })
411
- await knex.schema.createTable('uninitialized_bookings', table => {
412
- table.increments('id').primary()
413
- table.string('reference').notNullable()
414
- table.integer('pet_id').notNullable()
415
- })
416
-
417
- await api.addResource('pets', {
418
- schema: {
419
- id: { type: 'id' },
420
- firstName: { type: 'string', required: true },
421
- lastName: { type: 'string', required: true }
422
- },
423
- tableName: 'uninitialized_pets'
424
- })
425
-
426
- await api.addResource('bookings', {
427
- schema: {
428
- id: { type: 'id' },
429
- reference: { type: 'string', required: true },
430
- petId: {
431
- type: 'id',
432
- required: true,
433
- belongsTo: 'pets',
434
- as: 'pet'
435
- }
436
- },
437
- tableName: 'uninitialized_bookings'
438
- })
439
-
440
- return api
441
- }
442
-
443
- export async function seedRelationshipIncludeStorageAdapterApi (knex) {
444
- await knex('uninitialized_pets').insert({
445
- id: 7,
446
- first_name: 'Coco',
447
- last_name: 'Spaniel'
448
- })
449
- await knex('uninitialized_bookings').insert({
450
- id: 41,
451
- reference: 'BOOK-41',
452
- pet_id: 7
453
- })
454
- }
455
-
456
- /**
457
- * Creates a small API focused on resource ID normalization behavior.
458
- */
459
- export async function createIdNormalizationApi (knex, pluginOptions = {}) {
460
- const apiName = pluginOptions.apiName || 'id-normalization-test-api'
461
- const tablePrefix = pluginOptions.tablePrefix || 'id_norm'
462
- if (storageMode.isAnyApi()) {
463
- storageMode.clearRegistry()
464
- }
465
-
466
- const api = new Api({
467
- name: apiName,
468
- log: { level: process.env.LOG_LEVEL || 'info' }
469
- })
470
-
471
- const previousTenant = storageMode.currentTenant
472
- const tenantId = storageMode.isAnyApi()
473
- ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
474
- : storageMode.defaultTenant
475
- if (storageMode.isAnyApi()) {
476
- storageMode.setCurrentTenant(tenantId)
477
- }
478
-
479
- await api.use(RestApiPlugin, {
480
- simplifiedApi: false,
481
- simplifiedTransport: false,
482
- returnRecordApi: {
483
- post: true,
484
- put: false,
485
- patch: false
486
- },
487
- returnRecordTransport: {
488
- post: 'full',
489
- put: 'no',
490
- patch: 'minimal'
491
- },
492
- ...(pluginOptions['rest-api'] || {})
493
- })
494
-
495
- try {
496
- await withTenantContext(tenantId, async () => {
497
- await useStoragePlugin(api, knex, { tenantId })
498
- await resetAnyApiTables(knex)
499
-
500
- await api.addResource('countries', {
501
- schema: {
502
- id: { type: 'string', required: true },
503
- name: { type: 'string', required: true, max: 100 }
504
- },
505
- tableName: `${tablePrefix}_countries`,
506
- ...(pluginOptions.countryResourceOptions || {})
507
- })
508
- await knex.schema.dropTableIfExists(`${tablePrefix}_countries`)
509
- await knex.schema.createTable(`${tablePrefix}_countries`, (table) => {
510
- table.string('id').primary()
511
- table.string('name').notNullable()
512
- })
513
- mapTable(`${tablePrefix}_countries`, 'countries')
514
-
515
- await api.addResource('publishers', {
516
- schema: {
517
- id: { type: 'string', required: true },
518
- name: { type: 'string', required: true, max: 100 },
519
- country_id: { type: 'string', nullable: true, belongsTo: 'countries', as: 'country' }
520
- },
521
- relationships: {
522
- tags: { type: 'manyToMany', through: 'publisher_tags', foreignKey: 'publisher_id', otherKey: 'tag_id' }
523
- },
524
- tableName: `${tablePrefix}_publishers`,
525
- ...(pluginOptions.publisherResourceOptions || {})
526
- })
527
- await knex.schema.dropTableIfExists(`${tablePrefix}_publishers`)
528
- await knex.schema.createTable(`${tablePrefix}_publishers`, (table) => {
529
- table.string('id').primary()
530
- table.string('name').notNullable()
531
- table.string('country_id').nullable()
532
- })
533
- mapTable(`${tablePrefix}_publishers`, 'publishers')
534
-
535
- await api.addResource('tags', {
536
- schema: {
537
- id: { type: 'string', required: true },
538
- name: { type: 'string', required: true, max: 100 }
539
- },
540
- tableName: `${tablePrefix}_tags`,
541
- ...(pluginOptions.tagResourceOptions || {})
542
- })
543
- await knex.schema.dropTableIfExists(`${tablePrefix}_tags`)
544
- await knex.schema.createTable(`${tablePrefix}_tags`, (table) => {
545
- table.string('id').primary()
546
- table.string('name').notNullable()
547
- })
548
- mapTable(`${tablePrefix}_tags`, 'tags')
549
-
550
- await api.addResource('publisher_tags', {
551
- schema: {
552
- id: { type: 'id' },
553
- publisher_id: { type: 'string', required: true, belongsTo: 'publishers', as: 'publisher' },
554
- tag_id: { type: 'string', required: true, belongsTo: 'tags', as: 'tag' }
555
- },
556
- tableName: `${tablePrefix}_publisher_tags`
557
- })
558
- await knex.schema.dropTableIfExists(`${tablePrefix}_publisher_tags`)
559
- await knex.schema.createTable(`${tablePrefix}_publisher_tags`, (table) => {
560
- table.increments('id').primary()
561
- table.string('publisher_id').notNullable()
562
- table.string('tag_id').notNullable()
563
- })
564
- mapTable(`${tablePrefix}_publisher_tags`, 'publisher_tags')
565
- if (storageMode.isAnyApi()) {
566
- const descriptorTenant = api.anyapi?.tenantId || tenantId
567
- const publishersDescriptor = await api.anyapi.registry.getDescriptor(descriptorTenant, 'publishers')
568
- const tagsDescriptor = await api.anyapi.registry.getDescriptor(descriptorTenant, 'tags')
569
- const relationshipKey = publishersDescriptor?.manyToMany?.tags?.relationship
570
- const inverseRelationshipKey = tagsDescriptor?.manyToMany?.publishers?.relationship
571
- storageMode.registerLink(
572
- `${tablePrefix}_publisher_tags`,
573
- 'publishers',
574
- 'tags',
575
- relationshipKey,
576
- inverseRelationshipKey
577
- )
578
- }
579
- })
580
-
581
- return api
582
- } finally {
583
- if (storageMode.isAnyApi()) {
584
- storageMode.setCurrentTenant(previousTenant)
585
- }
586
- }
587
- }
588
-
589
- /**
590
- * Creates a basic API with bulk operations enabled
591
- */
592
- export async function createBulkOperationsApi (knex, pluginOptions = {}) {
593
- const { BulkOperationsPlugin } = await import('../../plugins/core/bulk-operations-plugin.js')
594
-
595
- const api = await createBasicApi(knex, {
596
- ...pluginOptions,
597
- tenantId: pluginOptions.tenantId || 'bulk_ops_tenant'
598
- })
599
-
600
- // Add bulk operations plugin
601
- await api.use(BulkOperationsPlugin, {
602
- 'bulk-operations': {
603
- maxBulkOperations: 100,
604
- defaultAtomic: true,
605
- batchSize: 10,
606
- enableOptimizations: true,
607
- ...pluginOptions['bulk-operations']
608
- }
609
- })
610
-
611
- return api
612
- }
613
-
614
- /**
615
- * Creates an extended API with additional fields for more complex testing
616
- */
617
- export async function createExtendedApi (knex) {
618
- const api = new Api({
619
- name: 'extended-test-api',
620
- })
621
-
622
- const tenantId = storageMode.isAnyApi() ? 'extended_tenant' : storageMode.defaultTenant
623
-
624
- await api.use(RestApiPlugin, {
625
- simplifiedApi: false,
626
- simplifiedTransport: false,
627
- returnRecordApi: {
628
- post: true, // Need to return record to get ID for tests
629
- put: false,
630
- patch: false
631
- },
632
- returnRecordTransport: {
633
- post: 'full', // Tests expect full JSON:API response
634
- put: 'no',
635
- patch: 'minimal' // Tests expect minimal response with ID
636
- },
637
- sortableFields: ['id', 'title', 'country_id', 'publisher_id', 'price', 'language', 'population', 'name', 'code']
638
- })
639
- await withTenantContext(tenantId, async () => {
640
- await useStoragePlugin(api, knex, { tenantId })
641
- await resetAnyApiTables(knex)
642
-
643
- // Countries with extended fields
644
- await api.addResource('countries', {
645
- schema: {
646
- id: { type: 'id' },
647
- name: { type: 'string', required: true, max: 100 },
648
- code: { type: 'string', max: 2, unique: true },
649
- capital: { type: 'string', max: 100 },
650
- population: { type: 'number' },
651
- currency: { type: 'string', max: 3 }
652
- },
653
- relationships: {
654
- publishers: { type: 'hasMany', target: 'publishers', foreignKey: 'country_id' },
655
- books: { type: 'hasMany', target: 'books', foreignKey: 'country_id' },
656
- authors: { type: 'hasMany', target: 'authors', foreignKey: 'nationality_id' }
657
- },
658
- tableName: 'ext_countries'
659
- })
660
- await api.resources.countries.createKnexTable()
661
- mapTable('ext_countries', 'countries')
662
-
663
- // Publishers with extended fields
664
- await api.addResource('publishers', {
665
- schema: {
666
- id: { type: 'id' },
667
- name: { type: 'string', required: true, max: 200 },
668
- country_id: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' },
669
- founded_year: { type: 'number' },
670
- website: { type: 'string', max: 255 },
671
- active: { type: 'boolean', default: true }
672
- },
673
- relationships: {
674
- books: { type: 'hasMany', target: 'books', foreignKey: 'publisher_id' },
675
- reviews: {
676
- type: 'hasMany',
677
- target: 'reviews',
678
- via: 'reviewable'
679
- }
680
- },
681
- tableName: 'ext_publishers'
682
- })
683
- await api.resources.publishers.createKnexTable()
684
- mapTable('ext_publishers', 'publishers')
685
-
686
- // Authors with extended fields
687
- await api.addResource('authors', {
688
- schema: {
689
- id: { type: 'id' },
690
- name: { type: 'string', required: true, max: 200 },
691
- birth_date: { type: 'date' },
692
- biography: { type: 'string', max: 5000 },
693
- nationality_id: { type: 'number', belongsTo: 'countries', as: 'nationality' }
694
- },
695
- relationships: {
696
- books: { type: 'manyToMany', through: 'book_authors', foreignKey: 'author_id', otherKey: 'book_id' },
697
- reviews: {
698
- type: 'hasMany',
699
- target: 'reviews',
700
- via: 'reviewable'
701
- }
702
- },
703
- tableName: 'ext_authors'
704
- })
705
- await api.resources.authors.createKnexTable()
706
- mapTable('ext_authors', 'authors')
707
-
708
- // Books with extended fields
709
- await api.addResource('books', {
710
- schema: {
711
- id: { type: 'id' },
712
- title: { type: 'string', required: true, max: 300, search: true },
713
- isbn: { type: 'string', max: 13 },
714
- pages: { type: 'number' },
715
- price: { type: 'number', search: true }, // Store price as string for decimal precision
716
- published_date: { type: 'date' },
717
- language: { type: 'string', max: 2, default: 'en', search: true },
718
- country_id: { type: 'number', required: true, belongsTo: 'countries', as: 'country', search: true },
719
- publisher_id: { type: 'number', belongsTo: 'publishers', as: 'publisher', search: true }
720
- },
721
- relationships: {
722
- authors: { type: 'manyToMany', through: 'book_authors', foreignKey: 'book_id', otherKey: 'author_id' },
723
- reviews: {
724
- type: 'hasMany',
725
- target: 'reviews',
726
- via: 'reviewable'
727
- }
728
- },
729
- tableName: 'ext_books'
730
- })
731
- await api.resources.books.createKnexTable()
732
- mapTable('ext_books', 'books')
733
-
734
- // Book-Authors pivot with extended fields
735
- await api.addResource('book_authors', {
736
- schema: {
737
- id: { type: 'id' },
738
- book_id: { type: 'number', required: true, belongsTo: 'books', as: 'book' },
739
- author_id: { type: 'number', required: true, belongsTo: 'authors', as: 'author' },
740
- contribution_type: { type: 'string', max: 50 },
741
- order: { type: 'number' }
742
- },
743
- tableName: 'ext_book_authors'
744
- })
745
- await api.resources.book_authors.createKnexTable()
746
- mapTable('ext_book_authors', 'book_authors')
747
- if (storageMode.isAnyApi()) {
748
- const booksDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'books')
749
- const authorsDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'authors')
750
- const relationshipKey = booksDescriptor?.manyToMany?.authors?.relationship
751
- const inverseRelationshipKey = authorsDescriptor?.manyToMany?.books?.relationship
752
- storageMode.registerLink('ext_book_authors', 'books', 'authors', relationshipKey, inverseRelationshipKey)
753
- }
754
-
755
- // Polymorphic reviews (can go on authors, books and publishers)
756
- await api.addResource('reviews', {
757
- schema: {
758
- id: { type: 'id' },
759
- rating: { type: 'number', required: true, min: 1, max: 5 },
760
- title: { type: 'string', max: 200 },
761
- content: { type: 'string', required: true, max: 5000 },
762
- reviewer_name: { type: 'string', required: true, max: 100 },
763
- review_date: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
764
- helpful_count: { type: 'number', default: 0 },
765
- reviewable_type: { type: 'string', required: true },
766
- reviewable_id: { type: 'number', required: true }
767
- },
768
- relationships: {
769
- reviewable: {
770
- belongsToPolymorphic: {
771
- types: ['books', 'authors', 'publishers'],
772
- typeField: 'reviewable_type',
773
- idField: 'reviewable_id'
774
- }
775
- }
776
- },
777
- tableName: 'ext_reviews'
778
- })
779
- await api.resources.reviews.createKnexTable()
780
- mapTable('ext_reviews', 'reviews')
781
- })
782
-
783
- return api
784
- }
785
-
786
- /**
787
- * Creates an API with limited include depth for testing depth validation
788
- * Uses 'limited_' prefix for all tables to avoid conflicts
789
- */
790
- export async function createLimitedDepthApi (knex) {
791
- const api = new Api({
792
- name: 'limited-depth-api',
793
- })
794
-
795
- const tenantId = storageMode.isAnyApi() ? 'limited_depth_tenant' : storageMode.defaultTenant
796
-
797
- await api.use(RestApiPlugin, {
798
- simplifiedApi: false,
799
- simplifiedTransport: false,
800
- returnRecordApi: {
801
- post: true,
802
- put: false,
803
- patch: false
804
- },
805
- returnRecordTransport: {
806
- post: 'full',
807
- put: 'no',
808
- patch: 'minimal'
809
- },
810
- sortableFields: ['id', 'title', 'country_id', 'publisher_id', 'name', 'code'],
811
- includeDepthLimit: 2 // Key difference: limit is 2 instead of default 3
812
- })
813
- await withTenantContext(tenantId, async () => {
814
- await useStoragePlugin(api, knex, { tenantId })
815
-
816
- // Use different table names with 'limited_' prefix to avoid conflicts
817
- await api.addResource('countries', {
818
- schema: {
819
- id: { type: 'id' },
820
- name: { type: 'string', required: true, max: 100 },
821
- code: { type: 'string', max: 2, unique: true }
822
- },
823
- relationships: {
824
- publishers: { type: 'hasMany', target: 'publishers', foreignKey: 'country_id' },
825
- books: { type: 'hasMany', target: 'books', foreignKey: 'country_id' }
826
- },
827
- tableName: 'limited_countries',
828
- })
829
- await api.resources.countries.createKnexTable()
830
- mapTable('limited_countries', 'countries')
831
-
832
- await api.addResource('publishers', {
833
- schema: {
834
- id: { type: 'id' },
835
- name: { type: 'string', required: true, max: 200 },
836
- country_id: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
837
- },
838
- relationships: {
839
- books: { type: 'hasMany', target: 'books', foreignKey: 'publisher_id' },
840
- authors: { type: 'hasMany', target: 'authors', foreignKey: 'publisher_id' }
841
- },
842
- tableName: 'limited_publishers'
843
- })
844
- await api.resources.publishers.createKnexTable()
845
- mapTable('limited_publishers', 'publishers')
846
-
847
- await api.addResource('authors', {
848
- schema: {
849
- id: { type: 'id' },
850
- name: { type: 'string', required: true, max: 200 },
851
- publisher_id: { type: 'number', nullable: true, belongsTo: 'publishers', as: 'publisher' }
852
- },
853
- relationships: {
854
- books: { type: 'manyToMany', through: 'book_authors', foreignKey: 'author_id', otherKey: 'book_id' }
855
- },
856
- tableName: 'limited_authors'
857
- })
858
- await api.resources.authors.createKnexTable()
859
-
860
- await api.addResource('books', {
861
- schema: {
862
- id: { type: 'id' },
863
- title: { type: 'string', required: true, max: 300, search: true },
864
- country_id: { type: 'number', required: true, belongsTo: 'countries', as: 'country', search: true },
865
- publisher_id: { type: 'number', belongsTo: 'publishers', as: 'publisher', search: true }
866
- },
867
- relationships: {
868
- authors: { type: 'manyToMany', through: 'book_authors', foreignKey: 'book_id', otherKey: 'author_id' }
869
- },
870
- tableName: 'limited_books'
871
- })
872
- await api.resources.books.createKnexTable()
873
-
874
- await api.addResource('book_authors', {
875
- schema: {
876
- id: { type: 'id' },
877
- book_id: { type: 'number', required: true, belongsTo: 'books', as: 'book' },
878
- author_id: { type: 'number', required: true, belongsTo: 'authors', as: 'author' }
879
- },
880
- tableName: 'limited_book_authors'
881
- })
882
- await api.resources.book_authors.createKnexTable()
883
- mapTable('limited_book_authors', 'book_authors')
884
- if (storageMode.isAnyApi()) {
885
- const booksDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'books')
886
- const authorsDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'authors')
887
- const relationshipKey = booksDescriptor?.manyToMany?.authors?.relationship
888
- const inverseRelationshipKey = authorsDescriptor?.manyToMany?.books?.relationship
889
- storageMode.registerLink('limited_book_authors', 'books', 'authors', relationshipKey, inverseRelationshipKey)
890
- }
891
- })
892
-
893
- return api
894
- }
895
-
896
- /**
897
- * Creates an API configuration for pagination testing
898
- */
899
- export async function createPaginationApi (knex, options = {}) {
900
- const api = new Api({
901
- name: 'pagination-test-api',
902
- })
903
-
904
- const tenantId = storageMode.isAnyApi() ? 'pagination_tenant' : storageMode.defaultTenant
905
-
906
- const restApiOptions = {
907
- simplifiedApi: false,
908
- simplifiedTransport: false,
909
- returnRecordApi: {
910
- post: true,
911
- put: false,
912
- patch: false
913
- },
914
- returnRecordTransport: {
915
- post: 'full',
916
- put: 'no',
917
- patch: 'minimal'
918
- },
919
- sortableFields: ['id', 'title', 'country_id', 'publisher_id', 'name', 'code'],
920
- ...options // Allow overriding options likereturnBasePath, enablePaginationCounts
921
- }
922
-
923
- await api.use(RestApiPlugin, restApiOptions)
924
- await withTenantContext(tenantId, async () => {
925
- await useStoragePlugin(api, knex, { tenantId })
926
-
927
- // Countries table
928
- await api.addResource('countries', {
929
- schema: {
930
- id: { type: 'id' },
931
- name: { type: 'string', required: true, max: 100 },
932
- code: { type: 'string', max: 2, unique: true }
933
- },
934
- relationships: {
935
- publishers: { type: 'hasMany', target: 'publishers', foreignKey: 'country_id' },
936
- books: { type: 'hasMany', target: 'books', foreignKey: 'country_id' }
937
- },
938
- tableName: 'pagination_countries'
939
- })
940
- await api.resources.countries.createKnexTable()
941
- mapTable('pagination_countries', 'countries')
942
-
943
- // Publishers table
944
- await api.addResource('publishers', {
945
- schema: {
946
- id: { type: 'id' },
947
- name: { type: 'string', required: true, max: 200 },
948
- country_id: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
949
- },
950
- relationships: {
951
- books: { type: 'hasMany', target: 'books', foreignKey: 'publisher_id' }
952
- },
953
- tableName: 'pagination_publishers'
954
- })
955
- await api.resources.publishers.createKnexTable()
956
- mapTable('pagination_publishers', 'publishers')
957
-
958
- // Books table
959
- await api.addResource('books', {
960
- schema: {
961
- id: { type: 'id' },
962
- title: { type: 'string', required: true, max: 300, search: true },
963
- country_id: { type: 'number', required: true, belongsTo: 'countries', as: 'country', search: true },
964
- publisher_id: { type: 'number', belongsTo: 'publishers', as: 'publisher', search: true }
965
- },
966
- tableName: 'pagination_books'
967
- })
968
- await api.resources.books.createKnexTable()
969
- mapTable('pagination_books', 'books')
970
- })
971
-
972
- return api
973
- }
974
-
975
- /**
976
- * Creates an API with WebSocket/Socket.IO support for testing
977
- */
978
- export async function createWebSocketApi (knex, pluginOptions = {}) {
979
- const { jwtVerify } = await import('jose')
980
-
981
- const api = await createBasicApi(knex, {
982
- ...pluginOptions,
983
- tenantId: pluginOptions.tenantId || 'socketio_tenant',
984
- includeExpress: true,
985
- express: {
986
- port: 0 // Let OS assign a port
987
- }
988
- })
989
-
990
- const socketioOptions = { ...(pluginOptions['socketio'] || {}) }
991
- const authOptions = { ...(socketioOptions.auth || {}) }
992
- const sharedSecret = authOptions.sharedSecret || socketioOptions.sharedSecret || 'test-secret-key'
993
-
994
- if (!authOptions.authenticate) {
995
- authOptions.authenticate = async ({ socket }) => {
996
- const token = socket.handshake.auth?.token
997
- if (!token) {
998
- throw new Error('Authentication required')
999
- }
1000
-
1001
- const encoder = new TextEncoder()
1002
- const key = encoder.encode(sharedSecret)
1003
- const { payload } = await jwtVerify(token, key)
1004
-
1005
- const roles = Array.isArray(payload.roles)
1006
- ? payload.roles
1007
- : payload.role ? [payload.role] : []
1008
-
1009
- const authContext = {
1010
- userId: payload.userId || payload.sub,
1011
- roles,
1012
- token: payload
1013
- }
1014
-
1015
- if (!authContext.userId) {
1016
- throw new Error('Token is missing required user identifier')
1017
- }
1018
-
1019
- return authContext
1020
- }
1021
- }
1022
-
1023
- if (authOptions.requireAuth === undefined) {
1024
- authOptions.requireAuth = true
1025
- }
1026
-
1027
- socketioOptions.auth = authOptions
1028
-
1029
- await api.use(SocketIOPlugin, socketioOptions)
1030
-
1031
- // Create and start Express server
1032
- const app = express()
1033
- api.http.express.app = app
1034
-
1035
- // Mount the API routes
1036
- app.use('/api', api.http.express.router)
1037
-
1038
- // Create HTTP server
1039
- const server = createServer(app)
1040
-
1041
- // Start Socket.IO server
1042
- await api.startSocketServer(server)
1043
-
1044
- // Start listening
1045
- await new Promise((resolve) => {
1046
- server.listen(0, () => {
1047
- resolve()
1048
- })
1049
- })
1050
-
1051
- return { api, server }
1052
- }
1053
-
1054
- /**
1055
- * Creates an API with computed fields for testing
1056
- */
1057
- export async function createComputedFieldsApi (knex, pluginOptions = {}) {
1058
- const api = new Api({
1059
- name: 'computed-fields-test-api',
1060
- log: { level: process.env.LOG_LEVEL || 'info' }
1061
- })
1062
-
1063
- const tenantId = storageMode.isAnyApi() ? 'computed_fields_tenant' : storageMode.defaultTenant
1064
-
1065
- await api.use(RestApiPlugin, {
1066
- simplifiedApi: true,
1067
- simplifiedTransport: true
1068
- })
1069
-
1070
- await withTenantContext(tenantId, async () => {
1071
- await useStoragePlugin(api, knex, { tenantId })
1072
- await resetAnyApiTables(knex)
1073
-
1074
- // Products resource with computed fields
1075
- await api.addResource('products', {
1076
- schema: {
1077
- id: { type: 'id' },
1078
- name: { type: 'string', required: true, max: 255 },
1079
- price: { type: 'number', required: true, min: 0 },
1080
- cost: { type: 'number', required: true, min: 0, normallyHidden: true },
1081
- internal_notes: { type: 'string', normallyHidden: true },
1082
- profit_margin: {
1083
- type: 'number',
1084
- computed: true,
1085
- dependencies: ['price', 'cost'],
1086
- compute: ({ attributes }) => {
1087
- if (!attributes.price || attributes.price === 0) return 0
1088
- return Number(((attributes.price - attributes.cost) / attributes.price * 100).toFixed(2))
1089
- }
1090
- },
1091
- profit_amount: {
1092
- type: 'number',
1093
- computed: true,
1094
- dependencies: ['price', 'cost'],
1095
- compute: ({ attributes }) => {
1096
- return Number((attributes.price - attributes.cost).toFixed(2))
1097
- }
1098
- },
1099
- calculated_at: {
1100
- type: 'dateTime',
1101
- temporalPrecision: 0,
1102
- computed: true,
1103
- compute: () => new Date('2026-08-25T23:45:01.987Z')
1104
- },
1105
- calculated_time: {
1106
- type: 'time',
1107
- temporalPrecision: 3,
1108
- computed: true,
1109
- compute: () => new Date('2026-08-25T23:45:01.987Z')
1110
- }
1111
- },
1112
- relationships: {
1113
- reviews: { type: 'hasMany', target: 'reviews', foreignKey: 'product_id' }
1114
- },
1115
- tableName: 'test_products'
1116
- })
1117
- await api.resources.products.createKnexTable()
1118
- mapTable('searchmerge_products', 'products')
1119
- mapTable('test_products', 'products')
1120
-
1121
- // Reviews resource with computed fields
1122
- await api.addResource('reviews', {
1123
- schema: {
1124
- id: { type: 'id' },
1125
- product_id: { type: 'number', required: true, belongsTo: 'products', as: 'product' },
1126
- reviewer_name: { type: 'string', required: true },
1127
- rating: { type: 'number', required: true, min: 1, max: 5 },
1128
- comment: { type: 'string', max: 1000 },
1129
- helpful_votes: { type: 'number', default: 0 },
1130
- total_votes: { type: 'number', default: 0 },
1131
- spam_score: { type: 'number', default: 0, normallyHidden: true },
1132
- helpfulness_score: {
1133
- type: 'number',
1134
- computed: true,
1135
- dependencies: ['helpful_votes', 'total_votes'],
1136
- compute: ({ attributes }) => {
1137
- if (attributes.total_votes === 0) return null
1138
- return Number(((attributes.helpful_votes / attributes.total_votes) * 100).toFixed(0))
1139
- }
1140
- },
1141
- is_helpful: {
1142
- type: 'boolean',
1143
- computed: true,
1144
- dependencies: ['helpful_votes', 'total_votes', 'spam_score'],
1145
- compute: ({ attributes }) => {
1146
- if (attributes.total_votes < 10) return null
1147
- const helpfulnessScore = (attributes.helpful_votes / attributes.total_votes) * 100
1148
- return helpfulnessScore > 70 && attributes.spam_score < 0.5
1149
- }
1150
- },
1151
- calculated_at: {
1152
- type: 'dateTime',
1153
- temporalPrecision: 0,
1154
- computed: true,
1155
- compute: () => new Date('2026-08-25T23:46:02.654Z')
1156
- }
1157
- },
1158
- tableName: 'test_reviews'
1159
- })
1160
- await api.resources.reviews.createKnexTable()
1161
- mapTable('test_reviews', 'reviews')
1162
- })
1163
-
1164
- return api
1165
- }
1166
-
1167
- /**
1168
- * Creates an API with field getters for testing
1169
- */
1170
- export async function createFieldGettersApi (knex, pluginOptions = {}) {
1171
- const api = new Api({
1172
- name: 'field-getters-test-api',
1173
- log: { level: process.env.LOG_LEVEL || 'info' }
1174
- })
1175
-
1176
- const tenantId = storageMode.isAnyApi() ? 'field_getters_tenant' : storageMode.defaultTenant
1177
- const previousTenant = storageMode.currentTenant
1178
- if (storageMode.isAnyApi()) {
1179
- storageMode.setCurrentTenant(tenantId)
1180
- }
1181
-
1182
- await api.use(RestApiPlugin, {
1183
- simplifiedApi: true,
1184
- simplifiedTransport: true
1185
- })
1186
-
1187
- await useStoragePlugin(api, knex, { tenantId })
1188
- await resetAnyApiTables(knex)
1189
-
1190
- // Users resource with basic getters
1191
- await api.addResource('users', {
1192
- schema: {
1193
- id: { type: 'id' },
1194
- email: {
1195
- type: 'string',
1196
- nullable: true,
1197
- getter: (value) => value?.toLowerCase().trim()
1198
- },
1199
- name: {
1200
- type: 'string',
1201
- nullable: true,
1202
- getter: (value) => value?.trim()
1203
- },
1204
- phone: {
1205
- type: 'string',
1206
- nullable: true,
1207
- getter: (value) => {
1208
- if (!value) return null
1209
- // Remove all non-digits and format
1210
- const digits = value.replace(/\D/g, '')
1211
- if (digits.length === 10) {
1212
- return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`
1213
- }
1214
- return value
1215
- }
1216
- },
1217
- metadata_json: {
1218
- type: 'string',
1219
- nullable: true,
1220
- getter: (value) => {
1221
- if (!value) return {}
1222
- try {
1223
- return JSON.parse(value)
1224
- } catch {
1225
- return {}
1226
- }
1227
- }
1228
- },
1229
- tags_csv: {
1230
- type: 'string',
1231
- nullable: true,
1232
- getter: (value) => {
1233
- if (!value) return []
1234
- return value.split(',').map(t => t.trim()).filter(t => t)
1235
- }
1236
- }
1237
- },
1238
- tableName: 'getter_users'
1239
- })
1240
- await api.resources.users.createKnexTable()
1241
- mapTable('searchmerge_users', 'users')
1242
- mapTable('getter_users', 'users')
1243
-
1244
- // Products resource with getters and computed fields
1245
- await api.addResource('products', {
1246
- schema: {
1247
- id: { type: 'id' },
1248
- name: {
1249
- type: 'string',
1250
- getter: (value) => value?.toUpperCase()
1251
- },
1252
- description: {
1253
- type: 'string',
1254
- getter: (value) => {
1255
- // Truncate long descriptions
1256
- if (!value) return ''
1257
- return value.length > 50 ? value.substring(0, 47) + '...' : value
1258
- }
1259
- },
1260
- price_str: {
1261
- type: 'string',
1262
- getter: (value) => parseFloat(value) || 0
1263
- },
1264
- tax_rate_str: {
1265
- type: 'string',
1266
- getter: (value) => parseFloat(value) || 0
1267
- },
1268
- total_price: {
1269
- type: 'number',
1270
- computed: true,
1271
- dependencies: ['price_str', 'tax_rate_str'],
1272
- compute: ({ attributes }) => {
1273
- // Should see numbers from getters, not strings
1274
- return attributes.price_str * (1 + attributes.tax_rate_str)
1275
- }
1276
- }
1277
- },
1278
- relationships: {
1279
- reviews: { type: 'hasMany', target: 'reviews', foreignKey: 'product_id' }
1280
- },
1281
- tableName: 'getter_products'
1282
- })
1283
- await api.resources.products.createKnexTable()
1284
- mapTable('getter_products', 'products')
1285
-
1286
- // Reviews resource with getters
1287
- await api.addResource('reviews', {
1288
- schema: {
1289
- id: { type: 'id' },
1290
- product_id: { type: 'id', belongsTo: 'products', as: 'product' },
1291
- content: {
1292
- type: 'string',
1293
- getter: (value) => `[REVIEW] ${value}`
1294
- },
1295
- rating: { type: 'number' }
1296
- },
1297
- tableName: 'getter_reviews'
1298
- })
1299
- await api.resources.reviews.createKnexTable()
1300
- mapTable('getter_reviews', 'reviews')
1301
-
1302
- // Resource with getter dependencies
1303
- await api.addResource('formatted_data', {
1304
- schema: {
1305
- id: { type: 'id' },
1306
- step1: {
1307
- type: 'string',
1308
- nullable: true,
1309
- getter: (value) => value?.trim()
1310
- },
1311
- step2: {
1312
- type: 'string',
1313
- nullable: true,
1314
- getter: (value, { attributes }) => {
1315
- if (!value) return value // Return null/undefined as-is
1316
- // Depends on step1 being trimmed first
1317
- return `${value} [step1: ${attributes.step1}]`
1318
- },
1319
- runGetterAfter: ['step1']
1320
- },
1321
- step3: {
1322
- type: 'string',
1323
- getter: (value, { attributes }) => {
1324
- if (!value) return value // Return null/undefined as-is
1325
- // Depends on step2
1326
- return `${value} [step2: ${attributes.step2}]`
1327
- },
1328
- runGetterAfter: ['step2']
1329
- }
1330
- },
1331
- tableName: 'getter_formatted'
1332
- })
1333
- await api.resources.formatted_data.createKnexTable()
1334
- mapTable('getter_formatted', 'formatted_data')
1335
-
1336
- // Async getters example
1337
- await api.addResource('encrypted_data', {
1338
- schema: {
1339
- id: { type: 'id' },
1340
- secret: {
1341
- type: 'string',
1342
- nullable: true,
1343
- getter: async (value) => {
1344
- if (!value) return null
1345
- // Simulate async decryption
1346
- await new Promise(resolve => setTimeout(resolve, 5))
1347
- return Buffer.from(value, 'base64').toString()
1348
- }
1349
- },
1350
- data: {
1351
- type: 'string',
1352
- nullable: true,
1353
- getter: async (value, context) => {
1354
- if (!value) return null
1355
- await new Promise(resolve => setTimeout(resolve, 5))
1356
- const decrypted = Buffer.from(value, 'base64').toString()
1357
- return `[${context.scopeName}] ${decrypted}`
1358
- }
1359
- }
1360
- },
1361
- tableName: 'getter_encrypted'
1362
- })
1363
- await api.resources.encrypted_data.createKnexTable()
1364
- mapTable('getter_encrypted', 'encrypted_data')
1365
-
1366
- if (storageMode.isAnyApi()) {
1367
- storageMode.setCurrentTenant(previousTenant)
1368
- }
1369
-
1370
- return api
1371
- }
1372
-
1373
- export async function createProjectedFieldsApi (knex, pluginOptions = {}) {
1374
- const api = new Api({
1375
- name: 'projected-fields-test-api',
1376
- log: { level: process.env.LOG_LEVEL || 'info' }
1377
- })
1378
-
1379
- const tenantId = storageMode.isAnyApi() ? 'projected_fields_tenant' : storageMode.defaultTenant
1380
- const previousTenant = storageMode.currentTenant
1381
- if (storageMode.isAnyApi()) {
1382
- storageMode.setCurrentTenant(tenantId)
1383
- }
1384
-
1385
- await api.use(RestApiPlugin, {
1386
- simplifiedApi: false,
1387
- simplifiedTransport: false,
1388
- returnRecordApi: {
1389
- post: true,
1390
- put: false,
1391
- patch: false
1392
- },
1393
- returnRecordTransport: {
1394
- post: 'full',
1395
- put: 'no',
1396
- patch: 'minimal'
1397
- },
1398
- sortableFields: ['id', 'first_name', 'last_name'],
1399
- ...pluginOptions['rest-api']
1400
- })
1401
- await api.use(QueryProjectionsPlugin)
1402
-
1403
- await useStoragePlugin(api, knex, { tenantId })
1404
- await resetAnyApiTables(knex)
1405
-
1406
- await api.addResource('authors', {
1407
- schema: {
1408
- id: { type: 'id' },
1409
- first_name: { type: 'string', required: true, max: 100 },
1410
- last_name: { type: 'string', required: true, max: 100 }
1411
- },
1412
- queryFields: {
1413
- full_name: {
1414
- type: 'string',
1415
- sortable: true,
1416
- select: ({ knex, column }) => knex.raw(
1417
- "trim(coalesce(??, '') || ' ' || coalesce(??, ''))",
1418
- [column('first_name'), column('last_name')]
1419
- )
1420
- },
1421
- sort_name: {
1422
- type: 'string',
1423
- sortable: true,
1424
- normallyHidden: true,
1425
- select: ({ knex, column }) => knex.raw(
1426
- "trim(coalesce(??, '') || ' ' || coalesce(??, ''))",
1427
- [column('first_name'), column('last_name')]
1428
- )
1429
- }
1430
- },
1431
- relationships: {
1432
- books: { type: 'hasMany', target: 'books', foreignKey: 'author_id' }
1433
- },
1434
- sortableFields: ['id', 'first_name', 'last_name'],
1435
- defaultSort: ['full_name'],
1436
- tableName: 'projected_authors'
1437
- })
1438
- await api.resources.authors.createKnexTable()
1439
- mapTable('projected_authors', 'authors')
1440
-
1441
- await api.addResource('books', {
1442
- schema: {
1443
- id: { type: 'id' },
1444
- title: { type: 'string', required: true, max: 200 },
1445
- author_id: { type: 'id', required: true, belongsTo: 'authors', as: 'author' }
1446
- },
1447
- tableName: 'projected_books'
1448
- })
1449
- await api.resources.books.createKnexTable()
1450
- mapTable('projected_books', 'books')
1451
-
1452
- if (storageMode.isAnyApi()) {
1453
- storageMode.setCurrentTenant(previousTenant)
1454
- }
1455
-
1456
- return api
1457
- }
1458
-
1459
- /**
1460
- * Creates an API with field setters for testing
1461
- */
1462
- export async function createFieldSettersApi (knex, pluginOptions = {}) {
1463
- const api = new Api({
1464
- name: 'field-setters-test-api',
1465
- log: { level: process.env.LOG_LEVEL || 'info' }
1466
- })
1467
-
1468
- const tenantId = storageMode.isAnyApi() ? 'field_setters_tenant' : storageMode.defaultTenant
1469
- const previousTenant = storageMode.currentTenant
1470
- if (storageMode.isAnyApi()) {
1471
- storageMode.setCurrentTenant(tenantId)
1472
- }
1473
-
1474
- await api.use(RestApiPlugin, {
1475
- simplifiedApi: true,
1476
- simplifiedTransport: true,
1477
- returnRecordApi: {
1478
- post: true,
1479
- put: true,
1480
- patch: true
1481
- }
1482
- })
1483
-
1484
- await useStoragePlugin(api, knex, { tenantId })
1485
- await resetAnyApiTables(knex)
1486
-
1487
- // Users resource with basic setters
1488
- await api.addResource('users', {
1489
- schema: {
1490
- id: { type: 'id' },
1491
- email: {
1492
- type: 'string',
1493
- required: true,
1494
- setter: (value) => value?.toLowerCase().trim()
1495
- },
1496
- username: {
1497
- type: 'string',
1498
- required: true,
1499
- setter: (value) => value?.toLowerCase().replace(/\s+/g, '')
1500
- },
1501
- tags: {
1502
- type: 'string', // Accepts string, stored as string
1503
- setter: (value) => value // Pass through
1504
- },
1505
- preferences: {
1506
- type: 'string', // Accepts string, stored as string
1507
- setter: (value) => value // Pass through
1508
- }
1509
- },
1510
- tableName: 'setter_users'
1511
- })
1512
- await api.resources.users.createKnexTable()
1513
-
1514
- // Products with type conversion setters
1515
- await api.addResource('products', {
1516
- schema: {
1517
- id: { type: 'id' },
1518
- name: { type: 'string', required: true },
1519
- price: {
1520
- type: 'number',
1521
- required: true,
1522
- min: 0,
1523
- setter: (value) => {
1524
- // Convert to cents and round
1525
- return Math.round(value * 100)
1526
- }
1527
- },
1528
- discount_percent: {
1529
- type: 'number',
1530
- min: 0,
1531
- max: 100,
1532
- setter: (value) => Math.round(value)
1533
- },
1534
- metadata: {
1535
- type: 'object',
1536
- setter: (value) => JSON.stringify(value || {})
1537
- }
1538
- },
1539
- tableName: 'setter_products'
1540
- })
1541
- await api.resources.products.createKnexTable()
1542
-
1543
- // Secure data with async setters
1544
- await api.addResource('secure_data', {
1545
- schema: {
1546
- id: { type: 'id' },
1547
- password: {
1548
- type: 'string',
1549
- required: true,
1550
- min: 8,
1551
- setter: async (value) => {
1552
- // Simulate async password hashing
1553
- await new Promise(resolve => setTimeout(resolve, 5))
1554
- return `hashed:${value}`
1555
- }
1556
- },
1557
- api_key: {
1558
- type: 'string',
1559
- setter: async (value) => {
1560
- if (!value) return null
1561
- // Simulate encryption
1562
- await new Promise(resolve => setTimeout(resolve, 5))
1563
- return Buffer.from(value).toString('base64')
1564
- }
1565
- },
1566
- data: {
1567
- type: 'string',
1568
- setter: async (value) => {
1569
- if (!value) return null
1570
- return Buffer.from(value).toString('base64')
1571
- }
1572
- }
1573
- },
1574
- tableName: 'setter_secure'
1575
- })
1576
- await api.resources.secure_data.createKnexTable()
1577
-
1578
- // Resource with setter dependencies
1579
- await api.addResource('computed_data', {
1580
- schema: {
1581
- id: { type: 'id' },
1582
- base_value: {
1583
- type: 'number',
1584
- setter: (value) => value || 0
1585
- },
1586
- multiplier: {
1587
- type: 'number',
1588
- setter: (value) => value || 1
1589
- },
1590
- adjustment: {
1591
- type: 'number',
1592
- setter: (value) => value || 0
1593
- },
1594
- calculated_value: {
1595
- type: 'number',
1596
- setter: (value, { attributes }) => {
1597
- // Calculate based on other fields
1598
- return (attributes.base_value || 0) * (attributes.multiplier || 1)
1599
- },
1600
- runSetterAfter: ['base_value', 'multiplier']
1601
- },
1602
- final_value: {
1603
- type: 'number',
1604
- setter: (value, { attributes }) => {
1605
- // Depends on calculated_value
1606
- return (attributes.calculated_value || 0) + (attributes.adjustment || 0)
1607
- },
1608
- runSetterAfter: ['calculated_value', 'adjustment']
1609
- }
1610
- },
1611
- tableName: 'setter_computed'
1612
- })
1613
- await api.resources.computed_data.createKnexTable()
1614
-
1615
- // Nullable fields handling
1616
- await api.addResource('nullable_data', {
1617
- schema: {
1618
- id: { type: 'id' },
1619
- field1: {
1620
- type: 'string',
1621
- nullable: true,
1622
- setter: (value) => value === undefined ? null : value
1623
- },
1624
- field2: {
1625
- type: 'string',
1626
- nullable: true,
1627
- setter: (value) => value === undefined ? null : value
1628
- },
1629
- field3: {
1630
- type: 'string',
1631
- setter: (value) => value === '' ? 'empty' : value
1632
- },
1633
- field4: {
1634
- type: 'number',
1635
- setter: (value) => value === 0 ? -1 : value
1636
- }
1637
- },
1638
- tableName: 'setter_nullable'
1639
- })
1640
- await api.resources.nullable_data.createKnexTable()
1641
-
1642
- // Validated data
1643
- await api.addResource('validated_data', {
1644
- schema: {
1645
- id: { type: 'id' },
1646
- email: {
1647
- type: 'string',
1648
- required: true,
1649
- format: 'email',
1650
- setter: (value) => value.toLowerCase().trim()
1651
- },
1652
- age: {
1653
- type: 'number',
1654
- min: 0,
1655
- max: 120,
1656
- setter: (value) => value // No transformation, just to verify it's a number
1657
- },
1658
- score: {
1659
- type: 'number',
1660
- min: 0,
1661
- max: 100,
1662
- setter: (value) => Math.ceil(value) // Round up
1663
- }
1664
- },
1665
- tableName: 'setter_validated'
1666
- })
1667
- await api.resources.validated_data.createKnexTable()
1668
-
1669
- if (storageMode.isAnyApi()) {
1670
- storageMode.setCurrentTenant(previousTenant)
1671
- }
1672
-
1673
- return api
1674
- }
1675
-
1676
- export async function createAutoFilterApi (knex, pluginOptions = {}) {
1677
- const { AutoFilterPlugin } = await import('../../plugins/core/rest-api-autofilter-plugin.js')
1678
-
1679
- const api = new Api({
1680
- name: 'autofilter-test-api',
1681
- })
1682
-
1683
- const tenantId = storageMode.isAnyApi() ? 'autofilter_tenant' : storageMode.defaultTenant
1684
- const previousTenant = storageMode.currentTenant
1685
- if (storageMode.isAnyApi()) {
1686
- storageMode.setCurrentTenant(tenantId)
1687
- }
1688
-
1689
- await api.use(RestApiPlugin, {
1690
- simplifiedApi: false,
1691
- simplifiedTransport: false,
1692
- returnRecordApi: {
1693
- post: true,
1694
- put: false,
1695
- patch: false
1696
- },
1697
- returnRecordTransport: {
1698
- post: 'full',
1699
- put: 'no',
1700
- patch: 'minimal'
1701
- },
1702
- sortableFields: ['id', 'title', 'name', 'workspace_id', 'user_id']
1703
- })
1704
-
1705
- await useStoragePlugin(api, knex, { tenantId })
1706
- await resetAnyApiTables(knex)
1707
-
1708
- // Add Express plugin if requested for transport testing
1709
- if (pluginOptions.includeExpress) {
1710
- await api.use(ExpressPlugin, pluginOptions.express || {})
1711
- }
1712
-
1713
- const autofilterOptions = pluginOptions.autofilter || {}
1714
- const workspaceField = pluginOptions.workspaceField || 'workspace_id'
1715
- const userField = pluginOptions.userField || 'user_id'
1716
-
1717
- await api.use(AutoFilterPlugin, {
1718
- ...autofilterOptions,
1719
- resolvers: {
1720
- workspace: ({ context }) => context.scopeValues?.workspaceId,
1721
- user: ({ context }) => context.scopeValues?.userId,
1722
- currentProject: ({ context }) => context.scopeValues?.projectId ?? null,
1723
- ...(autofilterOptions.resolvers || {})
1724
- },
1725
- presets: {
1726
- public: { filters: [] },
1727
- workspace: {
1728
- filters: [{ field: workspaceField, resolver: 'workspace' }]
1729
- },
1730
- user: {
1731
- filters: [{ field: userField, resolver: 'user' }]
1732
- },
1733
- workspace_user: {
1734
- filters: [
1735
- { field: workspaceField, resolver: 'workspace' },
1736
- { field: userField, resolver: 'user' }
1737
- ]
1738
- },
1739
- ...(autofilterOptions.presets || {})
1740
- }
1741
- })
1742
-
1743
- await api.addResource('workspace_reports', {
1744
- schema: {
1745
- id: { type: 'id' },
1746
- title: { type: 'string', required: true, max: 200 },
1747
- workspace_id: { type: 'string', required: true }
1748
- },
1749
- autofilter: 'workspace',
1750
- tableName: 'autofilter_workspace_reports'
1751
- })
1752
- await api.resources.workspace_reports.createKnexTable()
1753
- mapTable('autofilter_workspace_reports', 'workspace_reports')
1754
-
1755
- await api.addResource('user_notes', {
1756
- schema: {
1757
- id: { type: 'id' },
1758
- body: { type: 'string', required: true, max: 500 },
1759
- user_id: { type: 'number', required: true }
1760
- },
1761
- autofilter: 'user',
1762
- tableName: 'autofilter_user_notes'
1763
- })
1764
- await api.resources.user_notes.createKnexTable()
1765
- mapTable('autofilter_user_notes', 'user_notes')
1766
-
1767
- await api.addResource('projects', {
1768
- schema: {
1769
- id: { type: 'id' },
1770
- name: { type: 'string', required: true, max: 200 },
1771
- description: { type: 'string', max: 1000 },
1772
- status: { type: 'string', defaultTo: 'active' },
1773
- workspace_id: { type: 'string', required: true },
1774
- user_id: { type: 'number', required: true }
1775
- },
1776
- relationships: {
1777
- tasks: { type: 'hasMany', target: 'tasks', foreignKey: 'project_id' }
1778
- },
1779
- autofilter: 'workspace_user',
1780
- tableName: 'autofilter_projects'
1781
- })
1782
- await api.resources.projects.createKnexTable()
1783
- mapTable('autofilter_projects', 'projects')
1784
-
1785
- await api.addResource('tasks', {
1786
- schema: {
1787
- id: { type: 'id' },
1788
- title: { type: 'string', required: true, max: 200 },
1789
- completed: { type: 'boolean', defaultTo: false },
1790
- project_id: { type: 'number', belongsTo: 'projects', as: 'project' },
1791
- workspace_id: { type: 'string', required: true },
1792
- user_id: { type: 'number', required: true }
1793
- },
1794
- autofilter: 'workspace_user',
1795
- tableName: 'autofilter_tasks'
1796
- })
1797
- await api.resources.tasks.createKnexTable()
1798
- mapTable('autofilter_tasks', 'tasks')
1799
-
1800
- await api.addResource('optional_tasks', {
1801
- schema: {
1802
- id: { type: 'id' },
1803
- title: { type: 'string', required: true, max: 200 },
1804
- project_id: { type: 'number', belongsTo: 'projects', as: 'project', nullable: true }
1805
- },
1806
- autofilter: {
1807
- filters: [{ field: 'project_id', resolver: 'currentProject' }]
1808
- },
1809
- tableName: 'autofilter_optional_tasks'
1810
- })
1811
- await api.resources.optional_tasks.createKnexTable()
1812
- mapTable('autofilter_optional_tasks', 'optional_tasks')
1813
-
1814
- await api.addResource('system_settings', {
1815
- schema: {
1816
- id: { type: 'id' },
1817
- key: { type: 'string', required: true, unique: true },
1818
- value: { type: 'string', required: true }
1819
- },
1820
- autofilter: 'public',
1821
- tableName: 'autofilter_system_settings'
1822
- })
1823
- await api.resources.system_settings.createKnexTable()
1824
- mapTable('autofilter_system_settings', 'system_settings')
1825
-
1826
- if (storageMode.isAnyApi()) {
1827
- storageMode.setCurrentTenant(previousTenant)
1828
- }
1829
-
1830
- return api
1831
- }
1832
-
1833
- export async function createRowPolicyApi (knex, pluginOptions = {}) {
1834
- const api = new Api({
1835
- name: 'row-policy-test-api',
1836
- })
1837
-
1838
- const tenantId = storageMode.isAnyApi() ? 'row_policy_tenant' : storageMode.defaultTenant
1839
- const previousTenant = storageMode.currentTenant
1840
- if (storageMode.isAnyApi()) {
1841
- storageMode.setCurrentTenant(tenantId)
1842
- }
1843
-
1844
- await api.use(RestApiPlugin, {
1845
- simplifiedApi: false,
1846
- simplifiedTransport: false,
1847
- returnRecordApi: {
1848
- post: true,
1849
- put: false,
1850
- patch: false
1851
- },
1852
- sortableFields: ['id', 'name', 'title', 'access_group', 'project_id']
1853
- })
1854
-
1855
- await useStoragePlugin(api, knex, { tenantId })
1856
- await resetAnyApiTables(knex)
1857
-
1858
- const rowPolicyOptions = pluginOptions.rowPolicy || {}
1859
- await api.use(RowPolicyPlugin, {
1860
- ...rowPolicyOptions,
1861
- policies: {
1862
- groupVisibility: ({ query, context, column, value, scopeName, queryPurpose }) => {
1863
- pluginOptions.onPolicy?.({ scopeName, queryPurpose })
1864
-
1865
- if (context.visibility?.all === true) {
1866
- return true
1867
- }
1868
-
1869
- const groups = context.visibility?.groups
1870
- if (!Array.isArray(groups) || groups.length === 0) {
1871
- return false
1872
- }
1873
-
1874
- query.where(function visibilityGrants () {
1875
- this.whereRaw('1 = 0')
1876
- this.orWhere(function groupVisibilityGrant () {
1877
- this.whereIn(
1878
- column('access_group'),
1879
- groups.map((group) => value('access_group', group))
1880
- )
1881
- })
1882
- })
1883
- return true
1884
- },
1885
- ...(rowPolicyOptions.policies || {})
1886
- }
1887
- })
1888
-
1889
- await api.use(AutoFilterPlugin, {
1890
- resolvers: {
1891
- workspace: ({ context }) => context.scopeValues?.workspaceId
1892
- },
1893
- presets: {
1894
- workspace: {
1895
- filters: [{ field: 'workspace_id', resolver: 'workspace' }]
1896
- }
1897
- }
1898
- })
1899
-
1900
- await api.addResource('policy_projects', {
1901
- schema: {
1902
- id: { type: 'id' },
1903
- name: { type: 'string', required: true, max: 200 },
1904
- access_group: { type: 'string', required: true },
1905
- workspace_id: { type: 'string', required: true }
1906
- },
1907
- relationships: {
1908
- tasks: { type: 'hasMany', target: 'policy_tasks', foreignKey: 'project_id' },
1909
- shared_tasks: {
1910
- type: 'manyToMany',
1911
- target: 'policy_tasks',
1912
- through: 'policy_project_tasks',
1913
- foreignKey: 'project_id',
1914
- otherKey: 'task_id'
1915
- }
1916
- },
1917
- autofilter: 'workspace',
1918
- rowPolicy: 'groupVisibility',
1919
- tableName: 'row_policy_projects'
1920
- })
1921
- await api.resources.policy_projects.createKnexTable()
1922
- mapTable('row_policy_projects', 'policy_projects')
1923
-
1924
- await api.addResource('policy_tasks', {
1925
- schema: {
1926
- id: { type: 'id' },
1927
- title: { type: 'string', required: true, max: 200, search: true },
1928
- access_group: { type: 'string', required: true },
1929
- workspace_id: { type: 'string', required: true },
1930
- project_id: {
1931
- type: 'number',
1932
- nullable: true,
1933
- belongsTo: 'policy_projects',
1934
- as: 'project',
1935
- search: true
1936
- }
1937
- },
1938
- relationships: {
1939
- shared_projects: {
1940
- type: 'manyToMany',
1941
- target: 'policy_projects',
1942
- through: 'policy_project_tasks',
1943
- foreignKey: 'task_id',
1944
- otherKey: 'project_id'
1945
- }
1946
- },
1947
- autofilter: 'workspace',
1948
- rowPolicy: 'groupVisibility',
1949
- tableName: 'row_policy_tasks'
1950
- })
1951
- await api.resources.policy_tasks.createKnexTable()
1952
- mapTable('row_policy_tasks', 'policy_tasks')
1953
-
1954
- await api.addResource('policy_project_tasks', {
1955
- schema: {
1956
- id: { type: 'id' },
1957
- project_id: { type: 'number', required: true, belongsTo: 'policy_projects', as: 'project' },
1958
- task_id: { type: 'number', required: true, belongsTo: 'policy_tasks', as: 'task' }
1959
- },
1960
- tableName: 'row_policy_project_tasks'
1961
- })
1962
- await api.resources.policy_project_tasks.createKnexTable()
1963
- mapTable('row_policy_project_tasks', 'policy_project_tasks')
1964
-
1965
- if (storageMode.isAnyApi()) {
1966
- const projectsDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'policy_projects')
1967
- const tasksDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'policy_tasks')
1968
- storageMode.registerLink(
1969
- 'row_policy_project_tasks',
1970
- 'policy_projects',
1971
- 'shared_tasks',
1972
- projectsDescriptor?.manyToMany?.shared_tasks?.relationship,
1973
- tasksDescriptor?.manyToMany?.shared_projects?.relationship
1974
- )
1975
- }
1976
-
1977
- await api.addResource('policy_broken', {
1978
- schema: {
1979
- id: { type: 'id' },
1980
- name: { type: 'string', required: true }
1981
- },
1982
- rowPolicy: () => undefined,
1983
- tableName: 'row_policy_broken'
1984
- })
1985
- await api.resources.policy_broken.createKnexTable()
1986
- mapTable('row_policy_broken', 'policy_broken')
1987
-
1988
- if (storageMode.isAnyApi()) {
1989
- storageMode.setCurrentTenant(previousTenant)
1990
- }
1991
-
1992
- return api
1993
- }
1994
-
1995
- /**
1996
- * Creates an API with positioning support for testing
1997
- */
1998
- export async function createPositioningApi (knex, pluginOptions = {}) {
1999
- const apiName = pluginOptions.apiName || 'positioning-test-api'
2000
- const tablePrefix = pluginOptions.tablePrefix || 'positioning'
2001
- const api = new Api({
2002
- name: apiName,
2003
- })
2004
-
2005
- const restApiOptions = {
2006
- simplifiedApi: true, // Changed to true to allow simplified API calls in tests
2007
- simplifiedTransport: false,
2008
- returnRecordApi: {
2009
- post: true,
2010
- put: true,
2011
- patch: true
2012
- },
2013
- returnRecordTransport: {
2014
- post: 'full',
2015
- put: 'full',
2016
- patch: 'full'
2017
- },
2018
- sortableFields: ['id', 'title', 'name', 'position', 'sort_order', 'category_id', 'project_id', 'status'],
2019
- ...pluginOptions['rest-api']
2020
- }
2021
-
2022
- await api.use(RestApiPlugin, restApiOptions)
2023
- await useStoragePlugin(api, knex)
2024
- await resetAnyApiTables(knex)
2025
-
2026
- // Categories (for grouping tasks)
2027
- await api.addResource('categories', {
2028
- schema: {
2029
- id: { type: 'id' },
2030
- name: { type: 'string', required: true, max: 100 }
2031
- },
2032
- tableName: `${tablePrefix}_categories`
2033
- })
2034
- await api.resources.categories.createKnexTable()
2035
- mapTable(`${tablePrefix}_categories`, 'categories')
2036
-
2037
- // Tasks (main positioning test resource)
2038
- await api.addResource('tasks', {
2039
- schema: {
2040
- id: { type: 'id' },
2041
- title: { type: 'string', required: true, max: 200 },
2042
- category_id: { type: 'number', nullable: true, belongsTo: 'categories', as: 'category', search: true },
2043
- position: { type: 'string', max: 255, nullable: true },
2044
- beforeId: { type: 'string', virtual: true }, // Virtual field for positioning
2045
- deleted_at: { type: 'dateTime', nullable: true, search: true }, // For soft delete tests
2046
- version: { type: 'number', defaultTo: 1, search: true } // For versioning tests
2047
- },
2048
- relationships: {
2049
- category: { belongsTo: 'categories' }
2050
- },
2051
- tableName: `${tablePrefix}_tasks`
2052
- })
2053
- await api.resources.tasks.createKnexTable()
2054
- mapTable(`${tablePrefix}_tasks`, 'tasks')
2055
-
2056
- // Projects (for multi-filter testing)
2057
- await api.addResource('projects', {
2058
- schema: {
2059
- id: { type: 'id' },
2060
- name: { type: 'string', required: true, max: 100 }
2061
- },
2062
- tableName: `${tablePrefix}_projects`
2063
- })
2064
- await api.resources.projects.createKnexTable()
2065
- mapTable(`${tablePrefix}_projects`, 'projects')
2066
-
2067
- // Items (flexible resource for various positioning tests)
2068
- await api.addResource('items', {
2069
- schema: {
2070
- id: { type: 'id' },
2071
- name: { type: 'string', required: true, max: 200 },
2072
- project_id: { type: 'number', nullable: true, belongsTo: 'projects', as: 'project', search: true },
2073
- status: { type: 'string', defaultTo: 'active', nullable: true, search: true },
2074
- position: { type: 'string', max: 255, nullable: true },
2075
- sort_order: { type: 'string', max: 255, nullable: true }, // Alternative position field
2076
- beforeId: { type: 'string', virtual: true },
2077
- priority: { type: 'string', defaultTo: 'medium', search: true } // For multi-filter tests
2078
- },
2079
- relationships: {
2080
- project: { belongsTo: 'projects' }
2081
- },
2082
- tableName: `${tablePrefix}_items`
2083
- })
2084
- await api.resources.items.createKnexTable()
2085
- mapTable(`${tablePrefix}_items`, 'items')
2086
-
2087
- return api
2088
- }
2089
-
2090
- /**
2091
- * Creates an API with custom idProperty for all resources to test idProperty functionality
2092
- * Uses 'custom_id_' prefix for all tables to avoid conflicts
2093
- */
2094
- export async function createCustomIdPropertyApi (knex, pluginOptions = {}) {
2095
- const apiName = pluginOptions.apiName || 'custom-id-test-api'
2096
- const tablePrefix = pluginOptions.tablePrefix || 'custom_id'
2097
- const api = new Api({
2098
- name: apiName,
2099
- log: { level: process.env.LOG_LEVEL || 'info' }
2100
- })
2101
-
2102
- if (storageMode.isAnyApi()) {
2103
- storageMode.clearRegistry()
2104
- }
2105
-
2106
- const restApiOptions = {
2107
- simplifiedApi: false,
2108
- simplifiedTransport: false,
2109
- returnRecordApi: {
2110
- post: 'full',
2111
- put: 'full',
2112
- patch: 'full'
2113
- },
2114
- returnRecordTransport: {
2115
- post: 'full',
2116
- put: 'full',
2117
- patch: 'full'
2118
- },
2119
- sortableFields: ['country_id', 'publisher_id', 'author_id', 'book_id', 'title', 'name', 'code'],
2120
- ...pluginOptions['rest-api'] // Merge any custom options for rest-api plugin
2121
- }
2122
-
2123
- await api.use(RestApiPlugin, restApiOptions)
2124
- await useStoragePlugin(api, knex)
2125
- await resetAnyApiTables(knex)
2126
-
2127
- // Countries table with custom idProperty
2128
- await api.addResource('countries', {
2129
- schema: {
2130
- // NO id: { type: 'id' } - this is key!
2131
- name: { type: 'string', required: true, max: 100, search: true },
2132
- code: { type: 'string', max: 2, unique: true }
2133
- },
2134
- relationships: {
2135
- publishers: { type: 'hasMany', target: 'publishers', foreignKey: 'country_id' },
2136
- books: { type: 'hasMany', target: 'books', foreignKey: 'country_id' }
2137
- },
2138
- tableName: `${tablePrefix}_countries`,
2139
- idProperty: 'country_id' // Custom ID property
2140
- })
2141
- await api.resources.countries.createKnexTable()
2142
- mapTable(`${tablePrefix}_countries`, 'countries')
2143
-
2144
- // Publishers table with custom idProperty
2145
- await api.addResource('publishers', {
2146
- schema: {
2147
- name: { type: 'string', required: true, max: 200 },
2148
- country_id: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
2149
- },
2150
- relationships: {
2151
- books: { type: 'hasMany', target: 'books', foreignKey: 'publisher_id' },
2152
- reviews: {
2153
- type: 'hasMany',
2154
- target: 'reviews',
2155
- via: 'reviewable'
2156
- }
2157
- },
2158
- tableName: `${tablePrefix}_publishers`,
2159
- idProperty: 'publisher_id'
2160
- })
2161
- await api.resources.publishers.createKnexTable()
2162
- mapTable(`${tablePrefix}_publishers`, 'publishers')
2163
-
2164
- // Authors table with custom idProperty
2165
- await api.addResource('authors', {
2166
- schema: {
2167
- name: { type: 'string', required: true, max: 200 },
2168
- biography: { type: 'string', max: 5000 },
2169
- country_id: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
2170
- },
2171
- relationships: {
2172
- books: { type: 'manyToMany', through: 'book_authors', foreignKey: 'author_id', otherKey: 'book_id' },
2173
- reviews: {
2174
- type: 'hasMany',
2175
- target: 'reviews',
2176
- via: 'reviewable'
2177
- }
2178
- },
2179
- tableName: `${tablePrefix}_authors`,
2180
- idProperty: 'author_id'
2181
- })
2182
- await api.resources.authors.createKnexTable()
2183
- mapTable(`${tablePrefix}_authors`, 'authors')
2184
-
2185
- // Books table with custom idProperty
2186
- await api.addResource('books', {
2187
- schema: {
2188
- title: { type: 'string', required: true, max: 300, search: true },
2189
- isbn: { type: 'string', max: 13 },
2190
- pages: { type: 'number' },
2191
- published_date: { type: 'date' },
2192
- country_id: { type: 'number', required: true, belongsTo: 'countries', as: 'country', search: true },
2193
- publisher_id: { type: 'number', nullable: true, belongsTo: 'publishers', as: 'publisher', search: true }
2194
- },
2195
- relationships: {
2196
- authors: { type: 'manyToMany', through: 'book_authors', foreignKey: 'book_id', otherKey: 'author_id' },
2197
- reviews: {
2198
- type: 'hasMany',
2199
- target: 'reviews',
2200
- via: 'reviewable'
2201
- }
2202
- },
2203
- tableName: `${tablePrefix}_books`,
2204
- idProperty: 'book_id'
2205
- })
2206
- await api.resources.books.createKnexTable()
2207
- mapTable(`${tablePrefix}_books`, 'books')
2208
-
2209
- // Book-Authors pivot table with custom idProperty
2210
- await api.addResource('book_authors', {
2211
- schema: {
2212
- book_id: { type: 'number', required: true, belongsTo: 'books', as: 'book' },
2213
- author_id: { type: 'number', required: true, belongsTo: 'authors', as: 'author' },
2214
- contribution_type: { type: 'string', max: 50 },
2215
- order: { type: 'number' }
2216
- },
2217
- tableName: `${tablePrefix}_book_authors`,
2218
- idProperty: 'book_author_id'
2219
- })
2220
- await api.resources.book_authors.createKnexTable()
2221
- mapTable(`${tablePrefix}_book_authors`, 'book_authors')
2222
-
2223
- // Polymorphic reviews with custom idProperty
2224
- await api.addResource('reviews', {
2225
- schema: {
2226
- rating: { type: 'number', required: true, min: 1, max: 5 },
2227
- title: { type: 'string', max: 200 },
2228
- content: { type: 'string', required: true, max: 5000 },
2229
- reviewer_name: { type: 'string', required: true, max: 100 },
2230
- review_date: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
2231
- reviewable_type: { type: 'string', required: true, search: true },
2232
- reviewable_id: { type: 'number', required: true },
2233
- // Define the polymorphic field in schema
2234
- reviewable: {
2235
- belongsToPolymorphic: {
2236
- types: ['books', 'authors', 'publishers'],
2237
- typeField: 'reviewable_type',
2238
- idField: 'reviewable_id'
2239
- },
2240
- as: 'reviewable'
2241
- }
2242
- },
2243
- relationships: {},
2244
- tableName: `${tablePrefix}_reviews`,
2245
- idProperty: 'review_id'
2246
- })
2247
- await api.resources.reviews.createKnexTable()
2248
- mapTable(`${tablePrefix}_reviews`, 'reviews')
2249
-
2250
- if (storageMode.isAnyApi()) {
2251
- const tenant = storageMode.defaultTenant || 'default'
2252
- const booksDescriptor = await api.anyapi.registry.getDescriptor(tenant, 'books')
2253
- const authorsDescriptor = await api.anyapi.registry.getDescriptor(tenant, 'authors')
2254
- const relationshipKey = booksDescriptor?.manyToMany?.authors?.relationship
2255
- const inverseRelationshipKey = authorsDescriptor?.manyToMany?.books?.relationship
2256
- storageMode.registerLink(
2257
- `${tablePrefix}_book_authors`,
2258
- 'books',
2259
- 'authors',
2260
- relationshipKey,
2261
- inverseRelationshipKey
2262
- )
2263
- }
2264
-
2265
- return api
2266
- }
2267
-
2268
- /**
2269
- * Creates an API configuration for multi-field cursor pagination testing
2270
- * Uses 'cursor_' prefix for all tables to avoid conflicts
2271
- */
2272
- export async function createCursorPaginationApi (knex) {
2273
- const api = new Api({
2274
- name: 'cursor-pagination-test-api',
2275
- })
2276
-
2277
- const restApiOptions = {
2278
- simplified: false,
2279
- responseFormat: {
2280
- post: 'full',
2281
- put: 'full',
2282
- patch: 'minimal'
2283
- },
2284
- sortableFields: ['id', 'name', 'category', 'brand', 'price', 'sku', 'createdAt', 'code', 'status', 'type'],
2285
- queryDefaultLimit: 20,
2286
- queryMaxLimit: 100,
2287
- returnBasePath: 'https://api.example.com'
2288
- }
2289
-
2290
- await api.use(RestApiPlugin, restApiOptions)
2291
- await useStoragePlugin(api, knex)
2292
- await resetAnyApiTables(knex)
2293
-
2294
- // Create products table with fields suitable for multi-field sorting
2295
- await api.addResource('products', {
2296
- schema: {
2297
- id: { type: 'id' },
2298
- name: { type: 'string', required: true, max: 200, search: true },
2299
- category: { type: 'string', required: true, max: 100, search: true, indexed: true },
2300
- brand: { type: 'string', required: true, max: 100, search: true, indexed: true },
2301
- price: { type: 'number', required: true },
2302
- sku: { type: 'string', required: true, max: 50, unique: true },
2303
- status: { type: 'string', max: 50, default: 'active' },
2304
- createdAt: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() }
2305
- },
2306
- tableName: 'cursor_products'
2307
- })
2308
- await api.resources.products.createKnexTable()
2309
- mapTable('cursor_products', 'products')
2310
-
2311
- // Create items table with custom ID property
2312
- await api.addResource('items', {
2313
- schema: {
2314
- item_id: { type: 'id' },
2315
- code: { type: 'string', required: true, max: 10, unique: true },
2316
- name: { type: 'string', required: true, max: 200 },
2317
- category: { type: 'string', required: true, max: 100 },
2318
- type: { type: 'string', required: true, max: 50 }
2319
- },
2320
- idProperty: 'item_id',
2321
- tableName: 'cursor_items'
2322
- })
2323
- await api.resources.items.createKnexTable()
2324
- mapTable('cursor_items', 'items')
2325
-
2326
- return api
2327
- }
2328
-
2329
- /**
2330
- * Creates an API configuration for testing virtual fields validation
2331
- */
2332
- export async function createVirtualFieldsApi (knex, pluginOptions = {}) {
2333
- const apiName = pluginOptions.apiName || 'virtual-fields-test-api'
2334
- const api = new Api({
2335
- name: apiName,
2336
- log: { level: process.env.LOG_LEVEL || 'info' }
2337
- })
2338
-
2339
- const restApiOptions = {
2340
- simplifiedApi: false,
2341
- simplifiedTransport: false,
2342
- returnRecordApi: {
2343
- post: true,
2344
- put: false,
2345
- patch: false
2346
- },
2347
- returnRecordTransport: {
2348
- post: 'full',
2349
- put: 'no',
2350
- patch: 'minimal'
2351
- },
2352
- ...pluginOptions['rest-api']
2353
- }
2354
-
2355
- await api.use(RestApiPlugin, restApiOptions)
2356
- await useStoragePlugin(api, knex)
2357
- await resetAnyApiTables(knex)
2358
-
2359
- // Add a test resource with virtual fields
2360
- await api.addResource('users', {
2361
- schema: {
2362
- // Regular fields
2363
- username: { type: 'string', required: true },
2364
- email: { type: 'string', required: true },
2365
- age: { type: 'number' },
2366
-
2367
- // Virtual fields with validation rules
2368
- password: { type: 'string', virtual: true, minLength: 8 },
2369
- passwordConfirmation: { type: 'string', virtual: true, minLength: 8 },
2370
- termsAccepted: { type: 'boolean', virtual: true, required: true },
2371
- captchaScore: { type: 'number', virtual: true, min: 0, max: 1 },
2372
- tags: { type: 'array', virtual: true },
2373
- metadata: { type: 'object', virtual: true }
2374
- },
2375
- tableName: 'virtual_users'
2376
- })
2377
-
2378
- await api.resources.users.createKnexTable()
2379
-
2380
- return api
2381
- }
2382
-
2383
- /**
2384
- * Creates an API configuration for testing searchSchema merge behavior.
2385
- * Shared suites assert merge/validation behavior across storage backends;
2386
- * backend-specific execution cases live in storage-specific tests.
2387
- */
2388
- export async function createSearchSchemaMergeApi (knex, pluginOptions = {}) {
2389
- const api = new Api({
2390
- name: 'searchschema-merge-test-api',
2391
- log: { level: process.env.LOG_LEVEL || 'info' }
2392
- })
2393
-
2394
- await api.use(RestApiPlugin, {
2395
- simplifiedApi: false,
2396
- simplifiedTransport: false,
2397
- returnRecordApi: {
2398
- post: true,
2399
- put: false,
2400
- patch: false
2401
- },
2402
- ...pluginOptions['rest-api']
2403
- })
2404
-
2405
- await useStoragePlugin(api, knex)
2406
- await resetAnyApiTables(knex)
2407
-
2408
- // Test resource with both search:true and searchSchema
2409
- await api.addResource('products', {
2410
- schema: {
2411
- name: { type: 'string', search: true }, // Should be added to searchSchema
2412
- description: { type: 'string', search: true }, // Should be added to searchSchema
2413
- price: { type: 'number', search: true }, // Should be overridden by searchSchema
2414
- category: { type: 'number' }, // Not searchable
2415
- sku: { type: 'string', search: true }, // Should be added to searchSchema
2416
- status: { type: 'string' } // Not searchable but added via searchSchema
2417
- },
2418
- searchSchema: {
2419
- // This should override the price field from search:true
2420
- // 'between' operator requires array type for validation [min, max]
2421
- price: {
2422
- type: 'array',
2423
- filterOperator: 'between'
2424
- },
2425
- // Arbitrary public filter name that searches across multiple fields.
2426
- // The key `term` is part of the API contract even though there is no `term` column.
2427
- term: {
2428
- type: 'string',
2429
- oneOf: ['name', 'description'],
2430
- filterOperator: 'like'
2431
- },
2432
- // This is a virtual field
2433
- category_name: {
2434
- type: 'string',
2435
- actualField: 'category.name',
2436
- filterOperator: 'like'
2437
- },
2438
- // Arbitrary public filter name with fully custom server-side meaning.
2439
- // The Knex query layer decides what `availability` means.
2440
- availability: {
2441
- type: 'boolean',
2442
- applyFilter: (query, value) => {
2443
- query.where('status', value ? 'active' : 'inactive')
2444
- }
2445
- },
2446
- // This is an explicit field not marked with search:true
2447
- // 'in' operator requires array type for validation
2448
- status: {
2449
- type: 'array',
2450
- filterOperator: 'in'
2451
- }
2452
- },
2453
- tableName: 'searchmerge_products'
2454
- })
2455
- await api.resources.products.createKnexTable()
2456
-
2457
- // Test resource with only search:true fields
2458
- await api.addResource('users', {
2459
- schema: {
2460
- username: { type: 'string', search: true },
2461
- email: { type: 'string', search: true },
2462
- age: { type: 'number' },
2463
- bio: { type: 'string', search: { filterOperator: 'like' } }
2464
- },
2465
- tableName: 'searchmerge_users'
2466
- })
2467
- await api.resources.users.createKnexTable()
2468
-
2469
- // Test resource with only explicit searchSchema
2470
- await api.addResource('orders', {
2471
- schema: {
2472
- order_number: { type: 'string' },
2473
- total: { type: 'number' },
2474
- status: { type: 'string' }
2475
- },
2476
- searchSchema: {
2477
- order_number: { type: 'string', filterOperator: '=' },
2478
- status: { type: 'array', filterOperator: 'in' } // 'in' operator requires array type
2479
- },
2480
- tableName: 'searchmerge_orders'
2481
- })
2482
- await api.resources.orders.createKnexTable()
2483
- mapTable('searchmerge_orders', 'orders')
2484
-
2485
- return api
2486
- }
2487
-
2488
- export async function createFileUploadApi (knex, pluginOptions = {}) {
2489
- const apiName = pluginOptions.apiName || 'file-upload-test-api'
2490
- const tableName = pluginOptions.tableName || 'file_documents'
2491
- const tablePrefix = pluginOptions.tablePrefix || 'file'
2492
- const storage = pluginOptions.storage
2493
- const detectorState = pluginOptions.detectorState || { payload: null }
2494
-
2495
- if (!storage) {
2496
- throw new Error('createFileUploadApi requires a storage adapter')
2497
- }
2498
-
2499
- if (storageMode.isAnyApi()) {
2500
- storageMode.clearRegistry()
2501
- }
2502
-
2503
- const api = new Api({
2504
- name: apiName,
2505
- log: { level: process.env.LOG_LEVEL || 'info' }
2506
- })
2507
-
2508
- const previousTenant = storageMode.currentTenant
2509
- const tenantId = storageMode.isAnyApi()
2510
- ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
2511
- : storageMode.defaultTenant
2512
-
2513
- if (storageMode.isAnyApi()) {
2514
- storageMode.setCurrentTenant(tenantId)
2515
- }
2516
-
2517
- try {
2518
- await api.use(RestApiPlugin, {
2519
- simplifiedApi: false,
2520
- simplifiedTransport: false,
2521
- returnRecordApi: {
2522
- post: true,
2523
- put: false,
2524
- patch: false
2525
- },
2526
- returnRecordTransport: {
2527
- post: 'full',
2528
- put: 'no',
2529
- patch: 'minimal'
2530
- },
2531
- sortableFields: ['id', 'title'],
2532
- ...pluginOptions['rest-api']
2533
- })
2534
- await useStoragePlugin(api, knex, { tenantId })
2535
- await resetAnyApiTables(knex)
2536
- await api.use(FileHandlingPlugin)
2537
-
2538
- api.rest.registerFileDetector({
2539
- name: 'test-file-detector',
2540
- detect: () => Boolean(detectorState.payload),
2541
- parse: async () => detectorState.payload
2542
- })
2543
-
2544
- await api.addResource('documents', {
2545
- schema: {
2546
- id: { type: 'id' },
2547
- title: { type: 'string', required: true },
2548
- attachment: {
2549
- type: 'file',
2550
- storage,
2551
- accepts: ['image/png'],
2552
- maxSize: '1mb'
2553
- }
2554
- },
2555
- tableName
2556
- })
2557
- await api.resources.documents.createKnexTable()
2558
- mapTable(tableName, 'documents')
2559
-
2560
- return api
2561
- } finally {
2562
- if (storageMode.isAnyApi()) {
2563
- storageMode.setCurrentTenant(previousTenant)
2564
- }
2565
- }
2566
- }