alepha 0.20.3 → 0.20.4

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 (217) 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/keys/index.d.ts +4 -4
  6. package/dist/api/organizations/index.d.ts.map +1 -1
  7. package/dist/api/parameters/index.d.ts +8 -3
  8. package/dist/api/parameters/index.d.ts.map +1 -1
  9. package/dist/api/parameters/index.js +20 -4
  10. package/dist/api/parameters/index.js.map +1 -1
  11. package/dist/api/payments/index.d.ts.map +1 -1
  12. package/dist/api/users/index.browser.js +6 -0
  13. package/dist/api/users/index.browser.js.map +1 -1
  14. package/dist/api/users/index.d.ts +5037 -139
  15. package/dist/api/users/index.d.ts.map +1 -1
  16. package/dist/api/users/index.js +58 -10
  17. package/dist/api/users/index.js.map +1 -1
  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 +420 -13
  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/core/index.browser.js +27 -3
  51. package/dist/core/index.browser.js.map +1 -1
  52. package/dist/core/index.d.ts +6 -3
  53. package/dist/core/index.d.ts.map +1 -1
  54. package/dist/core/index.js +27 -3
  55. package/dist/core/index.js.map +1 -1
  56. package/dist/core/index.native.js +27 -3
  57. package/dist/core/index.native.js.map +1 -1
  58. package/dist/core/index.workerd.js +27 -3
  59. package/dist/core/index.workerd.js.map +1 -1
  60. package/dist/datetime/index.d.ts +69 -10
  61. package/dist/datetime/index.d.ts.map +1 -1
  62. package/dist/datetime/index.js +135 -13
  63. package/dist/datetime/index.js.map +1 -1
  64. package/dist/email/smtp/index.js +10636 -2
  65. package/dist/email/smtp/index.js.map +1 -1
  66. package/dist/fake/index.d.ts +8085 -4
  67. package/dist/fake/index.d.ts.map +1 -1
  68. package/dist/fake/index.js +33554 -3
  69. package/dist/fake/index.js.map +1 -1
  70. package/dist/lock/core/index.d.ts +30 -2
  71. package/dist/lock/core/index.d.ts.map +1 -1
  72. package/dist/lock/core/index.js +35 -12
  73. package/dist/lock/core/index.js.map +1 -1
  74. package/dist/mcp/index.d.ts +238 -31
  75. package/dist/mcp/index.d.ts.map +1 -1
  76. package/dist/mcp/index.js +198 -71
  77. package/dist/mcp/index.js.map +1 -1
  78. package/dist/orm/core/index.browser.js +1 -1
  79. package/dist/orm/core/index.browser.js.map +1 -1
  80. package/dist/orm/core/index.bun.js +4 -3
  81. package/dist/orm/core/index.bun.js.map +1 -1
  82. package/dist/orm/core/index.d.ts +4877 -9
  83. package/dist/orm/core/index.d.ts.map +1 -1
  84. package/dist/orm/core/index.js +4 -3
  85. package/dist/orm/core/index.js.map +1 -1
  86. package/dist/orm/postgres/index.d.ts +608 -1
  87. package/dist/orm/postgres/index.d.ts.map +1 -1
  88. package/dist/react/core/index.d.ts +102 -1
  89. package/dist/react/core/index.d.ts.map +1 -1
  90. package/dist/react/core/index.js +65 -1
  91. package/dist/react/core/index.js.map +1 -1
  92. package/dist/react/form/index.d.ts +6 -0
  93. package/dist/react/form/index.d.ts.map +1 -1
  94. package/dist/react/form/index.js +7 -7
  95. package/dist/react/form/index.js.map +1 -1
  96. package/dist/react/i18n/index.d.ts +7 -1
  97. package/dist/react/i18n/index.d.ts.map +1 -1
  98. package/dist/react/i18n/index.js +6 -0
  99. package/dist/react/i18n/index.js.map +1 -1
  100. package/dist/react/router/index.browser.js +20 -2
  101. package/dist/react/router/index.browser.js.map +1 -1
  102. package/dist/react/router/index.d.ts +36 -4
  103. package/dist/react/router/index.d.ts.map +1 -1
  104. package/dist/react/router/index.js +20 -2
  105. package/dist/react/router/index.js.map +1 -1
  106. package/dist/react/testing/chunk-6Ep1yQYe.js +16 -0
  107. package/dist/react/testing/index.d.ts +411 -1
  108. package/dist/react/testing/index.d.ts.map +1 -1
  109. package/dist/react/testing/index.js +12293 -13
  110. package/dist/react/testing/index.js.map +1 -1
  111. package/dist/react/ui/index.d.ts +195 -1
  112. package/dist/react/ui/index.d.ts.map +1 -1
  113. package/dist/react/ui/index.js +61 -1
  114. package/dist/react/ui/index.js.map +1 -1
  115. package/dist/scheduler/index.d.ts +84 -3
  116. package/dist/scheduler/index.d.ts.map +1 -1
  117. package/dist/scheduler/index.js +390 -1
  118. package/dist/scheduler/index.js.map +1 -1
  119. package/dist/scheduler/index.workerd.js +390 -1
  120. package/dist/scheduler/index.workerd.js.map +1 -1
  121. package/dist/security/index.d.ts +325 -2
  122. package/dist/security/index.d.ts.map +1 -1
  123. package/dist/security/index.js +1361 -2
  124. package/dist/security/index.js.map +1 -1
  125. package/dist/server/auth/index.d.ts +1054 -1
  126. package/dist/server/auth/index.d.ts.map +1 -1
  127. package/dist/server/auth/index.js +1223 -1
  128. package/dist/server/auth/index.js.map +1 -1
  129. package/dist/server/core/index.browser.js +10 -3
  130. package/dist/server/core/index.browser.js.map +1 -1
  131. package/dist/server/core/index.d.ts.map +1 -1
  132. package/dist/server/core/index.js +28 -5
  133. package/dist/server/core/index.js.map +1 -1
  134. package/dist/server/metrics/index.d.ts +514 -1
  135. package/dist/server/metrics/index.d.ts.map +1 -1
  136. package/dist/server/metrics/index.js +4374 -4
  137. package/dist/server/metrics/index.js.map +1 -1
  138. package/dist/server/swagger/index.d.ts.map +1 -1
  139. package/dist/server/swagger/index.js +3 -4
  140. package/dist/server/swagger/index.js.map +1 -1
  141. package/dist/websocket/index.browser.js +11 -5
  142. package/dist/websocket/index.browser.js.map +1 -1
  143. package/dist/websocket/index.d.ts +3 -1
  144. package/dist/websocket/index.d.ts.map +1 -1
  145. package/dist/websocket/index.js +21 -6
  146. package/dist/websocket/index.js.map +1 -1
  147. package/package.json +671 -263
  148. package/src/api/parameters/services/ParameterProvider.ts +21 -4
  149. package/src/api/users/__tests__/SessionService.spec.ts +99 -0
  150. package/src/api/users/__tests__/UserJobs.spec.ts +67 -0
  151. package/src/api/users/atoms/realmAuthSettingsAtom.ts +15 -0
  152. package/src/api/users/entities/sessions.ts +6 -0
  153. package/src/api/users/jobs/UserJobs.ts +44 -17
  154. package/src/api/users/providers/RealmProvider.ts +4 -0
  155. package/src/api/users/services/SessionService.ts +27 -0
  156. package/src/bucket/__tests__/NodeS3BucketProvider.spec.ts +74 -0
  157. package/src/bucket/index.ts +19 -2
  158. package/src/bucket/primitives/$bucket.ts +9 -1
  159. package/src/bucket/providers/CloudflareR2Provider.ts +2 -137
  160. package/src/bucket/providers/NodeS3BucketProvider.ts +218 -0
  161. package/src/cache/core/index.ts +29 -0
  162. package/src/cache/core/primitives/$cache.ts +14 -1
  163. package/src/cli/config/defineConfig.ts +13 -15
  164. package/src/cli/core/__tests__/init.spec.ts +6 -7
  165. package/src/cli/core/services/ProjectScaffolder.ts +18 -14
  166. package/src/cli/core/tasks/BuildCloudflareTask.ts +5 -0
  167. package/src/cli/core/templates/agentMd.ts +2 -10
  168. package/src/cli/core/templates/saasAdminLayoutTsx.ts +3 -3
  169. package/src/cli/devtools/index.ts +12 -26
  170. package/src/cli/platform/index.ts +15 -24
  171. package/src/cli/vendor/atoms/vendorOptions.ts +1 -1
  172. package/src/cli/vendor/index.ts +14 -23
  173. package/src/core/Alepha.ts +11 -1
  174. package/src/core/helpers/ref.ts +18 -0
  175. package/src/core/index.shared.ts +1 -0
  176. package/src/core/providers/SchemaValidator.ts +9 -1
  177. package/src/core/providers/TypeProvider.ts +1 -2
  178. package/src/datetime/REFACTORING.md +118 -0
  179. package/src/datetime/providers/DateTimeProvider.ts +203 -24
  180. package/src/lock/core/index.ts +31 -0
  181. package/src/lock/core/primitives/$lock.ts +14 -1
  182. package/src/mcp/__tests__/jsonrpc.spec.ts +1 -1
  183. package/src/mcp/helpers/jsonrpc.ts +26 -1
  184. package/src/mcp/index.ts +10 -5
  185. package/src/mcp/interfaces/McpTypes.ts +83 -6
  186. package/src/mcp/primitives/$prompt.ts +18 -1
  187. package/src/mcp/primitives/$resource.ts +18 -1
  188. package/src/mcp/primitives/$tool.ts +83 -7
  189. package/src/mcp/providers/McpServerProvider.ts +74 -16
  190. package/src/mcp/transports/StreamableHttpMcpTransport.ts +226 -0
  191. package/src/orm/REFACTORING.md +330 -0
  192. package/src/orm/core/primitives/$transactional.ts +11 -0
  193. package/src/orm/core/schemas/updateSchema.ts +1 -1
  194. package/src/orm/core/services/PgRelationManager.ts +4 -2
  195. package/src/react/core/__tests__/useQuery.browser.spec.tsx +86 -0
  196. package/src/react/core/hooks/useQuery.ts +153 -0
  197. package/src/react/core/index.ts +1 -0
  198. package/src/react/form/services/FormModel.ts +15 -6
  199. package/src/react/form/services/parseField.ts +8 -0
  200. package/src/react/i18n/providers/I18nProvider.ts +8 -2
  201. package/src/react/router/__tests__/$page.spec.tsx +0 -16
  202. package/src/react/router/__tests__/ssr.spec.tsx +339 -0
  203. package/src/react/router/primitives/$page.ts +28 -4
  204. package/src/react/router/providers/ReactPageProvider.ts +27 -9
  205. package/src/react/ui/atoms/uiThemeListAtom.ts +36 -0
  206. package/src/react/ui/index.ts +6 -0
  207. package/src/react/ui/services/SchemaControl.ts +209 -0
  208. package/src/security/primitives/$issuer.ts +6 -3
  209. package/src/server/core/__tests__/ServerRouterProvider-serializationError.spec.ts +75 -0
  210. package/src/server/core/__tests__/ServerRouterProvider-validationError.spec.ts +306 -0
  211. package/src/server/core/errors/ValidationError.ts +13 -1
  212. package/src/server/core/primitives/$action.ts +16 -5
  213. package/src/server/core/providers/ServerRouterProvider.ts +26 -4
  214. package/src/server/swagger/providers/ServerSwaggerProvider.ts +5 -7
  215. package/src/websocket/providers/NodeWebSocketServerProvider.ts +10 -4
  216. package/src/websocket/services/WebSocketClient.ts +11 -5
  217. package/src/mcp/transports/SseMcpTransport.ts +0 -182
@@ -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
+ });
@@ -0,0 +1,153 @@
1
+ import type { Async } from "alepha";
2
+ import type { DurationLike } from "alepha/datetime";
3
+ import { type DependencyList, useCallback, useState } from "react";
4
+ import type { ActionContext } from "./useAction.ts";
5
+ import { useAction } from "./useAction.ts";
6
+
7
+ /**
8
+ * Hook for declarative data fetching with automatic execution and refetch.
9
+ *
10
+ * Thin wrapper over {@link useAction}: it pre-applies `runOnInit: true`,
11
+ * exposes the last result as `data`, and provides a stable `refetch()` to
12
+ * re-run the query on demand. For optimistic mutations and side-effects,
13
+ * use {@link useAction} directly — `useQuery` is for the read path.
14
+ *
15
+ * Caching, request deduplication, and AbortSignal cancellation come from
16
+ * `useAction` + `HttpClient`. There is no separate cache layer — pass
17
+ * `localCache` to your `HttpClient.fetch()`/`fetchAction()` call inside
18
+ * the query handler if you want per-call caching.
19
+ *
20
+ * @example Basic
21
+ * ```tsx
22
+ * const client = useInject(HttpClient);
23
+ * const { data, loading, error, refetch } = useQuery({
24
+ * handler: async ({ signal }) => {
25
+ * const res = await client.fetch("/api/users", { request: { signal } });
26
+ * return res.data;
27
+ * },
28
+ * }, []);
29
+ * ```
30
+ *
31
+ * @example Re-fetch when a dep changes
32
+ * ```tsx
33
+ * const { data } = useQuery({
34
+ * handler: async () => api.getUser(userId),
35
+ * }, [userId]);
36
+ * ```
37
+ *
38
+ * @example Polling
39
+ * ```tsx
40
+ * const { data } = useQuery({
41
+ * handler: async () => api.getStatus(),
42
+ * runEvery: [5, "seconds"],
43
+ * }, []);
44
+ * ```
45
+ */
46
+ export function useQuery<Result>(
47
+ options: UseQueryOptions<Result>,
48
+ deps: DependencyList,
49
+ ): UseQueryReturn<Result> {
50
+ const [data, setData] = useState<Result | undefined>(options.initialData);
51
+
52
+ const action = useAction<[], Result>(
53
+ {
54
+ id: options.id,
55
+ handler: options.handler,
56
+ runOnInit: options.enabled !== false,
57
+ runEvery: options.runEvery,
58
+ debounce: options.debounce,
59
+ onError: options.onError,
60
+ onSuccess: async (result) => {
61
+ setData(result);
62
+ if (options.onSuccess) {
63
+ await options.onSuccess(result);
64
+ }
65
+ },
66
+ },
67
+ deps,
68
+ );
69
+
70
+ const refetch = useCallback(() => action.run(), [action.run]);
71
+
72
+ return {
73
+ data,
74
+ loading: action.loading,
75
+ error: action.error,
76
+ refetch,
77
+ cancel: action.cancel,
78
+ };
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------------------------------------------------
82
+
83
+ export interface UseQueryOptions<Result> {
84
+ /**
85
+ * Async query handler. Receives an {@link ActionContext} with an
86
+ * AbortSignal that fires on unmount, dependency change, or `cancel()`.
87
+ */
88
+ handler: (context: ActionContext) => Async<Result>;
89
+
90
+ /**
91
+ * Optional identifier (used in lifecycle events for debugging/analytics).
92
+ */
93
+ id?: string;
94
+
95
+ /**
96
+ * If `false`, skip automatic execution on mount and dep change. Use
97
+ * `refetch()` to trigger manually. Defaults to `true`.
98
+ */
99
+ enabled?: boolean;
100
+
101
+ /**
102
+ * Initial value for `data` before the first successful fetch.
103
+ */
104
+ initialData?: Result;
105
+
106
+ /**
107
+ * Re-run periodically. See {@link useAction} `runEvery`.
108
+ */
109
+ runEvery?: DurationLike;
110
+
111
+ /**
112
+ * Debounce delay in milliseconds. See {@link useAction} `debounce`.
113
+ */
114
+ debounce?: number;
115
+
116
+ /**
117
+ * Called on success with the resolved value.
118
+ */
119
+ onSuccess?: (result: Result) => void | Promise<void>;
120
+
121
+ /**
122
+ * Custom error handler. If provided, prevents default error re-throw.
123
+ */
124
+ onError?: (error: Error) => void | Promise<void>;
125
+ }
126
+
127
+ export interface UseQueryReturn<Result> {
128
+ /**
129
+ * The last successful result. `undefined` until the first fetch resolves
130
+ * (or the value of `initialData` if provided).
131
+ */
132
+ data: Result | undefined;
133
+
134
+ /**
135
+ * Loading state — `true` while a fetch is in flight.
136
+ */
137
+ loading: boolean;
138
+
139
+ /**
140
+ * Error from the last failed fetch, if any.
141
+ */
142
+ error?: Error;
143
+
144
+ /**
145
+ * Re-run the query. The previous in-flight request, if any, is aborted.
146
+ */
147
+ refetch: () => Promise<Result | undefined>;
148
+
149
+ /**
150
+ * Abort the in-flight request without scheduling another.
151
+ */
152
+ cancel: () => void;
153
+ }
@@ -13,6 +13,7 @@ export * from "./hooks/useAlepha.ts";
13
13
  export * from "./hooks/useClient.ts";
14
14
  export * from "./hooks/useEvents.ts";
15
15
  export * from "./hooks/useInject.ts";
16
+ export * from "./hooks/useQuery.ts";
16
17
  export * from "./hooks/useStore.ts";
17
18
 
18
19
  // ---------------------------------------------------------------------------------------------------------------------
@@ -140,15 +140,23 @@ export class FormModel<T extends TObject> {
140
140
 
141
141
  public readonly reset = (event?: FormEventLike) => {
142
142
  event?.preventDefault?.();
143
+ // Snapshot all keys that need notification — both keys present
144
+ // before reset (so subscribers learn the cleared value) and keys
145
+ // restored from initialValues. Without the union, fields that were
146
+ // typed but absent from initialValues stay visually stale.
147
+ const keys = new Set<string>([
148
+ ...Object.keys(this.values),
149
+ ...Object.keys(this.initialValues),
150
+ ]);
143
151
  for (const key in this.values) {
144
152
  delete this.values[key];
145
153
  }
146
154
  Object.assign(this.values, { ...this.initialValues });
147
- for (const [key, value] of Object.entries(this.values)) {
155
+ for (const key of keys) {
148
156
  const path = `/${key.replaceAll(".", "/")}`;
149
157
  this.alepha.events.emit(
150
158
  "form:change",
151
- { id: this.id, path, value },
159
+ { id: this.id, path, value: this.values[key] },
152
160
  { catch: true },
153
161
  );
154
162
  }
@@ -356,10 +364,11 @@ export class FormModel<T extends TObject> {
356
364
  name: key,
357
365
  };
358
366
 
359
- if (options.id) {
360
- attr.id = `${options.id}-${key}`;
361
- (attr as any)["data-testid"] = attr.id;
362
- }
367
+ // Use the form's runtime id (always set — comes from `useId()` when
368
+ // no explicit `options.id` was provided). This guarantees stable
369
+ // per-field DOM ids without forcing callers to pass `id`.
370
+ attr.id = `${this.id}-${key}`;
371
+ (attr as any)["data-testid"] = attr.id;
363
372
 
364
373
  if (t.schema.isString(field)) {
365
374
  if (field.maxLength != null) {