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.
- package/dist/api/audits/index.d.ts +391 -359
- package/dist/api/audits/index.d.ts.map +1 -1
- package/dist/api/audits/index.js +23 -1
- package/dist/api/audits/index.js.map +1 -1
- package/dist/api/files/index.d.ts +18 -0
- package/dist/api/files/index.d.ts.map +1 -1
- package/dist/api/files/index.js +51 -0
- package/dist/api/files/index.js.map +1 -1
- package/dist/api/jobs/index.browser.js +33 -14
- package/dist/api/jobs/index.browser.js.map +1 -1
- package/dist/api/jobs/index.d.ts +452 -155
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/jobs/index.js +474 -159
- package/dist/api/jobs/index.js.map +1 -1
- package/dist/api/keys/index.d.ts +32 -4
- package/dist/api/keys/index.d.ts.map +1 -1
- package/dist/api/keys/index.js +53 -0
- package/dist/api/keys/index.js.map +1 -1
- package/dist/api/notifications/index.d.ts +29 -1
- package/dist/api/notifications/index.d.ts.map +1 -1
- package/dist/api/notifications/index.js +55 -13
- package/dist/api/notifications/index.js.map +1 -1
- package/dist/api/organizations/index.js.map +1 -1
- package/dist/api/parameters/index.d.ts +15 -0
- package/dist/api/parameters/index.d.ts.map +1 -1
- package/dist/api/parameters/index.js +37 -0
- package/dist/api/parameters/index.js.map +1 -1
- package/dist/api/payments/index.js.map +1 -1
- package/dist/api/users/index.d.ts +150 -9
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +237 -28
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +3 -3
- package/dist/api/verifications/index.js.map +1 -1
- package/dist/batch/index.js.map +1 -1
- package/dist/bin/index.js +0 -0
- package/dist/bucket/index.d.ts +18 -0
- package/dist/bucket/index.d.ts.map +1 -1
- package/dist/bucket/index.js +47 -0
- package/dist/bucket/index.js.map +1 -1
- package/dist/bucket/index.workerd.js +24 -0
- package/dist/bucket/index.workerd.js.map +1 -1
- package/dist/cache/core/index.d.ts +20 -3
- package/dist/cache/core/index.d.ts.map +1 -1
- package/dist/cache/core/index.js.map +1 -1
- package/dist/cache/core/index.workerd.js.map +1 -1
- package/dist/cache/database/index.d.ts +155 -0
- package/dist/cache/database/index.d.ts.map +1 -0
- package/dist/cache/database/index.js +266 -0
- package/dist/cache/database/index.js.map +1 -0
- package/dist/cache/redis/index.js.map +1 -1
- package/dist/captcha/index.js.map +1 -1
- package/dist/cli/config/index.js.map +1 -1
- package/dist/cli/core/index.d.ts +35 -5
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +85 -6
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/devtools/index.js.map +1 -1
- package/dist/cli/platform/index.js +1 -1
- package/dist/cli/platform/index.js.map +1 -1
- package/dist/cli/vendor/index.js.map +1 -1
- package/dist/command/index.js.map +1 -1
- package/dist/core/index.browser.js.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js.map +1 -1
- package/dist/core/index.workerd.js.map +1 -1
- package/dist/crypto/index.browser.js.map +1 -1
- package/dist/crypto/index.js.map +1 -1
- package/dist/datetime/index.js.map +1 -1
- package/dist/email/brevo/index.js.map +1 -1
- package/dist/email/core/index.js.map +1 -1
- package/dist/email/core/index.workerd.js.map +1 -1
- package/dist/email/smtp/index.js.map +1 -1
- package/dist/fake/index.js.map +1 -1
- package/dist/lock/core/index.js.map +1 -1
- package/dist/lock/redis/index.js.map +1 -1
- package/dist/logger/index.js.map +1 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/orm/core/index.browser.js.map +1 -1
- package/dist/orm/core/index.bun.js.map +1 -1
- package/dist/orm/core/index.js.map +1 -1
- package/dist/orm/postgres/index.bun.js.map +1 -1
- package/dist/orm/postgres/index.js.map +1 -1
- package/dist/queue/core/index.js.map +1 -1
- package/dist/queue/core/index.workerd.js.map +1 -1
- package/dist/queue/redis/index.js.map +1 -1
- package/dist/react/auth/index.browser.js.map +1 -1
- package/dist/react/auth/index.js.map +1 -1
- package/dist/react/core/index.js.map +1 -1
- package/dist/react/form/index.js +2 -0
- package/dist/react/form/index.js.map +1 -1
- package/dist/react/head/index.browser.js.map +1 -1
- package/dist/react/head/index.js.map +1 -1
- package/dist/react/i18n/index.js.map +1 -1
- package/dist/react/intro/index.js.map +1 -1
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.js.map +1 -1
- package/dist/react/testing/index.js.map +1 -1
- package/dist/react/ui/index.js.map +1 -1
- package/dist/react/websocket/index.js.map +1 -1
- package/dist/redis/index.bun.js.map +1 -1
- package/dist/redis/index.js.map +1 -1
- package/dist/retry/index.js.map +1 -1
- package/dist/router/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +22 -0
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +12 -0
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/index.workerd.js +12 -0
- package/dist/scheduler/index.workerd.js.map +1 -1
- package/dist/security/index.browser.js.map +1 -1
- package/dist/security/index.js.map +1 -1
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/cookies/index.browser.js.map +1 -1
- package/dist/server/cookies/index.js.map +1 -1
- package/dist/server/core/index.browser.js.map +1 -1
- package/dist/server/core/index.js.map +1 -1
- package/dist/server/cors/index.js.map +1 -1
- package/dist/server/etag/index.js.map +1 -1
- package/dist/server/health/index.js.map +1 -1
- package/dist/server/links/index.browser.js.map +1 -1
- package/dist/server/links/index.js.map +1 -1
- package/dist/server/metrics/index.js.map +1 -1
- package/dist/server/proxy/index.js.map +1 -1
- package/dist/server/rate-limit/index.js.map +1 -1
- package/dist/server/static/index.js.map +1 -1
- package/dist/server/swagger/index.js.map +1 -1
- package/dist/sms/index.js.map +1 -1
- package/dist/system/index.browser.js.map +1 -1
- package/dist/system/index.js.map +1 -1
- package/dist/system/index.workerd.js.map +1 -1
- package/dist/topic/core/index.js.map +1 -1
- package/dist/topic/redis/index.js.map +1 -1
- package/dist/websocket/index.browser.js +4 -0
- package/dist/websocket/index.browser.js.map +1 -1
- package/dist/websocket/index.js +10 -0
- package/dist/websocket/index.js.map +1 -1
- package/package.json +282 -272
- package/src/api/audits/controllers/AdminAuditController.ts +29 -0
- package/src/api/files/controllers/FileController.ts +24 -0
- package/src/api/files/services/FileService.ts +41 -0
- package/src/api/jobs/__tests__/$job.spec.ts +427 -2
- package/src/api/jobs/entities/jobExecutionEntity.ts +3 -3
- package/src/api/jobs/index.ts +47 -10
- package/src/api/jobs/primitives/$job.ts +22 -9
- package/src/api/jobs/providers/DirectJobDispatcher.ts +71 -0
- package/src/api/jobs/providers/JobDispatcher.ts +49 -0
- package/src/api/jobs/providers/JobProvider.ts +365 -142
- package/src/api/jobs/providers/JobQueueProvider.ts +43 -18
- package/src/api/jobs/schemas/jobConfigAtom.ts +4 -3
- package/src/api/jobs/schemas/jobExecutionResourceSchema.ts +11 -0
- package/src/api/jobs/schemas/jobRegistrationSchema.ts +4 -2
- package/src/api/jobs/services/JobService.ts +21 -11
- package/src/api/keys/controllers/AdminApiKeyController.ts +23 -0
- package/src/api/keys/services/ApiKeyService.ts +42 -0
- package/src/api/notifications/__tests__/AlephaApiNotifications.spec.ts +63 -0
- package/src/api/notifications/controllers/AdminNotificationController.ts +48 -1
- package/src/api/notifications/index.ts +13 -3
- package/src/api/notifications/jobs/NotificationJobs.ts +0 -6
- package/src/api/parameters/controllers/AdminParameterController.ts +26 -0
- package/src/api/parameters/services/ParameterProvider.ts +18 -0
- package/src/api/users/__tests__/Registration-emailMode.spec.ts +203 -0
- package/src/api/users/__tests__/UsernameSlugger.spec.ts +138 -0
- package/src/api/users/atoms/realmAuthSettingsAtom.ts +41 -3
- package/src/api/users/controllers/AdminSessionController.ts +29 -0
- package/src/api/users/controllers/AdminUserController.ts +32 -0
- package/src/api/users/index.ts +3 -0
- package/src/api/users/services/CredentialService.ts +5 -0
- package/src/api/users/services/RegistrationService.ts +49 -1
- package/src/api/users/services/SessionCrudService.ts +16 -0
- package/src/api/users/services/SessionService.ts +17 -59
- package/src/api/users/services/UsernameSlugger.ts +195 -0
- package/src/bucket/primitives/$bucket.ts +21 -0
- package/src/bucket/providers/CloudflareR2Provider.ts +15 -0
- package/src/bucket/providers/FileStorageProvider.ts +9 -0
- package/src/bucket/providers/LocalFileStorageProvider.ts +14 -0
- package/src/bucket/providers/MemoryFileStorageProvider.ts +9 -0
- package/src/bucket/providers/NodeS3BucketProvider.ts +35 -0
- package/src/cache/core/primitives/$cache.ts +20 -3
- package/src/cache/database/__tests__/DatabaseCacheProvider.behavior.spec.ts +203 -0
- package/src/cache/database/__tests__/DatabaseCacheProvider.spec.ts +110 -0
- package/src/cache/database/entities/cacheEntries.ts +55 -0
- package/src/cache/database/index.ts +36 -0
- package/src/cache/database/providers/DatabaseCacheProvider.ts +348 -0
- package/src/cli/core/services/ProjectScaffolder.ts +0 -2
- package/src/cli/core/tasks/BuildCloudflareTask.ts +17 -3
- package/src/cli/core/tasks/BuildSitemapTask.ts +7 -0
- package/src/cli/core/tasks/BuildVercelTask.ts +82 -3
- package/src/cli/platform/__tests__/detectResources.spec.ts +96 -0
- package/src/cli/platform/commands/platform.ts +7 -1
- package/src/scheduler/index.ts +14 -0
- package/src/scheduler/providers/CronProvider.ts +13 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Alepha } from "alepha";
|
|
2
|
+
import { afterEach, beforeEach, describe, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
testCacheBasic,
|
|
5
|
+
testCacheClear,
|
|
6
|
+
testCacheCompress,
|
|
7
|
+
testCacheCompressTypes,
|
|
8
|
+
testCacheDisabled,
|
|
9
|
+
testCacheFalsyValues,
|
|
10
|
+
testCacheIncr,
|
|
11
|
+
testCacheInvalidateAll,
|
|
12
|
+
testCacheInvalidateByArgs,
|
|
13
|
+
testCacheInvalidateByKey,
|
|
14
|
+
testCacheKeys,
|
|
15
|
+
testCacheMissingProvider,
|
|
16
|
+
testCachePrimitiveIncr,
|
|
17
|
+
testCacheProviderClear,
|
|
18
|
+
testCacheReturnTypes,
|
|
19
|
+
testCacheSetDisabled,
|
|
20
|
+
testSimpleKeyMappingHandler,
|
|
21
|
+
} from "../../core/__tests__/shared.ts";
|
|
22
|
+
import { DatabaseCacheProvider } from "../index.ts";
|
|
23
|
+
|
|
24
|
+
const provider = DatabaseCacheProvider;
|
|
25
|
+
|
|
26
|
+
// Override DATABASE_URL via process.env BEFORE Alepha.create() runs in each
|
|
27
|
+
// shared test. The shared `testSimpleKeyMappingHandler` instantiates the app
|
|
28
|
+
// (and therefore the SQLite provider) inside the .with() chain, BEFORE the
|
|
29
|
+
// `configure` callback runs — so we can't rely on `app.store.set("env", ...)`
|
|
30
|
+
// to flip the URL fast enough; the env schema has already been parsed and
|
|
31
|
+
// cached by the time configure fires.
|
|
32
|
+
const sqlite = () => (_app: Alepha) => {};
|
|
33
|
+
|
|
34
|
+
describe("$cache - database (sqlite)", () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.stubEnv("DATABASE_URL", "sqlite://:memory:");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
vi.unstubAllEnvs();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should handle basic caching", async () => {
|
|
44
|
+
await testCacheBasic(sqlite(), provider);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should handle missing provider", async () => {
|
|
48
|
+
await testCacheMissingProvider(sqlite(), provider);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should handle disabled cache", async () => {
|
|
52
|
+
await testCacheDisabled(sqlite(), provider);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should invalidate by key", async () => {
|
|
56
|
+
await testCacheInvalidateByKey(sqlite(), provider);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should invalidate by args", async () => {
|
|
60
|
+
await testCacheInvalidateByArgs(sqlite(), provider);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should invalidate all entries", async () => {
|
|
64
|
+
await testCacheInvalidateAll(sqlite(), provider);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should clear cache", async () => {
|
|
68
|
+
await testCacheClear(sqlite(), provider);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should handle different return types", async () => {
|
|
72
|
+
await testCacheReturnTypes(sqlite(), provider);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should generate cache keys correctly", async () => {
|
|
76
|
+
await testCacheKeys(sqlite(), provider);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should handle unique key with args", async () => {
|
|
80
|
+
await testSimpleKeyMappingHandler(sqlite(), provider);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should clear provider cache", async () => {
|
|
84
|
+
await testCacheProviderClear(sqlite(), provider);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should increment values atomically", async () => {
|
|
88
|
+
await testCacheIncr(sqlite(), provider);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should cache falsy values (0, empty string, false, null)", async () => {
|
|
92
|
+
await testCacheFalsyValues(sqlite(), provider);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should not write to provider when cache is disabled", async () => {
|
|
96
|
+
await testCacheSetDisabled(sqlite(), provider);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should increment via primitive", async () => {
|
|
100
|
+
await testCachePrimitiveIncr(sqlite(), provider);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should compress cached values with gzip", async () => {
|
|
104
|
+
await testCacheCompress(sqlite(), provider);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should handle different types with compression", async () => {
|
|
108
|
+
await testCacheCompressTypes(sqlite(), provider);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type Static, t } from "alepha";
|
|
2
|
+
import { $entity, db } from "alepha/orm";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Storage table for the {@link DatabaseCacheProvider}.
|
|
6
|
+
*
|
|
7
|
+
* Each row represents one cache entry:
|
|
8
|
+
* - `(container, cacheKey)` is the logical key (uniqueness enforced by index).
|
|
9
|
+
* - `value` holds base64-encoded bytes for `set/get/setTyped/getTyped`.
|
|
10
|
+
* - `count` holds an integer counter for atomic `incr` operations.
|
|
11
|
+
* - `expiresAt` is null for entries that never expire, or a timestamp after
|
|
12
|
+
* which the entry is considered gone (filtered out at read time).
|
|
13
|
+
*/
|
|
14
|
+
export const cacheEntries = $entity({
|
|
15
|
+
name: "cache_entries",
|
|
16
|
+
schema: t.object({
|
|
17
|
+
id: db.primaryKey(t.uuid()),
|
|
18
|
+
|
|
19
|
+
createdAt: db.createdAt(),
|
|
20
|
+
|
|
21
|
+
container: t.text({
|
|
22
|
+
description: "Cache container name, set by the $cache primitive.",
|
|
23
|
+
}),
|
|
24
|
+
|
|
25
|
+
cacheKey: t.text({
|
|
26
|
+
description: "Per-container key chosen by the caller.",
|
|
27
|
+
}),
|
|
28
|
+
|
|
29
|
+
value: t.optional(
|
|
30
|
+
// No maxLength: cache values are arbitrary-sized (especially when
|
|
31
|
+
// `compress: true` is enabled on the $cache primitive, which can
|
|
32
|
+
// produce blobs well above the default 255-char `t.text()` cap). This
|
|
33
|
+
// resolves to TEXT in both Postgres and SQLite, which have no
|
|
34
|
+
// practical length limit either.
|
|
35
|
+
t.string({
|
|
36
|
+
description: "Base64-encoded bytes. Used by set/get.",
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
|
|
40
|
+
count: t.optional(
|
|
41
|
+
t.integer({
|
|
42
|
+
description: "Counter value. Used by atomic incr().",
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
|
|
46
|
+
expiresAt: t.optional(
|
|
47
|
+
t.datetime({
|
|
48
|
+
description: "Null means no expiration.",
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
}),
|
|
52
|
+
indexes: [{ columns: ["container", "cacheKey"], unique: true }, "expiresAt"],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export type CacheEntry = Static<typeof cacheEntries.schema>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { $module } from "alepha";
|
|
2
|
+
import { AlephaCache } from "alepha/cache";
|
|
3
|
+
import { DatabaseCacheProvider } from "./providers/DatabaseCacheProvider.ts";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export * from "./entities/cacheEntries.ts";
|
|
8
|
+
export * from "./providers/DatabaseCacheProvider.ts";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Plugin for Alepha Cache that stores entries in the application's SQL database.
|
|
14
|
+
*
|
|
15
|
+
* Adds a `cache_entries` table and a {@link DatabaseCacheProvider}
|
|
16
|
+
* implementation of {@link CacheProvider} that:
|
|
17
|
+
*
|
|
18
|
+
* - reads/writes through the framework's ORM (Postgres, SQLite, D1, Bun);
|
|
19
|
+
* - exposes an **atomic** `incr()` via `INSERT ... ON CONFLICT DO UPDATE`;
|
|
20
|
+
* - filters expired rows on every read (lazy expiration);
|
|
21
|
+
* - opportunistically sweeps a small batch of expired rows on writes
|
|
22
|
+
* (configurable via {@link databaseCacheOptions}).
|
|
23
|
+
*
|
|
24
|
+
* **Module is opt-in.** Importing this module does not change the default
|
|
25
|
+
* `CacheProvider` binding — pass `provider: DatabaseCacheProvider` explicitly
|
|
26
|
+
* to the relevant `$cache(...)` calls, or rebind globally via
|
|
27
|
+
* `alepha.with({ provide: CacheProvider, use: DatabaseCacheProvider })`.
|
|
28
|
+
*
|
|
29
|
+
* @see {@link DatabaseCacheProvider}
|
|
30
|
+
* @module alepha.cache.database
|
|
31
|
+
*/
|
|
32
|
+
export const AlephaCacheDatabase = $module({
|
|
33
|
+
name: "alepha.cache.database",
|
|
34
|
+
services: [DatabaseCacheProvider],
|
|
35
|
+
register: (alepha) => alepha.with(AlephaCache),
|
|
36
|
+
});
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { $atom, $hook, $inject, $state, Alepha, type Static, t } from "alepha";
|
|
2
|
+
import { CacheProvider } from "alepha/cache";
|
|
3
|
+
import { DateTimeProvider } from "alepha/datetime";
|
|
4
|
+
import { $logger } from "alepha/logger";
|
|
5
|
+
import { $repository, sql } from "alepha/orm";
|
|
6
|
+
import { cacheEntries } from "../entities/cacheEntries.ts";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Configuration atom for {@link DatabaseCacheProvider}.
|
|
12
|
+
*/
|
|
13
|
+
export const databaseCacheOptions = $atom({
|
|
14
|
+
name: "alepha.cache.database.options",
|
|
15
|
+
schema: t.object({
|
|
16
|
+
sweepProbability: t.number({
|
|
17
|
+
description:
|
|
18
|
+
"Probability (0..1) that a write operation triggers a sweep of expired rows. Set to 0 to disable opportunistic sweeping.",
|
|
19
|
+
default: 0.01,
|
|
20
|
+
minimum: 0,
|
|
21
|
+
maximum: 1,
|
|
22
|
+
}),
|
|
23
|
+
sweepBatchSize: t.integer({
|
|
24
|
+
description:
|
|
25
|
+
"Maximum number of expired rows deleted per opportunistic sweep.",
|
|
26
|
+
default: 100,
|
|
27
|
+
minimum: 1,
|
|
28
|
+
}),
|
|
29
|
+
}),
|
|
30
|
+
default: {
|
|
31
|
+
sweepProbability: 0.01,
|
|
32
|
+
sweepBatchSize: 100,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type DatabaseCacheOptions = Static<typeof databaseCacheOptions.schema>;
|
|
37
|
+
|
|
38
|
+
declare module "alepha" {
|
|
39
|
+
interface State {
|
|
40
|
+
[databaseCacheOptions.key]: DatabaseCacheOptions;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Cache provider backed by the application's SQL database.
|
|
48
|
+
*
|
|
49
|
+
* Uses the `cache_entries` table as a generic key/value store with optional
|
|
50
|
+
* TTL. Works on every database supported by Alepha's ORM (Postgres, SQLite,
|
|
51
|
+
* Cloudflare D1, Bun SQLite).
|
|
52
|
+
*
|
|
53
|
+
* **Why use this over Cloudflare KV / Redis ?**
|
|
54
|
+
*
|
|
55
|
+
* - You already have a SQL database, no extra resource to provision/secure.
|
|
56
|
+
* - `incr()` is **atomic** through `INSERT ... ON CONFLICT DO UPDATE`.
|
|
57
|
+
* - Reads/writes are **strongly consistent** (KV is eventually consistent).
|
|
58
|
+
* - Phase-2 flows (registration, password reset) become transactional with
|
|
59
|
+
* the user-creation INSERT.
|
|
60
|
+
*
|
|
61
|
+
* **When to prefer Cloudflare KV / Redis instead ?**
|
|
62
|
+
*
|
|
63
|
+
* - Hot read paths where DB latency matters.
|
|
64
|
+
* - Very high write rates where DB pressure becomes a concern.
|
|
65
|
+
*
|
|
66
|
+
* **Storage layout**
|
|
67
|
+
*
|
|
68
|
+
* - `value` column: base64-encoded bytes for `set/get/setTyped/getTyped`.
|
|
69
|
+
* - `count` column: integer counter for atomic `incr()`.
|
|
70
|
+
* - `expiresAt` column: nullable timestamp; expired rows are filtered at read
|
|
71
|
+
* time and reaped opportunistically on writes.
|
|
72
|
+
*
|
|
73
|
+
* @see {@link CacheProvider}
|
|
74
|
+
*/
|
|
75
|
+
export class DatabaseCacheProvider extends CacheProvider {
|
|
76
|
+
protected readonly log = $logger();
|
|
77
|
+
protected readonly alepha = $inject(Alepha);
|
|
78
|
+
protected readonly dateTimeProvider = $inject(DateTimeProvider);
|
|
79
|
+
protected readonly repository = $repository(cacheEntries);
|
|
80
|
+
protected readonly options = $state(databaseCacheOptions);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Total writes performed since startup. Useful for tests and metrics.
|
|
84
|
+
*/
|
|
85
|
+
public writes = 0;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Total opportunistic sweep cycles executed. Useful for tests.
|
|
89
|
+
*/
|
|
90
|
+
public sweeps = 0;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Last error caught while sweeping (sweeps must never throw).
|
|
94
|
+
*/
|
|
95
|
+
public lastSweepError?: unknown;
|
|
96
|
+
|
|
97
|
+
// -------------------------------------------------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
protected readonly onStart = $hook({
|
|
100
|
+
on: "start",
|
|
101
|
+
handler: () => {
|
|
102
|
+
this.log.debug("DatabaseCacheProvider ready");
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// -------------------------------------------------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
public async get(name: string, key: string): Promise<Uint8Array | undefined> {
|
|
109
|
+
if (!this.alepha.isStarted()) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const row = await this.repository.findOne({
|
|
114
|
+
where: this.unexpiredWhere(name, key) as any,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!row) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (row.value != null) {
|
|
122
|
+
return this.fromBase64(row.value);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (row.count != null) {
|
|
126
|
+
// The caller wrote the entry through `incr()`. Surface the count via
|
|
127
|
+
// the same byte format that MemoryCacheProvider uses, so `getTyped`
|
|
128
|
+
// returns the number transparently.
|
|
129
|
+
return this.serialize(row.count);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
public async set(
|
|
136
|
+
name: string,
|
|
137
|
+
key: string,
|
|
138
|
+
value: Uint8Array,
|
|
139
|
+
ttl?: number,
|
|
140
|
+
): Promise<Uint8Array> {
|
|
141
|
+
if (!this.alepha.isStarted()) {
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const expiresAt = this.computeExpiresAt(ttl);
|
|
146
|
+
|
|
147
|
+
await this.repository.upsert(
|
|
148
|
+
{
|
|
149
|
+
container: name,
|
|
150
|
+
cacheKey: key,
|
|
151
|
+
value: this.toBase64(value),
|
|
152
|
+
count: null,
|
|
153
|
+
expiresAt,
|
|
154
|
+
} as any,
|
|
155
|
+
{
|
|
156
|
+
target: ["container", "cacheKey"],
|
|
157
|
+
set: {
|
|
158
|
+
value: this.toBase64(value),
|
|
159
|
+
count: null,
|
|
160
|
+
expiresAt,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
this.writes++;
|
|
166
|
+
this.maybeSweep();
|
|
167
|
+
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public async del(name: string, ...keys: string[]): Promise<void> {
|
|
172
|
+
if (keys.length === 0) {
|
|
173
|
+
await this.repository.deleteMany({
|
|
174
|
+
container: { eq: name },
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await this.repository.deleteMany({
|
|
180
|
+
container: { eq: name },
|
|
181
|
+
cacheKey: { inArray: keys },
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
public async has(name: string, key: string): Promise<boolean> {
|
|
186
|
+
const row = await this.repository.findOne({
|
|
187
|
+
where: this.unexpiredWhere(name, key) as any,
|
|
188
|
+
});
|
|
189
|
+
return row != null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
public async keys(name: string, filter?: string): Promise<string[]> {
|
|
193
|
+
const baseAnd: any[] = [
|
|
194
|
+
{ container: { eq: name } },
|
|
195
|
+
this.unexpiredOrClause(),
|
|
196
|
+
];
|
|
197
|
+
if (filter) {
|
|
198
|
+
baseAnd.push({ cacheKey: { startsWith: filter } });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const rows = await this.repository.findMany({
|
|
202
|
+
where: { and: baseAnd } as any,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return rows.map((row) => row.cacheKey);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
public async clear(): Promise<void> {
|
|
209
|
+
await this.repository.deleteMany({});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
public async incr(
|
|
213
|
+
name: string,
|
|
214
|
+
key: string,
|
|
215
|
+
amount: number,
|
|
216
|
+
): Promise<number> {
|
|
217
|
+
if (!this.alepha.isStarted()) {
|
|
218
|
+
return amount;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Atomic upsert via `INSERT ... ON CONFLICT (container, cacheKey) DO UPDATE
|
|
222
|
+
// SET count = COALESCE(cache_entries.count, 0) + excluded.count`.
|
|
223
|
+
// Both Postgres and SQLite (incl. D1) support this in a single statement,
|
|
224
|
+
// so concurrent callers can never observe an interleaved read/write.
|
|
225
|
+
const table = this.repository.table;
|
|
226
|
+
|
|
227
|
+
const updated = await this.repository.upsert(
|
|
228
|
+
{
|
|
229
|
+
container: name,
|
|
230
|
+
cacheKey: key,
|
|
231
|
+
count: amount,
|
|
232
|
+
value: null,
|
|
233
|
+
expiresAt: null,
|
|
234
|
+
} as any,
|
|
235
|
+
{
|
|
236
|
+
target: ["container", "cacheKey"],
|
|
237
|
+
set: {
|
|
238
|
+
// `excluded.count` references the value being inserted on conflict.
|
|
239
|
+
count: sql`coalesce(${table.count}, 0) + ${amount}`,
|
|
240
|
+
value: null,
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
this.writes++;
|
|
246
|
+
this.maybeSweep();
|
|
247
|
+
|
|
248
|
+
return Number(updated.count ?? amount);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// -------------------------------------------------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Sweep all expired rows in one shot. Useful for tests and for users who
|
|
255
|
+
* want to schedule their own cleanup job.
|
|
256
|
+
*/
|
|
257
|
+
public async sweepExpired(): Promise<number> {
|
|
258
|
+
const nowIso = this.dateTimeProvider.nowISOString();
|
|
259
|
+
const ids = await this.repository.deleteMany({
|
|
260
|
+
expiresAt: { lt: nowIso },
|
|
261
|
+
});
|
|
262
|
+
return ids.length;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// -------------------------------------------------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
protected unexpiredOrClause(): Record<string, any> {
|
|
268
|
+
const nowIso = this.dateTimeProvider.nowISOString();
|
|
269
|
+
return {
|
|
270
|
+
or: [{ expiresAt: { isNull: true } }, { expiresAt: { gt: nowIso } }],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
protected unexpiredWhere(name: string, key: string): Record<string, any> {
|
|
275
|
+
return {
|
|
276
|
+
and: [
|
|
277
|
+
{ container: { eq: name } },
|
|
278
|
+
{ cacheKey: { eq: key } },
|
|
279
|
+
this.unexpiredOrClause(),
|
|
280
|
+
],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
protected computeExpiresAt(ttl?: number): string | null {
|
|
285
|
+
if (!ttl || ttl <= 0) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
return this.dateTimeProvider
|
|
289
|
+
.of(this.dateTimeProvider.nowMillis() + ttl)
|
|
290
|
+
.toISOString();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
protected toBase64(value: Uint8Array): string {
|
|
294
|
+
return Buffer.from(
|
|
295
|
+
value.buffer as ArrayBuffer,
|
|
296
|
+
value.byteOffset,
|
|
297
|
+
value.byteLength,
|
|
298
|
+
).toString("base64");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
protected fromBase64(value: string): Uint8Array {
|
|
302
|
+
return new Uint8Array(Buffer.from(value, "base64"));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Run an opportunistic sweep with `sweepProbability` chance per write.
|
|
307
|
+
*
|
|
308
|
+
* Sweep failures are swallowed: cleanup is best-effort, lazy expiration on
|
|
309
|
+
* read keeps correctness regardless. Errors are logged once on `lastSweepError`
|
|
310
|
+
* so tests can assert on them.
|
|
311
|
+
*/
|
|
312
|
+
protected maybeSweep(): void {
|
|
313
|
+
const probability = this.options.sweepProbability;
|
|
314
|
+
if (probability <= 0) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (Math.random() >= probability) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
this.runSweep().catch((err) => {
|
|
323
|
+
this.lastSweepError = err;
|
|
324
|
+
this.log.warn("DatabaseCacheProvider sweep failed", err);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
protected async runSweep(): Promise<void> {
|
|
329
|
+
this.sweeps++;
|
|
330
|
+
const nowIso = this.dateTimeProvider.nowISOString();
|
|
331
|
+
|
|
332
|
+
// Drizzle does not expose a portable LIMIT on DELETE, so we select first
|
|
333
|
+
// and then delete by ID. Two roundtrips, but only on the ~1% sweep path.
|
|
334
|
+
const expired = await this.repository.findMany({
|
|
335
|
+
where: { expiresAt: { lt: nowIso } },
|
|
336
|
+
columns: ["id"],
|
|
337
|
+
limit: this.options.sweepBatchSize,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
if (expired.length === 0) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await this.repository.deleteMany({
|
|
345
|
+
id: { inArray: expired.map((it) => it.id) },
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -700,8 +700,6 @@ export class ProjectScaffolder {
|
|
|
700
700
|
// --no-monorepo skip the monorepo prompt — we ship a single-app
|
|
701
701
|
// layout; users opt into monorepo via `--monorepo`
|
|
702
702
|
// on the alepha side later
|
|
703
|
-
// --silent suppress shadcn's own progress output; alepha's
|
|
704
|
-
// runner already prints a status line
|
|
705
703
|
//
|
|
706
704
|
// We deliberately do NOT pass `--defaults` (would force Next.js +
|
|
707
705
|
// base-nova preset) or `--template` (only applies to scratch projects;
|
|
@@ -235,11 +235,23 @@ export class BuildCloudflareTask extends BuildTask {
|
|
|
235
235
|
const workerCode = `
|
|
236
236
|
import "./index.js";
|
|
237
237
|
|
|
238
|
+
// Stash the per-invocation \`executionCtx.waitUntil\` in the Alepha store
|
|
239
|
+
// so background work (notably $job direct dispatch) can keep the isolate
|
|
240
|
+
// alive past the response.
|
|
241
|
+
const setWaitUntil = (executionCtx) => {
|
|
242
|
+
if (executionCtx && typeof executionCtx.waitUntil === "function") {
|
|
243
|
+
__alepha.set("cloudflare.waitUntil", (p) => executionCtx.waitUntil(p));
|
|
244
|
+
} else {
|
|
245
|
+
__alepha.set("cloudflare.waitUntil", undefined);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
238
249
|
export default {
|
|
239
|
-
fetch: async (request, env) => {
|
|
250
|
+
fetch: async (request, env, executionCtx) => {
|
|
240
251
|
const ctx = { req: request, res: undefined };
|
|
241
252
|
|
|
242
253
|
__alepha.set("cloudflare.env", env);
|
|
254
|
+
setWaitUntil(executionCtx);
|
|
243
255
|
|
|
244
256
|
try {
|
|
245
257
|
await __alepha.start();
|
|
@@ -253,8 +265,9 @@ export default {
|
|
|
253
265
|
return ctx.res;
|
|
254
266
|
},
|
|
255
267
|
|
|
256
|
-
scheduled: async (event, env,
|
|
268
|
+
scheduled: async (event, env, executionCtx) => {
|
|
257
269
|
__alepha.set("cloudflare.env", env);
|
|
270
|
+
setWaitUntil(executionCtx);
|
|
258
271
|
|
|
259
272
|
try {
|
|
260
273
|
await __alepha.start();
|
|
@@ -269,8 +282,9 @@ export default {
|
|
|
269
282
|
});
|
|
270
283
|
},
|
|
271
284
|
|
|
272
|
-
queue: async (batch, env) => {
|
|
285
|
+
queue: async (batch, env, executionCtx) => {
|
|
273
286
|
__alepha.set("cloudflare.env", env);
|
|
287
|
+
setWaitUntil(executionCtx);
|
|
274
288
|
|
|
275
289
|
try {
|
|
276
290
|
await __alepha.start();
|
|
@@ -39,9 +39,16 @@ export class BuildSitemapTask extends BuildTask {
|
|
|
39
39
|
const pages = ctx.alepha.primitives("page") as any[];
|
|
40
40
|
return pages.filter((page) => {
|
|
41
41
|
const options = page.options;
|
|
42
|
+
const path: string = options.path ?? "";
|
|
42
43
|
if (options.children) {
|
|
43
44
|
return false;
|
|
44
45
|
}
|
|
46
|
+
if (path.includes("*")) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (path === "/404") {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
45
52
|
if (!options.schema?.params) {
|
|
46
53
|
return true;
|
|
47
54
|
}
|