alepha 0.20.4 → 0.20.6

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 (192) hide show
  1. package/dist/api/audits/index.d.ts +391 -359
  2. package/dist/api/audits/index.d.ts.map +1 -1
  3. package/dist/api/audits/index.js +23 -1
  4. package/dist/api/audits/index.js.map +1 -1
  5. package/dist/api/files/index.d.ts +18 -0
  6. package/dist/api/files/index.d.ts.map +1 -1
  7. package/dist/api/files/index.js +51 -0
  8. package/dist/api/files/index.js.map +1 -1
  9. package/dist/api/jobs/index.browser.js +33 -14
  10. package/dist/api/jobs/index.browser.js.map +1 -1
  11. package/dist/api/jobs/index.d.ts +452 -155
  12. package/dist/api/jobs/index.d.ts.map +1 -1
  13. package/dist/api/jobs/index.js +474 -159
  14. package/dist/api/jobs/index.js.map +1 -1
  15. package/dist/api/keys/index.d.ts +32 -4
  16. package/dist/api/keys/index.d.ts.map +1 -1
  17. package/dist/api/keys/index.js +53 -0
  18. package/dist/api/keys/index.js.map +1 -1
  19. package/dist/api/notifications/index.d.ts +29 -1
  20. package/dist/api/notifications/index.d.ts.map +1 -1
  21. package/dist/api/notifications/index.js +55 -13
  22. package/dist/api/notifications/index.js.map +1 -1
  23. package/dist/api/organizations/index.js.map +1 -1
  24. package/dist/api/parameters/index.d.ts +15 -0
  25. package/dist/api/parameters/index.d.ts.map +1 -1
  26. package/dist/api/parameters/index.js +37 -0
  27. package/dist/api/parameters/index.js.map +1 -1
  28. package/dist/api/payments/index.js.map +1 -1
  29. package/dist/api/users/index.d.ts +150 -9
  30. package/dist/api/users/index.d.ts.map +1 -1
  31. package/dist/api/users/index.js +237 -28
  32. package/dist/api/users/index.js.map +1 -1
  33. package/dist/api/verifications/index.d.ts +3 -3
  34. package/dist/api/verifications/index.js.map +1 -1
  35. package/dist/batch/index.js.map +1 -1
  36. package/dist/bin/index.js +0 -0
  37. package/dist/bucket/index.d.ts +18 -0
  38. package/dist/bucket/index.d.ts.map +1 -1
  39. package/dist/bucket/index.js +47 -0
  40. package/dist/bucket/index.js.map +1 -1
  41. package/dist/bucket/index.workerd.js +24 -0
  42. package/dist/bucket/index.workerd.js.map +1 -1
  43. package/dist/cache/core/index.d.ts +20 -3
  44. package/dist/cache/core/index.d.ts.map +1 -1
  45. package/dist/cache/core/index.js.map +1 -1
  46. package/dist/cache/core/index.workerd.js.map +1 -1
  47. package/dist/cache/database/index.d.ts +155 -0
  48. package/dist/cache/database/index.d.ts.map +1 -0
  49. package/dist/cache/database/index.js +266 -0
  50. package/dist/cache/database/index.js.map +1 -0
  51. package/dist/cache/redis/index.js.map +1 -1
  52. package/dist/captcha/index.js.map +1 -1
  53. package/dist/cli/config/index.js.map +1 -1
  54. package/dist/cli/core/index.d.ts +35 -5
  55. package/dist/cli/core/index.d.ts.map +1 -1
  56. package/dist/cli/core/index.js +85 -6
  57. package/dist/cli/core/index.js.map +1 -1
  58. package/dist/cli/devtools/index.js.map +1 -1
  59. package/dist/cli/platform/index.js +1 -1
  60. package/dist/cli/platform/index.js.map +1 -1
  61. package/dist/cli/vendor/index.js.map +1 -1
  62. package/dist/command/index.js.map +1 -1
  63. package/dist/core/index.browser.js.map +1 -1
  64. package/dist/core/index.js.map +1 -1
  65. package/dist/core/index.native.js.map +1 -1
  66. package/dist/core/index.workerd.js.map +1 -1
  67. package/dist/crypto/index.browser.js.map +1 -1
  68. package/dist/crypto/index.js.map +1 -1
  69. package/dist/datetime/index.js.map +1 -1
  70. package/dist/email/brevo/index.js.map +1 -1
  71. package/dist/email/core/index.js.map +1 -1
  72. package/dist/email/core/index.workerd.js.map +1 -1
  73. package/dist/email/smtp/index.js.map +1 -1
  74. package/dist/fake/index.js.map +1 -1
  75. package/dist/lock/core/index.js.map +1 -1
  76. package/dist/lock/redis/index.js.map +1 -1
  77. package/dist/logger/index.js.map +1 -1
  78. package/dist/mcp/index.js.map +1 -1
  79. package/dist/orm/core/index.browser.js.map +1 -1
  80. package/dist/orm/core/index.bun.js.map +1 -1
  81. package/dist/orm/core/index.js.map +1 -1
  82. package/dist/orm/postgres/index.bun.js.map +1 -1
  83. package/dist/orm/postgres/index.js.map +1 -1
  84. package/dist/queue/core/index.js.map +1 -1
  85. package/dist/queue/core/index.workerd.js.map +1 -1
  86. package/dist/queue/redis/index.js.map +1 -1
  87. package/dist/react/auth/index.browser.js.map +1 -1
  88. package/dist/react/auth/index.js.map +1 -1
  89. package/dist/react/core/index.js.map +1 -1
  90. package/dist/react/form/index.js +2 -0
  91. package/dist/react/form/index.js.map +1 -1
  92. package/dist/react/head/index.browser.js.map +1 -1
  93. package/dist/react/head/index.js.map +1 -1
  94. package/dist/react/i18n/index.js.map +1 -1
  95. package/dist/react/intro/index.js.map +1 -1
  96. package/dist/react/router/index.browser.js.map +1 -1
  97. package/dist/react/router/index.js.map +1 -1
  98. package/dist/react/testing/index.js.map +1 -1
  99. package/dist/react/ui/index.js.map +1 -1
  100. package/dist/react/websocket/index.js.map +1 -1
  101. package/dist/redis/index.bun.js.map +1 -1
  102. package/dist/redis/index.js.map +1 -1
  103. package/dist/retry/index.js.map +1 -1
  104. package/dist/router/index.js.map +1 -1
  105. package/dist/scheduler/index.d.ts +22 -0
  106. package/dist/scheduler/index.d.ts.map +1 -1
  107. package/dist/scheduler/index.js +12 -0
  108. package/dist/scheduler/index.js.map +1 -1
  109. package/dist/scheduler/index.workerd.js +12 -0
  110. package/dist/scheduler/index.workerd.js.map +1 -1
  111. package/dist/security/index.browser.js.map +1 -1
  112. package/dist/security/index.js.map +1 -1
  113. package/dist/server/auth/index.js.map +1 -1
  114. package/dist/server/cookies/index.browser.js.map +1 -1
  115. package/dist/server/cookies/index.js.map +1 -1
  116. package/dist/server/core/index.browser.js.map +1 -1
  117. package/dist/server/core/index.js.map +1 -1
  118. package/dist/server/cors/index.js.map +1 -1
  119. package/dist/server/etag/index.js.map +1 -1
  120. package/dist/server/health/index.js.map +1 -1
  121. package/dist/server/links/index.browser.js.map +1 -1
  122. package/dist/server/links/index.js.map +1 -1
  123. package/dist/server/metrics/index.js.map +1 -1
  124. package/dist/server/proxy/index.js.map +1 -1
  125. package/dist/server/rate-limit/index.js.map +1 -1
  126. package/dist/server/static/index.js.map +1 -1
  127. package/dist/server/swagger/index.js.map +1 -1
  128. package/dist/sms/index.js.map +1 -1
  129. package/dist/system/index.browser.js.map +1 -1
  130. package/dist/system/index.js.map +1 -1
  131. package/dist/system/index.workerd.js.map +1 -1
  132. package/dist/topic/core/index.js.map +1 -1
  133. package/dist/topic/redis/index.js.map +1 -1
  134. package/dist/websocket/index.browser.js +4 -0
  135. package/dist/websocket/index.browser.js.map +1 -1
  136. package/dist/websocket/index.js +10 -0
  137. package/dist/websocket/index.js.map +1 -1
  138. package/package.json +282 -272
  139. package/src/api/audits/controllers/AdminAuditController.ts +29 -0
  140. package/src/api/files/controllers/FileController.ts +24 -0
  141. package/src/api/files/services/FileService.ts +41 -0
  142. package/src/api/jobs/__tests__/$job.spec.ts +427 -2
  143. package/src/api/jobs/entities/jobExecutionEntity.ts +3 -3
  144. package/src/api/jobs/index.ts +47 -10
  145. package/src/api/jobs/primitives/$job.ts +22 -9
  146. package/src/api/jobs/providers/DirectJobDispatcher.ts +71 -0
  147. package/src/api/jobs/providers/JobDispatcher.ts +49 -0
  148. package/src/api/jobs/providers/JobProvider.ts +365 -142
  149. package/src/api/jobs/providers/JobQueueProvider.ts +43 -18
  150. package/src/api/jobs/schemas/jobConfigAtom.ts +4 -3
  151. package/src/api/jobs/schemas/jobExecutionResourceSchema.ts +11 -0
  152. package/src/api/jobs/schemas/jobRegistrationSchema.ts +4 -2
  153. package/src/api/jobs/services/JobService.ts +21 -11
  154. package/src/api/keys/controllers/AdminApiKeyController.ts +23 -0
  155. package/src/api/keys/services/ApiKeyService.ts +42 -0
  156. package/src/api/notifications/__tests__/AlephaApiNotifications.spec.ts +63 -0
  157. package/src/api/notifications/controllers/AdminNotificationController.ts +48 -1
  158. package/src/api/notifications/index.ts +13 -3
  159. package/src/api/notifications/jobs/NotificationJobs.ts +0 -6
  160. package/src/api/parameters/controllers/AdminParameterController.ts +26 -0
  161. package/src/api/parameters/services/ParameterProvider.ts +18 -0
  162. package/src/api/users/__tests__/Registration-emailMode.spec.ts +203 -0
  163. package/src/api/users/__tests__/UsernameSlugger.spec.ts +138 -0
  164. package/src/api/users/atoms/realmAuthSettingsAtom.ts +41 -3
  165. package/src/api/users/controllers/AdminSessionController.ts +29 -0
  166. package/src/api/users/controllers/AdminUserController.ts +32 -0
  167. package/src/api/users/index.ts +3 -0
  168. package/src/api/users/services/CredentialService.ts +5 -0
  169. package/src/api/users/services/RegistrationService.ts +49 -1
  170. package/src/api/users/services/SessionCrudService.ts +16 -0
  171. package/src/api/users/services/SessionService.ts +17 -59
  172. package/src/api/users/services/UsernameSlugger.ts +195 -0
  173. package/src/bucket/primitives/$bucket.ts +21 -0
  174. package/src/bucket/providers/CloudflareR2Provider.ts +15 -0
  175. package/src/bucket/providers/FileStorageProvider.ts +9 -0
  176. package/src/bucket/providers/LocalFileStorageProvider.ts +14 -0
  177. package/src/bucket/providers/MemoryFileStorageProvider.ts +9 -0
  178. package/src/bucket/providers/NodeS3BucketProvider.ts +35 -0
  179. package/src/cache/core/primitives/$cache.ts +20 -3
  180. package/src/cache/database/__tests__/DatabaseCacheProvider.behavior.spec.ts +203 -0
  181. package/src/cache/database/__tests__/DatabaseCacheProvider.spec.ts +110 -0
  182. package/src/cache/database/entities/cacheEntries.ts +55 -0
  183. package/src/cache/database/index.ts +36 -0
  184. package/src/cache/database/providers/DatabaseCacheProvider.ts +348 -0
  185. package/src/cli/core/services/ProjectScaffolder.ts +0 -2
  186. package/src/cli/core/tasks/BuildCloudflareTask.ts +17 -3
  187. package/src/cli/core/tasks/BuildSitemapTask.ts +7 -0
  188. package/src/cli/core/tasks/BuildVercelTask.ts +82 -3
  189. package/src/cli/platform/__tests__/detectResources.spec.ts +96 -0
  190. package/src/cli/platform/commands/platform.ts +7 -1
  191. package/src/scheduler/index.ts +14 -0
  192. package/src/scheduler/providers/CronProvider.ts +13 -0
@@ -17,6 +17,7 @@ import { UserAudits } from "../audits/UserAudits.ts";
17
17
  import type { UserEntity } from "../entities/users.ts";
18
18
  import { UserNotifications } from "../notifications/UserNotifications.ts";
19
19
  import { RealmProvider } from "../providers/RealmProvider.ts";
20
+ import { UsernameSlugger } from "./UsernameSlugger.ts";
20
21
 
21
22
  export class SessionService {
22
23
  protected readonly alepha = $inject(Alepha);
@@ -27,6 +28,7 @@ export class SessionService {
27
28
  protected readonly realmProvider = $inject(RealmProvider);
28
29
  protected readonly fileController = $client<FileController>();
29
30
  protected readonly cacheProvider = $inject(CacheProvider);
31
+ protected readonly usernameSlugger = $inject(UsernameSlugger);
30
32
 
31
33
  protected userAudits(realmName?: string) {
32
34
  const realm = this.realmProvider.getRealm(realmName);
@@ -110,69 +112,24 @@ export class SessionService {
110
112
  /**
111
113
  * Generate a unique username from an OAuth profile.
112
114
  *
113
- * 1. Extract candidate from email prefix
114
- * 2. Sanitize against realm's usernameRegExp (strip invalid chars, truncate)
115
- * 3. Check case-insensitive uniqueness, append suffix (2, 3, ...) if taken
116
- * 4. Fall back to "user" + random 6-char alphanumeric if all else fails
115
+ * Routes through {@link UsernameSlugger}, which is the same code path as
116
+ * `username: "email"` registration. The OAuth profile's email is the
117
+ * primary signal; if absent (rare most IDPs return one), we fall back
118
+ * to `profile.name`, then to a random handle. The slugger applies the
119
+ * realm's `usernameBlocklist` and retries on collision.
117
120
  */
118
121
  protected async generateUniqueUsername(
119
122
  profile: OAuth2Profile,
120
- realmSettings: any,
121
- users: any,
123
+ _realmSettings: any,
124
+ _users: any,
125
+ realmName?: string,
122
126
  ): Promise<string> {
123
- const maxLength = 30;
124
- const maxSuffixAttempts = 10;
125
-
126
- // Extract candidate from email or profile name
127
- let candidate = profile.email?.split("@")[0] ?? profile.name ?? "";
128
-
129
- // Strip characters not allowed in usernames (keep alphanumeric, underscore, dot, hyphen)
130
- candidate = candidate.replace(/[^a-zA-Z0-9_.-]/g, "");
131
-
132
- // If realm has a custom regex, further sanitize
133
- if (realmSettings?.usernameRegExp) {
134
- try {
135
- const regex = new RegExp(realmSettings.usernameRegExp);
136
- if (!regex.test(candidate)) {
137
- // Strip to basic alphanumeric as safe fallback
138
- candidate = candidate.replace(/[^a-zA-Z0-9_]/g, "");
139
- }
140
- } catch {
141
- // Invalid regex, continue with sanitized candidate
142
- }
143
- }
144
-
145
- // Ensure minimum length
146
- if (candidate.length < 3) {
147
- candidate = `user${candidate}`;
148
- }
149
-
150
- // Truncate to leave room for suffix
151
- candidate = candidate.slice(0, maxLength - 2);
152
-
153
- // Check uniqueness (case-insensitive exact match)
154
- const isAvailable = async (name: string) => {
155
- const existing = await users.findOne({
156
- where: { username: { ilike: name } },
157
- });
158
- return !existing;
159
- };
160
-
161
- if (await isAvailable(candidate)) {
162
- return candidate;
163
- }
164
-
165
- // Try with numeric suffix
166
- for (let i = 2; i <= maxSuffixAttempts + 1; i++) {
167
- const withSuffix = `${candidate}${i}`;
168
- if (withSuffix.length <= maxLength && (await isAvailable(withSuffix))) {
169
- return withSuffix;
170
- }
171
- }
172
-
173
- // Final fallback: random username
174
- const random = Math.random().toString(36).slice(2, 8);
175
- return `user${random}`;
127
+ const seed =
128
+ profile.email ??
129
+ profile.name ??
130
+ `user-${Math.random().toString(36).slice(2, 8)}`;
131
+ const base = this.usernameSlugger.slug(seed);
132
+ return this.usernameSlugger.pickAvailable(realmName, base);
176
133
  }
177
134
 
178
135
  /**
@@ -766,6 +723,7 @@ export class SessionService {
766
723
  profile,
767
724
  realmSettings,
768
725
  users,
726
+ userRealmName,
769
727
  );
770
728
 
771
729
  const user = await users.create({
@@ -0,0 +1,195 @@
1
+ import { $inject, AlephaError } from "alepha";
2
+ import { $logger } from "alepha/logger";
3
+ import { RealmProvider } from "../providers/RealmProvider.ts";
4
+
5
+ /**
6
+ * Derive stable, URL-safe usernames from email addresses.
7
+ *
8
+ * Used by the registration flow when `realm.settings.username === "email"`,
9
+ * and reusable from any custom user-creation site (e.g. an OAuth-only flow
10
+ * that wants the same handle convention).
11
+ *
12
+ * **Slug rule** — the local-part of the email is kept as-is (gmail
13
+ * `+suffix` retained for predictability). Everything outside `[a-z0-9]` is
14
+ * replaced with `-`, runs of `-` are collapsed, leading/trailing `-` are
15
+ * trimmed, lowercased. If the result is shorter than {@link MIN_LENGTH} it
16
+ * is padded with random alphanumerics. Result is clamped to
17
+ * {@link MAX_LENGTH}.
18
+ *
19
+ * **Collision retry** — `pickAvailable()` checks the realm's `users` table
20
+ * and the realm's `usernameBlocklist`. On hit, it appends `-<4 random>` and
21
+ * retries up to {@link MAX_RETRIES} times. Best-effort against concurrent
22
+ * registrations: the unique index on `(realm, lower(username))` is the
23
+ * authoritative race guard, callers that hit a unique-violation should
24
+ * call `pickAvailable` again with a fresh suffix.
25
+ *
26
+ * @see RegistrationService.createRegistrationIntent
27
+ */
28
+ export class UsernameSlugger {
29
+ protected readonly realmProvider = $inject(RealmProvider);
30
+ protected readonly log = $logger();
31
+
32
+ /**
33
+ * Floor for derived usernames. Shorter slugs are padded with random
34
+ * alphanumerics. Matches the lower bound of the default `usernameRegExp`.
35
+ */
36
+ static readonly MIN_LENGTH = 3;
37
+
38
+ /**
39
+ * Ceiling for derived usernames. Matches the upper bound of the default
40
+ * `usernameRegExp` and gives enough headroom for the random suffix added
41
+ * on collisions.
42
+ */
43
+ static readonly MAX_LENGTH = 30;
44
+
45
+ /**
46
+ * Length of the random suffix appended on collision (e.g. `-3dp6`).
47
+ * 36⁴ ≈ 1.6M variants per base — plenty for a tiny number of retries.
48
+ */
49
+ static readonly SUFFIX_LENGTH = 4;
50
+
51
+ /**
52
+ * How many times `pickAvailable` retries before giving up.
53
+ */
54
+ static readonly MAX_RETRIES = 5;
55
+
56
+ /**
57
+ * Alphabet used for the random suffix and for padding short slugs.
58
+ */
59
+ static readonly ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
60
+
61
+ /**
62
+ * Default replacement when the email's local-part contains no
63
+ * `[a-z0-9]` characters at all (rare but possible: `é@example.com`).
64
+ */
65
+ static readonly EMPTY_LOCAL_FALLBACK = "user";
66
+
67
+ /**
68
+ * Sanitize an email into a base username slug.
69
+ *
70
+ * Pure function — no DB access. Always returns a string that satisfies
71
+ * `^[a-z0-9-]{MIN_LENGTH,MAX_LENGTH}$`.
72
+ *
73
+ * @example
74
+ * slug("ni.foures+testkv@gmail.com") // "ni-foures-testkv"
75
+ * slug("john.doe@example.com") // "john-doe"
76
+ * slug("é@example.com") // "user-XXX" (padded)
77
+ */
78
+ public slug(email: string | null | undefined): string {
79
+ const raw = (email ?? "").trim();
80
+ const at = raw.indexOf("@");
81
+ const local = at > 0 ? raw.slice(0, at) : raw;
82
+
83
+ const cleaned = local
84
+ .toLowerCase()
85
+ .replace(/[^a-z0-9]+/g, "-")
86
+ .replace(/^-+|-+$/g, "");
87
+
88
+ let result = cleaned || UsernameSlugger.EMPTY_LOCAL_FALLBACK;
89
+
90
+ if (result.length < UsernameSlugger.MIN_LENGTH) {
91
+ const needed = UsernameSlugger.MIN_LENGTH - result.length;
92
+ result += this.randomSuffix(needed);
93
+ }
94
+
95
+ return this.clamp(result);
96
+ }
97
+
98
+ /**
99
+ * Find an available username for the realm, starting from `base`.
100
+ *
101
+ * Returns `base` when nothing collides. On a hit (existing row OR
102
+ * blocklisted name) appends `-<4 random>` and tries again, up to
103
+ * {@link MAX_RETRIES} times.
104
+ *
105
+ * The check is best-effort: a concurrent registration may still claim
106
+ * the same value before the caller's INSERT runs, in which case the DB
107
+ * unique index throws and the caller should retry.
108
+ */
109
+ public async pickAvailable(
110
+ realmName: string | undefined,
111
+ base: string,
112
+ ): Promise<string> {
113
+ const blocklist = await this.getBlocklist(realmName);
114
+ const repo = this.realmProvider.userRepository(realmName);
115
+ const realm = this.realmProvider.getRealm(realmName);
116
+
117
+ const isAvailable = async (candidate: string): Promise<boolean> => {
118
+ if (this.isBlockedAgainst(candidate, blocklist)) {
119
+ return false;
120
+ }
121
+ const existing = await repo.findOne({
122
+ where: {
123
+ realm: { eq: realm.name },
124
+ username: { ilike: candidate },
125
+ },
126
+ });
127
+ return !existing;
128
+ };
129
+
130
+ if (await isAvailable(base)) {
131
+ return base;
132
+ }
133
+
134
+ // Reserve room for "-" + suffix at the end of the candidate.
135
+ const reserve = 1 + UsernameSlugger.SUFFIX_LENGTH;
136
+ const trimmedBase =
137
+ base.length > UsernameSlugger.MAX_LENGTH - reserve
138
+ ? base.slice(0, UsernameSlugger.MAX_LENGTH - reserve)
139
+ : base;
140
+
141
+ for (let i = 0; i < UsernameSlugger.MAX_RETRIES; i++) {
142
+ const candidate = `${trimmedBase}-${this.randomSuffix(
143
+ UsernameSlugger.SUFFIX_LENGTH,
144
+ )}`;
145
+ if (await isAvailable(candidate)) {
146
+ return candidate;
147
+ }
148
+ }
149
+
150
+ throw new AlephaError(
151
+ `Could not find an available username starting from "${base}" after ${UsernameSlugger.MAX_RETRIES} attempts.`,
152
+ );
153
+ }
154
+
155
+ /**
156
+ * Check a name against the realm's `usernameBlocklist`. Case-insensitive.
157
+ */
158
+ public async isBlocked(
159
+ realmName: string | undefined,
160
+ name: string,
161
+ ): Promise<boolean> {
162
+ const blocklist = await this.getBlocklist(realmName);
163
+ return this.isBlockedAgainst(name, blocklist);
164
+ }
165
+
166
+ // -------------------------------------------------------------------------
167
+
168
+ protected async getBlocklist(
169
+ realmName: string | undefined,
170
+ ): Promise<Set<string>> {
171
+ const realm = this.realmProvider.getRealm(realmName);
172
+ const settings = await realm.getSettings();
173
+ const list = settings?.usernameBlocklist ?? [];
174
+ return new Set(list.map((b) => b.toLowerCase()));
175
+ }
176
+
177
+ protected isBlockedAgainst(name: string, blocklist: Set<string>): boolean {
178
+ return blocklist.has(name.toLowerCase());
179
+ }
180
+
181
+ protected clamp(s: string): string {
182
+ return s.length > UsernameSlugger.MAX_LENGTH
183
+ ? s.slice(0, UsernameSlugger.MAX_LENGTH)
184
+ : s;
185
+ }
186
+
187
+ protected randomSuffix(length: number): string {
188
+ const chars = UsernameSlugger.ALPHABET;
189
+ let out = "";
190
+ for (let i = 0; i < length; i++) {
191
+ out += chars[Math.floor(Math.random() * chars.length)];
192
+ }
193
+ return out;
194
+ }
195
+ }
@@ -277,6 +277,27 @@ export class BucketPrimitive extends Primitive<BucketPrimitiveOptions> {
277
277
  });
278
278
  }
279
279
 
280
+ /**
281
+ * Delete many files in one round-trip when the underlying provider supports
282
+ * batch (R2/S3 up to 1000 keys per call). Emits one `bucket:file:deleted`
283
+ * event per id unless `skipHook` is set.
284
+ */
285
+ public async deleteMany(fileIds: string[], skipHook = false): Promise<void> {
286
+ if (fileIds.length === 0) return;
287
+ await this.provider.deleteMany(this.name, fileIds);
288
+
289
+ if (skipHook) {
290
+ return;
291
+ }
292
+
293
+ for (const id of fileIds) {
294
+ await this.alepha.events.emit("bucket:file:deleted", {
295
+ id,
296
+ bucket: this,
297
+ });
298
+ }
299
+ }
300
+
280
301
  /**
281
302
  * Checks if a file exists in the bucket.
282
303
  */
@@ -210,6 +210,21 @@ export class CloudflareR2Provider implements FileStorageProvider {
210
210
  await r2.delete(key);
211
211
  }
212
212
 
213
+ public async deleteMany(
214
+ bucketName: string,
215
+ fileIds: string[],
216
+ ): Promise<void> {
217
+ if (fileIds.length === 0) return;
218
+ const r2 = this.getR2();
219
+ const keys = fileIds.map((id) => this.key(bucketName, id));
220
+ this.log.trace(`Deleting ${keys.length} keys from '${bucketName}'`);
221
+ // R2 binding accepts a string[] and silently ignores missing keys.
222
+ // Chunk at 1000 to stay within the documented batch limit.
223
+ for (let i = 0; i < keys.length; i += 1000) {
224
+ await r2.delete(keys.slice(i, i + 1000));
225
+ }
226
+ }
227
+
213
228
  /**
214
229
  * Build the full R2 key: {prefix}/{bucketName}/{fileId}
215
230
  */
@@ -40,4 +40,13 @@ export abstract class FileStorageProvider {
40
40
  * @param fileId - Identifier of the file to delete
41
41
  */
42
42
  abstract delete(bucketName: string, fileId: string): Promise<void>;
43
+
44
+ /**
45
+ * Delete multiple files in one round-trip where the provider supports
46
+ * batch (R2/S3, up to 1000 per call). Memory/Local fall back to a loop.
47
+ *
48
+ * @param bucketName - Container name
49
+ * @param fileIds - Identifiers of the files to delete
50
+ */
51
+ abstract deleteMany(bucketName: string, fileIds: string[]): Promise<void>;
43
52
  }
@@ -157,6 +157,20 @@ export class LocalFileStorageProvider implements FileStorageProvider {
157
157
  }
158
158
  }
159
159
 
160
+ public async deleteMany(
161
+ bucketName: string,
162
+ fileIds: string[],
163
+ ): Promise<void> {
164
+ await Promise.all(
165
+ fileIds.map((id) =>
166
+ unlink(this.path(bucketName, id)).catch((error) => {
167
+ if (this.isErrorNoEntry(error)) return;
168
+ throw new AlephaError("Error deleting file", { cause: error });
169
+ }),
170
+ ),
171
+ );
172
+ }
173
+
160
174
  protected stat(bucket: string, fileId: string): Promise<fs.Stats> {
161
175
  return stat(this.path(bucket, fileId));
162
176
  }
@@ -70,6 +70,15 @@ export class MemoryFileStorageProvider implements FileStorageProvider {
70
70
  delete this.files[fileKey];
71
71
  }
72
72
 
73
+ public async deleteMany(
74
+ bucketName: string,
75
+ fileIds: string[],
76
+ ): Promise<void> {
77
+ for (const id of fileIds) {
78
+ delete this.files[`${bucketName}/${id}`];
79
+ }
80
+ }
81
+
73
82
  protected createId(): string {
74
83
  return randomUUID();
75
84
  }
@@ -215,4 +215,39 @@ export class NodeS3BucketProvider implements FileStorageProvider {
215
215
  throw error;
216
216
  }
217
217
  }
218
+
219
+ public async deleteMany(
220
+ bucketName: string,
221
+ fileIds: string[],
222
+ ): Promise<void> {
223
+ if (fileIds.length === 0) return;
224
+ this.log.trace(
225
+ `Deleting ${fileIds.length} files from bucket '${bucketName}'...`,
226
+ );
227
+ const client = this.getClient(bucketName);
228
+ // S3 DeleteObjects caps at 1000 keys per request.
229
+ for (let i = 0; i < fileIds.length; i += 1000) {
230
+ const chunk = fileIds.slice(i, i + 1000);
231
+ try {
232
+ // bun:s3 client exposes a per-key deleteObject; some SDKs also expose
233
+ // deleteObjects(keys: string[]). Prefer batch when available.
234
+ const batch = (
235
+ client as unknown as {
236
+ deleteObjects?: (keys: string[]) => Promise<unknown>;
237
+ }
238
+ ).deleteObjects;
239
+ if (typeof batch === "function") {
240
+ await batch.call(client, chunk);
241
+ } else {
242
+ await Promise.all(chunk.map((id) => client.deleteObject(id)));
243
+ }
244
+ } catch (error) {
245
+ this.log.error(`Failed to delete files: ${error}`);
246
+ if (error instanceof Error) {
247
+ throw new FileNotFoundError("Error deleting files", { cause: error });
248
+ }
249
+ throw error;
250
+ }
251
+ }
252
+ }
218
253
  }
@@ -4,11 +4,11 @@ import {
4
4
  $state,
5
5
  AlephaError,
6
6
  createPrimitive,
7
- type InstantiableClass,
8
7
  KIND,
9
8
  type MiddlewareMetadata,
10
9
  OPTIONS,
11
10
  Primitive,
11
+ type Service,
12
12
  type Static,
13
13
  t,
14
14
  } from "alepha";
@@ -112,9 +112,26 @@ export interface CachePrimitiveOptions<
112
112
 
113
113
  /**
114
114
  * The store provider for the cache.
115
- * If not provided, the default store provider will be used.
115
+ *
116
+ * Accepts:
117
+ * - `"memory"` — short-circuits to {@link MemoryCacheProvider} regardless
118
+ * of the default `CacheProvider` binding. Useful for caches that must
119
+ * stay process-local (e.g. ETag, HTTP client).
120
+ * - A {@link CacheProvider} class (concrete OR abstract) — resolved via
121
+ * `alepha.inject(...)` at primitive construction. Use this to opt a
122
+ * specific cache into a non-default backend (e.g.
123
+ * `provider: DatabaseCacheProvider` to keep one cache in SQL while the
124
+ * rest of the app uses Cloudflare KV).
125
+ * - `undefined` — falls back to whatever is bound to `CacheProvider` in
126
+ * the container. On Cloudflare workers this is
127
+ * {@link CloudflareKVProvider} by default; on Node it's
128
+ * {@link MemoryCacheProvider}.
129
+ *
130
+ * Note: passing an *abstract* class works because Alepha's DI resolves
131
+ * through substitutions, e.g. `alepha.with({ provide: CacheProvider, use:
132
+ * MyCacheProvider })`.
116
133
  */
117
- provider?: InstantiableClass<CacheProvider> | "memory";
134
+ provider?: Service<CacheProvider> | "memory";
118
135
 
119
136
  /**
120
137
  * The time-to-live for the cache in seconds.
@@ -0,0 +1,203 @@
1
+ import { Alepha } from "alepha";
2
+ import { $cache, CacheProvider } from "alepha/cache";
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ DatabaseCacheProvider,
6
+ databaseCacheOptions,
7
+ } from "../providers/DatabaseCacheProvider.ts";
8
+
9
+ const make = (
10
+ configure: (app: Alepha) => void = () => {},
11
+ ): { alepha: Alepha; provider: () => DatabaseCacheProvider } => {
12
+ const alepha = Alepha.create({
13
+ env: { DATABASE_URL: "sqlite://:memory:" },
14
+ }).with({
15
+ provide: CacheProvider,
16
+ use: DatabaseCacheProvider,
17
+ });
18
+ configure(alepha);
19
+ return {
20
+ alepha,
21
+ provider: () => alepha.inject(DatabaseCacheProvider),
22
+ };
23
+ };
24
+
25
+ // ---------------------------------------------------------------------------------------------------------------------
26
+
27
+ describe("DatabaseCacheProvider — atomic incr", () => {
28
+ it("increments through SQL upsert (no read/modify/write race)", async () => {
29
+ const { alepha, provider } = make();
30
+ await alepha.start();
31
+ const p = provider();
32
+
33
+ expect(await p.incr("counters", "k", 1)).toBe(1);
34
+ expect(await p.incr("counters", "k", 1)).toBe(2);
35
+ expect(await p.incr("counters", "k", 5)).toBe(7);
36
+ expect(await p.incr("counters", "k", -3)).toBe(4);
37
+ });
38
+
39
+ it("survives concurrent increments without losing updates", async () => {
40
+ const { alepha, provider } = make();
41
+ await alepha.start();
42
+ const p = provider();
43
+
44
+ // SQLite serializes writes; this proves that even concurrent .incr()
45
+ // calls all end up reflected in the final count, unlike the KV
46
+ // read-modify-write race we want to avoid.
47
+ const concurrent = 50;
48
+ await Promise.all(
49
+ Array.from({ length: concurrent }, () => p.incr("hits", "ip", 1)),
50
+ );
51
+
52
+ const bytes = await p.get("hits", "ip");
53
+ const value = JSON.parse(new TextDecoder().decode(bytes!.subarray(1)));
54
+ expect(value).toBe(concurrent);
55
+ });
56
+
57
+ it("reads back an incr'd value through get()", async () => {
58
+ const { alepha, provider } = make();
59
+ await alepha.start();
60
+ const p = provider();
61
+
62
+ await p.incr("counters", "k", 7);
63
+ const bytes = await p.get("counters", "k");
64
+ expect(bytes).toBeDefined();
65
+ // JSON-encoded number with the JSON marker byte (0x02).
66
+ expect(bytes![0]).toBe(0x02);
67
+ expect(new TextDecoder().decode(bytes!.subarray(1))).toBe("7");
68
+ });
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------------------------------------------------
72
+
73
+ describe("DatabaseCacheProvider — lazy expiration on read", () => {
74
+ it("returns undefined for an expired entry without sweeping it first", async () => {
75
+ const { alepha, provider } = make();
76
+ await alepha.start();
77
+ const p = provider();
78
+
79
+ // Write directly with a TTL of 1ms so the row is already expired by the
80
+ // time we read it, regardless of clock granularity.
81
+ await p.set("c", "k", new TextEncoder().encode("hello"), 1);
82
+ await new Promise((r) => setTimeout(r, 5));
83
+
84
+ expect(await p.get("c", "k")).toBeUndefined();
85
+ expect(await p.has("c", "k")).toBe(false);
86
+ });
87
+
88
+ it("excludes expired entries from keys() and from get() but leaves them in storage until swept", async () => {
89
+ const { alepha, provider } = make();
90
+ await alepha.start();
91
+ const p = provider();
92
+
93
+ await p.set("c", "fresh", new TextEncoder().encode("a"));
94
+ await p.set("c", "stale", new TextEncoder().encode("b"), 1);
95
+ await new Promise((r) => setTimeout(r, 5));
96
+
97
+ expect(await p.keys("c")).toEqual(["fresh"]);
98
+ expect(await p.has("c", "stale")).toBe(false);
99
+
100
+ // The expired row is still on disk until sweep runs; correctness is
101
+ // preserved by the read-time filter, not by aggressive cleanup.
102
+ const rows = await (p as any).repository.findMany({});
103
+ expect(rows.length).toBe(2);
104
+ });
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------------------------------------------------
108
+
109
+ describe("DatabaseCacheProvider — opportunistic sweep", () => {
110
+ it("does nothing when sweepProbability is 0", async () => {
111
+ const { alepha, provider } = make((app) => {
112
+ app.store.mut(databaseCacheOptions, (cur) => ({
113
+ ...cur,
114
+ sweepProbability: 0,
115
+ }));
116
+ });
117
+ await alepha.start();
118
+ const p = provider();
119
+
120
+ for (let i = 0; i < 30; i++) {
121
+ await p.set("c", `k${i}`, new TextEncoder().encode("x"));
122
+ }
123
+ expect(p.sweeps).toBe(0);
124
+ });
125
+
126
+ it("triggers a sweep on every write when sweepProbability is 1", async () => {
127
+ const { alepha, provider } = make((app) => {
128
+ app.store.mut(databaseCacheOptions, (cur) => ({
129
+ ...cur,
130
+ sweepProbability: 1,
131
+ }));
132
+ });
133
+ await alepha.start();
134
+ const p = provider();
135
+
136
+ await p.set("c", "fresh", new TextEncoder().encode("a"));
137
+ await p.set("c", "stale", new TextEncoder().encode("b"), 1);
138
+ await new Promise((r) => setTimeout(r, 5));
139
+
140
+ // Trigger another write; the stale row should be reaped during the sweep.
141
+ await p.set("c", "trigger", new TextEncoder().encode("c"));
142
+ expect(p.sweeps).toBeGreaterThan(0);
143
+
144
+ // Nothing references the swept row anymore.
145
+ expect(await p.has("c", "stale")).toBe(false);
146
+ });
147
+
148
+ it("sweepExpired() reaps every expired row in one shot", async () => {
149
+ const { alepha, provider } = make((app) => {
150
+ app.store.mut(databaseCacheOptions, (cur) => ({
151
+ ...cur,
152
+ sweepProbability: 0,
153
+ }));
154
+ });
155
+ await alepha.start();
156
+ const p = provider();
157
+
158
+ for (let i = 0; i < 5; i++) {
159
+ await p.set("c", `stale${i}`, new TextEncoder().encode("x"), 1);
160
+ }
161
+ await p.set("c", "fresh", new TextEncoder().encode("y"));
162
+ await new Promise((r) => setTimeout(r, 5));
163
+
164
+ const reaped = await p.sweepExpired();
165
+ expect(reaped).toBe(5);
166
+
167
+ // Sanity: the live entry is intact.
168
+ expect(await p.has("c", "fresh")).toBe(true);
169
+ });
170
+ });
171
+
172
+ // ---------------------------------------------------------------------------------------------------------------------
173
+
174
+ describe("DatabaseCacheProvider — provider option on $cache", () => {
175
+ it("$cache({ provider: DatabaseCacheProvider }) routes to the SQL backend even when the default CacheProvider is something else", async () => {
176
+ class App {
177
+ // Default fallback CacheProvider is MemoryCacheProvider in this test.
178
+ pinned = $cache<string>({
179
+ provider: DatabaseCacheProvider,
180
+ });
181
+ }
182
+
183
+ const alepha = Alepha.create({
184
+ env: { DATABASE_URL: "sqlite://:memory:" },
185
+ });
186
+ // Note: NO substitution of CacheProvider — the framework default is in
187
+ // play. The pinned `$cache` should still reach DatabaseCacheProvider via
188
+ // the explicit option.
189
+ const app = alepha.inject(App);
190
+ await alepha.start();
191
+
192
+ await app.pinned.set("k", "v");
193
+ expect(await app.pinned.get("k")).toBe("v");
194
+
195
+ // The row really lives in the cache_entries table, not in
196
+ // MemoryCacheProvider's map.
197
+ const rows = await (
198
+ alepha.inject(DatabaseCacheProvider) as any
199
+ ).repository.findMany({});
200
+ expect(rows.length).toBe(1);
201
+ expect(rows[0].cacheKey).toBe("k");
202
+ });
203
+ });