@rebasepro/server-core 0.0.1-canary.000dc36

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 (305) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +40 -0
  3. package/build-errors.txt +52 -0
  4. package/coverage/clover.xml +3739 -0
  5. package/coverage/coverage-final.json +31 -0
  6. package/coverage/lcov-report/base.css +224 -0
  7. package/coverage/lcov-report/block-navigation.js +87 -0
  8. package/coverage/lcov-report/favicon.png +0 -0
  9. package/coverage/lcov-report/index.html +266 -0
  10. package/coverage/lcov-report/prettify.css +1 -0
  11. package/coverage/lcov-report/prettify.js +2 -0
  12. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  13. package/coverage/lcov-report/sorter.js +210 -0
  14. package/coverage/lcov-report/src/api/ast-schema-editor.ts.html +952 -0
  15. package/coverage/lcov-report/src/api/errors.ts.html +472 -0
  16. package/coverage/lcov-report/src/api/graphql/graphql-schema-generator.ts.html +1069 -0
  17. package/coverage/lcov-report/src/api/graphql/index.html +116 -0
  18. package/coverage/lcov-report/src/api/index.html +176 -0
  19. package/coverage/lcov-report/src/api/openapi-generator.ts.html +565 -0
  20. package/coverage/lcov-report/src/api/rest/api-generator.ts.html +994 -0
  21. package/coverage/lcov-report/src/api/rest/index.html +131 -0
  22. package/coverage/lcov-report/src/api/rest/query-parser.ts.html +550 -0
  23. package/coverage/lcov-report/src/api/schema-editor-routes.ts.html +202 -0
  24. package/coverage/lcov-report/src/api/server.ts.html +823 -0
  25. package/coverage/lcov-report/src/auth/admin-routes.ts.html +973 -0
  26. package/coverage/lcov-report/src/auth/index.html +176 -0
  27. package/coverage/lcov-report/src/auth/jwt.ts.html +574 -0
  28. package/coverage/lcov-report/src/auth/middleware.ts.html +745 -0
  29. package/coverage/lcov-report/src/auth/password.ts.html +310 -0
  30. package/coverage/lcov-report/src/auth/services.ts.html +2074 -0
  31. package/coverage/lcov-report/src/collections/index.html +116 -0
  32. package/coverage/lcov-report/src/collections/loader.ts.html +232 -0
  33. package/coverage/lcov-report/src/db/auth-schema.ts.html +523 -0
  34. package/coverage/lcov-report/src/db/data-transformer.ts.html +1753 -0
  35. package/coverage/lcov-report/src/db/entityService.ts.html +700 -0
  36. package/coverage/lcov-report/src/db/index.html +146 -0
  37. package/coverage/lcov-report/src/db/services/EntityFetchService.ts.html +4048 -0
  38. package/coverage/lcov-report/src/db/services/EntityPersistService.ts.html +883 -0
  39. package/coverage/lcov-report/src/db/services/RelationService.ts.html +3121 -0
  40. package/coverage/lcov-report/src/db/services/entity-helpers.ts.html +442 -0
  41. package/coverage/lcov-report/src/db/services/index.html +176 -0
  42. package/coverage/lcov-report/src/db/services/index.ts.html +124 -0
  43. package/coverage/lcov-report/src/generate-drizzle-schema-logic.ts.html +1960 -0
  44. package/coverage/lcov-report/src/index.html +116 -0
  45. package/coverage/lcov-report/src/services/driver-registry.ts.html +631 -0
  46. package/coverage/lcov-report/src/services/index.html +131 -0
  47. package/coverage/lcov-report/src/services/postgresDataDriver.ts.html +3025 -0
  48. package/coverage/lcov-report/src/storage/LocalStorageController.ts.html +1189 -0
  49. package/coverage/lcov-report/src/storage/S3StorageController.ts.html +970 -0
  50. package/coverage/lcov-report/src/storage/index.html +161 -0
  51. package/coverage/lcov-report/src/storage/storage-registry.ts.html +646 -0
  52. package/coverage/lcov-report/src/storage/types.ts.html +451 -0
  53. package/coverage/lcov-report/src/utils/drizzle-conditions.ts.html +3082 -0
  54. package/coverage/lcov-report/src/utils/index.html +116 -0
  55. package/coverage/lcov.info +7179 -0
  56. package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
  57. package/dist/common/src/collections/index.d.ts +1 -0
  58. package/dist/common/src/data/buildRebaseData.d.ts +14 -0
  59. package/dist/common/src/index.d.ts +3 -0
  60. package/dist/common/src/util/builders.d.ts +57 -0
  61. package/dist/common/src/util/callbacks.d.ts +6 -0
  62. package/dist/common/src/util/collections.d.ts +11 -0
  63. package/dist/common/src/util/common.d.ts +2 -0
  64. package/dist/common/src/util/conditions.d.ts +26 -0
  65. package/dist/common/src/util/entities.d.ts +58 -0
  66. package/dist/common/src/util/enums.d.ts +3 -0
  67. package/dist/common/src/util/index.d.ts +16 -0
  68. package/dist/common/src/util/navigation_from_path.d.ts +34 -0
  69. package/dist/common/src/util/navigation_utils.d.ts +20 -0
  70. package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
  71. package/dist/common/src/util/paths.d.ts +14 -0
  72. package/dist/common/src/util/permissions.d.ts +5 -0
  73. package/dist/common/src/util/references.d.ts +2 -0
  74. package/dist/common/src/util/relations.d.ts +22 -0
  75. package/dist/common/src/util/resolutions.d.ts +72 -0
  76. package/dist/common/src/util/storage.d.ts +24 -0
  77. package/dist/index-DXVBFp5V.js +37 -0
  78. package/dist/index-DXVBFp5V.js.map +1 -0
  79. package/dist/index.es.js +49249 -0
  80. package/dist/index.es.js.map +1 -0
  81. package/dist/index.umd.js +49283 -0
  82. package/dist/index.umd.js.map +1 -0
  83. package/dist/server-core/src/api/ast-schema-editor.d.ts +21 -0
  84. package/dist/server-core/src/api/collections_for_test/callbacks_test_collection.d.ts +2 -0
  85. package/dist/server-core/src/api/errors.d.ts +35 -0
  86. package/dist/server-core/src/api/graphql/graphql-schema-generator.d.ts +35 -0
  87. package/dist/server-core/src/api/graphql/index.d.ts +1 -0
  88. package/dist/server-core/src/api/index.d.ts +9 -0
  89. package/dist/server-core/src/api/openapi-generator.d.ts +16 -0
  90. package/dist/server-core/src/api/rest/api-generator.d.ts +76 -0
  91. package/dist/server-core/src/api/rest/index.d.ts +1 -0
  92. package/dist/server-core/src/api/rest/query-parser.d.ts +9 -0
  93. package/dist/server-core/src/api/schema-editor-routes.d.ts +3 -0
  94. package/dist/server-core/src/api/server.d.ts +40 -0
  95. package/dist/server-core/src/api/types.d.ts +90 -0
  96. package/dist/server-core/src/auth/admin-routes.d.ts +21 -0
  97. package/dist/server-core/src/auth/apple-oauth.d.ts +30 -0
  98. package/dist/server-core/src/auth/bitbucket-oauth.d.ts +11 -0
  99. package/dist/server-core/src/auth/discord-oauth.d.ts +14 -0
  100. package/dist/server-core/src/auth/facebook-oauth.d.ts +14 -0
  101. package/dist/server-core/src/auth/github-oauth.d.ts +15 -0
  102. package/dist/server-core/src/auth/gitlab-oauth.d.ts +13 -0
  103. package/dist/server-core/src/auth/google-oauth.d.ts +14 -0
  104. package/dist/server-core/src/auth/index.d.ts +23 -0
  105. package/dist/server-core/src/auth/interfaces.d.ts +309 -0
  106. package/dist/server-core/src/auth/jwt.d.ts +43 -0
  107. package/dist/server-core/src/auth/linkedin-oauth.d.ts +18 -0
  108. package/dist/server-core/src/auth/microsoft-oauth.d.ts +16 -0
  109. package/dist/server-core/src/auth/middleware.d.ts +81 -0
  110. package/dist/server-core/src/auth/password.d.ts +22 -0
  111. package/dist/server-core/src/auth/rate-limiter.d.ts +31 -0
  112. package/dist/server-core/src/auth/routes.d.ts +27 -0
  113. package/dist/server-core/src/auth/slack-oauth.d.ts +12 -0
  114. package/dist/server-core/src/auth/spotify-oauth.d.ts +12 -0
  115. package/dist/server-core/src/auth/twitter-oauth.d.ts +18 -0
  116. package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
  117. package/dist/server-core/src/collections/BackendCollectionRegistry.d.ts +13 -0
  118. package/dist/server-core/src/collections/loader.d.ts +5 -0
  119. package/dist/server-core/src/cron/cron-loader.d.ts +17 -0
  120. package/dist/server-core/src/cron/cron-routes.d.ts +14 -0
  121. package/dist/server-core/src/cron/cron-scheduler.d.ts +106 -0
  122. package/dist/server-core/src/cron/cron-store.d.ts +32 -0
  123. package/dist/server-core/src/cron/index.d.ts +6 -0
  124. package/dist/server-core/src/db/interfaces.d.ts +18 -0
  125. package/dist/server-core/src/email/index.d.ts +6 -0
  126. package/dist/server-core/src/email/smtp-email-service.d.ts +25 -0
  127. package/dist/server-core/src/email/templates.d.ts +42 -0
  128. package/dist/server-core/src/email/types.d.ts +107 -0
  129. package/dist/server-core/src/functions/function-loader.d.ts +17 -0
  130. package/dist/server-core/src/functions/function-routes.d.ts +10 -0
  131. package/dist/server-core/src/functions/index.d.ts +3 -0
  132. package/dist/server-core/src/history/history-routes.d.ts +23 -0
  133. package/dist/server-core/src/history/index.d.ts +1 -0
  134. package/dist/server-core/src/index.d.ts +29 -0
  135. package/dist/server-core/src/init.d.ts +168 -0
  136. package/dist/server-core/src/serve-spa.d.ts +30 -0
  137. package/dist/server-core/src/services/driver-registry.d.ts +78 -0
  138. package/dist/server-core/src/singleton.d.ts +35 -0
  139. package/dist/server-core/src/storage/LocalStorageController.d.ts +46 -0
  140. package/dist/server-core/src/storage/S3StorageController.d.ts +36 -0
  141. package/dist/server-core/src/storage/index.d.ts +25 -0
  142. package/dist/server-core/src/storage/routes.d.ts +38 -0
  143. package/dist/server-core/src/storage/storage-registry.d.ts +78 -0
  144. package/dist/server-core/src/storage/types.d.ts +103 -0
  145. package/dist/server-core/src/types/index.d.ts +11 -0
  146. package/dist/server-core/src/utils/dev-port.d.ts +35 -0
  147. package/dist/server-core/src/utils/logger.d.ts +31 -0
  148. package/dist/server-core/src/utils/logging.d.ts +9 -0
  149. package/dist/server-core/src/utils/request-logger.d.ts +19 -0
  150. package/dist/server-core/src/utils/sql.d.ts +27 -0
  151. package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
  152. package/dist/types/src/controllers/auth.d.ts +119 -0
  153. package/dist/types/src/controllers/client.d.ts +170 -0
  154. package/dist/types/src/controllers/collection_registry.d.ts +46 -0
  155. package/dist/types/src/controllers/customization_controller.d.ts +60 -0
  156. package/dist/types/src/controllers/data.d.ts +168 -0
  157. package/dist/types/src/controllers/data_driver.d.ts +195 -0
  158. package/dist/types/src/controllers/database_admin.d.ts +11 -0
  159. package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
  160. package/dist/types/src/controllers/effective_role.d.ts +4 -0
  161. package/dist/types/src/controllers/email.d.ts +34 -0
  162. package/dist/types/src/controllers/index.d.ts +18 -0
  163. package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
  164. package/dist/types/src/controllers/navigation.d.ts +213 -0
  165. package/dist/types/src/controllers/registry.d.ts +54 -0
  166. package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
  167. package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
  168. package/dist/types/src/controllers/snackbar.d.ts +24 -0
  169. package/dist/types/src/controllers/storage.d.ts +171 -0
  170. package/dist/types/src/index.d.ts +4 -0
  171. package/dist/types/src/rebase_context.d.ts +105 -0
  172. package/dist/types/src/types/backend.d.ts +536 -0
  173. package/dist/types/src/types/backend_hooks.d.ts +187 -0
  174. package/dist/types/src/types/builders.d.ts +15 -0
  175. package/dist/types/src/types/chips.d.ts +5 -0
  176. package/dist/types/src/types/collections.d.ts +857 -0
  177. package/dist/types/src/types/cron.d.ts +102 -0
  178. package/dist/types/src/types/data_source.d.ts +64 -0
  179. package/dist/types/src/types/entities.d.ts +145 -0
  180. package/dist/types/src/types/entity_actions.d.ts +98 -0
  181. package/dist/types/src/types/entity_callbacks.d.ts +173 -0
  182. package/dist/types/src/types/entity_link_builder.d.ts +7 -0
  183. package/dist/types/src/types/entity_overrides.d.ts +10 -0
  184. package/dist/types/src/types/entity_views.d.ts +59 -0
  185. package/dist/types/src/types/export_import.d.ts +21 -0
  186. package/dist/types/src/types/formex.d.ts +40 -0
  187. package/dist/types/src/types/index.d.ts +25 -0
  188. package/dist/types/src/types/locales.d.ts +4 -0
  189. package/dist/types/src/types/modify_collections.d.ts +5 -0
  190. package/dist/types/src/types/plugins.d.ts +282 -0
  191. package/dist/types/src/types/properties.d.ts +1148 -0
  192. package/dist/types/src/types/property_config.d.ts +70 -0
  193. package/dist/types/src/types/relations.d.ts +336 -0
  194. package/dist/types/src/types/slots.d.ts +262 -0
  195. package/dist/types/src/types/translations.d.ts +874 -0
  196. package/dist/types/src/types/user_management_delegate.d.ts +121 -0
  197. package/dist/types/src/types/websockets.d.ts +78 -0
  198. package/dist/types/src/users/index.d.ts +2 -0
  199. package/dist/types/src/users/roles.d.ts +22 -0
  200. package/dist/types/src/users/user.d.ts +46 -0
  201. package/history_diff.log +385 -0
  202. package/jest.config.cjs +16 -0
  203. package/package.json +86 -0
  204. package/scratch.ts +9 -0
  205. package/src/api/ast-schema-editor.ts +289 -0
  206. package/src/api/collections_for_test/callbacks_test_collection.ts +60 -0
  207. package/src/api/errors.ts +179 -0
  208. package/src/api/graphql/graphql-schema-generator.ts +336 -0
  209. package/src/api/graphql/index.ts +2 -0
  210. package/src/api/index.ts +11 -0
  211. package/src/api/openapi-generator.ts +715 -0
  212. package/src/api/rest/api-generator-count.test.ts +113 -0
  213. package/src/api/rest/api-generator.ts +573 -0
  214. package/src/api/rest/index.ts +2 -0
  215. package/src/api/rest/query-parser.ts +155 -0
  216. package/src/api/schema-editor-routes.ts +41 -0
  217. package/src/api/server.ts +249 -0
  218. package/src/api/types.ts +90 -0
  219. package/src/auth/admin-routes.ts +605 -0
  220. package/src/auth/apple-oauth.ts +120 -0
  221. package/src/auth/bitbucket-oauth.ts +82 -0
  222. package/src/auth/discord-oauth.ts +83 -0
  223. package/src/auth/facebook-oauth.ts +72 -0
  224. package/src/auth/github-oauth.ts +110 -0
  225. package/src/auth/gitlab-oauth.ts +70 -0
  226. package/src/auth/google-oauth.ts +48 -0
  227. package/src/auth/index.ts +34 -0
  228. package/src/auth/interfaces.ts +363 -0
  229. package/src/auth/jwt.ts +181 -0
  230. package/src/auth/linkedin-oauth.ts +81 -0
  231. package/src/auth/microsoft-oauth.ts +88 -0
  232. package/src/auth/middleware.ts +384 -0
  233. package/src/auth/password.ts +77 -0
  234. package/src/auth/rate-limiter.ts +133 -0
  235. package/src/auth/routes.ts +788 -0
  236. package/src/auth/slack-oauth.ts +71 -0
  237. package/src/auth/spotify-oauth.ts +67 -0
  238. package/src/auth/twitter-oauth.ts +120 -0
  239. package/src/bootstrappers/index.ts +1 -0
  240. package/src/collections/BackendCollectionRegistry.ts +20 -0
  241. package/src/collections/loader.ts +49 -0
  242. package/src/cron/cron-loader.ts +89 -0
  243. package/src/cron/cron-routes.test.ts +265 -0
  244. package/src/cron/cron-routes.ts +85 -0
  245. package/src/cron/cron-scheduler.test.ts +547 -0
  246. package/src/cron/cron-scheduler.ts +576 -0
  247. package/src/cron/cron-store.ts +163 -0
  248. package/src/cron/index.ts +6 -0
  249. package/src/db/interfaces.ts +60 -0
  250. package/src/email/index.ts +18 -0
  251. package/src/email/smtp-email-service.ts +91 -0
  252. package/src/email/templates.ts +388 -0
  253. package/src/email/types.ts +105 -0
  254. package/src/functions/function-loader.ts +119 -0
  255. package/src/functions/function-routes.ts +31 -0
  256. package/src/functions/index.ts +3 -0
  257. package/src/history/history-routes.ts +129 -0
  258. package/src/history/index.ts +2 -0
  259. package/src/index.ts +66 -0
  260. package/src/init.ts +737 -0
  261. package/src/serve-spa.ts +81 -0
  262. package/src/services/driver-registry.ts +182 -0
  263. package/src/singleton.test.ts +28 -0
  264. package/src/singleton.ts +70 -0
  265. package/src/storage/LocalStorageController.ts +365 -0
  266. package/src/storage/S3StorageController.ts +298 -0
  267. package/src/storage/index.ts +43 -0
  268. package/src/storage/routes.ts +264 -0
  269. package/src/storage/storage-registry.ts +187 -0
  270. package/src/storage/types.ts +134 -0
  271. package/src/types/index.ts +27 -0
  272. package/src/utils/dev-port.ts +176 -0
  273. package/src/utils/logger.ts +143 -0
  274. package/src/utils/logging.ts +38 -0
  275. package/src/utils/request-logger.ts +66 -0
  276. package/src/utils/sql.ts +38 -0
  277. package/test/admin-routes.test.ts +640 -0
  278. package/test/api-generator.test.ts +501 -0
  279. package/test/ast-schema-editor.test.ts +63 -0
  280. package/test/auth-middleware-hono.test.ts +556 -0
  281. package/test/auth-routes.test.ts +1047 -0
  282. package/test/backend-hooks-admin.test.ts +394 -0
  283. package/test/backend-hooks-data.test.ts +408 -0
  284. package/test/driver-registry.test.ts +282 -0
  285. package/test/error-propagation.test.ts +226 -0
  286. package/test/errors-hono.test.ts +133 -0
  287. package/test/errors.test.ts +155 -0
  288. package/test/jwt-security.test.ts +182 -0
  289. package/test/jwt.test.ts +324 -0
  290. package/test/middleware.test.ts +300 -0
  291. package/test/password.test.ts +165 -0
  292. package/test/query-parser.test.ts +263 -0
  293. package/test/rate-limiter.test.ts +102 -0
  294. package/test/safe-compare.test.ts +66 -0
  295. package/test/singleton.test.ts +59 -0
  296. package/test/storage-local.test.ts +271 -0
  297. package/test/storage-registry.test.ts +282 -0
  298. package/test/storage-routes.test.ts +222 -0
  299. package/test/storage-s3.test.ts +304 -0
  300. package/test-ast.ts +28 -0
  301. package/test.ts +6 -0
  302. package/test_output.txt +1133 -0
  303. package/tsconfig.json +49 -0
  304. package/tsconfig.prod.json +20 -0
  305. package/vite.config.ts +80 -0
@@ -0,0 +1,113 @@
1
+ import { jest } from "@jest/globals";
2
+ import { RestApiGenerator } from "./api-generator";
3
+ import type { DataDriver, EntityCollection, FetchCollectionProps } from "@rebasepro/types";
4
+
5
+ /**
6
+ * Minimal mock DataDriver for testing.
7
+ */
8
+ function createMockDriver(overrides?: Partial<DataDriver>): DataDriver {
9
+ return {
10
+ fetchCollection: jest.fn().mockResolvedValue([]),
11
+ fetchEntity: jest.fn().mockResolvedValue(null),
12
+ saveEntity: jest.fn().mockResolvedValue({ id: "1", path: "test", values: {} }),
13
+ deleteEntity: jest.fn().mockResolvedValue(undefined),
14
+ countEntities: jest.fn().mockResolvedValue(0),
15
+ ...overrides,
16
+ } as unknown as DataDriver;
17
+ }
18
+
19
+ function createTestCollection(slug: string): EntityCollection {
20
+ return {
21
+ slug,
22
+ name: slug.charAt(0).toUpperCase() + slug.slice(1),
23
+ path: slug,
24
+ properties: {},
25
+ } as unknown as EntityCollection;
26
+ }
27
+
28
+ describe("RestApiGenerator - Count Endpoint", () => {
29
+ let driver: DataDriver;
30
+ let collection: EntityCollection;
31
+
32
+ beforeEach(() => {
33
+ driver = createMockDriver({
34
+ countEntities: jest.fn().mockResolvedValue(42),
35
+ });
36
+ collection = createTestCollection("products");
37
+ });
38
+
39
+ it("GET /products/count should return a count object", async () => {
40
+ const generator = new RestApiGenerator([collection], driver);
41
+ const app = generator.generateRoutes();
42
+
43
+ const res = await app.request("/products/count");
44
+ expect(res.status).toBe(200);
45
+
46
+ const json = await res.json() as { count: number };
47
+ expect(json.count).toBe(42);
48
+ });
49
+
50
+ it("should pass filters to countEntities driver", async () => {
51
+ const generator = new RestApiGenerator([collection], driver);
52
+ const app = generator.generateRoutes();
53
+
54
+ const res = await app.request("/products/count?status=eq.active");
55
+ expect(res.status).toBe(200);
56
+
57
+ const json = await res.json() as { count: number };
58
+ expect(json.count).toBe(42);
59
+
60
+ // Verify countEntities was called with the filter
61
+ expect(driver.countEntities).toHaveBeenCalled();
62
+ const callArgs = (driver.countEntities as ReturnType<typeof jest.fn>).mock.calls[0][0] as FetchCollectionProps;
63
+ expect(callArgs.path).toBe("products");
64
+ expect(callArgs.filter).toHaveProperty("status");
65
+ });
66
+
67
+ it("should pass searchString to countEntities driver", async () => {
68
+ const generator = new RestApiGenerator([collection], driver);
69
+ const app = generator.generateRoutes();
70
+
71
+ const res = await app.request("/products/count?searchString=widget");
72
+ expect(res.status).toBe(200);
73
+
74
+ const json = await res.json() as { count: number };
75
+ expect(json.count).toBe(42);
76
+
77
+ expect(driver.countEntities).toHaveBeenCalled();
78
+ const callArgs = (driver.countEntities as ReturnType<typeof jest.fn>).mock.calls[0][0] as FetchCollectionProps;
79
+ expect(callArgs.searchString).toBe("widget");
80
+ });
81
+
82
+ it("should return 0 when countEntities is not available on driver", async () => {
83
+ const driverWithoutCount = createMockDriver({ countEntities: undefined });
84
+ const generator = new RestApiGenerator([collection], driverWithoutCount);
85
+ const app = generator.generateRoutes();
86
+
87
+ const res = await app.request("/products/count");
88
+ expect(res.status).toBe(200);
89
+
90
+ const json = await res.json() as { count: number };
91
+ expect(json.count).toBe(0);
92
+ });
93
+
94
+ it("GET /products/count should not be confused with GET /products/:id", async () => {
95
+ // Ensure the count route is registered before the :id route
96
+ const fetchEntity = jest.fn().mockResolvedValue(null);
97
+ const driverCustom = createMockDriver({
98
+ countEntities: jest.fn().mockResolvedValue(99),
99
+ fetchEntity,
100
+ });
101
+ const generator = new RestApiGenerator([collection], driverCustom);
102
+ const app = generator.generateRoutes();
103
+
104
+ const res = await app.request("/products/count");
105
+ expect(res.status).toBe(200);
106
+
107
+ const json = await res.json() as { count: number };
108
+ expect(json.count).toBe(99);
109
+
110
+ // fetchEntity should NOT have been called (i.e. "count" was not treated as an entity ID)
111
+ expect(fetchEntity).not.toHaveBeenCalled();
112
+ });
113
+ });
@@ -0,0 +1,573 @@
1
+ import { Hono } from "hono";
2
+ import { DataDriver, Entity, EntityCollection, FetchCollectionProps, DataHooks, BackendHookContext, RestFetchService } from "@rebasepro/types";
3
+ import { QueryOptions, HonoEnv } from "../types";
4
+ import { ApiError } from "../errors";
5
+ import { parseQueryOptions } from "./query-parser";
6
+
7
+
8
+ /**
9
+ * Lightweight REST API generator that leverages existing Rebase DataDriver.
10
+ * Supports `include` query parameter for eager-loading relations via Drizzle.
11
+ */
12
+ export class RestApiGenerator {
13
+ private collections: EntityCollection[];
14
+ private router: Hono<HonoEnv>;
15
+ private driver: DataDriver;
16
+ private dataHooks?: DataHooks;
17
+
18
+ constructor(collections: EntityCollection[], driver: DataDriver, dataHooks?: DataHooks) {
19
+ this.collections = collections;
20
+ this.driver = driver;
21
+ this.dataHooks = dataHooks;
22
+ this.router = new Hono<HonoEnv>();
23
+ }
24
+
25
+ /** Build a BackendHookContext from a Hono context */
26
+ private buildHookContext(c: { get: (key: string) => unknown }, method: BackendHookContext["method"]): BackendHookContext {
27
+ const user = c.get("user") as { userId: string; roles?: string[] } | undefined;
28
+ return {
29
+ requestUser: user ? { userId: user.userId, roles: user.roles ?? [] } : undefined,
30
+ method
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Generate REST routes using existing DataDriver
36
+ */
37
+ generateRoutes(): Hono<HonoEnv> {
38
+ this.collections.forEach(collection => {
39
+ this.createCollectionRoutes(collection);
40
+ });
41
+
42
+ // Catch-all routes for subcollection paths like
43
+ // /authors/111094/posts and /authors/111094/posts/43
44
+ // The DataDriver already knows how to resolve nested relation paths.
45
+ this.createSubcollectionRoutes();
46
+
47
+ return this.router;
48
+ }
49
+
50
+ /**
51
+ * Get the typed RestFetchService from a driver if it exposes one (for include support).
52
+ */
53
+ private getFetchService(driver: DataDriver): RestFetchService | undefined {
54
+ return driver.restFetchService;
55
+ }
56
+
57
+ /**
58
+ * Create REST routes for a collection using existing Rebase patterns
59
+ */
60
+ private createCollectionRoutes(collection: EntityCollection): void {
61
+ const basePath = `/${collection.slug}`;
62
+ const resolvedCollection = collection;
63
+
64
+ // GET /collection/count - Count entities (with optional filters)
65
+ this.router.get(`${basePath}/count`, async (c) => {
66
+ const queryDict = c.req.query();
67
+ const queryOptions = parseQueryOptions(queryDict);
68
+ const searchString = queryDict.searchString as string | undefined;
69
+ const driver = c.get("driver") || this.driver;
70
+
71
+ const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
72
+ return c.json({ count: total });
73
+ });
74
+
75
+ // GET /collection - List entities
76
+ this.router.get(basePath, async (c) => {
77
+ const queryDict = c.req.query();
78
+ const queryOptions = parseQueryOptions(queryDict);
79
+ const searchString = queryDict.searchString as string | undefined;
80
+
81
+ const driver = c.get("driver") || this.driver;
82
+ const fetchService = this.getFetchService(driver);
83
+ const hookCtx = this.buildHookContext(c, "GET");
84
+
85
+ // Use include-aware path when available
86
+ if (fetchService) {
87
+ const collectionPath = collection.slug;
88
+ let entities = await fetchService.fetchCollectionForRest(
89
+ collectionPath,
90
+ {
91
+ filter: queryOptions.where as FetchCollectionProps["filter"],
92
+ limit: queryOptions.limit,
93
+ offset: queryOptions.offset,
94
+ orderBy: queryOptions.orderBy?.[0]?.field,
95
+ order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
96
+ searchString
97
+ },
98
+ queryOptions.include
99
+ );
100
+
101
+ entities = await this.applyAfterReadBatch(collection.slug, entities, hookCtx);
102
+
103
+ const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
104
+
105
+ return c.json({
106
+ data: entities,
107
+ meta: {
108
+ total,
109
+ limit: queryOptions.limit,
110
+ offset: queryOptions.offset,
111
+ hasMore: (queryOptions.offset || 0) + entities.length < total
112
+ }
113
+ });
114
+ }
115
+
116
+ // Fallback path
117
+ let entities = await this.fetchRawCollection(driver, resolvedCollection, queryOptions, searchString);
118
+
119
+ entities = await this.applyAfterReadBatch(collection.slug, entities, hookCtx);
120
+
121
+ const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
122
+
123
+ return c.json({
124
+ data: entities,
125
+ meta: {
126
+ total,
127
+ limit: queryOptions.limit,
128
+ offset: queryOptions.offset,
129
+ hasMore: (queryOptions.offset || 0) + entities.length < total
130
+ }
131
+ });
132
+ });
133
+
134
+ // GET /collection/:id - Get single entity
135
+ this.router.get(`${basePath}/:id`, async (c) => {
136
+ const id = c.req.param("id");
137
+ const queryDict = c.req.query();
138
+ const queryOptions = parseQueryOptions(queryDict);
139
+ const driver = c.get("driver") || this.driver;
140
+ const fetchService = this.getFetchService(driver);
141
+ const hookCtx = this.buildHookContext(c, "GET");
142
+
143
+ // Use include-aware path when available
144
+ if (fetchService) {
145
+ const collectionPath = collection.slug;
146
+ let entity = await fetchService.fetchEntityForRest(
147
+ collectionPath,
148
+ String(id),
149
+ queryOptions.include
150
+ );
151
+
152
+ if (!entity) {
153
+ throw ApiError.notFound("Entity not found");
154
+ }
155
+
156
+ entity = await this.applyAfterRead(collection.slug, entity, hookCtx);
157
+ if (!entity) {
158
+ throw ApiError.notFound("Entity not found");
159
+ }
160
+
161
+ return c.json(entity);
162
+ }
163
+
164
+ // Fallback
165
+ let entity = await this.fetchRawEntity(driver, resolvedCollection, String(id));
166
+
167
+ if (!entity) {
168
+ throw ApiError.notFound("Entity not found");
169
+ }
170
+
171
+ entity = await this.applyAfterRead(collection.slug, entity, hookCtx);
172
+ if (!entity) {
173
+ throw ApiError.notFound("Entity not found");
174
+ }
175
+
176
+ return c.json(entity);
177
+ });
178
+
179
+ // POST /collection - Create entity
180
+ this.router.post(basePath, async (c) => {
181
+ try {
182
+ const driver = c.get("driver") || this.driver;
183
+ const path = collection.slug;
184
+ const hookCtx = this.buildHookContext(c, "POST");
185
+
186
+ let body = await c.req.json().catch(() => ({}));
187
+
188
+ if (this.dataHooks?.beforeSave) {
189
+ body = await this.dataHooks.beforeSave(path, body, undefined, hookCtx);
190
+ }
191
+
192
+ const entity = await driver.saveEntity({
193
+ path,
194
+ values: body,
195
+ collection: resolvedCollection,
196
+ status: "new"
197
+ });
198
+
199
+ const response = this.formatResponse(entity);
200
+
201
+ if (this.dataHooks?.afterSave) {
202
+ Promise.resolve(this.dataHooks.afterSave(path, response as Record<string, unknown>, hookCtx)).catch(err => {
203
+ console.error("[BackendHooks] data.afterSave error:", err instanceof Error ? err.message : err);
204
+ });
205
+ }
206
+
207
+ return c.json(response, 201);
208
+ } catch (error) {
209
+ const err = error as Error & { code?: string };
210
+ err.code = err.code || "BAD_REQUEST";
211
+ throw err;
212
+ }
213
+ });
214
+
215
+ // PUT /collection/:id - Update entity
216
+ this.router.put(`${basePath}/:id`, async (c) => {
217
+ try {
218
+ const id = c.req.param("id");
219
+ const driver = c.get("driver") || this.driver;
220
+ const hookCtx = this.buildHookContext(c, "PUT");
221
+
222
+ const existingEntity = await driver.fetchEntity({
223
+ path: collection.slug,
224
+ entityId: String(id),
225
+ collection: resolvedCollection
226
+ });
227
+
228
+ if (!existingEntity) {
229
+ throw ApiError.notFound("Entity not found");
230
+ }
231
+
232
+ let body = await c.req.json().catch(() => ({}));
233
+
234
+ if (this.dataHooks?.beforeSave) {
235
+ body = await this.dataHooks.beforeSave(collection.slug, body, String(id), hookCtx);
236
+ }
237
+
238
+ const entity = await driver.saveEntity({
239
+ path: collection.slug,
240
+ entityId: String(id),
241
+ values: body,
242
+ collection: resolvedCollection,
243
+ status: "existing"
244
+ });
245
+
246
+ const response = this.formatResponse(entity);
247
+
248
+ if (this.dataHooks?.afterSave) {
249
+ Promise.resolve(this.dataHooks.afterSave(collection.slug, response as Record<string, unknown>, hookCtx)).catch(err => {
250
+ console.error("[BackendHooks] data.afterSave error:", err instanceof Error ? err.message : err);
251
+ });
252
+ }
253
+
254
+ return c.json(response);
255
+ } catch (error) {
256
+ const err = error as Error & { code?: string };
257
+ err.code = err.code || "BAD_REQUEST";
258
+ throw err;
259
+ }
260
+ });
261
+
262
+ // DELETE /collection/:id - Delete entity
263
+ this.router.delete(`${basePath}/:id`, async (c) => {
264
+ const id = c.req.param("id");
265
+ const driver = c.get("driver") || this.driver;
266
+ const hookCtx = this.buildHookContext(c, "DELETE");
267
+
268
+ const existingEntity = await driver.fetchEntity({
269
+ path: collection.slug,
270
+ entityId: String(id),
271
+ collection: resolvedCollection
272
+ });
273
+
274
+ if (!existingEntity) {
275
+ throw ApiError.notFound("Entity not found");
276
+ }
277
+
278
+ if (this.dataHooks?.beforeDelete) {
279
+ await this.dataHooks.beforeDelete(collection.slug, String(id), hookCtx);
280
+ }
281
+
282
+ await driver.deleteEntity({
283
+ entity: existingEntity,
284
+ collection: resolvedCollection
285
+ });
286
+
287
+ if (this.dataHooks?.afterDelete) {
288
+ Promise.resolve(this.dataHooks.afterDelete(collection.slug, String(id), hookCtx)).catch(err => {
289
+ console.error("[BackendHooks] data.afterDelete error:", err instanceof Error ? err.message : err);
290
+ });
291
+ }
292
+
293
+ return new Response(null, { status: 204 });
294
+ });
295
+ }
296
+
297
+ /**
298
+ * Catch-all routes for subcollection paths.
299
+ *
300
+ * Matches URL patterns like:
301
+ * GET /authors/111094/posts → list child collection
302
+ * GET /authors/111094/posts/43 → get child entity
303
+ * POST /authors/111094/posts → create child entity
304
+ * PUT /authors/111094/posts/43 → update child entity
305
+ * DELETE /authors/111094/posts/43 → delete child entity
306
+ *
307
+ * The `:rest{.+}` regex param captures the full remainder of the URL
308
+ * path (Hono v4 `*` wildcard does not populate `c.req.param("*")`).
309
+ * We split it into segments and reconstruct the `collectionPath`
310
+ * (e.g. "authors/111094/posts") and optional `entityId` (e.g. "43").
311
+ *
312
+ * The DataDriver.saveEntity / fetchCollection / etc. already know how to
313
+ * resolve multi-segment relation paths, so we just forward to them.
314
+ */
315
+ private createSubcollectionRoutes(): void {
316
+ // Reserved path segments that should NOT be treated as relation names.
317
+ // These are handled by dedicated route handlers (e.g., history routes)
318
+ // mounted on the same data router.
319
+ const RESERVED_SEGMENTS = new Set(["history"]);
320
+
321
+ // Helper: parse a path like "authors/111094/posts/43" into
322
+ // { collectionPath: "authors/111094/posts", entityId: "43" }
323
+ // or "authors/111094/posts" into
324
+ // { collectionPath: "authors/111094/posts", entityId: undefined }
325
+ const parseSubPath = (rawPath: string): { collectionPath: string; entityId?: string } | null => {
326
+ const segments = rawPath.split("/").filter(s => s && s !== "undefined");
327
+ // Need at least 3 segments for a subcollection path (parent/id/child)
328
+ if (segments.length < 3) return null;
329
+
330
+ // If any segment is a reserved path (e.g. "history"), this is not a
331
+ // subcollection route — let it fall through to other handlers.
332
+ if (segments.some(s => RESERVED_SEGMENTS.has(s))) return null;
333
+
334
+ // Odd segment count → collection path (parent/id/child or parent/id/child/id2/grandchild)
335
+ // Even segment count → entity path (parent/id/child/entityId)
336
+ if (segments.length % 2 === 1) {
337
+ return { collectionPath: segments.join("/") };
338
+ } else {
339
+ const entityId = segments.pop()!;
340
+ return { collectionPath: segments.join("/"),
341
+ entityId };
342
+ }
343
+ };
344
+
345
+ // GET /<subcollection-path> — list or get single entity
346
+ // Use :rest{.+} instead of * because Hono v4's wildcard doesn't
347
+ // capture into c.req.param("*") — it always returns undefined.
348
+ this.router.get("/:parent/:parentId/:rest{.+}", async (c, next) => {
349
+ const rest = c.req.param("rest");
350
+ if (!rest || rest === "undefined") return next();
351
+ const rawPath = `${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`;
352
+ const parsed = parseSubPath(rawPath);
353
+ if (!parsed) return next();
354
+
355
+ const driver = c.get("driver") || this.driver;
356
+
357
+ if (parsed.entityId === "count") {
358
+ // GET /parent/:parentId/child/count — count child entities
359
+ const queryDict = c.req.query();
360
+ const queryOptions = parseQueryOptions(queryDict);
361
+
362
+ const total = driver.countEntities ? await driver.countEntities({
363
+ path: parsed.collectionPath,
364
+ filter: queryOptions.where as FetchCollectionProps["filter"],
365
+ searchString: queryDict.searchString as string | undefined
366
+ }) : 0;
367
+
368
+ return c.json({ count: total });
369
+ } else if (parsed.entityId) {
370
+ // GET /parent/:parentId/child/:id — single entity
371
+ const entity = await driver.fetchEntity({
372
+ path: parsed.collectionPath,
373
+ entityId: parsed.entityId
374
+ });
375
+ if (!entity) throw ApiError.notFound("Entity not found");
376
+ return c.json(this.flattenEntity(entity));
377
+ } else {
378
+ // GET /parent/:parentId/child — list entities
379
+ const queryDict = c.req.query();
380
+ const queryOptions = parseQueryOptions(queryDict);
381
+ const entities = await driver.fetchCollection({
382
+ path: parsed.collectionPath,
383
+ filter: queryOptions.where as FetchCollectionProps["filter"],
384
+ limit: queryOptions.limit,
385
+ orderBy: queryOptions.orderBy?.[0]?.field,
386
+ order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
387
+ searchString: queryDict.searchString as string | undefined
388
+ });
389
+ return c.json({
390
+ data: entities.map(e => this.flattenEntity(e)),
391
+ meta: {
392
+ total: entities.length,
393
+ limit: queryOptions.limit,
394
+ offset: queryOptions.offset,
395
+ hasMore: false
396
+ }
397
+ });
398
+ }
399
+ });
400
+
401
+ // POST /<subcollection-path> — create entity
402
+ this.router.post("/:parent/:parentId/:rest{.+}", async (c, next) => {
403
+ const rest = c.req.param("rest");
404
+ if (!rest || rest === "undefined") return next();
405
+ const rawPath = `${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`;
406
+ const parsed = parseSubPath(rawPath);
407
+ if (!parsed || parsed.entityId) return next();
408
+
409
+ const driver = c.get("driver") || this.driver;
410
+ const body = await c.req.json().catch(() => ({}));
411
+
412
+ const entity = await driver.saveEntity({
413
+ path: parsed.collectionPath,
414
+ values: body,
415
+ status: "new"
416
+ });
417
+
418
+ return c.json(this.formatResponse(entity), 201);
419
+ });
420
+
421
+ // PUT /<subcollection-path>/:id — update entity
422
+ this.router.put("/:parent/:parentId/:rest{.+}", async (c, next) => {
423
+ const rest = c.req.param("rest");
424
+ if (!rest || rest === "undefined") return next();
425
+ const rawPath = `${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`;
426
+ const parsed = parseSubPath(rawPath);
427
+ if (!parsed || !parsed.entityId) return next();
428
+
429
+ const driver = c.get("driver") || this.driver;
430
+ const body = await c.req.json().catch(() => ({}));
431
+
432
+ const entity = await driver.saveEntity({
433
+ path: parsed.collectionPath,
434
+ entityId: parsed.entityId,
435
+ values: body,
436
+ status: "existing"
437
+ });
438
+
439
+ return c.json(this.formatResponse(entity));
440
+ });
441
+
442
+ // DELETE /<subcollection-path>/:id — delete entity
443
+ this.router.delete("/:parent/:parentId/:rest{.+}", async (c, next) => {
444
+ const rest = c.req.param("rest");
445
+ if (!rest || rest === "undefined") return next();
446
+ const rawPath = `${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`;
447
+ const parsed = parseSubPath(rawPath);
448
+ if (!parsed || !parsed.entityId) return next();
449
+
450
+ const driver = c.get("driver") || this.driver;
451
+
452
+ const existingEntity = await driver.fetchEntity({
453
+ path: parsed.collectionPath,
454
+ entityId: parsed.entityId
455
+ });
456
+
457
+ if (!existingEntity) throw ApiError.notFound("Entity not found");
458
+
459
+ await driver.deleteEntity({ entity: existingEntity });
460
+
461
+ return new Response(null, { status: 204 });
462
+ });
463
+ }
464
+
465
+ /**
466
+ * Format successful API response - flattened for traditional REST API
467
+ */
468
+ private formatResponse<T>(data: T, meta?: Record<string, unknown>): unknown {
469
+ if (Array.isArray(data)) {
470
+ const flattenedData = data.map(entity => this.flattenEntity(entity));
471
+ if (meta) {
472
+ return {
473
+ data: flattenedData,
474
+ meta
475
+ };
476
+ }
477
+ return flattenedData;
478
+ }
479
+
480
+ if (data && typeof data === "object" && "values" in data) {
481
+ return this.flattenEntity(data as unknown as Entity<Record<string, unknown>>);
482
+ }
483
+
484
+ if (meta) {
485
+ return {
486
+ data,
487
+ meta
488
+ };
489
+ }
490
+ return data;
491
+ }
492
+
493
+ /**
494
+ * Flatten Rebase entity structure to traditional REST format
495
+ */
496
+ private flattenEntity(entity: Entity<Record<string, unknown>>): Record<string, unknown> {
497
+ if (!entity || typeof entity !== "object") {
498
+ return entity;
499
+ }
500
+
501
+ if ("values" in entity && typeof entity.values === "object") {
502
+ return {
503
+ id: entity.id,
504
+ ...entity.values
505
+ };
506
+ }
507
+
508
+ return entity as unknown as Record<string, unknown>;
509
+ }
510
+
511
+ /**
512
+ * Fetch raw collection data without Entity wrapper (fallback for non-Postgres)
513
+ */
514
+ private async fetchRawCollection(driver: DataDriver, collection: EntityCollection, queryOptions: QueryOptions, searchString?: string) {
515
+ const entities = await driver.fetchCollection({
516
+ path: collection.slug,
517
+ collection,
518
+ filter: queryOptions.where as FetchCollectionProps["filter"],
519
+ limit: queryOptions.limit,
520
+ orderBy: queryOptions.orderBy?.[0]?.field,
521
+ order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
522
+ startAfter: queryOptions.offset ? String(queryOptions.offset) : undefined,
523
+ searchString
524
+ });
525
+
526
+ return entities.map(entity => this.flattenEntity(entity));
527
+ }
528
+
529
+ /**
530
+ * Count raw entities for a collection
531
+ */
532
+ private async countRawEntities(driver: DataDriver, collection: EntityCollection, queryOptions: QueryOptions, searchString?: string): Promise<number> {
533
+ return driver.countEntities ? await driver.countEntities({
534
+ path: collection.slug,
535
+ collection,
536
+ filter: queryOptions.where as FetchCollectionProps["filter"],
537
+ searchString
538
+ }) : 0;
539
+ }
540
+
541
+ /**
542
+ * Fetch single entity raw data without Entity wrapper (fallback)
543
+ */
544
+ private async fetchRawEntity(driver: DataDriver, collection: EntityCollection, entityId: string) {
545
+ const entity = await driver.fetchEntity({
546
+ path: collection.slug,
547
+ entityId,
548
+ collection
549
+ });
550
+
551
+ return entity ? this.flattenEntity(entity) : null;
552
+ }
553
+
554
+ /**
555
+ * Apply data.afterRead hook to a single entity.
556
+ * Returns the transformed entity, or null to filter it out.
557
+ */
558
+ private async applyAfterRead(slug: string, entity: Record<string, unknown>, ctx: BackendHookContext): Promise<Record<string, unknown> | null> {
559
+ if (!this.dataHooks?.afterRead) return entity;
560
+ return this.dataHooks.afterRead(slug, entity, ctx);
561
+ }
562
+
563
+ /**
564
+ * Apply data.afterRead hook to an array of entities, filtering out nulls.
565
+ */
566
+ private async applyAfterReadBatch(slug: string, entities: Record<string, unknown>[], ctx: BackendHookContext): Promise<Record<string, unknown>[]> {
567
+ if (!this.dataHooks?.afterRead) return entities;
568
+ const results = await Promise.all(
569
+ entities.map(e => this.applyAfterRead(slug, e, ctx))
570
+ );
571
+ return results.filter((e): e is Record<string, unknown> => e !== null);
572
+ }
573
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./api-generator";
2
+