json-rest-api 1.0.29 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/LICENSE +10 -670
  2. package/LICENSE-GPL-3.0 +674 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +39 -30
  5. package/docs/API.md +744 -1273
  6. package/docs/GUIDE/01-getting-started.md +338 -0
  7. package/docs/GUIDE/02-resources-and-relationships.md +33 -0
  8. package/docs/GUIDE/03-running-example.md +87 -0
  9. package/docs/GUIDE/04-creating-and-querying.md +181 -0
  10. package/docs/GUIDE/05-belongs-to.md +177 -0
  11. package/docs/GUIDE/06-has-many.md +184 -0
  12. package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
  13. package/docs/GUIDE/08-many-to-many.md +184 -0
  14. package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
  15. package/docs/GUIDE/10-put-and-patch.md +174 -0
  16. package/docs/GUIDE/11-relationship-endpoints.md +174 -0
  17. package/docs/GUIDE/12-field-transformations.md +287 -0
  18. package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
  19. package/docs/GUIDE/14-custom-resource-methods.md +125 -0
  20. package/docs/GUIDE/15-query-projections.md +266 -0
  21. package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
  22. package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
  23. package/docs/GUIDE/18-labels.md +86 -0
  24. package/docs/GUIDE/19-managed-transactions.md +179 -0
  25. package/docs/GUIDE/20-transaction-outcomes.md +207 -0
  26. package/docs/GUIDE/21-schema-and-migrations.md +733 -0
  27. package/docs/GUIDE/22-version-field-migrations.md +208 -0
  28. package/docs/GUIDE/23-fastify.md +179 -0
  29. package/docs/GUIDE/24-cors.md +172 -0
  30. package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
  31. package/docs/GUIDE/26-file-uploads.md +278 -0
  32. package/docs/GUIDE/27-bulk-operations.md +277 -0
  33. package/docs/GUIDE/28-socketio.md +325 -0
  34. package/docs/GUIDE/29-writing-plugins.md +278 -0
  35. package/docs/GUIDE/30-backend-capabilities.md +103 -0
  36. package/docs/GUIDE/31-positioning.md +181 -0
  37. package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +74 -30
  38. package/docs/GUIDE/33-migrating-to-v2.md +3838 -0
  39. package/docs/GUIDE/index.md +51 -59
  40. package/docs/QUICKSTART.md +120 -287
  41. package/docs/architecture.md +75 -0
  42. package/docs/contributing.md +178 -0
  43. package/index.d.ts +37 -0
  44. package/index.js +3 -22
  45. package/lib/enhanced-logger.js +91 -85
  46. package/lib/error-context.js +445 -5
  47. package/lib/error-formatter.js +219 -59
  48. package/lib/knex-transaction.js +70 -0
  49. package/lib/logger-types.d.ts +22 -0
  50. package/lib/missing-package.js +4 -0
  51. package/lib/rest-api-errors.js +103 -8
  52. package/lib/runtime/json-rest-api.js +172 -0
  53. package/lib/transaction-types.d.ts +59 -0
  54. package/package.json +97 -27
  55. package/plugins/core/bulk-operations-plugin.js +191 -184
  56. package/plugins/core/connectors/express-plugin.js +148 -219
  57. package/plugins/core/connectors/fastify-plugin.js +125 -82
  58. package/plugins/core/connectors/lib/busboy-detector.js +74 -156
  59. package/plugins/core/connectors/lib/connector-core.js +144 -20
  60. package/plugins/core/connectors/lib/formidable-detector.js +63 -120
  61. package/plugins/core/connectors/lib/http-validators.js +77 -0
  62. package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
  63. package/plugins/core/connectors/lib/request-helpers.js +10 -1
  64. package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
  65. package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
  66. package/plugins/core/file-handling-plugin.js +87 -177
  67. package/plugins/core/lib/anyapi/anyapi-registry.js +586 -418
  68. package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
  69. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
  70. package/plugins/core/lib/anyapi/schema-utils.js +120 -56
  71. package/plugins/core/lib/anyapi/stored-definition.js +109 -0
  72. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
  73. package/plugins/core/lib/dbIntrospection.js +228 -21
  74. package/plugins/core/lib/dbTablesOperations.js +409 -92
  75. package/plugins/core/lib/fractional-positioning.js +12 -48
  76. package/plugins/core/lib/querying/identifier-query.js +19 -0
  77. package/plugins/core/lib/querying/include-query-helpers.js +364 -0
  78. package/plugins/core/lib/querying/include-to-many.js +428 -0
  79. package/plugins/core/lib/querying/include-to-one.js +579 -0
  80. package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
  81. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
  82. package/plugins/core/lib/querying/knex-pagination-helpers.js +48 -317
  83. package/plugins/core/lib/querying/knex-process-includes.js +5 -110
  84. package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
  85. package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
  86. package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
  87. package/plugins/core/lib/querying/knex-window-queries.js +62 -253
  88. package/plugins/core/lib/querying/query-builder-utils.js +44 -4
  89. package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
  90. package/plugins/core/lib/querying/query-constraint.js +27 -0
  91. package/plugins/core/lib/querying/query-field-sort-helpers.js +134 -37
  92. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  93. package/plugins/core/lib/querying/sort-helpers.js +13 -1
  94. package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
  95. package/plugins/core/lib/querying/url-helpers.js +11 -4
  96. package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
  97. package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
  98. package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
  99. package/plugins/core/lib/querying-writing/database-value-normalizers.js +179 -194
  100. package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
  101. package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
  102. package/plugins/core/lib/querying-writing/field-utils.js +113 -92
  103. package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
  104. package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
  105. package/plugins/core/lib/querying-writing/knex-field-helpers.js +100 -290
  106. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
  107. package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
  108. package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
  109. package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
  110. package/plugins/core/lib/querying-writing/request-contracts.js +93 -38
  111. package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
  112. package/plugins/core/lib/querying-writing/response-options.js +35 -0
  113. package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
  114. package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
  115. package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
  116. package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
  117. package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
  118. package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
  119. package/plugins/core/lib/storage/storage-adapter.js +128 -62
  120. package/plugins/core/lib/storage/storage-mapping.js +78 -28
  121. package/plugins/core/lib/storage/storage-types.d.ts +502 -0
  122. package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
  123. package/plugins/core/lib/writing/relationship-processor.js +94 -158
  124. package/plugins/core/lib/writing/resource-version.js +172 -0
  125. package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
  126. package/plugins/core/rest-api-anyapi-knex-plugin.js +290 -2475
  127. package/plugins/core/rest-api-autofilter-plugin.js +17 -19
  128. package/plugins/core/rest-api-cors-plugin.js +52 -205
  129. package/plugins/core/rest-api-knex-plugin.js +45 -1077
  130. package/plugins/core/rest-api-label-plugin.js +25 -38
  131. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
  132. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
  133. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
  134. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
  135. package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
  136. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
  137. package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
  138. package/plugins/core/rest-api-plugin-methods/common.js +291 -395
  139. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
  140. package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
  141. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -148
  142. package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
  143. package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
  144. package/plugins/core/rest-api-plugin-methods/get.js +36 -68
  145. package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
  146. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
  147. package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
  148. package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
  149. package/plugins/core/rest-api-plugin-methods/post.js +51 -51
  150. package/plugins/core/rest-api-plugin-methods/put.js +69 -148
  151. package/plugins/core/rest-api-plugin-methods/query.js +37 -81
  152. package/plugins/core/rest-api-plugin-methods/release.js +2 -1
  153. package/plugins/core/rest-api-plugin.js +32 -46
  154. package/plugins/core/rest-api-positioning-plugin.js +183 -628
  155. package/plugins/core/rest-api-query-projections-plugin.js +19 -102
  156. package/plugins/core/rest-api-row-policy-plugin.js +3 -3
  157. package/plugins/core/socketio-plugin.js +364 -344
  158. package/plugins/storage/local-storage.js +40 -36
  159. package/types/anyapi.d.ts +30 -0
  160. package/types/autofilter.d.ts +57 -0
  161. package/types/bulk-methods.d.ts +63 -0
  162. package/types/errors.d.ts +118 -0
  163. package/types/fastify-options.d.ts +19 -0
  164. package/types/file-detectors.d.ts +33 -0
  165. package/types/file-storage.d.ts +48 -0
  166. package/types/hook-context.d.ts +180 -0
  167. package/types/plugin-options.d.ts +65 -0
  168. package/types/relationship-methods.d.ts +74 -0
  169. package/types/representations.d.ts +50 -0
  170. package/types/resource-methods.d.ts +95 -0
  171. package/types/resource-schema.d.ts +157 -0
  172. package/types/row-policy.d.ts +31 -0
  173. package/types/runtime.d.ts +132 -0
  174. package/types/transactions.d.ts +21 -0
  175. package/.claude/agents/library-guide-expert.md +0 -30
  176. package/.claude/agents/rest-api-internals-expert.md +0 -54
  177. package/.claude/settings.local.json +0 -70
  178. package/AGENTS.md +0 -34
  179. package/CLAUDE.md +0 -160
  180. package/CRUSH.md +0 -55
  181. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
  182. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
  183. package/TODO.todo +0 -13
  184. package/connectors_ideas/fastify.js +0 -217
  185. package/connectors_ideas/koa-plugin.js +0 -112
  186. package/docs/.bundle/config +0 -2
  187. package/docs/COMPARISON.md +0 -433
  188. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
  189. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
  190. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
  191. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
  192. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
  193. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
  194. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
  195. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1247
  196. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
  197. package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
  198. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
  199. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
  200. package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
  201. package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
  202. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
  203. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
  204. package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
  205. package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
  206. package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
  207. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
  208. package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
  209. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
  210. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
  211. package/docs/Gemfile +0 -6
  212. package/docs/Gemfile.lock +0 -308
  213. package/docs/ONBOARDING.md +0 -315
  214. package/docs/_config.yml +0 -36
  215. package/docs/_layouts/default.html +0 -123
  216. package/docs/apple-touch-icon.svg +0 -4
  217. package/docs/assets/css/style.scss +0 -392
  218. package/docs/favicon.svg +0 -4
  219. package/docs/index.md +0 -164
  220. package/docs/mini-guides/file-uploads-complete.md +0 -161
  221. package/eslint.config.js +0 -13
  222. package/examples/url-override-example.js +0 -137
  223. package/fixing_plan.md +0 -112
  224. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
  225. package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
  226. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
  227. package/plugins/storage/s3-storage.js +0 -93
  228. package/quickTest.js +0 -136
  229. package/scripts/debug-anyapi-review-filter.mjs +0 -63
  230. package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
  231. package/scripts/inspect-anyapi-descriptor.mjs +0 -19
  232. package/scripts/run-docs.js +0 -133
  233. package/test-debug.js +0 -59
  234. package/tests/README.md +0 -202
  235. package/tests/TEST_TEMPLATE.test.js +0 -178
  236. package/tests/anyapi-basic.test.js +0 -105
  237. package/tests/anyapi-cursor-pagination.test.js +0 -148
  238. package/tests/anyapi-custom-idproperty.test.js +0 -163
  239. package/tests/autofilter.test.js +0 -463
  240. package/tests/bulk-operations.test.js +0 -657
  241. package/tests/computed-fields.test.js +0 -586
  242. package/tests/cors.test.js +0 -834
  243. package/tests/curl.test.js +0 -467
  244. package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
  245. package/tests/custom-id-simplified-post.test.js +0 -75
  246. package/tests/custom-idproperty-relationships.test.js +0 -932
  247. package/tests/db-introspection.test.js +0 -521
  248. package/tests/db-table-operations.test.js +0 -798
  249. package/tests/fastify-plugin.test.js +0 -184
  250. package/tests/field-getters.test.js +0 -370
  251. package/tests/field-setters.test.js +0 -306
  252. package/tests/file-handling.test.js +0 -155
  253. package/tests/fixtures/api-configs.js +0 -2566
  254. package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
  255. package/tests/helpers/fake-fastify.js +0 -99
  256. package/tests/helpers/storage-mode.js +0 -65
  257. package/tests/helpers/test-utils.js +0 -203
  258. package/tests/http-connectors-parity.test.js +0 -395
  259. package/tests/id-normalization.test.js +0 -444
  260. package/tests/include-depth-validation.test.js +0 -331
  261. package/tests/includes.test.js +0 -427
  262. package/tests/local-storage.test.js +0 -96
  263. package/tests/logical-id-write-validation.test.js +0 -151
  264. package/tests/nested-includes.test.js +0 -617
  265. package/tests/pagination-boundaries.test.js +0 -154
  266. package/tests/pagination-contract.test.js +0 -122
  267. package/tests/pagination-cursor-multifield.test.js +0 -641
  268. package/tests/pagination-enhanced.test.js +0 -563
  269. package/tests/polymorphic-relationships.test.js +0 -347
  270. package/tests/polymorphic-simple.test.js +0 -148
  271. package/tests/positioning-advanced.test.js +0 -692
  272. package/tests/positioning-extended.test.js +0 -915
  273. package/tests/positioning-multifilter.test.js +0 -699
  274. package/tests/positioning.test.js +0 -560
  275. package/tests/post-commit-hooks.test.js +0 -120
  276. package/tests/projected-fields.test.js +0 -319
  277. package/tests/public-exports.test.js +0 -15
  278. package/tests/put-create-parity.test.js +0 -216
  279. package/tests/queries.test.js +0 -521
  280. package/tests/query-limits.test.js +0 -584
  281. package/tests/relationship-endpoints.test.js +0 -620
  282. package/tests/relationship-include-storage-adapter.test.js +0 -56
  283. package/tests/relationships.test.js +0 -681
  284. package/tests/rest-api.test.js +0 -337
  285. package/tests/return-record-settings.test.js +0 -184
  286. package/tests/row-policy.test.js +0 -489
  287. package/tests/s3-storage.test.js +0 -38
  288. package/tests/searchschema-knex-behavior.test.js +0 -187
  289. package/tests/searchschema-merge.test.js +0 -151
  290. package/tests/socketio.test.js +0 -860
  291. package/tests/storage-mapping.test.js +0 -362
  292. package/tests/temporal-boundaries.test.js +0 -208
  293. package/tests/test-development-guide.md +0 -370
  294. package/tests/virtual-fields.test.js +0 -404
@@ -1,81 +1,54 @@
1
+ import { createCanonicalDataHelpers } from './lib/storage/canonical-data-helpers.js'
2
+ import { createCanonicalLinkStore } from './lib/anyapi/canonical-link-store.js'
3
+ import { createCanonicalRelationshipReader } from './lib/anyapi/canonical-relationship-reader.js'
4
+ import { createKnexTransaction } from '../../lib/knex-transaction.js'
5
+ import { createEnhancedLogger } from '../../lib/enhanced-logger.js'
1
6
  import { ensureAnyApiSchema } from './lib/anyapi/schema-utils.js'
2
7
  import { AnyapiRegistry } from './lib/anyapi/anyapi-registry.js'
3
- import { RestApiValidationError } from '../../lib/rest-api-errors.js'
4
- import { createSchema } from 'json-rest-schema'
5
- import {
6
- DEFAULT_QUERY_LIMIT,
7
- DEFAULT_MAX_QUERY_LIMIT,
8
- COMPUTED_DEPENDENCIES_KEY,
9
- } from './lib/querying-writing/knex-constants.js'
10
- import {
11
- calculatePaginationMeta,
12
- generatePaginationLinks,
13
- generateCursorPaginationLinks,
14
- buildCursorMeta,
15
- parseCursor,
16
- } from './lib/querying/knex-pagination-helpers.js'
17
- import { getUrlPrefix, buildResourceUrl, buildRelationshipUrl } from './lib/querying/url-helpers.js'
18
- import {
19
- normalizeId,
20
- resolveFieldInfo,
21
- normalizeFilterValues,
22
- ensureFilterableField,
23
- } from './lib/anyapi/utils/descriptor-helpers.js'
24
- import {
25
- AnyapiQueryAdapter,
26
- preloadRelatedDescriptors,
27
- } from './lib/anyapi/query/anyapi-query-adapter.js'
28
- import {
29
- ensureSearchFieldsAreIndexed,
30
- generateSearchSchemaFromSchema,
31
- } from './lib/querying-writing/schema-helpers.js'
32
- import { applyFieldSelectionToQuery, buildFieldSelection } from './lib/querying-writing/knex-field-helpers.js'
33
- import {
34
- polymorphicFiltersHook,
35
- crossTableFiltersHook,
36
- basicFiltersHook,
37
- resolveSearchOperator,
38
- applyWhereForOperator,
39
- } from './lib/querying/knex-query-helpers.js'
40
- import {
41
- createStorageAdapter,
42
- } from './lib/storage/storage-adapter.js'
43
- import {
44
- translateCanonicalAttributesForStorage,
45
- getCanonicalResourceId,
46
- getCanonicalResourceIdColumn,
47
- translateCanonicalRecordFromStorage,
48
- } from './lib/storage/canonical-storage-mapping.js'
49
- import {
50
- applyCursorPredicate,
51
- validateCursorValues,
52
- applyQueryFieldOrder,
53
- buildEffectiveSortList,
54
- parseSortEntry
55
- } from './lib/querying/query-field-sort-helpers.js'
56
- import { unwrapQueryBuilderState } from './lib/querying/query-builder-utils.js'
57
- import { serializeJsonApiQuery } from './lib/querying-writing/connectors-query-parser.js'
58
- import { normalizeValueForDatabaseStorage } from './lib/querying-writing/database-value-normalizers.js'
8
+ import { composeResourceOptions, relatedResources } from './lib/anyapi/stored-definition.js'
9
+ import { RestApiResourceError } from '../../lib/rest-api-errors.js'
10
+ import { compileSchemas } from './lib/querying-writing/compile-schemas.js'
11
+ import { snapshotResourceConfiguration } from './lib/querying-writing/schema-helpers.js'
12
+ import { assertFieldNameMap } from './lib/querying-writing/field-utils.js'
13
+ import { polymorphicFiltersHook, crossTableFiltersHook, basicFiltersHook } from './lib/querying/knex-query-helpers.js'
14
+ import { createStorageAdapterLookup } from './lib/storage/storage-adapter.js'
59
15
 
60
16
  const DEFAULT_TENANT = 'default'
61
- const LINKS_TABLE = 'any_links'
17
+ // Methods that read a published schema; schema checks wrap these.
18
+ const REQUEST_METHODS = ['query', 'get', 'post', 'put', 'patch', 'delete', 'getRelated', 'getRelationship', 'postRelationship', 'patchRelationship', 'deleteRelationship']
19
+
20
+ const optionsSignature = options => JSON.stringify([Object.keys(options.schema || {}).sort(), Object.keys(options.searchSchema || {}).sort()])
21
+
22
+ // A validation failure may come from a schema another server already changed.
23
+ // Writes that may have committed are never repeated.
24
+ const isStaleSchemaRejection = error => error?.type === 'rest_api_validation' && !['committed', 'unknown'].includes(error.transactionOutcome)
62
25
 
63
26
  export const RestApiAnyapiKnexPlugin = {
64
27
  name: 'rest-api-anyapi-knex',
65
28
  dependencies: ['rest-api'],
66
29
 
67
- async install ({ helpers, vars, pluginOptions, api, log, addHook, addScopeMethod, scopes }) {
30
+ async install ({ helpers, pluginOptions, api, log, addHook, addResourceMethod, scopes }) {
31
+ log = createEnhancedLogger(log)
68
32
  const options = pluginOptions || {}
69
33
  const knex = options.knex
70
34
  const tenantId = options.tenantId || DEFAULT_TENANT
35
+ const { ensureSchema = true, ownsKnex = true, onSchemaChanged, schemaCheckInterval, slots } = options
71
36
 
72
37
  if (!knex) {
73
38
  throw new Error('AnyapiKnexPlugin requires a knex instance')
74
39
  }
40
+ if (options.registry !== undefined && (!(options.registry instanceof AnyapiRegistry) || options.registry.knex !== knex)) {
41
+ throw new Error('AnyapiKnexPlugin registry must be an AnyapiRegistry for the same knex instance')
42
+ }
43
+ if (onSchemaChanged !== undefined && typeof onSchemaChanged !== 'function') {
44
+ throw new Error('AnyapiKnexPlugin onSchemaChanged must be a function')
45
+ }
46
+ if (schemaCheckInterval !== undefined && !(Number.isFinite(schemaCheckInterval) && schemaCheckInterval >= 0)) {
47
+ throw new Error('AnyapiKnexPlugin schemaCheckInterval must be a non-negative number of milliseconds')
48
+ }
75
49
 
76
- await ensureAnyApiSchema(knex)
77
-
78
- const registry = new AnyapiRegistry({ knex, log })
50
+ const registry = options.registry || new AnyapiRegistry({ knex, log })
51
+ if (ensureSchema) await registry.ensureSchema({ slots })
79
52
 
80
53
  api.anyapi = api.anyapi || {}
81
54
  api.anyapi.registry = registry
@@ -83,2482 +56,324 @@ export const RestApiAnyapiKnexPlugin = {
83
56
  api.knex = {
84
57
  instance: knex,
85
58
  helpers: {},
59
+ capabilities: await registry.getCapabilities(log),
86
60
  }
87
61
 
88
- const storageAdapterCache = new Map()
89
-
90
- const getScopeStorageAdapter = (scopeName) => {
91
- if (!scopeName) return null
92
- const resource = api.resources?.[scopeName] || scopes?.[scopeName]
93
- const schemaInfo = resource?.vars?.schemaInfo
94
- if (!schemaInfo) return null
95
-
96
- const cached = storageAdapterCache.get(scopeName)
97
- if (cached && cached.schemaInfo === schemaInfo) {
98
- return cached.adapter
99
- }
100
-
101
- const adapter = createStorageAdapter({ knex, schemaInfo })
102
- storageAdapterCache.set(scopeName, { adapter, schemaInfo })
103
- if (resource?.vars) {
104
- resource.vars.storageAdapter = adapter
105
- }
106
- return adapter
107
- }
62
+ const getScopeStorageAdapter = createStorageAdapterLookup({
63
+ knex,
64
+ getResource: scopeName => api.resources?.[scopeName] || scopes?.[scopeName]
65
+ })
108
66
 
109
67
  api.knex.helpers.getStorageAdapter = getScopeStorageAdapter
110
68
  helpers.getStorageAdapter = getScopeStorageAdapter
111
69
 
112
- const scopeOptionsRegistry = new Map()
70
+ // Owned copies of what this server declared and added, callbacks included.
71
+ const declarations = new Map()
72
+ const runtimeDeclarations = new Map()
73
+ const storageLoaded = new Set()
74
+ const compiledSignatures = new Map()
75
+ const lastChecked = new Map()
76
+ const reloads = new Map()
77
+ const checks = new Map()
78
+ const loading = new Map()
113
79
 
114
- helpers.newTransaction = async () => knex.transaction()
80
+ /** @type {import('../../lib/transaction-types.js').TransactionFactory} */
81
+ helpers.newTransaction = context => createKnexTransaction(knex, context)
115
82
 
116
83
  addHook('release', 'anyapi-knex-release', {}, async ({ api }) => {
117
- if (api.knex?.instance) {
84
+ if (ownsKnex && api.knex?.instance) {
118
85
  await api.knex.instance.destroy()
119
86
  }
120
87
  })
121
88
 
122
- const getDescriptor = async (scopeName) => {
123
- const descriptor = await registry.getDescriptor(tenantId, scopeName)
89
+ const getDescriptor = (scopeName) => {
90
+ const descriptor = api.resources?.[scopeName]?.vars?.schemaInfo?.descriptor
124
91
  if (!descriptor) {
125
92
  throw new Error(`Descriptor not found for resource '${scopeName}'`)
126
93
  }
127
- const scope = api.resources?.[scopeName] || scopes?.[scopeName]
128
- const stored = scopeOptionsRegistry.get(scopeName) || {}
129
- descriptor.idProperty = stored.idProperty ||
130
- scope?.vars?.schemaInfo?.idProperty ||
131
- scope?.scopeOptions?.idProperty ||
132
- descriptor.idProperty ||
133
- vars.idProperty ||
134
- 'id'
135
94
  return descriptor
136
95
  }
137
96
 
138
- const decorateResourceLinks = ({ resource, scope, scopeName, context }) => {
139
- if (!resource || !scope || !scopeName || !context) return
140
-
141
- resource.links = resource.links || {}
142
- resource.links.self = buildResourceUrl(context, scope, scopeName, resource.id)
143
-
144
- if (!resource.relationships || typeof resource.relationships !== 'object') {
145
- return
146
- }
147
-
148
- for (const [relationshipName, relationshipObject] of Object.entries(resource.relationships)) {
149
- if (!relationshipObject || typeof relationshipObject !== 'object') {
150
- continue
151
- }
152
-
153
- relationshipObject.links = {
154
- self: buildRelationshipUrl(context, scope, scopeName, resource.id, relationshipName, true),
155
- related: buildRelationshipUrl(context, scope, scopeName, resource.id, relationshipName, false),
156
- }
157
- }
158
- }
159
-
160
- const getAllowedQueryFieldNames = (scopeName) => (
161
- Object.keys(api.resources?.[scopeName]?.vars?.queryFields || {})
162
- )
163
-
164
- const getLogicalResourceIdColumn = (descriptor) => getCanonicalResourceIdColumn(descriptor)
165
-
166
- const getLogicalResourceId = (row, descriptor) => getCanonicalResourceId(row, descriptor)
167
-
168
- const applyFiltersToQuery = ({ query, filters, descriptor, searchSchema, adapter }) => {
169
- if (!filters || Object.keys(filters).length === 0) return
170
-
171
- const normalizeValue = (field, value) => {
172
- if (!adapter?.translateFilterValue) {
173
- return value
174
- }
175
- if (Array.isArray(value)) {
176
- return value.map((entry) => adapter.translateFilterValue(field, entry))
177
- }
178
- return adapter.translateFilterValue(field, value)
179
- }
180
-
181
- for (const [filterKey, rawValue] of Object.entries(filters)) {
182
- const fieldDef = searchSchema?.[filterKey]
183
-
184
- if (fieldDef?.polymorphicField) {
185
- continue
186
- }
187
-
188
- if (fieldDef?.actualField?.includes('.')) {
189
- continue
190
- }
191
-
192
- if (fieldDef?.oneOf?.some((field) => field.includes('.'))) {
193
- continue
194
- }
195
-
196
- if (fieldDef?.oneOf && Array.isArray(fieldDef.oneOf)) {
197
- continue
198
- }
199
-
200
- if (fieldDef?.applyFilter && typeof fieldDef.applyFilter === 'function') {
201
- continue
202
- }
203
-
204
- const actualField = fieldDef?.actualField || filterKey
205
- const fieldInfo = ensureFilterableField(descriptor, filterKey)
206
- const columnRef = adapter ? adapter.translateColumn(actualField) : fieldInfo.column
207
- const operator = resolveSearchOperator(fieldDef || fieldInfo.definition)
208
- const values = normalizeFilterValues(rawValue, fieldInfo.definition, {
209
- isRelationship: fieldInfo.isRelationship,
210
- })
211
- const normalizedValues = values.map((value) => normalizeValue(actualField, value))
212
- const nonNullValues = normalizedValues.filter((value) => value !== null)
213
- const hasNull = normalizedValues.length !== nonNullValues.length
214
-
215
- if (nonNullValues.length === 0 && hasNull) {
216
- query.whereNull(columnRef)
217
- continue
218
- }
219
- if (nonNullValues.length === 1 && !hasNull) {
220
- applyWhereForOperator({
221
- builder: query,
222
- columnRef,
223
- operator,
224
- value: nonNullValues[0],
225
- knex,
226
- })
227
- continue
228
- }
229
- if (nonNullValues.length > 1 && !hasNull) {
230
- query.whereIn(columnRef, nonNullValues)
231
- continue
232
- }
233
- if (nonNullValues.length > 0 && hasNull) {
234
- query.where(function anyApiNullFilterGroup () {
235
- this.whereIn(columnRef, nonNullValues).orWhereNull(columnRef)
236
- })
237
- }
238
- }
239
- }
97
+ const linkStore = createCanonicalLinkStore({ api, getDescriptor, getScopeStorageAdapter })
98
+ api.anyapi.links = linkStore.methods
99
+ helpers.dataRelatedIdsQuery = linkStore.dataRelatedIdsQuery
100
+ const { buildIncludes, attachReverseRelationships, attachManyToManyRelationships } = createCanonicalRelationshipReader({
101
+ api, knex, getDescriptor, getScopeStorageAdapter, linkStore
102
+ })
240
103
 
241
- const applySortingToQuery = ({ query, sort, descriptor, scope, adapter, before = false, queryFieldRuntimeByField = new Map() }) => {
242
- const effectiveSort = buildEffectiveSortList(sort, {
243
- defaultSort: scope?.vars?.defaultSort,
244
- idField: 'id'
245
- })
246
- const descriptors = []
104
+ const publishedVersion = scopeName => api.resources[scopeName]?.vars?.schemaInfo?.descriptor?.schemaVersion ?? 0
247
105
 
248
- for (const entry of effectiveSort) {
249
- const { field, direction } = parseSortEntry(entry)
250
- const queryDirection = before ? (direction === 'asc' ? 'desc' : 'asc') : direction
251
- const queryFieldRuntime = queryFieldRuntimeByField.get(field)
252
- if (queryFieldRuntime) {
253
- applyQueryFieldOrder(query, queryFieldRuntime, queryDirection.toUpperCase(), before ? 'first' : 'last')
254
- descriptors.push({
255
- field,
256
- direction,
257
- definition: queryFieldRuntime.definition,
258
- isRelationship: false,
259
- queryFieldRuntime,
260
- })
261
- continue
262
- }
106
+ const composeOptions = (scopeName, descriptor) => composeResourceOptions({
107
+ declaration: declarations.get(scopeName),
108
+ stored: descriptor.definition,
109
+ local: runtimeDeclarations.get(scopeName),
110
+ fromStorage: storageLoaded.has(scopeName)
111
+ })
263
112
 
264
- const fieldInfo = resolveFieldInfo(descriptor, field)
265
- if (!fieldInfo?.column) continue
266
- query.orderByRaw(`?? IS NULL ${before ? 'DESC' : 'ASC'}`, [adapter.translateColumn(fieldInfo.column)])
267
- query.orderBy(fieldInfo.column, queryDirection)
268
- descriptors.push({
269
- field,
270
- column: fieldInfo.column,
271
- direction,
272
- definition: fieldInfo.definition || null,
273
- isRelationship: fieldInfo.isRelationship || false,
113
+ const compileOptions = async (scopeName, scopeOptions) => {
114
+ const compiled = { scopeOptions, vars: { idProperty: api.resources[scopeName].vars.idProperty } }
115
+ await compileSchemas(compiled, { context: { scopeName }, runHooks: (name, context) => api.runHooks(name, context) })
116
+ return compiled.vars.schemaInfo
117
+ }
118
+
119
+ // Publish a descriptor with the schema it implies. Recompile only when the
120
+ // field set changed, so schema hooks do not rerun on every refresh.
121
+ const publish = async (scopeName, descriptor, { compiled } = {}) => {
122
+ const scope = api.resources[scopeName]
123
+ if (publishedVersion(scopeName) > descriptor.schemaVersion) return
124
+ const { options: scopeOptions, hidden, hiddenFilters } = composeOptions(scopeName, descriptor)
125
+ if (hidden.length || hiddenFilters.length) {
126
+ await log.warn(`AnyAPI resource '${scopeName}' hides stored fields whose callbacks this server does not declare`, {
127
+ tenant: tenantId, scopeName, fields: hidden, filters: hiddenFilters
274
128
  })
275
129
  }
130
+ const signature = optionsSignature(scopeOptions)
131
+ let schemaInfo = scope.vars.schemaInfo
132
+ if (compiled?.signature === signature) schemaInfo = compiled.schemaInfo
133
+ else if (compiledSignatures.get(scopeName) !== signature) schemaInfo = await compileOptions(scopeName, scopeOptions)
134
+ compiledSignatures.set(scopeName, signature)
276
135
 
277
- if (descriptors.length === 0) {
278
- descriptors.push({
279
- field: 'id',
280
- column: 'id',
281
- direction: 'asc',
282
- definition: { type: 'id' },
283
- isRelationship: false,
284
- })
136
+ descriptor.idProperty = schemaInfo.idProperty
137
+ scope.vars.schemaInfo = {
138
+ ...schemaInfo,
139
+ descriptor,
140
+ canonicalFieldMap: descriptor.canonicalFieldMap,
285
141
  }
286
-
287
- return descriptors
142
+ getScopeStorageAdapter(scopeName)
143
+ lastChecked.set(scopeName, Date.now())
288
144
  }
289
145
 
290
- const applyPaginationToQuery = ({
291
- query,
292
- scope,
293
- queryParams,
294
- sortDescriptors,
295
- adapter,
296
- }) => {
297
- const pageParams = queryParams?.page || {}
298
- const scopeVars = scope?.vars || {}
299
- const defaultLimit = scopeVars.queryDefaultLimit || DEFAULT_QUERY_LIMIT
300
- const maxLimit = scopeVars.queryMaxLimit || DEFAULT_MAX_QUERY_LIMIT
301
-
302
- const descriptors = (sortDescriptors && sortDescriptors.length > 0)
303
- ? sortDescriptors
304
- : [{
305
- field: 'id',
306
- column: 'id',
307
- direction: 'asc',
308
- definition: { type: 'id' },
309
- isRelationship: false,
310
- }]
311
-
312
- const hasCursorParam = pageParams.after !== undefined || pageParams.before !== undefined
313
- const hasPageSize = pageParams.size !== undefined
314
- const hasPageNumber = pageParams.number !== undefined
315
-
316
- const parseCursorOrThrow = (rawCursor, paramName) => {
317
- try {
318
- return parseCursor(rawCursor)
319
- } catch (error) {
320
- throw new RestApiValidationError(
321
- `Invalid cursor format in ${paramName} parameter`,
322
- {
323
- fields: [`page.${paramName}`],
324
- violations: [{
325
- field: `page.${paramName}`,
326
- rule: 'invalid_cursor',
327
- message: error.message,
328
- }],
329
- }
330
- )
331
- }
332
- }
333
-
334
- const applyRawCursorPredicate = (cursorValues, operatorSelector) => {
335
- applyCursorPredicate(
336
- query,
337
- descriptors,
338
- cursorValues,
339
- operatorSelector,
340
- (builder, descriptor, operator, value) => builder.where(
341
- descriptor.column,
342
- operator,
343
- normalizeValueForDatabaseStorage(
344
- value, descriptor.isRelationship ? null : descriptor.definition?.type,
345
- { temporalPrecision: descriptor.definition?.temporalPrecision }
346
- )
347
- )
348
- )
349
- }
350
-
351
- const ensurePageSize = () => {
352
- const requestedSize = Number(pageParams.size ?? defaultLimit)
353
- if (!Number.isFinite(requestedSize) || requestedSize <= 0) {
354
- throw new RestApiValidationError('Page size must be greater than 0', {
355
- fields: ['page.size'],
356
- violations: [{
357
- field: 'page.size',
358
- rule: 'min_value',
359
- message: 'Page size must be a positive number',
360
- }],
146
+ // Reloads of one resource run in order, so an older read never wins.
147
+ const reload = (scopeName) => {
148
+ const run = (reloads.get(scopeName) || Promise.resolve()).catch(() => {}).then(async () => {
149
+ registry.invalidateDescriptor(tenantId, scopeName)
150
+ const descriptor = await registry.getDescriptor(tenantId, scopeName)
151
+ if (!descriptor) {
152
+ throw new RestApiResourceError(`Resource '${scopeName}' is not registered in AnyAPI storage`, {
153
+ subtype: 'not_found', resourceType: 'scope', resourceId: scopeName
361
154
  })
362
155
  }
363
- return Math.min(Math.trunc(requestedSize), maxLimit)
364
- }
365
-
366
- if (hasCursorParam || (hasPageSize && !hasPageNumber)) {
367
- if (pageParams.after && pageParams.before) {
368
- throw new RestApiValidationError(
369
- 'page[after] and page[before] cannot be used together',
370
- {
371
- fields: ['page.after', 'page.before'],
372
- violations: [{
373
- field: 'page.after',
374
- rule: 'conflict',
375
- message: 'Provide either page[after] or page[before], not both',
376
- }],
377
- }
378
- )
379
- }
380
-
381
- const pageSize = ensurePageSize()
382
- query.limit(pageSize + 1)
383
-
384
- if (pageParams.after) {
385
- const cursorMap = parseCursorOrThrow(pageParams.after, 'after')
386
- const cursorValues = validateCursorValues(descriptors, cursorMap, 'after')
387
- applyRawCursorPredicate(cursorValues, (direction) => (direction === 'desc' ? '<' : '>'))
388
- } else if (pageParams.before) {
389
- const cursorMap = parseCursorOrThrow(pageParams.before, 'before')
390
- const cursorValues = validateCursorValues(descriptors, cursorMap, 'before')
391
- applyRawCursorPredicate(cursorValues, (direction) => (direction === 'desc' ? '>' : '<'))
392
- }
393
-
394
- return {
395
- mode: 'cursor',
396
- pageSize,
397
- sortDescriptors: descriptors,
398
- }
399
- }
400
-
401
- if (hasPageSize || hasPageNumber) {
402
- const pageSize = ensurePageSize()
403
- const pageNumber = Math.trunc(Number(pageParams.number ?? 1))
404
- const safePageNumber = Number.isFinite(pageNumber) && pageNumber > 0 ? pageNumber : 1
405
- const offset = (safePageNumber - 1) * pageSize
406
-
407
- query.limit(pageSize).offset(offset)
408
-
409
- return {
410
- mode: 'offset',
411
- page: safePageNumber,
412
- pageSize,
413
- sortDescriptors: descriptors,
414
- }
415
- }
416
-
417
- query.limit(Math.min(defaultLimit, maxLimit))
418
- return {
419
- mode: 'default',
420
- page: 1,
421
- pageSize: Math.min(defaultLimit, maxLimit),
422
- sortDescriptors: descriptors,
423
- }
424
- }
425
-
426
- const canonicalizeLinkPair = ({
427
- tenantId,
428
- relationshipKey,
429
- inverseRelationshipKey,
430
- leftResource,
431
- leftId,
432
- rightResource,
433
- rightId,
434
- }) => {
435
- const normalizedLeftId = normalizeId(leftId)
436
- const normalizedRightId = normalizeId(rightId)
437
-
438
- if (normalizedLeftId == null || normalizedRightId == null) {
439
- return null
440
- }
441
-
442
- if (inverseRelationshipKey && inverseRelationshipKey < relationshipKey) {
443
- return {
444
- tenant_id: tenantId,
445
- relationship: inverseRelationshipKey,
446
- inverse_relationship: relationshipKey,
447
- left_resource: rightResource,
448
- left_id: normalizedRightId,
449
- right_resource: leftResource,
450
- right_id: normalizedLeftId,
451
- }
452
- }
453
-
454
- return {
455
- tenant_id: tenantId,
456
- relationship: relationshipKey,
457
- inverse_relationship: inverseRelationshipKey || null,
458
- left_resource: leftResource,
459
- left_id: normalizedLeftId,
460
- right_resource: rightResource,
461
- right_id: normalizedRightId,
462
- }
156
+ await publish(scopeName, descriptor)
157
+ return publishedVersion(scopeName)
158
+ })
159
+ reloads.set(scopeName, run)
160
+ run.finally(() => { if (reloads.get(scopeName) === run) reloads.delete(scopeName) }).catch(() => {})
161
+ return run
463
162
  }
464
163
 
465
- const buildLinkIdentity = (canonicalRow) => ({
466
- tenant_id: canonicalRow.tenant_id,
467
- relationship: canonicalRow.relationship,
468
- left_resource: canonicalRow.left_resource,
469
- left_id: canonicalRow.left_id,
470
- right_resource: canonicalRow.right_resource,
471
- right_id: canonicalRow.right_id,
472
- })
473
-
474
- const findInverseManyToMany = async ({ descriptor, relInfo }) => {
475
- if (!relInfo?.target) return null
476
-
477
- let targetDescriptor
164
+ const notifySchemaChanged = async (scopeName, version) => {
165
+ if (!onSchemaChanged) return
478
166
  try {
479
- targetDescriptor = await getDescriptor(relInfo.target)
167
+ await onSchemaChanged({ tenant: tenantId, resource: scopeName, version })
480
168
  } catch (error) {
481
- return null
482
- }
483
-
484
- const entries = Object.entries(targetDescriptor.manyToMany || {})
485
- for (const [candidateName, candidateInfo] of entries) {
486
- const candidateTarget = candidateInfo.target || descriptor.resource
487
- if (candidateTarget !== descriptor.resource) continue
488
-
489
- if (relInfo.through && candidateInfo.through && relInfo.through !== candidateInfo.through) {
490
- continue
491
- }
492
-
493
- if (
494
- relInfo.foreignKey && relInfo.otherKey &&
495
- candidateInfo.foreignKey && candidateInfo.otherKey
496
- ) {
497
- const foreignMatches = relInfo.foreignKey === candidateInfo.otherKey
498
- const otherMatches = relInfo.otherKey === candidateInfo.foreignKey
499
- if (!foreignMatches || !otherMatches) continue
500
- }
501
-
502
- const relationshipKey = candidateInfo.relationship ||
503
- `${targetDescriptor.tenant}:${targetDescriptor.resource}:${candidateName}`
504
-
505
- return {
506
- descriptor: targetDescriptor,
507
- relName: candidateName,
508
- relInfo: candidateInfo,
509
- relationshipKey,
169
+ // The change is committed; other servers still catch up through schema checks.
170
+ await log.logError('AnyAPI onSchemaChanged failed', error, { tenant: tenantId, scopeName, version })
171
+ }
172
+ }
173
+
174
+ const ensureFresh = (scopeName, maxAge) => {
175
+ if (maxAge > 0 && Date.now() - (lastChecked.get(scopeName) ?? 0) < maxAge) return false
176
+ if (!checks.has(scopeName)) {
177
+ const check = (async () => {
178
+ lastChecked.set(scopeName, Date.now())
179
+ const version = await registry.getSchemaVersion(tenantId, scopeName)
180
+ if (version === null || version <= publishedVersion(scopeName)) return false
181
+ await reload(scopeName)
182
+ return true
183
+ })()
184
+ checks.set(scopeName, check)
185
+ check.finally(() => checks.delete(scopeName)).catch(() => {})
186
+ }
187
+ return checks.get(scopeName)
188
+ }
189
+
190
+ const watchSchema = (scope) => {
191
+ for (const name of REQUEST_METHODS) {
192
+ const method = scope[name]
193
+ if (typeof method !== 'function') continue
194
+ scope[name] = async function (params = {}, context = {}) {
195
+ // A caller transaction may hold uncommitted metadata; never publish from it.
196
+ if (params?.transaction) return method.call(this, params, context)
197
+ await ensureFresh(scope.name, schemaCheckInterval)
198
+ try {
199
+ return await method.call(this, params, context)
200
+ } catch (error) {
201
+ if (!isStaleSchemaRejection(error)) throw error
202
+ let reloaded = false
203
+ try { reloaded = await ensureFresh(scope.name, 0) } catch { throw error }
204
+ if (!reloaded) throw error
205
+ return method.call(this, params, context)
206
+ }
510
207
  }
511
208
  }
512
-
513
- return null
514
- }
515
-
516
- const getManyToManyInfo = async (scopeName, relName) => {
517
- const descriptor = await getDescriptor(scopeName)
518
- const relInfo = descriptor.manyToMany?.[relName]
519
- if (!relInfo) return null
520
- const relationshipKey = relInfo.relationship || `${descriptor.tenant}:${descriptor.resource}:${relName}`
521
- const inverse = await findInverseManyToMany({ descriptor, relInfo })
522
- const inverseRelationshipKey = inverse?.relationshipKey || null
523
- return { descriptor, relInfo, relationshipKey, inverseRelationshipKey }
524
209
  }
525
210
 
526
- const loadLinkRowsForResource = async ({ descriptor, relationshipKey, resourceId, db }) => {
527
- const ownerId = normalizeId(resourceId)
528
- if (ownerId == null) return new Map()
529
-
530
- const rows = await db(LINKS_TABLE)
531
- .where('tenant_id', descriptor.tenant)
532
- .andWhere((builder) => {
533
- builder
534
- .where((q) => {
535
- q.where('relationship', relationshipKey)
536
- .andWhere('left_resource', descriptor.resource)
537
- .andWhere('left_id', ownerId)
538
- })
539
- .orWhere((q) => {
540
- q.where('inverse_relationship', relationshipKey)
541
- .andWhere('right_resource', descriptor.resource)
542
- .andWhere('right_id', ownerId)
543
- })
544
- })
545
- .select('id', 'relationship', 'inverse_relationship', 'left_resource', 'left_id', 'right_resource', 'right_id')
546
-
547
- const map = new Map()
548
-
549
- for (const row of rows) {
550
- let relatedResource
551
- let relatedId
552
- let otherKey = null
553
-
554
- if (row.relationship === relationshipKey && row.left_resource === descriptor.resource) {
555
- relatedResource = row.right_resource
556
- relatedId = normalizeId(row.right_id)
557
- otherKey = row.inverse_relationship || null
558
- } else if (row.inverse_relationship === relationshipKey && row.right_resource === descriptor.resource) {
559
- relatedResource = row.left_resource
560
- relatedId = normalizeId(row.left_id)
561
- otherKey = row.relationship || null
562
- } else {
563
- continue
564
- }
565
-
566
- if (relatedId == null) continue
567
-
568
- const canonical = canonicalizeLinkPair({
569
- tenantId: descriptor.tenant,
570
- relationshipKey,
571
- inverseRelationshipKey: otherKey,
572
- leftResource: descriptor.resource,
573
- leftId: ownerId,
574
- rightResource: relatedResource,
575
- rightId: relatedId,
576
- })
577
-
578
- if (!canonical) continue
579
-
580
- map.set(relatedId, {
581
- rowId: row.id,
582
- relatedResource,
583
- canonical,
584
- otherKey,
585
- })
211
+ const loadResource = async (name, chain = new Set()) => {
212
+ if (typeof name !== 'string' || !name) throw new TypeError('loadResource requires a resource name')
213
+ if (Object.hasOwn(api.resources, name)) return api.resources[name]
214
+ if (chain.has(name)) return null
215
+ if (!loading.has(name)) {
216
+ const load = (async () => {
217
+ const descriptor = await registry.getDescriptor(tenantId, name)
218
+ if (!descriptor) return null
219
+ if (!descriptor.definition?.options) {
220
+ throw new Error(`AnyAPI resource '${name}' has no stored definition; declare it in code or register it again with this version`)
221
+ }
222
+ const { options: declaration } = composeResourceOptions({
223
+ declaration: descriptor.definition.options,
224
+ stored: { callbacks: descriptor.definition.callbacks },
225
+ fromStorage: true
226
+ })
227
+ const { before, after } = relatedResources(declaration)
228
+ const nextChain = new Set([...chain, name])
229
+ for (const target of before) await loadResource(target, nextChain)
230
+ if (Object.hasOwn(api.resources, name)) return api.resources[name]
231
+ storageLoaded.add(name)
232
+ try {
233
+ await api.addResource(name, declaration)
234
+ } catch (error) {
235
+ // A failed registration must not leave a half-published resource behind.
236
+ storageLoaded.delete(name)
237
+ declarations.delete(name)
238
+ delete api.resources[name]
239
+ throw error
240
+ }
241
+ for (const target of after) await loadResource(target, nextChain)
242
+ return api.resources[name]
243
+ })()
244
+ loading.set(name, load)
245
+ load.finally(() => loading.delete(name)).catch(() => {})
586
246
  }
587
-
588
- return map
247
+ return loading.get(name)
589
248
  }
590
249
 
591
- const fetchLinksForParents = async ({ descriptor, relationshipKey, parentIds, db }) => {
592
- const normalizedIds = parentIds
593
- .map((id) => normalizeId(id))
594
- .filter((id) => id !== null)
595
-
596
- if (normalizedIds.length === 0) return []
597
-
598
- const rows = await db(LINKS_TABLE)
599
- .where('tenant_id', descriptor.tenant)
600
- .andWhere((builder) => {
601
- builder
602
- .where((q) => {
603
- q.where('relationship', relationshipKey)
604
- .andWhere('left_resource', descriptor.resource)
605
- .whereIn('left_id', normalizedIds)
606
- })
607
- .orWhere((q) => {
608
- q.where('inverse_relationship', relationshipKey)
609
- .andWhere('right_resource', descriptor.resource)
610
- .whereIn('right_id', normalizedIds)
611
- })
612
- })
613
- .select('relationship', 'inverse_relationship', 'left_resource', 'left_id', 'right_resource', 'right_id')
614
-
615
- const results = []
616
-
617
- for (const row of rows) {
618
- let parentId
619
- let childId
620
- let childType
621
-
622
- if (row.relationship === relationshipKey && row.left_resource === descriptor.resource) {
623
- parentId = normalizeId(row.left_id)
624
- childId = normalizeId(row.right_id)
625
- childType = row.right_resource
626
- } else if (row.inverse_relationship === relationshipKey && row.right_resource === descriptor.resource) {
627
- parentId = normalizeId(row.right_id)
628
- childId = normalizeId(row.left_id)
629
- childType = row.left_resource
630
- } else {
631
- continue
632
- }
633
-
634
- if (parentId == null || childId == null) continue
635
-
636
- results.push({ parentId, childId, childType })
637
- }
638
-
639
- return results
250
+ api.anyapi.loadResource = name => loadResource(name)
251
+ api.anyapi.loadResources = async () => {
252
+ for (const { resource } of await registry.listResources(tenantId)) await loadResource(resource)
253
+ return api.resources
254
+ }
255
+ api.anyapi.handleSchemaChange = async ({ tenant, resource, version } = {}) => {
256
+ if ((tenant !== undefined && tenant !== tenantId) || typeof resource !== 'string' || !Object.hasOwn(api.resources, resource)) return false
257
+ if (version !== undefined && publishedVersion(resource) >= version) return false
258
+ await reload(resource)
259
+ return true
640
260
  }
641
261
 
642
- const applyTargetScopeFilters = async ({
643
- query,
644
- resourceName,
645
- tableName,
646
- context,
647
- filters,
648
- queryPurpose = 'include'
649
- }) => {
650
- const targetScope = api.resources?.[resourceName]
651
- if (!targetScope?.applyQueryFilters) {
652
- return { query }
653
- }
654
-
655
- const storageAdapter = getScopeStorageAdapter(resourceName)
656
- const queryState = await targetScope.applyQueryFilters({
657
- query,
658
- filters,
659
- schemaInfo: targetScope.vars?.schemaInfo,
660
- scopeName: resourceName,
661
- tableName,
662
- db: context.db || context.transaction || api.knex.instance,
663
- isAnyApi: true,
664
- queryPurpose,
665
- storageAdapter
666
- }, {
667
- ...context,
668
- scopeName: resourceName,
669
- schemaInfo: targetScope.vars?.schemaInfo,
670
- storageAdapter
671
- })
672
-
262
+ const buildSearchMembershipQuery = ({ sourceScopeName, relationshipName, targetScopeName, foreignKey, otherKey, db }) => {
263
+ const info = linkStore.getManyToManyInfo(sourceScopeName, relationshipName)
264
+ if (!info) throw new RestApiResourceError(`Relationship '${sourceScopeName}.${relationshipName}' not found`, { subtype: 'related_type_not_found' })
265
+ const links = linkStore.buildRelatedLinkQuery({ ...info, db, allParents: true })
266
+ const pivotAdapter = getScopeStorageAdapter(targetScopeName)
673
267
  return {
674
- query: unwrapQueryBuilderState(queryState, query)
268
+ query: db.select({
269
+ [pivotAdapter.translateColumn(foreignKey)]: 'parentId',
270
+ [pivotAdapter.translateColumn(otherKey)]: 'childId'
271
+ }).from(links.as('search_membership'))
675
272
  }
676
273
  }
677
274
 
678
- const filterVisibleIdentifiers = async ({ identifiers, context }) => {
679
- if (!identifiers || identifiers.length === 0) return []
680
-
681
- const db = context.db || context.transaction || api.knex.instance
682
- const identifiersByType = new Map()
683
-
684
- for (const identifier of identifiers) {
685
- if (!identifier?.type || identifier.id === undefined || identifier.id === null) continue
686
- if (!identifiersByType.has(identifier.type)) {
687
- identifiersByType.set(identifier.type, new Set())
688
- }
689
- identifiersByType.get(identifier.type).add(normalizeId(identifier.id))
690
- }
691
-
692
- const visibleKeys = new Set()
693
-
694
- for (const [resourceName, idSet] of identifiersByType.entries()) {
695
- const descriptor = await getDescriptor(resourceName)
696
- const idColumn = getLogicalResourceIdColumn(descriptor)
697
- let query = db(descriptor.canonical.tableName)
698
- .where(descriptor.canonical.tenantColumn, descriptor.tenant)
699
- .where(descriptor.canonical.resourceColumn, descriptor.resource)
700
- .whereIn(idColumn, [...idSet])
701
- .select(idColumn)
702
-
703
- const scopedQueryState = await applyTargetScopeFilters({
704
- query,
705
- resourceName,
706
- tableName: descriptor.canonical.tableName,
707
- context,
708
- queryPurpose: 'relationship-identifiers'
709
- })
710
- query = scopedQueryState.query
711
-
712
- const rows = await query
713
- for (const row of rows) {
714
- visibleKeys.add(`${resourceName}:${String(getLogicalResourceId(row, descriptor))}`)
715
- }
716
- }
717
-
718
- return identifiers.filter((identifier) => (
719
- visibleKeys.has(`${identifier.type}:${String(identifier.id)}`)
720
- ))
721
- }
275
+ const queryHookDependencies = { log, scopes: api.resources, knex, getStorageAdapter: getScopeStorageAdapter, buildSearchMembershipQuery }
722
276
 
723
- const ensureTargetsExist = async ({ relInfo, relData, context }) => {
724
- const relArray = Array.isArray(relData) ? relData : []
725
- if (relArray.length === 0) return
726
- const targetScope = api.resources[relInfo.target]
727
- if (!targetScope) {
728
- throw new Error(`Target resource '${relInfo.target}' not found`)
729
- }
730
- for (const identifier of relArray) {
731
- if (identifier?.id === undefined || identifier.id === null) {
732
- throw new Error('Relationship data requires resource identifier with id')
733
- }
734
- if (identifier.type && identifier.type !== relInfo.target) {
735
- throw new Error(`Relationship expects type '${relInfo.target}' but received '${identifier.type}'`)
736
- }
737
- await targetScope.get({
738
- id: identifier.id,
739
- transaction: context.transaction,
740
- simplified: false
741
- }, { ...context })
742
- }
277
+ const applyBuiltInAnyApiQueryFilters = async (context) => {
278
+ const hookParams = { context }
279
+ await polymorphicFiltersHook(hookParams, queryHookDependencies)
280
+ await crossTableFiltersHook(hookParams, queryHookDependencies)
281
+ await basicFiltersHook(hookParams, queryHookDependencies)
743
282
  }
744
283
 
745
- const attachLinks = async ({ descriptor, relInfo, relationshipKey, inverseRelationshipKey, leftId, relData, db }) => {
746
- const relArray = Array.isArray(relData) ? relData : []
747
- if (relArray.length === 0) return
748
-
749
- for (const identifier of relArray) {
750
- const rightId = normalizeId(identifier.id)
751
- if (rightId == null) continue
752
-
753
- const canonical = canonicalizeLinkPair({
754
- tenantId: descriptor.tenant,
755
- relationshipKey,
756
- inverseRelationshipKey,
757
- leftResource: descriptor.resource,
758
- leftId,
759
- rightResource: relInfo.target,
760
- rightId,
761
- })
762
-
763
- if (!canonical) continue
284
+ Object.assign(helpers, createCanonicalDataHelpers({
285
+ api,
286
+ knex,
287
+ getScopeStorageAdapter,
288
+ getDescriptor,
289
+ linkStore,
290
+ buildIncludes,
291
+ attachReverseRelationships,
292
+ attachManyToManyRelationships,
293
+ applyBuiltInAnyApiQueryFilters
294
+ }))
764
295
 
765
- const identity = buildLinkIdentity(canonical)
766
-
767
- const existing = await db(LINKS_TABLE)
768
- .where(identity)
769
- .first()
770
-
771
- if (existing) {
772
- if (!existing.inverse_relationship && canonical.inverse_relationship) {
773
- await db(LINKS_TABLE)
774
- .where({ id: existing.id })
775
- .update({
776
- inverse_relationship: canonical.inverse_relationship,
777
- updated_at: db.fn.now(),
778
- })
779
- }
780
- continue
781
- }
782
-
783
- await db(LINKS_TABLE).insert({
784
- ...canonical,
785
- payload: null,
786
- created_at: db.fn.now(),
787
- updated_at: db.fn.now(),
788
- })
296
+ addHook('resource:added', 'anyapi-register-resource', {}, async ({ context }) => {
297
+ const { scopeName, scopeOptions = {} } = context
298
+ const scope = api.resources[scopeName]
299
+ const declaration = snapshotResourceConfiguration(scopeOptions)
300
+ declarations.set(scopeName, declaration)
301
+ compiledSignatures.set(scopeName, optionsSignature(declaration))
302
+ let descriptor
303
+ let changed = false
304
+ if (storageLoaded.has(scopeName)) {
305
+ descriptor = await registry.getDescriptor(tenantId, scopeName)
306
+ if (!descriptor) throw new Error(`Descriptor not found for resource '${scopeName}'`)
307
+ } else {
308
+ const { schemaStructure, computed, schemaRelationships, idProperty } = scope.vars.schemaInfo
309
+ ;({ descriptor, changed } = await registry.register({
310
+ tenant: tenantId,
311
+ resource: scopeName,
312
+ schema: { ...schemaStructure, ...computed },
313
+ relationships: schemaRelationships,
314
+ canonicalFieldMap: scopeOptions.canonicalFieldsMap || null,
315
+ idProperty,
316
+ declaration
317
+ }))
789
318
  }
790
- }
791
-
792
- api.anyapi.links = {
793
- attachMany: async ({ context, scopeName, relName, relDef, relData }) => {
794
- const info = await getManyToManyInfo(scopeName, relName)
795
- if (!info) {
796
- throw new Error(`Many-to-many relationship '${relName}' not found on '${scopeName}'`)
797
- }
798
- const db = context.transaction || context.db || api.knex.instance
799
- const leftId = normalizeId(context.id)
800
- await ensureTargetsExist({ relInfo: info.relInfo, relData, context })
801
- await attachLinks({
802
- descriptor: info.descriptor,
803
- relInfo: info.relInfo,
804
- relationshipKey: info.relationshipKey,
805
- inverseRelationshipKey: info.inverseRelationshipKey,
806
- leftId,
807
- relData,
808
- db,
809
- })
810
- },
811
- syncMany: async ({ context, scopeName, relName, relDef, relData, isUpdate }) => {
812
- const info = await getManyToManyInfo(scopeName, relName)
813
- if (!info) {
814
- throw new Error(`Many-to-many relationship '${relName}' not found on '${scopeName}'`)
815
- }
816
- const db = context.transaction || context.db || api.knex.instance
817
- const leftId = normalizeId(context.id)
818
- await ensureTargetsExist({ relInfo: info.relInfo, relData, context })
819
- if (isUpdate) {
820
- await syncLinks({
821
- descriptor: info.descriptor,
822
- relInfo: info.relInfo,
823
- relationshipKey: info.relationshipKey,
824
- inverseRelationshipKey: info.inverseRelationshipKey,
825
- leftId,
826
- relData,
827
- db,
828
- })
829
- } else {
830
- await attachLinks({
831
- descriptor: info.descriptor,
832
- relInfo: info.relInfo,
833
- relationshipKey: info.relationshipKey,
834
- inverseRelationshipKey: info.inverseRelationshipKey,
835
- leftId,
836
- relData,
837
- db,
838
- })
839
- }
840
- },
841
- removeMany: async ({ context, scopeName, relName, relData }) => {
842
- const info = await getManyToManyInfo(scopeName, relName)
843
- if (!info) {
844
- throw new Error(`Many-to-many relationship '${relName}' not found on '${scopeName}'`)
845
- }
846
- const db = context.transaction || context.db || api.knex.instance
847
- const leftId = normalizeId(context.id)
848
- await removeLinks({
849
- descriptor: info.descriptor,
850
- relInfo: info.relInfo,
851
- relationshipKey: info.relationshipKey,
852
- inverseRelationshipKey: info.inverseRelationshipKey,
853
- leftId,
854
- relData,
855
- db,
856
- })
857
- },
858
- listMany: async ({ context, scopeName, relName }) => {
859
- const info = await getManyToManyInfo(scopeName, relName)
860
- if (!info) {
861
- throw new Error(`Many-to-many relationship '${relName}' not found on '${scopeName}'`)
862
- }
863
- const db = context.transaction || context.db || api.knex.instance
864
- const leftId = normalizeId(context.id)
865
- const identifiers = await listLinks({
866
- descriptor: info.descriptor,
867
- relInfo: info.relInfo,
868
- relationshipKey: info.relationshipKey,
869
- inverseRelationshipKey: info.inverseRelationshipKey,
870
- leftId,
871
- db,
872
- })
873
- return filterVisibleIdentifiers({
874
- identifiers,
875
- context
876
- })
877
- },
878
- fetchManyToManyRows: async ({ scopeName, relName, parentIds, context }) => {
879
- const info = await getManyToManyInfo(scopeName, relName)
880
- if (!info) return []
881
- const db = context.db || context.transaction || api.knex.instance
882
- return fetchLinksForParents({
883
- descriptor: info.descriptor,
884
- relationshipKey: info.relationshipKey,
885
- parentIds,
886
- db,
887
- })
888
- },
889
- }
890
-
891
- const syncLinks = async ({ descriptor, relInfo, relationshipKey, inverseRelationshipKey, leftId, relData, db }) => {
892
- const relArray = Array.isArray(relData) ? relData : []
893
- const existingMap = await loadLinkRowsForResource({
894
- descriptor,
895
- relationshipKey,
896
- resourceId: leftId,
897
- db,
898
- })
899
-
900
- const desired = new Set(
901
- relArray
902
- .map((identifier) => normalizeId(identifier.id))
903
- .filter((id) => id !== null)
904
- )
319
+ await publish(scopeName, descriptor)
320
+ if (schemaCheckInterval !== undefined) watchSchema(scope)
321
+ if (changed) await notifySchemaChanged(scopeName, descriptor.schemaVersion)
322
+ })
905
323
 
906
- const existingIds = new Set(existingMap.keys())
324
+ addResourceMethod('createKnexTable', async ({ scopeName }) => {
325
+ if (ensureSchema) await ensureAnyApiSchema(knex, { slots })
326
+ const descriptor = await registry.getDescriptor(tenantId, scopeName)
327
+ if (!descriptor) throw new Error(`Descriptor not found for resource '${scopeName}'`)
328
+ await publish(scopeName, descriptor)
329
+ })
907
330
 
908
- const toAdd = [...desired].filter((id) => !existingIds.has(id))
909
- const toRemove = [...existingIds].filter((id) => !desired.has(id))
331
+ addResourceMethod('reloadSchema', async ({ scopeName }) => reload(scopeName))
910
332
 
911
- if (toAdd.length > 0) {
912
- const data = toAdd.map((id) => ({ id }))
913
- await attachLinks({
914
- descriptor,
915
- relInfo,
916
- relationshipKey,
917
- inverseRelationshipKey,
918
- leftId,
919
- relData: data,
920
- db,
921
- })
333
+ addResourceMethod('addKnexFields', async ({ scopeName, params }) => {
334
+ if (!params?.fields || typeof params.fields !== 'object' || Array.isArray(params.fields)) {
335
+ throw new Error('fields parameter is required for addKnexFields')
922
336
  }
923
-
924
- if (toRemove.length > 0) {
925
- for (const id of toRemove) {
926
- const entry = existingMap.get(id)
927
- if (!entry?.canonical) continue
928
- await db(LINKS_TABLE)
929
- .where(buildLinkIdentity(entry.canonical))
930
- .delete()
931
- }
337
+ assertFieldNameMap(params.fields, `added fields in '${scopeName}'`)
338
+ assertFieldNameMap(params.searchSchema, `added search schema in '${scopeName}'`)
339
+ assertFieldNameMap(params.canonicalFieldsMap, `added canonical fields in '${scopeName}'`)
340
+ const scope = api.resources[scopeName]
341
+ const unknown = Object.keys(params.canonicalFieldsMap || {}).filter(field => !Object.hasOwn(params.fields, field))
342
+ if (unknown.length) {
343
+ throw new Error(`canonicalFieldsMap contains unknown fields for addKnexFields: ${unknown}`)
932
344
  }
933
- }
934
-
935
- const removeLinks = async ({ descriptor, relInfo, relationshipKey, inverseRelationshipKey, leftId, relData, db }) => {
936
- const relArray = Array.isArray(relData) ? relData : []
937
- if (relArray.length === 0) return
938
-
939
- const existingMap = await loadLinkRowsForResource({
940
- descriptor,
941
- relationshipKey,
942
- resourceId: leftId,
943
- db,
345
+ const fields = snapshotResourceConfiguration(params.fields)
346
+ const searchSchema = snapshotResourceConfiguration(params.searchSchema || {})
347
+ const current = composeOptions(scopeName, scope.vars.schemaInfo.descriptor).options
348
+ const candidate = snapshotResourceConfiguration({
349
+ ...current,
350
+ schema: { ...current.schema, ...fields },
351
+ searchSchema: { ...current.searchSchema, ...searchSchema }
944
352
  })
945
-
946
- for (const identifier of relArray) {
947
- const relatedId = normalizeId(identifier.id)
948
- if (relatedId == null) continue
949
-
950
- const entry = existingMap.get(relatedId)
951
- if (entry?.canonical) {
952
- await db(LINKS_TABLE)
953
- .where(buildLinkIdentity(entry.canonical))
954
- .delete()
955
- continue
956
- }
957
-
958
- const canonical = canonicalizeLinkPair({
959
- tenantId: descriptor.tenant,
960
- relationshipKey,
961
- inverseRelationshipKey,
962
- leftResource: descriptor.resource,
963
- leftId,
964
- rightResource: relInfo.target,
965
- rightId: relatedId,
966
- })
967
-
968
- if (!canonical) continue
969
-
970
- await db(LINKS_TABLE)
971
- .where(buildLinkIdentity(canonical))
972
- .delete()
973
- }
974
- }
975
-
976
- const listLinks = async ({ descriptor, relInfo, relationshipKey, leftId, db }) => {
977
- const existingMap = await loadLinkRowsForResource({
978
- descriptor,
979
- relationshipKey,
980
- resourceId: leftId,
981
- db,
353
+ const schemaInfo = await compileOptions(scopeName, candidate)
354
+ await api.transaction(transaction => registry.allocateFields({
355
+ tenant: tenantId,
356
+ resource: scopeName,
357
+ fields: Object.fromEntries(Object.keys(fields).map(fieldName => [fieldName, {
358
+ definition: schemaInfo.schemaStructure[fieldName] || schemaInfo.computed[fieldName],
359
+ declaration: fields[fieldName],
360
+ canonicalField: params.canonicalFieldsMap?.[fieldName]
361
+ }])),
362
+ searchSchema
363
+ }, { transaction }))
364
+ registry.invalidateDescriptor(tenantId, scopeName)
365
+ const local = runtimeDeclarations.get(scopeName) || {}
366
+ runtimeDeclarations.set(scopeName, {
367
+ schema: { ...local.schema, ...fields },
368
+ searchSchema: { ...local.searchSchema, ...searchSchema }
982
369
  })
983
-
984
- return [...existingMap.entries()].map(([relatedId, entry]) => ({
985
- type: entry.relatedResource || relInfo.target,
986
- id: relatedId,
987
- }))
988
- }
989
-
990
- helpers.dataExists = async ({ scopeName, context }) => {
991
- const storageAdapter = getScopeStorageAdapter(scopeName)
992
- if (storageAdapter) {
993
- context.storageAdapter = storageAdapter
994
- }
995
- const descriptor = await getDescriptor(scopeName)
996
- const { canonical } = descriptor
997
- const id = context.id
998
- const logicalIdColumn = getLogicalResourceIdColumn(descriptor)
999
-
1000
- const row = await context.db(canonical.tableName)
1001
- .select('id')
1002
- .where(canonical.tenantColumn, descriptor.tenant)
1003
- .where(canonical.resourceColumn, descriptor.resource)
1004
- .where(logicalIdColumn, id)
1005
- .first()
1006
-
1007
- return !!row
1008
- }
1009
-
1010
- helpers.dataPost = async ({ scopeName, context }) => {
1011
- const storageAdapter = getScopeStorageAdapter(scopeName)
1012
- if (storageAdapter) {
1013
- context.storageAdapter = storageAdapter
1014
- }
1015
- const descriptor = await getDescriptor(scopeName)
1016
- const { canonical } = descriptor
1017
- const logicalIdColumn = getLogicalResourceIdColumn(descriptor)
1018
- const explicitId = context.inputRecord?.data?.id
1019
- const attributes = context.inputRecord?.data?.attributes || {}
1020
- const row = storageAdapter?.toStorageRow
1021
- ? storageAdapter.toStorageRow(attributes)
1022
- : translateCanonicalAttributesForStorage(attributes, descriptor)
1023
-
1024
- if (explicitId !== undefined && explicitId !== null) {
1025
- row[logicalIdColumn] = explicitId
1026
- }
1027
-
1028
- row[canonical.tenantColumn] = descriptor.tenant
1029
- row[canonical.resourceColumn] = descriptor.resource
1030
-
1031
- const result = await context.db(canonical.tableName)
1032
- .insert(row)
1033
- .returning('id')
1034
-
1035
- const inserted = Array.isArray(result) ? result[0] : result
1036
- const insertedRowId = inserted && typeof inserted === 'object' && 'id' in inserted
1037
- ? inserted.id
1038
- : inserted
1039
-
1040
- if (explicitId !== undefined && explicitId !== null) {
1041
- return explicitId
1042
- }
1043
-
1044
- if (insertedRowId === undefined || insertedRowId === null) {
1045
- return insertedRowId
1046
- }
1047
-
1048
- const generatedLogicalId = String(insertedRowId)
1049
-
1050
- await context.db(canonical.tableName)
1051
- .where({ id: insertedRowId })
1052
- .update({ [logicalIdColumn]: generatedLogicalId })
1053
-
1054
- return generatedLogicalId
1055
- }
1056
-
1057
- helpers.dataPut = async ({ scopeName, context }) => {
1058
- const storageAdapter = getScopeStorageAdapter(scopeName)
1059
- if (storageAdapter) {
1060
- context.storageAdapter = storageAdapter
1061
- }
1062
- const descriptor = await getDescriptor(scopeName)
1063
- const { canonical } = descriptor
1064
- const id = context.id
1065
- const logicalIdColumn = getLogicalResourceIdColumn(descriptor)
1066
- const attributes = context.inputRecord?.data?.attributes || {}
1067
- const row = storageAdapter?.toStorageRow
1068
- ? storageAdapter.toStorageRow(attributes)
1069
- : translateCanonicalAttributesForStorage(attributes, descriptor)
1070
-
1071
- row[canonical.resourceColumn] = descriptor.resource
1072
- row[canonical.tenantColumn] = descriptor.tenant
1073
-
1074
- const writeRow = Object.fromEntries(
1075
- Object.entries(row).filter(([, value]) => value !== undefined)
1076
- )
1077
-
1078
- if (context.isCreate) {
1079
- await context.db(canonical.tableName).insert({
1080
- ...writeRow,
1081
- [logicalIdColumn]: id,
1082
- })
1083
- return 1
1084
- }
1085
-
1086
- if (Object.keys(writeRow).length === 0) {
1087
- return 0
1088
- }
1089
-
1090
- const result = await context.db(canonical.tableName)
1091
- .where(logicalIdColumn, id)
1092
- .where(canonical.resourceColumn, descriptor.resource)
1093
- .where(canonical.tenantColumn, descriptor.tenant)
1094
- .update(writeRow)
1095
-
1096
- return result
1097
- }
1098
-
1099
- helpers.dataPatch = async ({ scopeName, context }) => {
1100
- const storageAdapter = getScopeStorageAdapter(scopeName)
1101
- if (storageAdapter) {
1102
- context.storageAdapter = storageAdapter
1103
- }
1104
- const descriptor = await getDescriptor(scopeName)
1105
- const { canonical } = descriptor
1106
- const id = context.id
1107
- const logicalIdColumn = getLogicalResourceIdColumn(descriptor)
1108
- const attributes = context.inputRecord?.data?.attributes || {}
1109
- const row = storageAdapter?.toStorageRow
1110
- ? storageAdapter.toStorageRow(attributes)
1111
- : translateCanonicalAttributesForStorage(attributes, descriptor)
1112
-
1113
- const updateRow = Object.fromEntries(
1114
- Object.entries(row).filter(([, value]) => value !== undefined)
1115
- )
1116
-
1117
- if (Object.keys(updateRow).length === 0) {
1118
- return 0
1119
- }
1120
-
1121
- const result = await context.db(canonical.tableName)
1122
- .where(logicalIdColumn, id)
1123
- .where(canonical.resourceColumn, descriptor.resource)
1124
- .where(canonical.tenantColumn, descriptor.tenant)
1125
- .update(updateRow)
1126
-
1127
- return result
1128
- }
1129
-
1130
- helpers.dataDelete = async ({ scopeName, context }) => {
1131
- const storageAdapter = getScopeStorageAdapter(scopeName)
1132
- if (storageAdapter) {
1133
- context.storageAdapter = storageAdapter
1134
- }
1135
- const descriptor = await getDescriptor(scopeName)
1136
- const { canonical } = descriptor
1137
- const id = context.id
1138
- const logicalIdColumn = getLogicalResourceIdColumn(descriptor)
1139
-
1140
- const result = await context.db(canonical.tableName)
1141
- .where(logicalIdColumn, id)
1142
- .where(canonical.resourceColumn, descriptor.resource)
1143
- .where(canonical.tenantColumn, descriptor.tenant)
1144
- .delete()
1145
-
1146
- return result
1147
- }
1148
-
1149
- helpers.dataGetMinimal = async ({
1150
- scopeName,
1151
- context,
1152
- runHooks,
1153
- applyQueryFilters,
1154
- filters = context.queryParams?.filters,
1155
- queryPurpose = 'single'
1156
- }) => {
1157
- const storageAdapter = getScopeStorageAdapter(scopeName)
1158
- if (storageAdapter) {
1159
- context.storageAdapter = storageAdapter
1160
- }
1161
- const descriptor = await getDescriptor(scopeName)
1162
- const { canonical } = descriptor
1163
- const id = context.id
1164
- const logicalIdColumn = getLogicalResourceIdColumn(descriptor)
1165
- const scope = api.resources?.[scopeName]
1166
-
1167
- let query = context.db(canonical.tableName)
1168
- .where(logicalIdColumn, id)
1169
- .where(canonical.resourceColumn, descriptor.resource)
1170
- .where(canonical.tenantColumn, descriptor.tenant)
1171
-
1172
- if (typeof applyQueryFilters === 'function') {
1173
- const scopedQueryState = await applyQueryFilters({
1174
- query,
1175
- filters,
1176
- tableName: canonical.tableName,
1177
- db: context.db,
1178
- scopeName,
1179
- queryPurpose,
1180
- storageAdapter,
1181
- isAnyApi: true
1182
- })
1183
- query = unwrapQueryBuilderState(scopedQueryState, query)
1184
- } else if (runHooks) {
1185
- const previousKnexQuery = context.knexQuery
1186
- context.knexQuery = {
1187
- query,
1188
- filters,
1189
- schemaInfo: context.schemaInfo,
1190
- scopeName,
1191
- tableName: canonical.tableName,
1192
- db: context.db,
1193
- queryPurpose,
1194
- isAnyApi: true,
1195
- adapter: storageAdapter,
1196
- storageAdapter,
1197
- }
1198
-
1199
- try {
1200
- await applyBuiltInAnyApiQueryFilters(context)
1201
- await runHooks('knexQueryFiltering')
1202
- query = context.knexQuery?.query || query
1203
- } finally {
1204
- if (previousKnexQuery === undefined) {
1205
- delete context.knexQuery
1206
- } else {
1207
- context.knexQuery = previousKnexQuery
1208
- }
1209
- }
1210
- }
1211
-
1212
- const row = await query.first()
1213
-
1214
- if (!row) return null
1215
- const translated = translateCanonicalRecordFromStorage(
1216
- row,
1217
- descriptor,
1218
- { allowedExtraFields: getAllowedQueryFieldNames(scopeName) }
1219
- )
1220
- const resourceId = getLogicalResourceId(row, descriptor)
1221
- const minimal = {
1222
- type: descriptor.resource,
1223
- id: resourceId,
1224
- attributes: translated.attributes,
1225
- relationships: translated.relationships,
1226
- }
1227
- if (scope) {
1228
- decorateResourceLinks({ resource: minimal, scope, scopeName, context })
1229
- }
1230
- return minimal
1231
- }
1232
-
1233
- const parseIncludeTree = (includeParam) => {
1234
- const tree = {}
1235
- if (!includeParam) return tree
1236
-
1237
- const pushPath = (raw) => {
1238
- if (!raw) return
1239
- const segments = raw.split('.').map((segment) => segment.trim()).filter(Boolean)
1240
- if (segments.length === 0) return
1241
- let node = tree
1242
- for (const segment of segments) {
1243
- node[segment] = node[segment] || {}
1244
- node = node[segment]
1245
- }
1246
- }
1247
-
1248
- if (Array.isArray(includeParam)) {
1249
- for (const entry of includeParam) {
1250
- String(entry).split(',').forEach((part) => pushPath(part.trim()))
1251
- }
1252
- } else {
1253
- String(includeParam).split(',').forEach((part) => pushPath(part.trim()))
1254
- }
1255
-
1256
- return tree
1257
- }
1258
-
1259
- const applyIncludeFieldSelection = async ({ query, resourceName, tableName, context }) => {
1260
- const targetScope = api.resources?.[resourceName]
1261
- if (!targetScope) {
1262
- return { query, fieldSelectionInfo: null }
1263
- }
1264
-
1265
- const storageAdapter = getScopeStorageAdapter(resourceName)
1266
- const fieldSelectionInfo = await buildFieldSelection(targetScope, {
1267
- context: {
1268
- ...context,
1269
- scopeName: resourceName,
1270
- schemaInfo: targetScope.vars.schemaInfo,
1271
- storageAdapter,
1272
- }
1273
- })
1274
-
1275
- const selectionState = await applyFieldSelectionToQuery({
1276
- query,
1277
- scope: targetScope,
1278
- fieldSelectionInfo,
1279
- tableName,
1280
- useTablePrefix: false,
1281
- storageAdapter,
1282
- db: context.db || context.transaction || api.knex.instance,
1283
- context,
1284
- scopeName: resourceName
1285
- })
1286
-
1287
- return {
1288
- query: selectionState.query,
1289
- fieldSelectionInfo
1290
- }
1291
- }
1292
-
1293
- const buildIncludedResource = ({ row, descriptor, context, fieldSelectionInfo = null }) => {
1294
- const translated = translateCanonicalRecordFromStorage(
1295
- row,
1296
- descriptor,
1297
- { allowedExtraFields: getAllowedQueryFieldNames(descriptor.resource) }
1298
- )
1299
- const resourceId = getLogicalResourceId(row, descriptor)
1300
- const includeResource = {
1301
- type: descriptor.resource,
1302
- id: resourceId,
1303
- attributes: translated.attributes,
1304
- }
1305
-
1306
- if (translated.relationships && Object.keys(translated.relationships).length > 0) {
1307
- includeResource.relationships = translated.relationships
1308
- }
1309
-
1310
- if (fieldSelectionInfo?.computedDependencies?.length) {
1311
- includeResource[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
1312
- }
1313
-
1314
- const targetScope = api.resources?.[descriptor.resource]
1315
- if (targetScope) {
1316
- decorateResourceLinks({
1317
- resource: includeResource,
1318
- scope: targetScope,
1319
- scopeName: descriptor.resource,
1320
- context,
1321
- })
1322
- }
1323
-
1324
- return includeResource
1325
- }
1326
-
1327
- const collectIncludes = async ({ descriptor, resources, includeTree, context, includes, seen }) => {
1328
- const entries = Object.entries(includeTree || {})
1329
- if (entries.length === 0 || !resources || resources.length === 0) return
1330
-
1331
- const db = context.db || context.transaction || api.knex.instance
1332
-
1333
- for (const [relName, childTree] of entries) {
1334
- const childKeys = Object.keys(childTree || {})
1335
-
1336
- const belongsToInfo = descriptor.belongsTo?.[relName]
1337
- if (belongsToInfo) {
1338
- const ids = [...new Set(resources
1339
- .map((resource) => resource.relationships?.[relName]?.data?.id)
1340
- .filter((id) => id !== undefined && id !== null))]
1341
-
1342
- if (ids.length === 0) continue
1343
-
1344
- const targetDescriptor = await registry.getDescriptor(tenantId, belongsToInfo.target)
1345
- if (!targetDescriptor) continue
1346
-
1347
- let query = db(targetDescriptor.canonical.tableName)
1348
- .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
1349
- .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
1350
- .whereIn(getLogicalResourceIdColumn(targetDescriptor), ids)
1351
- const selectionState = await applyIncludeFieldSelection({
1352
- query,
1353
- resourceName: targetDescriptor.resource,
1354
- tableName: targetDescriptor.canonical.tableName,
1355
- context
1356
- })
1357
- query = selectionState.query
1358
- const scopedQueryState = await applyTargetScopeFilters({
1359
- query,
1360
- resourceName: targetDescriptor.resource,
1361
- tableName: targetDescriptor.canonical.tableName,
1362
- context
1363
- })
1364
- query = scopedQueryState.query
1365
- const rows = await query
1366
-
1367
- const newResources = []
1368
- for (const row of rows) {
1369
- const includeId = getLogicalResourceId(row, targetDescriptor)
1370
- const includeKey = `${targetDescriptor.resource}:${includeId}`
1371
- if (seen.has(includeKey)) continue
1372
- seen.add(includeKey)
1373
- const includeResource = buildIncludedResource({
1374
- row,
1375
- descriptor: targetDescriptor,
1376
- context,
1377
- fieldSelectionInfo: selectionState.fieldSelectionInfo
1378
- })
1379
- includes.push(includeResource)
1380
- newResources.push(includeResource)
1381
- }
1382
-
1383
- if (childKeys.length > 0 && newResources.length > 0) {
1384
- await attachHasManyRelationships({ resources: newResources, descriptor: targetDescriptor, context })
1385
- await attachManyToManyRelationships({ resources: newResources, descriptor: targetDescriptor, context })
1386
- await collectIncludes({
1387
- descriptor: targetDescriptor,
1388
- resources: newResources,
1389
- includeTree: childTree,
1390
- context,
1391
- includes,
1392
- seen,
1393
- })
1394
- }
1395
- continue
1396
- }
1397
-
1398
- const polymorphicInfo = descriptor.polymorphicBelongsTo?.[relName]
1399
- if (polymorphicInfo) {
1400
- const typeToIds = new Map()
1401
- for (const resource of resources) {
1402
- const relData = resource.relationships?.[relName]?.data
1403
- if (!relData?.type || relData?.id == null) continue
1404
- const targetType = String(relData.type)
1405
- const targetId = normalizeId(relData.id)
1406
- if (targetId == null) continue
1407
- if (!typeToIds.has(targetType)) {
1408
- typeToIds.set(targetType, new Set())
1409
- }
1410
- typeToIds.get(targetType).add(targetId)
1411
- }
1412
-
1413
- const newResources = []
1414
- for (const [targetType, idSet] of typeToIds.entries()) {
1415
- if (idSet.size === 0) continue
1416
- let targetDescriptor
1417
- try {
1418
- targetDescriptor = await getDescriptor(targetType)
1419
- } catch (error) {
1420
- continue
1421
- }
1422
-
1423
- let query = db(targetDescriptor.canonical.tableName)
1424
- .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
1425
- .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
1426
- .whereIn(getLogicalResourceIdColumn(targetDescriptor), [...idSet])
1427
- const selectionState = await applyIncludeFieldSelection({
1428
- query,
1429
- resourceName: targetDescriptor.resource,
1430
- tableName: targetDescriptor.canonical.tableName,
1431
- context
1432
- })
1433
- query = selectionState.query
1434
- const scopedQueryState = await applyTargetScopeFilters({
1435
- query,
1436
- resourceName: targetDescriptor.resource,
1437
- tableName: targetDescriptor.canonical.tableName,
1438
- context
1439
- })
1440
- query = scopedQueryState.query
1441
- const rows = await query
1442
-
1443
- for (const row of rows) {
1444
- const includeId = getLogicalResourceId(row, targetDescriptor)
1445
- const includeKey = `${targetDescriptor.resource}:${includeId}`
1446
- if (seen.has(includeKey)) continue
1447
- seen.add(includeKey)
1448
- const includeResource = buildIncludedResource({
1449
- row,
1450
- descriptor: targetDescriptor,
1451
- context,
1452
- fieldSelectionInfo: selectionState.fieldSelectionInfo
1453
- })
1454
- includes.push(includeResource)
1455
- newResources.push(includeResource)
1456
- }
1457
- }
1458
-
1459
- if (childKeys.length > 0 && newResources.length > 0) {
1460
- const resourcesByType = new Map()
1461
- for (const includeResource of newResources) {
1462
- if (!resourcesByType.has(includeResource.type)) {
1463
- resourcesByType.set(includeResource.type, [])
1464
- }
1465
- resourcesByType.get(includeResource.type).push(includeResource)
1466
- }
1467
-
1468
- for (const [targetType, groupedResources] of resourcesByType.entries()) {
1469
- let targetDescriptor
1470
- try {
1471
- targetDescriptor = await getDescriptor(targetType)
1472
- } catch (error) {
1473
- continue
1474
- }
1475
-
1476
- await attachHasManyRelationships({ resources: groupedResources, descriptor: targetDescriptor, context })
1477
- await attachManyToManyRelationships({ resources: groupedResources, descriptor: targetDescriptor, context })
1478
- await collectIncludes({
1479
- descriptor: targetDescriptor,
1480
- resources: groupedResources,
1481
- includeTree: childTree,
1482
- context,
1483
- includes,
1484
- seen,
1485
- })
1486
- }
1487
- }
1488
-
1489
- continue
1490
- }
1491
-
1492
- const hasManyInfo = descriptor.relationships?.[relName]
1493
- if (hasManyInfo?.type === 'hasMany' && hasManyInfo.target && (hasManyInfo.foreignKey || hasManyInfo.via)) {
1494
- let targetDescriptor
1495
- try {
1496
- targetDescriptor = await getDescriptor(hasManyInfo.target)
1497
- } catch (error) {
1498
- continue
1499
- }
1500
-
1501
- const parentIds = resources
1502
- .map((resource) => normalizeId(resource.id))
1503
- .filter((id) => id !== null)
1504
-
1505
- if (parentIds.length === 0) continue
1506
-
1507
- const queryIds = Array.from(new Set([
1508
- ...parentIds,
1509
- ...parentIds
1510
- .map((id) => {
1511
- const numeric = Number(id)
1512
- return Number.isFinite(numeric) ? numeric : null
1513
- })
1514
- .filter((value) => value !== null),
1515
- ]))
1516
-
1517
- let rows = []
1518
- let groupingColumn = null
1519
- let fieldSelectionInfo = null
1520
-
1521
- if (hasManyInfo.foreignKey) {
1522
- const foreignField = targetDescriptor.fields?.[hasManyInfo.foreignKey]
1523
- if (!foreignField?.slot) {
1524
- continue
1525
- }
1526
- groupingColumn = foreignField.slot
1527
- let query = db(targetDescriptor.canonical.tableName)
1528
- .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
1529
- .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
1530
- .whereIn(groupingColumn, parentIds)
1531
- const selectionState = await applyIncludeFieldSelection({
1532
- query,
1533
- resourceName: targetDescriptor.resource,
1534
- tableName: targetDescriptor.canonical.tableName,
1535
- context
1536
- })
1537
- query = selectionState.query
1538
- const scopedQueryState = await applyTargetScopeFilters({
1539
- query,
1540
- resourceName: targetDescriptor.resource,
1541
- tableName: targetDescriptor.canonical.tableName,
1542
- context
1543
- })
1544
- query = scopedQueryState.query
1545
- fieldSelectionInfo = selectionState.fieldSelectionInfo
1546
- rows = await query
1547
- } else if (hasManyInfo.via) {
1548
- const polyInfo = targetDescriptor.polymorphicBelongsTo?.[hasManyInfo.via]
1549
- if (!polyInfo?.idColumn || !polyInfo?.typeColumn) {
1550
- continue
1551
- }
1552
- groupingColumn = polyInfo.idColumn
1553
- let query = db(targetDescriptor.canonical.tableName)
1554
- .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
1555
- .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
1556
- .where(polyInfo.typeColumn, descriptor.resource)
1557
- .whereIn(groupingColumn, queryIds)
1558
- const selectionState = await applyIncludeFieldSelection({
1559
- query,
1560
- resourceName: targetDescriptor.resource,
1561
- tableName: targetDescriptor.canonical.tableName,
1562
- context
1563
- })
1564
- query = selectionState.query
1565
- const scopedQueryState = await applyTargetScopeFilters({
1566
- query,
1567
- resourceName: targetDescriptor.resource,
1568
- tableName: targetDescriptor.canonical.tableName,
1569
- context
1570
- })
1571
- query = scopedQueryState.query
1572
- fieldSelectionInfo = selectionState.fieldSelectionInfo
1573
- rows = await query
1574
- }
1575
-
1576
- if (!groupingColumn || rows.length === 0) {
1577
- continue
1578
- }
1579
-
1580
- const newResources = []
1581
- for (const row of rows) {
1582
- const includeId = getLogicalResourceId(row, targetDescriptor)
1583
- const includeKey = `${targetDescriptor.resource}:${includeId}`
1584
- if (seen.has(includeKey)) continue
1585
- seen.add(includeKey)
1586
- const includeResource = buildIncludedResource({
1587
- row,
1588
- descriptor: targetDescriptor,
1589
- context,
1590
- fieldSelectionInfo
1591
- })
1592
- includes.push(includeResource)
1593
- newResources.push(includeResource)
1594
- }
1595
-
1596
- if (childKeys.length > 0 && newResources.length > 0) {
1597
- await attachHasManyRelationships({ resources: newResources, descriptor: targetDescriptor, context })
1598
- await attachManyToManyRelationships({ resources: newResources, descriptor: targetDescriptor, context })
1599
- await collectIncludes({
1600
- descriptor: targetDescriptor,
1601
- resources: newResources,
1602
- includeTree: childTree,
1603
- context,
1604
- includes,
1605
- seen,
1606
- })
1607
- }
1608
-
1609
- continue
1610
- }
1611
-
1612
- const manyInfo = descriptor.manyToMany?.[relName]
1613
- if (manyInfo) {
1614
- const info = await getManyToManyInfo(descriptor.resource, relName)
1615
- if (!info) continue
1616
- const linkRows = await fetchLinksForParents({
1617
- descriptor: info.descriptor,
1618
- relationshipKey: info.relationshipKey,
1619
- parentIds: resources.map((resource) => resource.id),
1620
- db,
1621
- })
1622
-
1623
- const childIds = [...new Set(linkRows.map((row) => row.childId))]
1624
- if (childIds.length === 0) continue
1625
-
1626
- const targetDescriptor = await registry.getDescriptor(tenantId, info.relInfo.target)
1627
- if (!targetDescriptor) continue
1628
-
1629
- let query = db(targetDescriptor.canonical.tableName)
1630
- .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
1631
- .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
1632
- .whereIn(getLogicalResourceIdColumn(targetDescriptor), childIds)
1633
- const selectionState = await applyIncludeFieldSelection({
1634
- query,
1635
- resourceName: targetDescriptor.resource,
1636
- tableName: targetDescriptor.canonical.tableName,
1637
- context
1638
- })
1639
- query = selectionState.query
1640
- const scopedQueryState = await applyTargetScopeFilters({
1641
- query,
1642
- resourceName: targetDescriptor.resource,
1643
- tableName: targetDescriptor.canonical.tableName,
1644
- context
1645
- })
1646
- query = scopedQueryState.query
1647
- const rows = await query
1648
-
1649
- const newResources = []
1650
- for (const row of rows) {
1651
- const includeId = getLogicalResourceId(row, targetDescriptor)
1652
- const includeKey = `${targetDescriptor.resource}:${includeId}`
1653
- if (seen.has(includeKey)) continue
1654
- seen.add(includeKey)
1655
- const includeResource = buildIncludedResource({
1656
- row,
1657
- descriptor: targetDescriptor,
1658
- context,
1659
- fieldSelectionInfo: selectionState.fieldSelectionInfo
1660
- })
1661
- includes.push(includeResource)
1662
- newResources.push(includeResource)
1663
- }
1664
-
1665
- if (childKeys.length > 0 && newResources.length > 0) {
1666
- await attachHasManyRelationships({ resources: newResources, descriptor: targetDescriptor, context })
1667
- await attachManyToManyRelationships({ resources: newResources, descriptor: targetDescriptor, context })
1668
- await collectIncludes({
1669
- descriptor: targetDescriptor,
1670
- resources: newResources,
1671
- includeTree: childTree,
1672
- context,
1673
- includes,
1674
- seen,
1675
- })
1676
- }
1677
- }
1678
- }
1679
- }
1680
-
1681
- const buildIncludes = async ({ parentResources, descriptor, context }) => {
1682
- const includeTree = parseIncludeTree(context.queryParams?.include)
1683
- if (Object.keys(includeTree).length === 0) return []
1684
-
1685
- const includes = []
1686
- const seen = new Set()
1687
-
1688
- await collectIncludes({
1689
- descriptor,
1690
- resources: parentResources,
1691
- includeTree,
1692
- context,
1693
- includes,
1694
- seen,
1695
- })
1696
-
1697
- return includes
1698
- }
1699
-
1700
- const attachHasManyRelationships = async ({ resources, descriptor, context }) => {
1701
- if (!resources || resources.length === 0) return
1702
- const hasManyEntries = Object.entries(descriptor.relationships || {})
1703
- .filter(([, relDef]) => relDef?.type === 'hasMany' && relDef.target && (relDef.foreignKey || relDef.via))
1704
-
1705
- if (hasManyEntries.length === 0) return
1706
-
1707
- const db = context.db || context.transaction || api.knex.instance
1708
- const parentIds = resources.map((resource) => resource.id)
1709
- const normalizedParentIds = parentIds
1710
- .map((id) => normalizeId(id))
1711
- .filter((id) => id !== null)
1712
-
1713
- if (normalizedParentIds.length === 0) return
1714
-
1715
- for (const [relName, relDef] of hasManyEntries) {
1716
- let targetDescriptor
1717
- try {
1718
- targetDescriptor = await getDescriptor(relDef.target)
1719
- } catch (error) {
1720
- continue
1721
- }
1722
-
1723
- if (relDef.foreignKey) {
1724
- const foreignField = targetDescriptor.fields?.[relDef.foreignKey]
1725
- if (!foreignField?.slot) continue
1726
- const column = foreignField.slot
1727
-
1728
- let query = db(targetDescriptor.canonical.tableName)
1729
- .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
1730
- .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
1731
- .whereIn(column, normalizedParentIds)
1732
- const scopedQueryState = await applyTargetScopeFilters({
1733
- query,
1734
- resourceName: targetDescriptor.resource,
1735
- tableName: targetDescriptor.canonical.tableName,
1736
- context,
1737
- queryPurpose: 'relationship-identifiers'
1738
- })
1739
- query = scopedQueryState.query
1740
- const rows = await query
1741
-
1742
- const grouped = rows.reduce((acc, row) => {
1743
- const parentId = row[column]
1744
- if (parentId == null) return acc
1745
- const key = String(parentId)
1746
- acc[key] = acc[key] || []
1747
- acc[key].push({ type: targetDescriptor.resource, id: getLogicalResourceId(row, targetDescriptor) })
1748
- return acc
1749
- }, {})
1750
-
1751
- for (const resource of resources) {
1752
- resource.relationships = resource.relationships || {}
1753
- const related = grouped[String(resource.id)] || []
1754
- resource.relationships[relName] = { data: related }
1755
- }
1756
-
1757
- continue
1758
- }
1759
-
1760
- if (relDef.via) {
1761
- const polyInfo = targetDescriptor.polymorphicBelongsTo?.[relDef.via]
1762
- if (!polyInfo?.idColumn || !polyInfo?.typeColumn) continue
1763
-
1764
- const queryIds = Array.from(new Set([
1765
- ...normalizedParentIds,
1766
- ...normalizedParentIds
1767
- .map((id) => {
1768
- const numeric = Number(id)
1769
- return Number.isFinite(numeric) ? numeric : null
1770
- })
1771
- .filter((value) => value !== null),
1772
- ]))
1773
-
1774
- let query = db(targetDescriptor.canonical.tableName)
1775
- .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
1776
- .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
1777
- .where(polyInfo.typeColumn, descriptor.resource)
1778
- .whereIn(polyInfo.idColumn, queryIds)
1779
- const scopedQueryState = await applyTargetScopeFilters({
1780
- query,
1781
- resourceName: targetDescriptor.resource,
1782
- tableName: targetDescriptor.canonical.tableName,
1783
- context,
1784
- queryPurpose: 'relationship-identifiers'
1785
- })
1786
- query = scopedQueryState.query
1787
- const rows = await query
1788
-
1789
- const grouped = rows.reduce((acc, row) => {
1790
- const parentId = row[polyInfo.idColumn]
1791
- if (parentId == null) return acc
1792
- const key = String(parentId)
1793
- acc[key] = acc[key] || []
1794
- acc[key].push({ type: targetDescriptor.resource, id: getLogicalResourceId(row, targetDescriptor) })
1795
- return acc
1796
- }, {})
1797
-
1798
- for (const resource of resources) {
1799
- resource.relationships = resource.relationships || {}
1800
- const related = grouped[String(resource.id)] || []
1801
- resource.relationships[relName] = { data: related }
1802
- }
1803
- }
1804
- }
1805
- }
1806
-
1807
- const attachManyToManyRelationships = async ({ resources, descriptor, context }) => {
1808
- if (!resources || resources.length === 0) return
1809
- const manyEntries = Object.entries(descriptor.manyToMany || {})
1810
- if (manyEntries.length === 0) return
1811
-
1812
- const db = context.db || context.transaction || api.knex.instance
1813
- const parentIds = resources.map((resource) => resource.id)
1814
-
1815
- for (const [relName] of manyEntries) {
1816
- const info = await getManyToManyInfo(descriptor.resource, relName)
1817
- if (!info) continue
1818
- let rows = await fetchLinksForParents({
1819
- descriptor: info.descriptor,
1820
- relationshipKey: info.relationshipKey,
1821
- parentIds,
1822
- db,
1823
- })
1824
-
1825
- const visibleIdentifiers = await filterVisibleIdentifiers({
1826
- identifiers: rows.map((row) => ({ type: row.childType, id: row.childId })),
1827
- context
1828
- })
1829
- const visibleKeys = new Set(
1830
- visibleIdentifiers.map((identifier) => `${identifier.type}:${String(identifier.id)}`)
1831
- )
1832
- rows = rows.filter((row) => visibleKeys.has(`${row.childType}:${String(row.childId)}`))
1833
-
1834
- const grouped = rows.reduce((acc, row) => {
1835
- const key = String(row.parentId)
1836
- acc[key] = acc[key] || []
1837
- acc[key].push({ type: row.childType, id: String(row.childId) })
1838
- return acc
1839
- }, {})
1840
-
1841
- for (const resource of resources) {
1842
- const related = grouped[resource.id] || []
1843
- resource.relationships = resource.relationships || {}
1844
- resource.relationships[relName] = { data: related }
1845
- }
1846
- }
1847
- }
1848
-
1849
- helpers.dataGet = async ({ scopeName, context }) => {
1850
- const storageAdapter = getScopeStorageAdapter(scopeName)
1851
- if (storageAdapter) {
1852
- context.storageAdapter = storageAdapter
1853
- }
1854
- const descriptor = await getDescriptor(scopeName)
1855
- const { canonical } = descriptor
1856
- const id = context.id
1857
- const logicalIdColumn = getLogicalResourceIdColumn(descriptor)
1858
- const scope = api.resources?.[scopeName]
1859
-
1860
- let fieldSelectionInfo = null
1861
- if (scope) {
1862
- fieldSelectionInfo = await buildFieldSelection(scope, { context })
1863
- context.computedDependencies = fieldSelectionInfo.computedDependencies
1864
- } else {
1865
- context.computedDependencies = []
1866
- }
1867
-
1868
- let query = context.db(canonical.tableName)
1869
- .where(logicalIdColumn, id)
1870
- .where(canonical.resourceColumn, descriptor.resource)
1871
- .where(canonical.tenantColumn, descriptor.tenant)
1872
-
1873
- if (scope && fieldSelectionInfo) {
1874
- const selectionState = await applyFieldSelectionToQuery({
1875
- query,
1876
- scope,
1877
- fieldSelectionInfo,
1878
- tableName: canonical.tableName,
1879
- useTablePrefix: false,
1880
- storageAdapter,
1881
- db: context.db,
1882
- context,
1883
- scopeName
1884
- })
1885
- query = selectionState.query
1886
- }
1887
-
1888
- const row = await query.first()
1889
-
1890
- if (!row) return null
1891
-
1892
- const record = translateCanonicalRecordFromStorage(
1893
- row,
1894
- descriptor,
1895
- { allowedExtraFields: getAllowedQueryFieldNames(scopeName) }
1896
- )
1897
- const resourceId = getLogicalResourceId(row, descriptor)
1898
- const data = {
1899
- type: descriptor.resource,
1900
- id: resourceId,
1901
- attributes: record.attributes,
1902
- }
1903
-
1904
- if (record.relationships && Object.keys(record.relationships).length > 0) {
1905
- data.relationships = record.relationships
1906
- }
1907
-
1908
- await attachHasManyRelationships({
1909
- resources: [data],
1910
- descriptor,
1911
- context,
1912
- })
1913
-
1914
- await attachManyToManyRelationships({
1915
- resources: [data],
1916
- descriptor,
1917
- context,
1918
- })
1919
-
1920
- const included = await buildIncludes({
1921
- parentResources: [data],
1922
- descriptor,
1923
- context,
1924
- })
1925
-
1926
- const response = { data }
1927
- if (included.length > 0) {
1928
- response.included = included
1929
- }
1930
-
1931
- if (scope) {
1932
- decorateResourceLinks({ resource: data, scope, scopeName, context })
1933
- response.links = { self: data.links.self }
1934
- }
1935
-
1936
- if (included.length > 0) {
1937
- for (const includeResource of included) {
1938
- const targetScope = api.resources?.[includeResource.type]
1939
- if (!targetScope) continue
1940
- decorateResourceLinks({
1941
- resource: includeResource,
1942
- scope: targetScope,
1943
- scopeName: includeResource.type,
1944
- context,
1945
- })
1946
- }
1947
- }
1948
-
1949
- return response
1950
- }
1951
-
1952
- const rehydrateSchemaInfo = async (scopeName) => {
1953
- const scope = api.resources?.[scopeName] || scopes?.[scopeName] || {}
1954
- const storedOptions = scopeOptionsRegistry.get(scopeName) || {}
1955
- const rawSchema = scope.scopeOptions?.schema || storedOptions.schema || {}
1956
-
1957
- if (!scope.scopeOptions) {
1958
- scope.scopeOptions = {
1959
- schema: rawSchema,
1960
- relationships: storedOptions.relationships || {},
1961
- searchSchema: storedOptions.searchSchema || null,
1962
- tableName: storedOptions.tableName || scopeName,
1963
- idProperty: storedOptions.idProperty || scope?.vars?.idProperty || vars.idProperty || 'id',
1964
- canonicalFieldsMap: storedOptions.canonicalFieldsMap || {},
1965
- }
1966
- }
1967
-
370
+ // The fresh descriptor also carries fields other servers added meanwhile.
1968
371
  const descriptor = await registry.getDescriptor(tenantId, scopeName)
1969
- if (!descriptor) return
1970
-
1971
- const existingInfo = scope.vars?.schemaInfo || {}
1972
-
1973
- const mergedSchema = { ...(existingInfo.schemaInstance?.structure || {}) }
1974
- const computed = { ...(existingInfo.computed || {}) }
1975
- for (const [fieldName, definition] of Object.entries(descriptor.schema || {})) {
1976
- if (definition?.computed) {
1977
- if (!computed[fieldName]) {
1978
- computed[fieldName] = { ...definition }
1979
- }
1980
- continue
1981
- }
1982
- mergedSchema[fieldName] = {
1983
- ...(mergedSchema[fieldName] || {}),
1984
- ...definition,
1985
- }
1986
- }
1987
-
1988
- if (!mergedSchema.id) {
1989
- mergedSchema.id = { type: 'id' }
1990
- }
1991
-
1992
- const schemaInstance = createSchema(mergedSchema)
1993
-
1994
- const rawSearchFields = generateSearchSchemaFromSchema(
1995
- mergedSchema,
1996
- scope.scopeOptions?.searchSchema || storedOptions.searchSchema || null
1997
- )
1998
- if (rawSearchFields) {
1999
- ensureSearchFieldsAreIndexed(rawSearchFields)
2000
- }
2001
- const searchSchemaInstance = createSchema(rawSearchFields || {})
2002
-
2003
- const tableName = storedOptions.tableName ||
2004
- existingInfo.tableName ||
2005
- scope.scopeOptions?.tableName ||
2006
- scopeName
2007
-
2008
- const idProperty = storedOptions.idProperty ||
2009
- existingInfo.idProperty ||
2010
- scope.scopeOptions?.idProperty ||
2011
- scope.vars?.idProperty ||
2012
- vars.idProperty ||
2013
- 'id'
2014
- descriptor.idProperty = idProperty
2015
-
2016
- scope.vars.schemaInfo = {
2017
- ...existingInfo,
2018
- schemaInstance,
2019
- schemaStructure: schemaInstance.structure,
2020
- searchSchemaInstance,
2021
- searchSchemaStructure: searchSchemaInstance.structure,
2022
- schemaRelationships: descriptor.relationships ||
2023
- scope.scopeOptions?.relationships ||
2024
- storedOptions.relationships ||
2025
- existingInfo.schemaRelationships ||
2026
- {},
2027
- computed,
2028
- tableName,
2029
- idProperty,
2030
- descriptor,
2031
- canonicalFieldMap: descriptor.canonicalFieldMap || {},
2032
- }
2033
-
2034
- getScopeStorageAdapter(scopeName)
2035
-
2036
- scope.scopeOptions = {
2037
- ...(scope.scopeOptions || {}),
2038
- canonicalFieldsMap: descriptor.canonicalFieldMap || scope.scopeOptions?.canonicalFieldsMap || {},
2039
- }
2040
-
2041
- scopeOptionsRegistry.set(scopeName, {
2042
- schema: storedOptions.schema || scope.scopeOptions?.schema || {},
2043
- relationships: storedOptions.relationships || scope.scopeOptions?.relationships || {},
2044
- searchSchema: scope.scopeOptions?.searchSchema || storedOptions.searchSchema || null,
2045
- tableName,
2046
- idProperty,
2047
- canonicalFieldsMap: descriptor.canonicalFieldMap || {},
2048
- })
2049
- }
2050
-
2051
- const buildTableNameMaps = () => {
2052
- const resourceToTableName = new Map()
2053
- const tableNameToResource = new Map()
2054
- for (const [resourceName, resourceScope] of Object.entries(api.resources || {})) {
2055
- const tableName = resourceScope?.vars?.schemaInfo?.tableName
2056
- if (!tableName) continue
2057
- resourceToTableName.set(resourceName, tableName)
2058
- tableNameToResource.set(tableName, resourceName)
2059
- }
2060
- return { resourceToTableName, tableNameToResource }
2061
- }
2062
-
2063
- const queryHookDependencies = { log, scopes: api.resources, knex, getStorageAdapter: getScopeStorageAdapter }
2064
-
2065
- const applyBuiltInAnyApiQueryFilters = async (context) => {
2066
- const hookParams = { context }
2067
- await polymorphicFiltersHook(hookParams, queryHookDependencies)
2068
- await crossTableFiltersHook(hookParams, queryHookDependencies)
2069
- await basicFiltersHook(hookParams, queryHookDependencies)
2070
- }
2071
-
2072
- helpers.dataQuery = async ({ scopeName, context, runHooks }) => {
2073
- const storageAdapter = getScopeStorageAdapter(scopeName)
2074
- if (storageAdapter) {
2075
- context.storageAdapter = storageAdapter
2076
- }
2077
- const descriptor = await getDescriptor(scopeName)
2078
- const db = context.db || context.transaction || api.knex.instance
2079
- const scope = api.resources?.[scopeName]
2080
- const queryParams = context.queryParams || {}
2081
-
2082
- let fieldSelectionInfo = null
2083
- if (scope) {
2084
- fieldSelectionInfo = await buildFieldSelection(scope, { context })
2085
- context.computedDependencies = fieldSelectionInfo.computedDependencies
2086
- } else {
2087
- context.computedDependencies = []
2088
- }
2089
-
2090
- const { resourceToTableName, tableNameToResource } = buildTableNameMaps()
2091
- context.resourceToTableName = resourceToTableName
2092
- const descriptorsMap = await preloadRelatedDescriptors({ registry, descriptor })
2093
- const adapter = new AnyapiQueryAdapter({
2094
- descriptor,
2095
- db,
2096
- registry,
2097
- descriptorsMap,
2098
- resourceToTableName,
2099
- tableNameToResource,
2100
- log,
2101
- })
2102
- const queryBuilder = adapter.query
2103
-
2104
- const schemaInfo = scope?.vars?.schemaInfo
2105
- const tableNameForHooks = schemaInfo?.tableName || adapter.tableAlias
2106
-
2107
- applyFiltersToQuery({
2108
- query: queryBuilder,
2109
- filters: queryParams.filters,
2110
- descriptor,
2111
- searchSchema: schemaInfo?.searchSchemaStructure,
2112
- adapter,
2113
- })
2114
-
2115
- context.knexQuery = {
2116
- query: queryBuilder,
2117
- filters: queryParams.filters,
2118
- schemaInfo,
2119
- scopeName,
2120
- tableName: tableNameForHooks,
2121
- db,
2122
- queryPurpose: 'collection',
2123
- isAnyApi: true,
2124
- adapter,
2125
- storageAdapter,
2126
- }
2127
-
2128
- await applyBuiltInAnyApiQueryFilters(context)
2129
-
2130
- if (runHooks) {
2131
- await runHooks('knexQueryFiltering')
2132
- }
2133
-
2134
- delete context.knexQuery
2135
-
2136
- const countQuery = queryBuilder.clone()
2137
-
2138
- let selectionState = { query: queryBuilder, queryFieldRuntimeByField: new Map() }
2139
- if (scope && fieldSelectionInfo) {
2140
- selectionState = await applyFieldSelectionToQuery({
2141
- query: queryBuilder,
2142
- scope,
2143
- fieldSelectionInfo,
2144
- tableName: adapter.tableAlias,
2145
- useTablePrefix: false,
2146
- storageAdapter,
2147
- db,
2148
- context,
2149
- scopeName
2150
- })
2151
- }
2152
-
2153
- const sortDescriptors = applySortingToQuery({
2154
- query: selectionState.query,
2155
- sort: queryParams.sort,
2156
- descriptor,
2157
- scope,
2158
- adapter,
2159
- before: Boolean(queryParams.page?.before),
2160
- queryFieldRuntimeByField: selectionState.queryFieldRuntimeByField
2161
- })
2162
-
2163
- const paginationInfo = applyPaginationToQuery({
2164
- query: queryBuilder,
2165
- scope,
2166
- queryParams,
2167
- sortDescriptors,
2168
- adapter,
2169
- })
2170
-
2171
- let rows = await queryBuilder
2172
- let cursorRecords = null
2173
- let hasMore = false
2174
-
2175
- if (paginationInfo.mode === 'cursor') {
2176
- const { pageSize, sortDescriptors: cursorDescriptors } = paginationInfo
2177
- cursorRecords = rows.map((row) => {
2178
- const record = {}
2179
- for (const descriptorEntry of cursorDescriptors) {
2180
- if (descriptorEntry.field === 'id') {
2181
- record.id = getLogicalResourceId(row, descriptor)
2182
- } else if (descriptorEntry.queryFieldRuntime) {
2183
- record[descriptorEntry.field] = row[descriptorEntry.field]
2184
- } else if (descriptorEntry.column) {
2185
- record[descriptorEntry.field] = row[descriptorEntry.column]
2186
- }
2187
- }
2188
- return record
2189
- })
2190
-
2191
- if (rows.length > pageSize) {
2192
- hasMore = true
2193
- rows = rows.slice(0, pageSize)
2194
- cursorRecords = cursorRecords.slice(0, pageSize)
2195
- }
2196
- if (queryParams.page?.before) {
2197
- rows.reverse()
2198
- cursorRecords.reverse()
2199
- }
2200
- }
2201
-
2202
- const data = rows.map((row) => {
2203
- const translated = translateCanonicalRecordFromStorage(
2204
- row,
2205
- descriptor,
2206
- { allowedExtraFields: getAllowedQueryFieldNames(scopeName) }
2207
- )
2208
- const resourceId = getLogicalResourceId(row, descriptor)
2209
- const resource = {
2210
- type: descriptor.resource,
2211
- id: resourceId,
2212
- attributes: translated.attributes,
2213
- }
2214
- if (translated.relationships && Object.keys(translated.relationships).length > 0) {
2215
- resource.relationships = translated.relationships
2216
- }
2217
- const resourceScope = scope
2218
- if (resourceScope) {
2219
- decorateResourceLinks({ resource, scope: resourceScope, scopeName, context })
2220
- }
2221
- if (fieldSelectionInfo?.computedDependencies?.length) {
2222
- resource.__$jsonrestapi_computed_deps$__ = fieldSelectionInfo.computedDependencies
2223
- }
2224
- return resource
2225
- })
2226
-
2227
- await attachHasManyRelationships({
2228
- resources: data,
2229
- descriptor,
2230
- context,
2231
- })
2232
-
2233
- await attachManyToManyRelationships({
2234
- resources: data,
2235
- descriptor,
2236
- context,
2237
- })
2238
-
2239
- const included = await buildIncludes({
2240
- parentResources: data,
2241
- descriptor,
2242
- context,
2243
- })
2244
-
2245
- const response = { data }
2246
- if (included.length > 0) {
2247
- response.included = included
2248
- }
2249
-
2250
- if (included.length > 0) {
2251
- for (const includeResource of included) {
2252
- const targetScope = api.resources?.[includeResource.type]
2253
- if (!targetScope) continue
2254
- decorateResourceLinks({
2255
- resource: includeResource,
2256
- scope: targetScope,
2257
- scopeName: includeResource.type,
2258
- context,
2259
- })
2260
- }
2261
- }
2262
-
2263
- context.returnMeta = context.returnMeta || {}
2264
- const queryString = serializeJsonApiQuery(queryParams)
2265
- context.returnMeta.queryString = queryString ? `?${queryString}` : ''
2266
- delete context.returnMeta.paginationMeta
2267
- delete context.returnMeta.paginationLinks
2268
-
2269
- if (paginationInfo.mode === 'offset') {
2270
- const { page, pageSize } = paginationInfo
2271
- let paginationMeta
2272
-
2273
- if (scope?.vars?.enablePaginationCounts) {
2274
- const countResult = await countQuery.count({ count: '*' }).first()
2275
- const total = Number(countResult?.count ?? countResult?.total ?? 0)
2276
- paginationMeta = calculatePaginationMeta(total, page, pageSize)
2277
- } else {
2278
- paginationMeta = { page, pageSize }
2279
- }
2280
-
2281
- context.returnMeta.paginationMeta = paginationMeta
2282
- const urlPrefix = getUrlPrefix(context, scope)
2283
- context.returnMeta.paginationLinks = generatePaginationLinks(
2284
- urlPrefix,
2285
- scopeName,
2286
- queryParams,
2287
- paginationMeta
2288
- )
2289
- } else if (paginationInfo.mode === 'cursor' && cursorRecords) {
2290
- const cursorFields = paginationInfo.sortDescriptors.map((descriptorEntry) => descriptorEntry.field)
2291
- const cursorOptions = {
2292
- schemaInfo,
2293
- definitions: Object.fromEntries(paginationInfo.sortDescriptors.map(({ field, definition }) => [field, definition])),
2294
- before: Boolean(queryParams.page?.before)
2295
- }
2296
- const paginationMeta = buildCursorMeta(
2297
- cursorRecords,
2298
- paginationInfo.pageSize,
2299
- hasMore,
2300
- cursorFields,
2301
- cursorOptions
2302
- )
2303
- context.returnMeta.paginationMeta = paginationMeta
2304
- const urlPrefix = getUrlPrefix(context, scope)
2305
- context.returnMeta.paginationLinks = generateCursorPaginationLinks(
2306
- urlPrefix,
2307
- scopeName,
2308
- queryParams,
2309
- cursorRecords,
2310
- paginationInfo.pageSize,
2311
- hasMore,
2312
- cursorFields,
2313
- cursorOptions
2314
- )
2315
- }
2316
-
2317
- if (context.returnMeta.paginationMeta) {
2318
- response.meta = {
2319
- pagination: context.returnMeta.paginationMeta,
2320
- }
2321
- }
2322
-
2323
- if (context.returnMeta.paginationLinks) {
2324
- response.links = context.returnMeta.paginationLinks
2325
- } else if (scope) {
2326
- const urlPrefix = getUrlPrefix(context, scope)
2327
- response.links = {
2328
- self: `${urlPrefix}/${scopeName}${context.returnMeta.queryString || ''}`,
2329
- }
2330
- }
2331
-
2332
- return response
2333
- }
2334
-
2335
- helpers.dataQueryCount = async ({ scopeName, context }) => {
2336
- const storageAdapter = getScopeStorageAdapter(scopeName)
2337
- if (storageAdapter) {
2338
- context.storageAdapter = storageAdapter
2339
- }
2340
- const descriptor = await getDescriptor(scopeName)
2341
- const db = context.db || context.transaction || api.knex.instance
2342
- const { resourceToTableName, tableNameToResource } = buildTableNameMaps()
2343
- const descriptorsMap = await preloadRelatedDescriptors({ registry, descriptor })
2344
- const adapter = new AnyapiQueryAdapter({
2345
- descriptor,
2346
- db,
2347
- registry,
2348
- descriptorsMap,
2349
- resourceToTableName,
2350
- tableNameToResource,
2351
- log,
2352
- })
2353
-
2354
- // Apply the same filter hooks as dataQuery for consistent counts
2355
- const scope = api.resources?.[scopeName]
2356
- const schemaInfo = scope?.vars?.schemaInfo
2357
- const tableNameForHooks = schemaInfo?.tableName || adapter.tableAlias
2358
-
2359
- const countContext = {
2360
- ...context,
2361
- knexQuery: {
2362
- query: adapter.query,
2363
- filters: context.queryParams?.filters,
2364
- schemaInfo,
2365
- scopeName,
2366
- tableName: tableNameForHooks,
2367
- db,
2368
- queryPurpose: 'count',
2369
- isAnyApi: true,
2370
- adapter,
2371
- storageAdapter,
2372
- }
2373
- }
2374
-
2375
- const hookParams = { context: countContext }
2376
- await polymorphicFiltersHook(hookParams, queryHookDependencies)
2377
- await crossTableFiltersHook(hookParams, queryHookDependencies)
2378
- await basicFiltersHook(hookParams, queryHookDependencies)
2379
- await api.runHooks('knexQueryFiltering', countContext)
2380
-
2381
- const countQuery = countContext.knexQuery?.query || adapter.query
2382
- const [{ count }] = await countQuery.count({ count: '*' })
2383
- return Number(count)
2384
- }
2385
-
2386
- addHook('scope:added', 'anyapi-register-resource', { sequence: 50 }, async ({ context }) => {
2387
- const { scopeName, scopeOptions = {} } = context
2388
- const scope = api.scopes?.[scopeName] || scopes?.[scopeName]
2389
- const stored = scopeOptionsRegistry.get(scopeName) || {}
2390
-
2391
- const baseSchema = scopeOptions.schema || scope?.scopeOptions?.schema || stored.schema || {}
2392
- const relationships = scopeOptions.relationships || scope?.scopeOptions?.relationships || stored.relationships || {}
2393
- const searchSchemaDef = scopeOptions.searchSchema || scope?.scopeOptions?.searchSchema || stored.searchSchema || null
2394
- const tableName = scopeOptions.tableName || scope?.scopeOptions?.tableName || stored.tableName || scopeName
2395
- const idProperty = scopeOptions.idProperty || scope?.scopeOptions?.idProperty || stored.idProperty || scope?.vars?.idProperty || vars.idProperty || 'id'
2396
- const canonicalFieldsMap = scope?.scopeOptions?.canonicalFieldsMap || stored.canonicalFieldsMap || null
2397
-
2398
- const schema = { ...baseSchema }
2399
- if (idProperty && idProperty !== 'id' && !schema[idProperty]) {
2400
- schema[idProperty] = { type: 'number' }
2401
- }
2402
-
2403
- scopeOptionsRegistry.set(scopeName, {
2404
- schema,
2405
- relationships,
2406
- searchSchema: searchSchemaDef,
2407
- tableName,
2408
- idProperty,
2409
- canonicalFieldsMap,
2410
- })
2411
-
2412
- const descriptor = await registry.registerResource({
2413
- tenant: tenantId,
2414
- resource: scopeName,
2415
- schema,
2416
- relationships,
2417
- canonicalFieldMap: canonicalFieldsMap,
2418
- idProperty,
2419
- })
2420
-
2421
- const updatedStored = scopeOptionsRegistry.get(scopeName) || {}
2422
- updatedStored.canonicalFieldsMap = descriptor.canonicalFieldMap || updatedStored.canonicalFieldsMap || null
2423
- scopeOptionsRegistry.set(scopeName, updatedStored)
2424
-
2425
- if (scope) {
2426
- scope.scopeOptions = {
2427
- ...(scope.scopeOptions || {}),
2428
- schema,
2429
- relationships,
2430
- searchSchema: searchSchemaDef || undefined,
2431
- tableName,
2432
- idProperty,
2433
- canonicalFieldsMap: descriptor.canonicalFieldMap || scope.scopeOptions?.canonicalFieldsMap || {},
2434
- }
2435
- }
2436
-
2437
- await rehydrateSchemaInfo(scopeName)
2438
- })
2439
-
2440
- addScopeMethod('createKnexTable', async ({ scopeName, scopeOptions }) => {
2441
- const scope = api.resources?.[scopeName] || scopes?.[scopeName]
2442
- const schemaInput = scopeOptions?.schema || scope?.scopeOptions?.schema || {}
2443
- const relationshipInput = scopeOptions?.relationships || scope?.scopeOptions?.relationships || {}
2444
- const storedBefore = scopeOptionsRegistry.get(scopeName) || {}
2445
- const idProperty = scopeOptions?.idProperty ||
2446
- scope?.scopeOptions?.idProperty ||
2447
- storedBefore.idProperty ||
2448
- scope?.vars?.schemaInfo?.idProperty ||
2449
- vars.idProperty ||
2450
- 'id'
2451
-
2452
- const effectiveSchema = { ...schemaInput }
2453
- if (idProperty && idProperty !== 'id' && !effectiveSchema[idProperty]) {
2454
- effectiveSchema[idProperty] = { type: 'number' }
2455
- }
2456
-
2457
- if (scope) {
2458
- const existingOptions = scope.scopeOptions || {}
2459
- scope.scopeOptions = {
2460
- ...existingOptions,
2461
- schema: {
2462
- ...(existingOptions.schema || {}),
2463
- ...schemaInput,
2464
- ...(idProperty && idProperty !== 'id' && !schemaInput[idProperty] ? { [idProperty]: { type: 'number' } } : {}),
2465
- },
2466
- relationships: {
2467
- ...(existingOptions.relationships || {}),
2468
- ...relationshipInput,
2469
- },
2470
- searchSchema: existingOptions.searchSchema || scopeOptions?.searchSchema,
2471
- }
2472
- }
2473
- scopeOptionsRegistry.set(scopeName, {
2474
- schema: scope?.scopeOptions?.schema || effectiveSchema || storedBefore.schema || {},
2475
- relationships: scope?.scopeOptions?.relationships || relationshipInput || storedBefore.relationships || {},
2476
- searchSchema: scope?.scopeOptions?.searchSchema || scopeOptions?.searchSchema || storedBefore.searchSchema || null,
2477
- tableName: scope?.scopeOptions?.tableName || scopeOptions?.tableName || storedBefore.tableName || scopeName,
2478
- idProperty: scope?.scopeOptions?.idProperty || scopeOptions?.idProperty || storedBefore.idProperty || scope?.vars?.idProperty || vars.idProperty || 'id',
2479
- canonicalFieldsMap: storedBefore.canonicalFieldsMap || null,
2480
- })
2481
- const descriptor = await registry.registerResource({
2482
- tenant: tenantId,
2483
- resource: scopeName,
2484
- schema: effectiveSchema,
2485
- relationships: relationshipInput,
2486
- canonicalFieldMap: scopeOptions?.canonicalFieldsMap ||
2487
- scope?.scopeOptions?.canonicalFieldsMap ||
2488
- null,
2489
- idProperty,
2490
- })
2491
- if (scope) {
2492
- scope.scopeOptions = {
2493
- ...(scope.scopeOptions || {}),
2494
- canonicalFieldsMap: descriptor.canonicalFieldMap || scope.scopeOptions?.canonicalFieldsMap || {},
2495
- }
2496
- }
2497
- const storedAfter = scopeOptionsRegistry.get(scopeName) || {}
2498
- storedAfter.canonicalFieldsMap = descriptor.canonicalFieldMap || storedAfter.canonicalFieldsMap || null
2499
- scopeOptionsRegistry.set(scopeName, storedAfter)
2500
- await rehydrateSchemaInfo(scopeName)
2501
- })
2502
-
2503
- addScopeMethod('addKnexFields', async ({ scopeName, params }) => {
2504
- if (!params?.fields) return
2505
- const scope = api.resources?.[scopeName] || scopes?.[scopeName]
2506
- if (scope) {
2507
- const existingOptions = scope.scopeOptions || {}
2508
- scope.scopeOptions = {
2509
- ...existingOptions,
2510
- schema: {
2511
- ...(existingOptions.schema || {}),
2512
- },
2513
- relationships: existingOptions.relationships || {},
2514
- searchSchema: existingOptions.searchSchema,
2515
- }
2516
- }
2517
- const storedBefore = scopeOptionsRegistry.get(scopeName) || {}
2518
- const canonicalEntries = params.canonicalFieldsMap ? { ...params.canonicalFieldsMap } : null
2519
- let latestDescriptor = null
2520
- for (const [fieldName, definition] of Object.entries(params.fields)) {
2521
- if (scope) {
2522
- scope.scopeOptions.schema[fieldName] = definition
2523
- }
2524
- const canonicalEntry = canonicalEntries ? canonicalEntries[fieldName] : null
2525
- if (canonicalEntries) {
2526
- delete canonicalEntries[fieldName]
2527
- }
2528
- latestDescriptor = await registry.allocateField({
2529
- tenant: tenantId,
2530
- resource: scopeName,
2531
- fieldName,
2532
- definition,
2533
- canonicalField: canonicalEntry,
2534
- })
2535
- }
2536
- if (canonicalEntries && Object.keys(canonicalEntries).length > 0) {
2537
- const unknown = Object.keys(canonicalEntries).join(', ')
2538
- throw new Error(`canonicalFieldsMap contains unknown fields for addKnexFields: ${unknown}`)
2539
- }
2540
- if (scope && latestDescriptor?.canonicalFieldMap) {
2541
- scope.scopeOptions.canonicalFieldsMap = latestDescriptor.canonicalFieldMap
2542
- }
2543
- if (scope && params.searchSchema) {
2544
- scope.scopeOptions.searchSchema = {
2545
- ...(scope.scopeOptions.searchSchema || {}),
2546
- ...params.searchSchema,
2547
- }
2548
- }
2549
- const updatedStored = {
2550
- schema: scope?.scopeOptions?.schema || storedBefore.schema || {},
2551
- relationships: scope?.scopeOptions?.relationships || storedBefore.relationships || {},
2552
- searchSchema: scope?.scopeOptions?.searchSchema || storedBefore.searchSchema || null,
2553
- tableName: scope?.scopeOptions?.tableName || storedBefore.tableName || scopeName,
2554
- idProperty: scope?.scopeOptions?.idProperty || storedBefore.idProperty || scope?.vars?.idProperty || vars.idProperty || 'id',
2555
- canonicalFieldsMap: scope?.scopeOptions?.canonicalFieldsMap || storedBefore.canonicalFieldsMap || null,
2556
- }
2557
- scopeOptionsRegistry.set(scopeName, updatedStored)
2558
- await rehydrateSchemaInfo(scopeName)
372
+ await publish(scopeName, descriptor, { compiled: { signature: optionsSignature(candidate), schemaInfo } })
373
+ await notifySchemaChanged(scopeName, descriptor.schemaVersion)
2559
374
  })
2560
375
 
2561
- addScopeMethod('alterKnexFields', async () => {
376
+ addResourceMethod('alterKnexFields', async () => {
2562
377
  throw new Error('alterKnexFields is not supported by AnyAPI Knex plugin yet')
2563
378
  })
2564
379
  },