alepha 0.20.3 → 0.20.5

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 (218) hide show
  1. package/dist/api/audits/index.d.ts.map +1 -1
  2. package/dist/api/files/index.d.ts.map +1 -1
  3. package/dist/api/jobs/index.d.ts +14 -14
  4. package/dist/api/jobs/index.d.ts.map +1 -1
  5. package/dist/api/organizations/index.d.ts.map +1 -1
  6. package/dist/api/parameters/index.d.ts +6 -1
  7. package/dist/api/parameters/index.d.ts.map +1 -1
  8. package/dist/api/parameters/index.js +20 -4
  9. package/dist/api/parameters/index.js.map +1 -1
  10. package/dist/api/payments/index.d.ts.map +1 -1
  11. package/dist/api/users/index.browser.js +6 -0
  12. package/dist/api/users/index.browser.js.map +1 -1
  13. package/dist/api/users/index.d.ts +5032 -134
  14. package/dist/api/users/index.d.ts.map +1 -1
  15. package/dist/api/users/index.js +58 -10
  16. package/dist/api/users/index.js.map +1 -1
  17. package/dist/bin/index.js +0 -0
  18. package/dist/bucket/index.d.ts +77 -107
  19. package/dist/bucket/index.d.ts.map +1 -1
  20. package/dist/bucket/index.js +148 -4
  21. package/dist/bucket/index.js.map +1 -1
  22. package/dist/bucket/index.workerd.js +7 -1
  23. package/dist/bucket/index.workerd.js.map +1 -1
  24. package/dist/cache/core/index.d.ts +26 -0
  25. package/dist/cache/core/index.d.ts.map +1 -1
  26. package/dist/cache/core/index.js +11 -1
  27. package/dist/cache/core/index.js.map +1 -1
  28. package/dist/cache/core/index.workerd.js +11 -1
  29. package/dist/cache/core/index.workerd.js.map +1 -1
  30. package/dist/cli/config/index.d.ts +7 -5
  31. package/dist/cli/config/index.d.ts.map +1 -1
  32. package/dist/cli/config/index.js +2 -3
  33. package/dist/cli/config/index.js.map +1 -1
  34. package/dist/cli/core/index.d.ts +419 -12
  35. package/dist/cli/core/index.d.ts.map +1 -1
  36. package/dist/cli/core/index.js +22 -511
  37. package/dist/cli/core/index.js.map +1 -1
  38. package/dist/cli/devtools/index.d.ts +4 -8
  39. package/dist/cli/devtools/index.d.ts.map +1 -1
  40. package/dist/cli/devtools/index.js +13 -15
  41. package/dist/cli/devtools/index.js.map +1 -1
  42. package/dist/cli/platform/index.d.ts +10 -13
  43. package/dist/cli/platform/index.d.ts.map +1 -1
  44. package/dist/cli/platform/index.js +18 -15
  45. package/dist/cli/platform/index.js.map +1 -1
  46. package/dist/cli/vendor/index.d.ts +10 -13
  47. package/dist/cli/vendor/index.d.ts.map +1 -1
  48. package/dist/cli/vendor/index.js +16 -13
  49. package/dist/cli/vendor/index.js.map +1 -1
  50. package/dist/command/index.d.ts +1 -1
  51. package/dist/core/index.browser.js +27 -3
  52. package/dist/core/index.browser.js.map +1 -1
  53. package/dist/core/index.d.ts +6 -3
  54. package/dist/core/index.d.ts.map +1 -1
  55. package/dist/core/index.js +27 -3
  56. package/dist/core/index.js.map +1 -1
  57. package/dist/core/index.native.js +27 -3
  58. package/dist/core/index.native.js.map +1 -1
  59. package/dist/core/index.workerd.js +27 -3
  60. package/dist/core/index.workerd.js.map +1 -1
  61. package/dist/datetime/index.d.ts +69 -10
  62. package/dist/datetime/index.d.ts.map +1 -1
  63. package/dist/datetime/index.js +135 -13
  64. package/dist/datetime/index.js.map +1 -1
  65. package/dist/email/smtp/index.js +10636 -2
  66. package/dist/email/smtp/index.js.map +1 -1
  67. package/dist/fake/index.d.ts +8085 -4
  68. package/dist/fake/index.d.ts.map +1 -1
  69. package/dist/fake/index.js +33554 -3
  70. package/dist/fake/index.js.map +1 -1
  71. package/dist/lock/core/index.d.ts +30 -2
  72. package/dist/lock/core/index.d.ts.map +1 -1
  73. package/dist/lock/core/index.js +35 -12
  74. package/dist/lock/core/index.js.map +1 -1
  75. package/dist/mcp/index.d.ts +238 -31
  76. package/dist/mcp/index.d.ts.map +1 -1
  77. package/dist/mcp/index.js +198 -71
  78. package/dist/mcp/index.js.map +1 -1
  79. package/dist/orm/core/index.browser.js +1 -1
  80. package/dist/orm/core/index.browser.js.map +1 -1
  81. package/dist/orm/core/index.bun.js +4 -3
  82. package/dist/orm/core/index.bun.js.map +1 -1
  83. package/dist/orm/core/index.d.ts +4877 -9
  84. package/dist/orm/core/index.d.ts.map +1 -1
  85. package/dist/orm/core/index.js +4 -3
  86. package/dist/orm/core/index.js.map +1 -1
  87. package/dist/orm/postgres/index.d.ts +608 -1
  88. package/dist/orm/postgres/index.d.ts.map +1 -1
  89. package/dist/react/core/index.d.ts +102 -1
  90. package/dist/react/core/index.d.ts.map +1 -1
  91. package/dist/react/core/index.js +65 -1
  92. package/dist/react/core/index.js.map +1 -1
  93. package/dist/react/form/index.d.ts +6 -0
  94. package/dist/react/form/index.d.ts.map +1 -1
  95. package/dist/react/form/index.js +7 -7
  96. package/dist/react/form/index.js.map +1 -1
  97. package/dist/react/i18n/index.d.ts +7 -1
  98. package/dist/react/i18n/index.d.ts.map +1 -1
  99. package/dist/react/i18n/index.js +6 -0
  100. package/dist/react/i18n/index.js.map +1 -1
  101. package/dist/react/router/index.browser.js +20 -2
  102. package/dist/react/router/index.browser.js.map +1 -1
  103. package/dist/react/router/index.d.ts +36 -4
  104. package/dist/react/router/index.d.ts.map +1 -1
  105. package/dist/react/router/index.js +20 -2
  106. package/dist/react/router/index.js.map +1 -1
  107. package/dist/react/testing/chunk-6Ep1yQYe.js +16 -0
  108. package/dist/react/testing/index.d.ts +411 -1
  109. package/dist/react/testing/index.d.ts.map +1 -1
  110. package/dist/react/testing/index.js +12293 -13
  111. package/dist/react/testing/index.js.map +1 -1
  112. package/dist/react/ui/index.d.ts +195 -1
  113. package/dist/react/ui/index.d.ts.map +1 -1
  114. package/dist/react/ui/index.js +61 -1
  115. package/dist/react/ui/index.js.map +1 -1
  116. package/dist/scheduler/index.d.ts +84 -3
  117. package/dist/scheduler/index.d.ts.map +1 -1
  118. package/dist/scheduler/index.js +390 -1
  119. package/dist/scheduler/index.js.map +1 -1
  120. package/dist/scheduler/index.workerd.js +390 -1
  121. package/dist/scheduler/index.workerd.js.map +1 -1
  122. package/dist/security/index.d.ts +325 -2
  123. package/dist/security/index.d.ts.map +1 -1
  124. package/dist/security/index.js +1361 -2
  125. package/dist/security/index.js.map +1 -1
  126. package/dist/server/auth/index.d.ts +1054 -1
  127. package/dist/server/auth/index.d.ts.map +1 -1
  128. package/dist/server/auth/index.js +1223 -1
  129. package/dist/server/auth/index.js.map +1 -1
  130. package/dist/server/core/index.browser.js +10 -3
  131. package/dist/server/core/index.browser.js.map +1 -1
  132. package/dist/server/core/index.d.ts.map +1 -1
  133. package/dist/server/core/index.js +28 -5
  134. package/dist/server/core/index.js.map +1 -1
  135. package/dist/server/metrics/index.d.ts +514 -1
  136. package/dist/server/metrics/index.d.ts.map +1 -1
  137. package/dist/server/metrics/index.js +4374 -4
  138. package/dist/server/metrics/index.js.map +1 -1
  139. package/dist/server/swagger/index.d.ts.map +1 -1
  140. package/dist/server/swagger/index.js +3 -4
  141. package/dist/server/swagger/index.js.map +1 -1
  142. package/dist/websocket/index.browser.js +11 -5
  143. package/dist/websocket/index.browser.js.map +1 -1
  144. package/dist/websocket/index.d.ts +3 -1
  145. package/dist/websocket/index.d.ts.map +1 -1
  146. package/dist/websocket/index.js +21 -6
  147. package/dist/websocket/index.js.map +1 -1
  148. package/package.json +416 -8
  149. package/src/api/parameters/services/ParameterProvider.ts +21 -4
  150. package/src/api/users/__tests__/SessionService.spec.ts +99 -0
  151. package/src/api/users/__tests__/UserJobs.spec.ts +67 -0
  152. package/src/api/users/atoms/realmAuthSettingsAtom.ts +15 -0
  153. package/src/api/users/entities/sessions.ts +6 -0
  154. package/src/api/users/jobs/UserJobs.ts +44 -17
  155. package/src/api/users/providers/RealmProvider.ts +4 -0
  156. package/src/api/users/services/SessionService.ts +27 -0
  157. package/src/bucket/__tests__/NodeS3BucketProvider.spec.ts +74 -0
  158. package/src/bucket/index.ts +19 -2
  159. package/src/bucket/primitives/$bucket.ts +9 -1
  160. package/src/bucket/providers/CloudflareR2Provider.ts +2 -137
  161. package/src/bucket/providers/NodeS3BucketProvider.ts +218 -0
  162. package/src/cache/core/index.ts +29 -0
  163. package/src/cache/core/primitives/$cache.ts +14 -1
  164. package/src/cli/config/defineConfig.ts +13 -15
  165. package/src/cli/core/__tests__/init.spec.ts +6 -7
  166. package/src/cli/core/services/ProjectScaffolder.ts +18 -14
  167. package/src/cli/core/tasks/BuildCloudflareTask.ts +5 -0
  168. package/src/cli/core/templates/agentMd.ts +2 -10
  169. package/src/cli/core/templates/saasAdminLayoutTsx.ts +3 -3
  170. package/src/cli/devtools/index.ts +12 -26
  171. package/src/cli/platform/index.ts +15 -24
  172. package/src/cli/vendor/atoms/vendorOptions.ts +1 -1
  173. package/src/cli/vendor/index.ts +14 -23
  174. package/src/core/Alepha.ts +11 -1
  175. package/src/core/helpers/ref.ts +18 -0
  176. package/src/core/index.shared.ts +1 -0
  177. package/src/core/providers/SchemaValidator.ts +9 -1
  178. package/src/core/providers/TypeProvider.ts +1 -2
  179. package/src/datetime/REFACTORING.md +118 -0
  180. package/src/datetime/providers/DateTimeProvider.ts +203 -24
  181. package/src/lock/core/index.ts +31 -0
  182. package/src/lock/core/primitives/$lock.ts +14 -1
  183. package/src/mcp/__tests__/jsonrpc.spec.ts +1 -1
  184. package/src/mcp/helpers/jsonrpc.ts +26 -1
  185. package/src/mcp/index.ts +10 -5
  186. package/src/mcp/interfaces/McpTypes.ts +83 -6
  187. package/src/mcp/primitives/$prompt.ts +18 -1
  188. package/src/mcp/primitives/$resource.ts +18 -1
  189. package/src/mcp/primitives/$tool.ts +83 -7
  190. package/src/mcp/providers/McpServerProvider.ts +74 -16
  191. package/src/mcp/transports/StreamableHttpMcpTransport.ts +226 -0
  192. package/src/orm/REFACTORING.md +330 -0
  193. package/src/orm/core/primitives/$transactional.ts +11 -0
  194. package/src/orm/core/schemas/updateSchema.ts +1 -1
  195. package/src/orm/core/services/PgRelationManager.ts +4 -2
  196. package/src/react/core/__tests__/useQuery.browser.spec.tsx +86 -0
  197. package/src/react/core/hooks/useQuery.ts +153 -0
  198. package/src/react/core/index.ts +1 -0
  199. package/src/react/form/services/FormModel.ts +15 -6
  200. package/src/react/form/services/parseField.ts +8 -0
  201. package/src/react/i18n/providers/I18nProvider.ts +8 -2
  202. package/src/react/router/__tests__/$page.spec.tsx +0 -16
  203. package/src/react/router/__tests__/ssr.spec.tsx +339 -0
  204. package/src/react/router/primitives/$page.ts +28 -4
  205. package/src/react/router/providers/ReactPageProvider.ts +27 -9
  206. package/src/react/ui/atoms/uiThemeListAtom.ts +36 -0
  207. package/src/react/ui/index.ts +6 -0
  208. package/src/react/ui/services/SchemaControl.ts +209 -0
  209. package/src/security/primitives/$issuer.ts +6 -3
  210. package/src/server/core/__tests__/ServerRouterProvider-serializationError.spec.ts +75 -0
  211. package/src/server/core/__tests__/ServerRouterProvider-validationError.spec.ts +306 -0
  212. package/src/server/core/errors/ValidationError.ts +13 -1
  213. package/src/server/core/primitives/$action.ts +16 -5
  214. package/src/server/core/providers/ServerRouterProvider.ts +26 -4
  215. package/src/server/swagger/providers/ServerSwaggerProvider.ts +5 -7
  216. package/src/websocket/providers/NodeWebSocketServerProvider.ts +10 -4
  217. package/src/websocket/services/WebSocketClient.ts +11 -5
  218. package/src/mcp/transports/SseMcpTransport.ts +0 -182
@@ -0,0 +1,226 @@
1
+ import { $atom, $inject, $state, t } from "alepha";
2
+ import { $logger } from "alepha/logger";
3
+ import { $route } from "alepha/server";
4
+ import {
5
+ createErrorResponse,
6
+ createParseError,
7
+ JsonRpcParseError,
8
+ parseMessage,
9
+ } from "../helpers/jsonrpc.ts";
10
+ import type { McpContext } from "../interfaces/McpTypes.ts";
11
+ import { McpServerProvider } from "../providers/McpServerProvider.ts";
12
+
13
+ // ---------------------------------------------------------------------------------------------------------------------
14
+
15
+ export const mcpStreamableHttpOptions = $atom({
16
+ name: "alepha.mcp.streamableHttp.options",
17
+ description: "Configuration options for the MCP Streamable HTTP transport.",
18
+ schema: t.object({
19
+ /**
20
+ * Path for the MCP endpoint. Single endpoint for both requests and
21
+ * (optional) server-streamed responses, per spec 2025-03-26+.
22
+ */
23
+ path: t.text({ default: "/mcp" }),
24
+ /**
25
+ * Allow-list of `Origin` header values accepted on incoming requests.
26
+ * Empty array (default) means "allow any". When set, browser-originated
27
+ * requests with a non-matching `Origin` are rejected with 403 Forbidden,
28
+ * blocking DNS-rebinding attacks against localhost MCP servers.
29
+ *
30
+ * Server-to-server callers (no `Origin` header) are always allowed.
31
+ *
32
+ * Spec 2025-11-25, PR #1439.
33
+ */
34
+ allowedOrigins: t.array(t.text(), { default: [] }),
35
+ }),
36
+ default: {
37
+ path: "/mcp",
38
+ allowedOrigins: [],
39
+ },
40
+ });
41
+
42
+ // Backward-compat alias for the legacy atom name. Prefer
43
+ // `mcpStreamableHttpOptions` going forward; this re-export keeps existing
44
+ // consumer imports compiling and will be removed once they migrate.
45
+ export const mcpSseOptions = mcpStreamableHttpOptions;
46
+
47
+ // ---------------------------------------------------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Streamable HTTP transport for MCP communication.
51
+ *
52
+ * Implements the 2025-03-26+ Streamable HTTP transport: a single `/mcp`
53
+ * endpoint that accepts JSON-RPC over POST and returns either
54
+ * `application/json` (single response, the default) or
55
+ * `text/event-stream` (when the server wants to stream multiple messages).
56
+ *
57
+ * Designed for serverless deployment (Cloudflare Workers, etc.) — there is
58
+ * no long-lived GET stream. GET on the endpoint returns 405 Method Not
59
+ * Allowed; clients that want server-initiated push must rely on the POST
60
+ * response stream when the server upgrades to SSE for that particular call.
61
+ *
62
+ * Spec compliance:
63
+ * - 2025-06-18: validates `MCP-Protocol-Version` header on every request
64
+ * after `initialize` against the version negotiated and stored on
65
+ * `McpServerProvider`.
66
+ * - 2025-11-25: rejects requests with a non-allow-listed `Origin` header
67
+ * (PR #1439). See {@link mcpStreamableHttpOptions.allowedOrigins}.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * import { Alepha, run } from "alepha";
72
+ * import { AlephaServer } from "alepha/server";
73
+ * import { AlephaMcp, StreamableHttpMcpTransport } from "alepha/mcp";
74
+ *
75
+ * class MyTools {
76
+ * // ... tool definitions
77
+ * }
78
+ *
79
+ * run(
80
+ * Alepha.create()
81
+ * .with(AlephaServer)
82
+ * .with(AlephaMcp)
83
+ * .with(StreamableHttpMcpTransport)
84
+ * .with(MyTools)
85
+ * );
86
+ * ```
87
+ */
88
+ export class StreamableHttpMcpTransport {
89
+ protected readonly log = $logger();
90
+ protected readonly options = $state(mcpStreamableHttpOptions);
91
+ protected readonly mcpServer = $inject(McpServerProvider);
92
+
93
+ /**
94
+ * GET on the MCP endpoint is not supported in this transport. Returning
95
+ * 405 (rather than serving the legacy two-endpoint SSE pattern) is the
96
+ * spec-allowed response for servers that don't offer server-initiated
97
+ * push outside of an active POST.
98
+ */
99
+ notAllowed = $route({
100
+ method: "GET",
101
+ path: this.options.path,
102
+ handler: (request) => {
103
+ request.reply.status = 405;
104
+ request.reply.headers.allow = "POST";
105
+ request.reply.headers["content-type"] = "application/json";
106
+ request.reply.body = JSON.stringify({
107
+ error: "Method Not Allowed. Use POST for MCP messages.",
108
+ });
109
+ },
110
+ });
111
+
112
+ /**
113
+ * POST endpoint for client-to-server JSON-RPC messages.
114
+ * Returns `application/json` for single responses; tools that need to
115
+ * stream progress would upgrade to `text/event-stream` (deferred until a
116
+ * concrete need exists).
117
+ */
118
+ message = $route({
119
+ method: "POST",
120
+ path: this.options.path,
121
+ schema: {
122
+ body: t.json(),
123
+ },
124
+ handler: async (request) => {
125
+ try {
126
+ // Origin allow-list check (spec 2025-11-25 / PR #1439).
127
+ const originRaw = request.headers.origin;
128
+ const origin = Array.isArray(originRaw) ? originRaw[0] : originRaw;
129
+ if (
130
+ origin &&
131
+ this.options.allowedOrigins.length > 0 &&
132
+ !this.options.allowedOrigins.includes(origin)
133
+ ) {
134
+ this.log.warn("Rejected MCP request with non-allowed Origin", {
135
+ origin,
136
+ allowed: this.options.allowedOrigins,
137
+ });
138
+ request.reply.status = 403;
139
+ request.reply.headers["content-type"] = "application/json";
140
+ request.reply.body = JSON.stringify({
141
+ error: "Forbidden: Origin not allowed",
142
+ });
143
+ return;
144
+ }
145
+
146
+ const body =
147
+ typeof request.body === "string"
148
+ ? request.body
149
+ : JSON.stringify(request.body);
150
+
151
+ this.log.debug("MCP request body", {
152
+ body,
153
+ bodyType: typeof request.body,
154
+ });
155
+
156
+ const rpcRequest = parseMessage(body);
157
+
158
+ // Build context from request headers
159
+ const headers = { ...request.headers } as Record<
160
+ string,
161
+ string | string[] | undefined
162
+ >;
163
+
164
+ // Spec 2025-06-18+: every HTTP request after `initialize` MUST carry
165
+ // an `MCP-Protocol-Version` header matching the negotiated version.
166
+ // Reject mismatches with 400 so the client doesn't silently drift.
167
+ if (rpcRequest.method !== "initialize") {
168
+ const headerRaw = headers["mcp-protocol-version"];
169
+ const headerVersion = Array.isArray(headerRaw)
170
+ ? headerRaw[0]
171
+ : headerRaw;
172
+ if (
173
+ headerVersion &&
174
+ headerVersion !== this.mcpServer.negotiatedVersion
175
+ ) {
176
+ this.log.warn("MCP-Protocol-Version header mismatch", {
177
+ header: headerVersion,
178
+ negotiated: this.mcpServer.negotiatedVersion,
179
+ });
180
+ request.reply.status = 400;
181
+ request.reply.headers["content-type"] = "application/json";
182
+ request.reply.body = JSON.stringify({
183
+ error: `MCP-Protocol-Version mismatch: expected ${this.mcpServer.negotiatedVersion}, got ${headerVersion}`,
184
+ });
185
+ return;
186
+ }
187
+ }
188
+
189
+ const context: McpContext = { headers };
190
+
191
+ const response = await this.mcpServer.handleMessage(
192
+ rpcRequest,
193
+ context,
194
+ );
195
+
196
+ if (response) {
197
+ request.reply.headers["content-type"] = "application/json";
198
+ request.reply.body = JSON.stringify(response);
199
+ } else {
200
+ request.reply.status = 204;
201
+ }
202
+ } catch (error) {
203
+ if (error instanceof JsonRpcParseError) {
204
+ request.reply.status = 400;
205
+ request.reply.headers["content-type"] = "application/json";
206
+ request.reply.body = JSON.stringify(
207
+ createErrorResponse(0, createParseError(error.message)),
208
+ );
209
+ } else {
210
+ this.log.error("Failed to process MCP message", error);
211
+ request.reply.status = 500;
212
+ request.reply.body = JSON.stringify({
213
+ error: (error as Error).message,
214
+ });
215
+ }
216
+ }
217
+ },
218
+ });
219
+ }
220
+
221
+ /**
222
+ * @deprecated Use {@link StreamableHttpMcpTransport}. The 2024-11-05
223
+ * two-endpoint HTTP+SSE pattern was replaced by Streamable HTTP in spec
224
+ * 2025-03-26. This alias is preserved for one release to ease migration.
225
+ */
226
+ export const SseMcpTransport = StreamableHttpMcpTransport;
@@ -0,0 +1,330 @@
1
+ # ORM — Refactoring Roadmap
2
+
3
+ ## Context
4
+
5
+ Alepha currently runs on `drizzle-orm@0.45.x` (stable). This document captures the planned overhaul to **Drizzle v1** — the version that ships **Relations v2**, the official `node:sqlite` driver, and a rewritten `drizzle-kit`.
6
+
7
+ This refactoring is **deferred** until Drizzle v1 reaches GA. As of writing (2026-05) v1 is still in beta (`v1.0.0-beta.20`, March 2025) with no GA date announced. The framework's stability needs the upstream library to be stable too — pulling in a beta would force every consumer to absorb breakage on someone else's release schedule.
8
+
9
+ When v1 GA ships, this becomes a **bigbang upgrade** (no incremental migration path). The plan below is the playbook to execute on that day.
10
+
11
+ ## Why upgrade
12
+
13
+ Alepha wraps Drizzle at the wrong layer. The `Repository` uses the low-level SQL builder (`db.select().from().leftJoin()`) and re-implements join handling manually via `PgRelationManager`. Drizzle's real power — the relational query API — is completely bypassed.
14
+
15
+ Drizzle v1 Relations v2 solves every limitation of Alepha's current join system:
16
+
17
+ | Current limitation | Drizzle v1 solution |
18
+ |---|---|
19
+ | No one-to-many joins | `r.many()` — lateral join + `json_agg` under the hood |
20
+ | No many-to-many | `through()` — junction tables hidden from results |
21
+ | No per-relation `where`/`orderBy`/`limit` | First-class in `db.query.table.findMany({ with: { posts: { where, orderBy, limit } } })` |
22
+ | No `columns` on joined tables | `with: { posts: { columns: { id: true, title: true } } }` |
23
+ | No computed fields on relations | `extras: { fullName: sql\`...\` }` |
24
+ | Manual `.alias()` for self-joins | Handled by relation definition (`alias` param) |
25
+ | `node:sqlite` shim around `better-sqlite3` | Official `drizzle-orm/node-sqlite` driver |
26
+
27
+ ## What Alepha keeps (the genuine value-add)
28
+
29
+ These layers justify the Repository pattern. None of this exists in raw Drizzle and they survive the upgrade unchanged:
30
+
31
+ - **Soft deletes** — automatic `deletedAt IS NULL` injection, transparent to all queries
32
+ - **Multi-tenancy** — automatic org scoping via `currentUserAtom`
33
+ - **Optimistic locking** — `save()` with version checking, `DbVersionMismatchError`
34
+ - **Typed error hierarchy** — `DbConflictError`, `DbForeignKeyError`, `DbDeadlockError`, etc.
35
+ - **Pagination** — `paginate()` with count, metadata, sort string parsing
36
+ - **Aggregate API** — type-safe `aggregate()` with GROUP BY, HAVING, dot-notation ordering
37
+ - **Transaction propagation** — implicit via `alepha.store`, no manual `{ tx }` drilling
38
+ - **Query caching** — per-table TTL cache with auto-invalidation on writes
39
+ - **Lifecycle events** — `repository:create:before/after`, `repository:read:before/after`, etc.
40
+ - **Codec integration** — `DateTime`, custom types auto-encoded in WHERE clauses
41
+ - **Schema transforms** — `insertSchema` / `updateSchema` — auto-exclude generated cols, handle defaults
42
+ - **DI integration** — `$repository()`, `$inject()`, service substitution for tests
43
+ - **JSON query DSL** — `{ where: { age: { gt: 18 } } }` — composable, serializable, loggable
44
+
45
+ ## What the current wrapper blocks
46
+
47
+ Drizzle features the current Alepha layer hides from users:
48
+
49
+ | Blocked feature | Impact |
50
+ |---|---|
51
+ | Relational query API (`db.query.table.findMany({ with })`) | Critical — one-to-many, per-relation filtering/ordering/limiting |
52
+ | Lateral joins (`leftJoinLateral`) | High — top-N-per-group patterns |
53
+ | CTEs (`$with` / `with`) | High — recursive queries, complex analytics |
54
+ | Per-relation `where`/`orderBy`/`limit` | High |
55
+ | `columns` on relations | Medium |
56
+ | `extras` / computed fields | Medium |
57
+ | Set operators (`union`, `intersect`, `except`) | Medium |
58
+ | UPDATE with FROM/JOINs | Medium — join-based batch updates |
59
+ | INSERT from SELECT | Medium |
60
+ | `onConflictDoNothing` | Low-medium |
61
+ | `selectDistinctOn` | Low-medium |
62
+
63
+ ## Drizzle v1 — feature reference
64
+
65
+ ### Relations v2
66
+
67
+ ```typescript
68
+ const relations = defineRelations({ users, posts, comments }, (r) => ({
69
+ users: {
70
+ posts: r.many.posts(), // one-to-many
71
+ groups: r.many.groups({ // many-to-many via junction
72
+ from: r.users.id.through(r.usersToGroups.userId),
73
+ to: r.groups.id.through(r.usersToGroups.groupId),
74
+ }),
75
+ },
76
+ posts: {
77
+ author: r.one.users({ // many-to-one
78
+ from: r.posts.authorId,
79
+ to: r.users.id,
80
+ }),
81
+ comments: r.many.comments(),
82
+ },
83
+ comments: {
84
+ post: r.one.posts({
85
+ from: r.comments.postId,
86
+ to: r.posts.id,
87
+ }),
88
+ },
89
+ }));
90
+ ```
91
+
92
+ Key features:
93
+ - `r.one` / `r.many` — declarative relation types
94
+ - `through()` — many-to-many, junction table hidden from query results
95
+ - `optional: true` — nullable relations (type-level)
96
+ - `alias` — disambiguate multiple relations between same tables
97
+ - `where` — predefined filters on target table (polymorphic relations)
98
+ - `defineRelationsPart()` — modules define their own relations independently, then merge
99
+
100
+ ### Relational Query Builder v2
101
+
102
+ ```typescript
103
+ const result = await db.query.users.findMany({
104
+ columns: { id: true, name: true },
105
+ where: { verified: true, age: { gt: 18 } },
106
+ orderBy: { name: "asc" },
107
+ limit: 10,
108
+ offset: 20,
109
+ extras: {
110
+ postCount: (users) => db.$count(posts, eq(posts.authorId, users.id)),
111
+ },
112
+ with: {
113
+ posts: {
114
+ columns: { id: true, title: true },
115
+ where: { publishedAt: { isNotNull: true } },
116
+ orderBy: { publishedAt: "desc" },
117
+ limit: 5,
118
+ with: {
119
+ comments: {
120
+ limit: 3,
121
+ orderBy: { createdAt: "desc" },
122
+ },
123
+ },
124
+ },
125
+ },
126
+ });
127
+ ```
128
+
129
+ Compiles to a single SQL statement using lateral joins with `json_agg`:
130
+
131
+ ```sql
132
+ SELECT ...
133
+ FROM "users" AS "d0"
134
+ LEFT JOIN LATERAL (
135
+ SELECT coalesce(json_agg(row_to_json("t".*)), '[]') AS "r"
136
+ FROM (
137
+ SELECT ... FROM "posts" AS "d1"
138
+ WHERE "d0"."id" = "d1"."author_id"
139
+ AND "d1"."published_at" IS NOT NULL
140
+ ORDER BY "d1"."published_at" DESC
141
+ LIMIT 5
142
+ ) AS "t"
143
+ ) AS "posts" ON true
144
+ ```
145
+
146
+ Where syntax operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `notIn`, `like`, `ilike`, `notLike`, `notIlike`, `isNull`, `isNotNull`, `arrayOverlaps`, `arrayContained`, `arrayContains`, `OR`, `AND`, `NOT`, `RAW`.
147
+
148
+ ### Official `node:sqlite` driver
149
+
150
+ ```typescript
151
+ import { drizzle } from "drizzle-orm/node-sqlite";
152
+
153
+ // Simple — just a path
154
+ const db = drizzle("sqlite.db");
155
+
156
+ // Advanced — pass existing DatabaseSync
157
+ import { DatabaseSync } from "node:sqlite";
158
+ const sqlite = new DatabaseSync("sqlite.db");
159
+ const db = drizzle({ client: sqlite });
160
+ ```
161
+
162
+ No more `better-sqlite3` shim, no `aliasSelectColumns` SQL rewriting.
163
+
164
+ ### Other v1 features
165
+
166
+ - **MSSQL support** — full dialect with drizzle-orm, drizzle-kit, drizzle-seed
167
+ - **CockroachDB support** — new dialect
168
+ - **Migration folders v3** — no `journal.json`, grouped folders, fewer Git conflicts
169
+ - **`drizzle-kit` rewrite** — DDL snapshots, ~10x faster introspection
170
+ - **Validator consolidation** — `drizzle-zod` → `drizzle-orm/zod`, `drizzle-typebox` → `drizzle-orm/typebox`
171
+ - **Alternation engine** — advanced query branching (beta)
172
+ - **Commutativity checks** (`drizzle-kit check`) — detect migration collisions in teams
173
+ - **Column `.as()` alias** — direct column aliasing
174
+ - **Subqueries in select fields** — computed columns inline
175
+ - **RLS** — moved to `pgTable.withRLS()`
176
+ - **Prepared statements** — work inside relational queries with `sql.placeholder()`
177
+ - **PostgreSQL type fixes** — arrays of intervals, timestamps, dates now map correctly
178
+
179
+ ## Breaking changes to handle
180
+
181
+ | Breaking change | Alepha impact |
182
+ |---|---|
183
+ | Migration folder restructure (v3 layout) | Run `drizzle-kit up` once. Update `DrizzleKitProvider`. |
184
+ | Relations v1 → v2 | Alepha doesn't use Drizzle relations today — adopt v2 fresh. |
185
+ | PostgreSQL array/timestamp type fixes | Audit `db.createdAt()`, `db.updatedAt()` and any array columns. |
186
+ | Database/session/migrator gain 2 new generics | Update `DatabaseProvider` types. |
187
+ | `DrizzleConfig` gains `TRelations` generic + `relations` field | Pass relations to `drizzle()` constructor. |
188
+ | Validator packages moved into `drizzle-orm/*` | Low impact — Alepha uses TypeBox directly. |
189
+ | `.enableRLS()` → `pgTable.withRLS()` | Audit Alepha for RLS usage (currently none expected). |
190
+
191
+ ## Migration playbook (when v1 GA ships)
192
+
193
+ ### 1. Bump dependencies
194
+
195
+ ```bash
196
+ yarn add drizzle-orm@^1.0.0
197
+ yarn add -D drizzle-kit@^1.0.0
198
+ ```
199
+
200
+ ### 2. Replace the `node:sqlite` shim
201
+
202
+ Delete the `better-sqlite3` shim code in `core/providers/drivers/`:
203
+ - `shimDatabaseSync()` (~50 lines)
204
+ - `aliasSelectColumns()` (~60 lines)
205
+ - `initDrizzle()` manual session construction (~20 lines)
206
+
207
+ Replace with the official driver:
208
+
209
+ ```typescript
210
+ import { drizzle } from "drizzle-orm/node-sqlite";
211
+ this.drizzleDb = drizzle({ client: this.sqlite, relations });
212
+ ```
213
+
214
+ ### 3. Auto-generate `defineRelations()` from `db.ref()`
215
+
216
+ The FK info already exists in `$entity` schemas. Either extend `ModelBuilder` or add a new `RelationBuilder` that walks all registered entities and emits:
217
+
218
+ ```typescript
219
+ const relations = defineRelations(tables, (r) => ({
220
+ players: {
221
+ team: r.one.teams({
222
+ from: r.players.teamId,
223
+ to: r.teams.id,
224
+ }),
225
+ },
226
+ teams: {
227
+ players: r.many.players(),
228
+ },
229
+ }));
230
+ ```
231
+
232
+ This is fully derivable from `db.ref()` declarations — each ref knows its source column and target `EntityColumn`.
233
+
234
+ ### 4. Add `relationalQuery()` to `Repository`
235
+
236
+ A new method that delegates to `db.query.table.findMany()` while applying Alepha's concerns:
237
+
238
+ ```typescript
239
+ public async relationalQuery<Config>(config: RelationalQueryConfig<T>) {
240
+ // Inject soft-delete filter
241
+ // Inject org scoping
242
+ // Encode values via codec
243
+ // Emit repository:read:before/after events
244
+ // Handle caching
245
+ return await db.query[this.tableName].findMany(config);
246
+ }
247
+ ```
248
+
249
+ ### 5. Deprecate `findMany({ with })` for joins
250
+
251
+ Keep it working for backward compatibility but log a deprecation warning pointing to `relationalQuery()`. Remove in a follow-up release once consumers migrate.
252
+
253
+ ### 6. Run `drizzle-kit up`
254
+
255
+ Migrate existing migration folders to the v3 layout.
256
+
257
+ ### 7. Audit timestamp/array column types
258
+
259
+ PostgreSQL type fixes in v1 may change runtime values for:
260
+ - `db.createdAt()` / `db.updatedAt()` — timestamp columns
261
+ - Any array columns (intervals, timestamps, dates)
262
+
263
+ ### 8. Pass relations to `drizzle()`
264
+
265
+ Update `DatabaseProvider` subclasses to pass the auto-generated relations when constructing the Drizzle instance.
266
+
267
+ ## Files affected
268
+
269
+ ### Delete / heavily simplify
270
+
271
+ | File | Approx. lines | Reason |
272
+ |---|---|---|
273
+ | `core/services/PgRelationManager.ts` | 131 | Replaced by Drizzle's relational query engine |
274
+ | `node:sqlite` driver shim | ~150 | `shimDatabaseSync()`, `aliasSelectColumns()` gone |
275
+ | Join types in query layer (`PgRelation`, `PgRelationMap`, `PgStatic`) | ~40 | Drizzle handles relation types natively |
276
+ | Relation where types (`PgQueryWhereRelations`) | ~10 | Drizzle v2 where syntax handles this |
277
+
278
+ ### Modify
279
+
280
+ | File | Change |
281
+ |---|---|
282
+ | `core/services/Repository.ts` | Add `relationalQuery()`, deprecate `findMany({ with })` joins |
283
+ | `core/providers/RepositoryProvider.ts` / `DatabaseTypeProvider.ts` | Accept and pass `relations` to Drizzle constructor |
284
+ | `core/services/ModelBuilder.ts` (or new `RelationBuilder.ts`) | Generate `defineRelations()` from `db.ref()` |
285
+ | `core/providers/DrizzleKitProvider.ts` | Update for migration folder v3 |
286
+ | `postgres/` ModelBuilder | Audit timestamp/array type changes |
287
+ | `core/providers/drivers/` (sqlite) | Replace shim with official driver |
288
+
289
+ ## Where syntax — Alepha → Drizzle v2
290
+
291
+ Alepha's current syntax maps almost 1:1 to Drizzle v2:
292
+
293
+ ```
294
+ Alepha Drizzle v2
295
+ ────── ──────────
296
+ { age: { gt: 18 } } { age: { gt: 18 } }
297
+ { age: { gte: 18, lte: 65 } } { age: { gte: 18, lte: 65 } }
298
+ { name: { contains: "foo" } } { name: { ilike: "%foo%" } }
299
+ { status: { inArray: [...] } } { status: { in: [...] } }
300
+ { isNull: true } { isNull: true }
301
+ { and: [...] } { AND: [...] }
302
+ { or: [...] } { OR: [...] }
303
+ { not: {...} } { NOT: {...} }
304
+ ```
305
+
306
+ Differences to bridge:
307
+ - `inArray` → `in`, `notInArray` → `notIn`
308
+ - `and` / `or` / `not` → `AND` / `OR` / `NOT` (capitalized)
309
+ - `contains` / `startsWith` / `endsWith` — Alepha sugar with no direct Drizzle equivalent (expand to `ilike`)
310
+ - Drizzle adds `RAW: (table) => sql\`...\`` for inline raw SQL inside `where`
311
+
312
+ Alepha's JSON DSL stays the public API; the bridge to Drizzle v2 lives inside `Repository` / `QueryManager`.
313
+
314
+ ## Why not now
315
+
316
+ - **Beta risk** — Drizzle v1 is still in beta. Pinning Alepha to a beta would couple every Alepha release to upstream's stabilization timeline.
317
+ - **Bigbang nature** — the upgrade is not incremental (Relations v2, migration folder layout, type generics, driver swap all land together). Better executed in one focused window post-GA than dripped over months.
318
+ - **No urgent user pain** — current Repository covers ~95% of usage. The blocked features (one-to-many joins, lateral joins, CTEs) are real gaps but have manual workarounds today.
319
+
320
+ ## Estimated effort when triggered
321
+
322
+ Multi-day, not single-day:
323
+ - Replace `node:sqlite` shim — ~half day
324
+ - Auto-generate `defineRelations()` from entity refs — 1 day
325
+ - Add `relationalQuery()` with all Repository concerns wired through — 1 day
326
+ - Migration folder v3 + `drizzle-kit` provider update — half day
327
+ - PG type audit + test suite stabilization — 1 day
328
+ - Total: ~4 days of focused work, plus regression hunt against the existing test suite.
329
+
330
+ The framework's `~3400` ORM-touching tests are the safety net. If they pass, the upgrade landed cleanly.
@@ -31,6 +31,17 @@ export interface TransactionalOptions {
31
31
  * }
32
32
  * ```
33
33
  */
34
+ // TODO: support savepoints for nested transactions.
35
+ // Today, a nested $transactional reuses the outer tx (correct for ACID), but
36
+ // there is no way to roll back ONLY the inner block while preserving the
37
+ // outer one. Drizzle exposes savepoints (`tx.transaction(...)` inside an
38
+ // existing tx maps to `SAVEPOINT` on Postgres). Plan:
39
+ // 1. Detect "already inside a tx" in DatabaseProvider.transactional().
40
+ // 2. When nested, open a savepoint instead of reusing the outer scope.
41
+ // 3. On inner throw, ROLLBACK TO SAVEPOINT (don't bubble unless re-thrown).
42
+ // 4. Add an option { savepoint?: boolean } on $transactional (default true
43
+ // when nested) so users can opt out for "inherit-and-bubble" semantics.
44
+ // Needs care around connection-bound state and Drizzle's tx proxy.
34
45
  export const $transactional = (options?: TransactionalOptions): Middleware => {
35
46
  return createMiddleware({
36
47
  name: "$transactional",
@@ -40,7 +40,7 @@ export const updateSchema = <T extends TObject>(
40
40
  }
41
41
 
42
42
  if (t.schema.isOptional(prop)) {
43
- newProperties[key] = t.optional(t.union([prop, t.raw.Null()]));
43
+ newProperties[key] = t.optional(t.union([prop, t.null()]));
44
44
  } else {
45
45
  newProperties[key] = prop;
46
46
  }
@@ -1,4 +1,4 @@
1
- import { type TObject, t, Value } from "alepha";
1
+ import { $inject, SchemaValidator, type TObject, t } from "alepha";
2
2
  import { getTableName, type SQL, sql } from "drizzle-orm";
3
3
  import type { PgSelectBase, PgTableWithColumns } from "drizzle-orm/pg-core";
4
4
  import { isSQLWrapper } from "drizzle-orm/sql/sql";
@@ -8,6 +8,8 @@ import type { DatabaseProvider } from "../providers/drivers/DatabaseProvider.ts"
8
8
  import type { PgJoin } from "./QueryManager.ts";
9
9
 
10
10
  export class PgRelationManager {
11
+ protected readonly schemaValidator = $inject(SchemaValidator);
12
+
11
13
  /**
12
14
  * Recursively build joins for the query builder based on the relations map
13
15
  */
@@ -103,7 +105,7 @@ export class PgRelationManager {
103
105
  joins: PgJoin[],
104
106
  parentPath?: string,
105
107
  ): TObject {
106
- const schema = Value.Clone(baseSchema) as TObject;
108
+ const schema = this.schemaValidator.clone(baseSchema) as TObject;
107
109
 
108
110
  // Group joins by parent
109
111
  const joinsAtThisLevel = joins.filter((j) => j.parent === parentPath);
@@ -0,0 +1,86 @@
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
+ import { Alepha } from "alepha";
3
+ import { AlephaDateTime } from "alepha/datetime";
4
+ import { describe, test, vi } from "vitest";
5
+ import { AlephaContext } from "../contexts/AlephaContext.ts";
6
+ import { useQuery } from "../hooks/useQuery.ts";
7
+
8
+ describe("useQuery", () => {
9
+ test("runs on mount and exposes data/loading/error/refetch", async ({
10
+ expect,
11
+ }) => {
12
+ const alepha = Alepha.create().with(AlephaDateTime);
13
+ await alepha.start();
14
+
15
+ const handler = vi.fn(async () => ({ users: ["a", "b"] }));
16
+
17
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
18
+ <AlephaContext.Provider value={alepha}>{children}</AlephaContext.Provider>
19
+ );
20
+
21
+ const { result } = renderHook(() => useQuery({ handler }, []), { wrapper });
22
+
23
+ expect(result.current.data).toBe(undefined);
24
+ expect(typeof result.current.refetch).toBe("function");
25
+
26
+ await waitFor(() => {
27
+ expect(result.current.data).toEqual({ users: ["a", "b"] });
28
+ });
29
+
30
+ expect(handler).toHaveBeenCalledTimes(1);
31
+ expect(result.current.loading).toBe(false);
32
+ expect(result.current.error).toBe(undefined);
33
+
34
+ // refetch
35
+ handler.mockResolvedValueOnce({ users: ["c"] });
36
+ await act(async () => {
37
+ await result.current.refetch();
38
+ });
39
+
40
+ expect(result.current.data).toEqual({ users: ["c"] });
41
+ expect(handler).toHaveBeenCalledTimes(2);
42
+ });
43
+
44
+ test("does not run when enabled is false", async ({ expect }) => {
45
+ const alepha = Alepha.create().with(AlephaDateTime);
46
+ await alepha.start();
47
+
48
+ const handler = vi.fn(async () => "value");
49
+
50
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
51
+ <AlephaContext.Provider value={alepha}>{children}</AlephaContext.Provider>
52
+ );
53
+
54
+ const { result } = renderHook(
55
+ () => useQuery({ handler, enabled: false }, []),
56
+ { wrapper },
57
+ );
58
+
59
+ // give it a tick to ensure no spontaneous run
60
+ await new Promise((r) => setTimeout(r, 20));
61
+ expect(handler).not.toHaveBeenCalled();
62
+ expect(result.current.data).toBe(undefined);
63
+ });
64
+
65
+ test("supports initialData", async ({ expect }) => {
66
+ const alepha = Alepha.create().with(AlephaDateTime);
67
+ await alepha.start();
68
+
69
+ const handler = vi.fn(async () => "fetched");
70
+
71
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
72
+ <AlephaContext.Provider value={alepha}>{children}</AlephaContext.Provider>
73
+ );
74
+
75
+ const { result } = renderHook(
76
+ () => useQuery({ handler, initialData: "seed" }, []),
77
+ { wrapper },
78
+ );
79
+
80
+ expect(result.current.data).toBe("seed");
81
+
82
+ await waitFor(() => {
83
+ expect(result.current.data).toBe("fetched");
84
+ });
85
+ });
86
+ });