json-rest-api 1.0.29 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (292) 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 +634 -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 +100 -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 +3827 -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 +36 -0
  44. package/index.js +1 -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 +64 -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 +213 -164
  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 +25 -16
  71. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
  72. package/plugins/core/lib/dbIntrospection.js +228 -21
  73. package/plugins/core/lib/dbTablesOperations.js +409 -92
  74. package/plugins/core/lib/fractional-positioning.js +12 -48
  75. package/plugins/core/lib/querying/identifier-query.js +19 -0
  76. package/plugins/core/lib/querying/include-query-helpers.js +364 -0
  77. package/plugins/core/lib/querying/include-to-many.js +428 -0
  78. package/plugins/core/lib/querying/include-to-one.js +579 -0
  79. package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
  80. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
  81. package/plugins/core/lib/querying/knex-pagination-helpers.js +48 -317
  82. package/plugins/core/lib/querying/knex-process-includes.js +5 -110
  83. package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
  84. package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
  85. package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
  86. package/plugins/core/lib/querying/knex-window-queries.js +62 -253
  87. package/plugins/core/lib/querying/query-builder-utils.js +44 -4
  88. package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
  89. package/plugins/core/lib/querying/query-constraint.js +27 -0
  90. package/plugins/core/lib/querying/query-field-sort-helpers.js +134 -37
  91. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  92. package/plugins/core/lib/querying/sort-helpers.js +13 -1
  93. package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
  94. package/plugins/core/lib/querying/url-helpers.js +11 -4
  95. package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
  96. package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
  97. package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
  98. package/plugins/core/lib/querying-writing/database-value-normalizers.js +179 -194
  99. package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
  100. package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
  101. package/plugins/core/lib/querying-writing/field-utils.js +113 -92
  102. package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
  103. package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
  104. package/plugins/core/lib/querying-writing/knex-field-helpers.js +100 -290
  105. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
  106. package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
  107. package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
  108. package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
  109. package/plugins/core/lib/querying-writing/request-contracts.js +93 -38
  110. package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
  111. package/plugins/core/lib/querying-writing/response-options.js +35 -0
  112. package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
  113. package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
  114. package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
  115. package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
  116. package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
  117. package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
  118. package/plugins/core/lib/storage/storage-adapter.js +128 -62
  119. package/plugins/core/lib/storage/storage-mapping.js +78 -28
  120. package/plugins/core/lib/storage/storage-types.d.ts +502 -0
  121. package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
  122. package/plugins/core/lib/writing/relationship-processor.js +94 -158
  123. package/plugins/core/lib/writing/resource-version.js +172 -0
  124. package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
  125. package/plugins/core/rest-api-anyapi-knex-plugin.js +116 -2485
  126. package/plugins/core/rest-api-autofilter-plugin.js +17 -19
  127. package/plugins/core/rest-api-cors-plugin.js +52 -205
  128. package/plugins/core/rest-api-knex-plugin.js +45 -1077
  129. package/plugins/core/rest-api-label-plugin.js +25 -38
  130. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
  131. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
  132. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
  133. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
  134. package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
  135. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
  136. package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
  137. package/plugins/core/rest-api-plugin-methods/common.js +291 -395
  138. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
  139. package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
  140. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -148
  141. package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
  142. package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
  143. package/plugins/core/rest-api-plugin-methods/get.js +36 -68
  144. package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
  145. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
  146. package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
  147. package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
  148. package/plugins/core/rest-api-plugin-methods/post.js +51 -51
  149. package/plugins/core/rest-api-plugin-methods/put.js +69 -148
  150. package/plugins/core/rest-api-plugin-methods/query.js +37 -81
  151. package/plugins/core/rest-api-plugin-methods/release.js +2 -1
  152. package/plugins/core/rest-api-plugin.js +32 -46
  153. package/plugins/core/rest-api-positioning-plugin.js +183 -628
  154. package/plugins/core/rest-api-query-projections-plugin.js +19 -102
  155. package/plugins/core/rest-api-row-policy-plugin.js +3 -3
  156. package/plugins/core/socketio-plugin.js +364 -344
  157. package/plugins/storage/local-storage.js +40 -36
  158. package/types/autofilter.d.ts +57 -0
  159. package/types/bulk-methods.d.ts +63 -0
  160. package/types/errors.d.ts +118 -0
  161. package/types/fastify-options.d.ts +19 -0
  162. package/types/file-detectors.d.ts +33 -0
  163. package/types/file-storage.d.ts +48 -0
  164. package/types/hook-context.d.ts +180 -0
  165. package/types/plugin-options.d.ts +53 -0
  166. package/types/relationship-methods.d.ts +74 -0
  167. package/types/representations.d.ts +50 -0
  168. package/types/resource-methods.d.ts +95 -0
  169. package/types/resource-schema.d.ts +157 -0
  170. package/types/row-policy.d.ts +31 -0
  171. package/types/runtime.d.ts +132 -0
  172. package/types/transactions.d.ts +21 -0
  173. package/.claude/agents/library-guide-expert.md +0 -30
  174. package/.claude/agents/rest-api-internals-expert.md +0 -54
  175. package/.claude/settings.local.json +0 -70
  176. package/AGENTS.md +0 -34
  177. package/CLAUDE.md +0 -160
  178. package/CRUSH.md +0 -55
  179. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
  180. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
  181. package/TODO.todo +0 -13
  182. package/connectors_ideas/fastify.js +0 -217
  183. package/connectors_ideas/koa-plugin.js +0 -112
  184. package/docs/.bundle/config +0 -2
  185. package/docs/COMPARISON.md +0 -433
  186. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
  187. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
  188. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
  189. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
  190. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
  191. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
  192. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
  193. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1247
  194. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
  195. package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
  196. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
  197. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
  198. package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
  199. package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
  200. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
  201. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
  202. package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
  203. package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
  204. package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
  205. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
  206. package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
  207. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
  208. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
  209. package/docs/Gemfile +0 -6
  210. package/docs/Gemfile.lock +0 -308
  211. package/docs/ONBOARDING.md +0 -315
  212. package/docs/_config.yml +0 -36
  213. package/docs/_layouts/default.html +0 -123
  214. package/docs/apple-touch-icon.svg +0 -4
  215. package/docs/assets/css/style.scss +0 -392
  216. package/docs/favicon.svg +0 -4
  217. package/docs/index.md +0 -164
  218. package/docs/mini-guides/file-uploads-complete.md +0 -161
  219. package/eslint.config.js +0 -13
  220. package/examples/url-override-example.js +0 -137
  221. package/fixing_plan.md +0 -112
  222. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
  223. package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
  224. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
  225. package/plugins/storage/s3-storage.js +0 -93
  226. package/quickTest.js +0 -136
  227. package/scripts/debug-anyapi-review-filter.mjs +0 -63
  228. package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
  229. package/scripts/inspect-anyapi-descriptor.mjs +0 -19
  230. package/scripts/run-docs.js +0 -133
  231. package/test-debug.js +0 -59
  232. package/tests/README.md +0 -202
  233. package/tests/TEST_TEMPLATE.test.js +0 -178
  234. package/tests/anyapi-basic.test.js +0 -105
  235. package/tests/anyapi-cursor-pagination.test.js +0 -148
  236. package/tests/anyapi-custom-idproperty.test.js +0 -163
  237. package/tests/autofilter.test.js +0 -463
  238. package/tests/bulk-operations.test.js +0 -657
  239. package/tests/computed-fields.test.js +0 -586
  240. package/tests/cors.test.js +0 -834
  241. package/tests/curl.test.js +0 -467
  242. package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
  243. package/tests/custom-id-simplified-post.test.js +0 -75
  244. package/tests/custom-idproperty-relationships.test.js +0 -932
  245. package/tests/db-introspection.test.js +0 -521
  246. package/tests/db-table-operations.test.js +0 -798
  247. package/tests/fastify-plugin.test.js +0 -184
  248. package/tests/field-getters.test.js +0 -370
  249. package/tests/field-setters.test.js +0 -306
  250. package/tests/file-handling.test.js +0 -155
  251. package/tests/fixtures/api-configs.js +0 -2566
  252. package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
  253. package/tests/helpers/fake-fastify.js +0 -99
  254. package/tests/helpers/storage-mode.js +0 -65
  255. package/tests/helpers/test-utils.js +0 -203
  256. package/tests/http-connectors-parity.test.js +0 -395
  257. package/tests/id-normalization.test.js +0 -444
  258. package/tests/include-depth-validation.test.js +0 -331
  259. package/tests/includes.test.js +0 -427
  260. package/tests/local-storage.test.js +0 -96
  261. package/tests/logical-id-write-validation.test.js +0 -151
  262. package/tests/nested-includes.test.js +0 -617
  263. package/tests/pagination-boundaries.test.js +0 -154
  264. package/tests/pagination-contract.test.js +0 -122
  265. package/tests/pagination-cursor-multifield.test.js +0 -641
  266. package/tests/pagination-enhanced.test.js +0 -563
  267. package/tests/polymorphic-relationships.test.js +0 -347
  268. package/tests/polymorphic-simple.test.js +0 -148
  269. package/tests/positioning-advanced.test.js +0 -692
  270. package/tests/positioning-extended.test.js +0 -915
  271. package/tests/positioning-multifilter.test.js +0 -699
  272. package/tests/positioning.test.js +0 -560
  273. package/tests/post-commit-hooks.test.js +0 -120
  274. package/tests/projected-fields.test.js +0 -319
  275. package/tests/public-exports.test.js +0 -15
  276. package/tests/put-create-parity.test.js +0 -216
  277. package/tests/queries.test.js +0 -521
  278. package/tests/query-limits.test.js +0 -584
  279. package/tests/relationship-endpoints.test.js +0 -620
  280. package/tests/relationship-include-storage-adapter.test.js +0 -56
  281. package/tests/relationships.test.js +0 -681
  282. package/tests/rest-api.test.js +0 -337
  283. package/tests/return-record-settings.test.js +0 -184
  284. package/tests/row-policy.test.js +0 -489
  285. package/tests/s3-storage.test.js +0 -38
  286. package/tests/searchschema-knex-behavior.test.js +0 -187
  287. package/tests/searchschema-merge.test.js +0 -151
  288. package/tests/socketio.test.js +0 -860
  289. package/tests/storage-mapping.test.js +0 -362
  290. package/tests/temporal-boundaries.test.js +0 -208
  291. package/tests/test-development-guide.md +0 -370
  292. 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
- }