@podosoft/podokit 1.0.1 → 1.1.2

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 (118) hide show
  1. package/README.md +19 -5
  2. package/dist/add.d.ts +14 -1
  3. package/dist/add.js +42 -5
  4. package/dist/create.d.ts +4 -0
  5. package/dist/create.js +9 -0
  6. package/dist/deploy-compose-profile.d.ts +3 -0
  7. package/dist/deploy-compose-profile.js +74 -8
  8. package/dist/deploy-compose-render.d.ts +2 -1
  9. package/dist/deploy-compose-render.js +50 -5
  10. package/dist/deploy-compose.js +2 -1
  11. package/dist/deploy-profile.js +18 -8
  12. package/dist/deploy-render.js +5 -5
  13. package/dist/deploy.js +157 -1
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +41 -0
  16. package/dist/lib.d.ts +2 -0
  17. package/dist/lib.js +8 -2
  18. package/dist/lockfile.d.ts +6 -1
  19. package/dist/lockfile.js +22 -4
  20. package/dist/provider.d.ts +24 -0
  21. package/dist/provider.js +142 -0
  22. package/dist/remove.js +6 -3
  23. package/dist/templates/fullstack/AGENTS.md +5 -4
  24. package/dist/templates/fullstack/README.md +10 -3
  25. package/dist/templates/fullstack/apps/api/package.json +3 -2
  26. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +1 -0
  27. package/dist/templates/fullstack/apps/api/src/config/providers.ts +9 -0
  28. package/dist/templates/fullstack/apps/api/src/core/services.ts +7 -2
  29. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +30 -8
  30. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +58 -0
  31. package/dist/templates/fullstack/apps/api/src/database/database.ts +58 -2
  32. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +24 -2
  33. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.spec.ts +50 -0
  34. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +134 -0
  35. package/dist/templates/fullstack/compose.dev.yaml +2 -2
  36. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  37. package/dist/templates/fullstack/dot-env.example +2 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +2 -2
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +10 -4
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +10 -4
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +10 -4
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +1 -1
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +1 -1
  44. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +10 -2
  45. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -5
  46. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +2 -5
  47. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +33 -0
  48. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +13 -0
  49. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +17 -8
  50. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +5 -4
  51. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  52. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +170 -0
  53. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +260 -0
  54. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +2 -2
  55. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -0
  56. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +2 -2
  57. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -4
  58. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +55 -10
  59. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +10 -14
  60. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +22 -6
  61. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +2 -1
  62. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +4 -3
  63. package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts +15 -4
  64. package/dist/templates/modules/auth/files/apps/api/src/settings/auth-config.migration.spec.ts +30 -0
  65. package/dist/templates/modules/auth/module.manifest.json +1 -1
  66. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +10 -0
  67. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +107 -0
  68. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +12 -11
  69. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +5 -0
  70. package/dist/templates/modules/bullmq/module.manifest.json +7 -0
  71. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.spec.ts +16 -0
  72. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.ts +12 -0
  73. package/dist/templates/modules/cache-memory/module.manifest.json +30 -0
  74. package/dist/templates/modules/events-memory/files/apps/api/src/events-provider-memory/events-memory.module.ts +14 -0
  75. package/dist/templates/modules/events-memory/module.manifest.json +30 -0
  76. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +16 -0
  77. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +75 -0
  78. package/dist/templates/modules/events-redis/module.manifest.json +32 -0
  79. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -5
  80. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  81. package/dist/templates/modules/file-upload/module.manifest.json +4 -3
  82. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +36 -14
  83. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +9 -32
  84. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +16 -11
  85. package/dist/templates/modules/job-progress/module.manifest.json +7 -8
  86. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +42 -0
  87. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +45 -0
  88. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +212 -0
  89. package/dist/templates/modules/jobs-local/module.manifest.json +34 -0
  90. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +7 -3
  91. package/dist/templates/modules/mailer/module.manifest.json +2 -4
  92. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +25 -0
  93. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +60 -0
  94. package/dist/templates/modules/object-storage-local/module.manifest.json +34 -0
  95. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +50 -0
  96. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +8 -0
  97. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +4 -4
  98. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +20 -12
  99. package/dist/templates/modules/object-storage-s3/module.manifest.json +4 -2
  100. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +14 -1
  101. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +18 -37
  102. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +12 -0
  103. package/dist/templates/modules/rate-limit/module.manifest.json +8 -6
  104. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +82 -0
  105. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +6 -0
  106. package/dist/templates/modules/redis/module.manifest.json +2 -0
  107. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +5 -6
  108. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +4 -4
  109. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +6 -5
  110. package/dist/templates/modules/sse/module.manifest.json +4 -6
  111. package/dist/templates/todo/AGENTS.md +1 -1
  112. package/dist/templates/todo/README.md +8 -3
  113. package/dist/toolchain.d.ts +1 -0
  114. package/dist/toolchain.js +4 -0
  115. package/package.json +2 -1
  116. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +0 -32
  117. package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +0 -13
  118. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +0 -111
@@ -0,0 +1,107 @@
1
+ import { Queue, Worker } from "bullmq";
2
+ import type {
3
+ EnqueueOptions,
4
+ JobHandler,
5
+ JobQueue,
6
+ JobReference,
7
+ JobSnapshot,
8
+ JobStatus,
9
+ Unsubscribe,
10
+ } from "@podosoft/podokit-runtime";
11
+ import { redisConnection } from "./queue";
12
+
13
+ const ID_SEPARATOR = "~";
14
+
15
+ export function externalJobId(name: string, id: string): string {
16
+ return `${encodeURIComponent(name)}${ID_SEPARATOR}${id}`;
17
+ }
18
+
19
+ export function parseExternalJobId(id: string): { name: string; id: string } | null {
20
+ const separator = id.indexOf(ID_SEPARATOR);
21
+ if (separator < 1 || separator === id.length - 1) return null;
22
+ try {
23
+ return { name: decodeURIComponent(id.slice(0, separator)), id: id.slice(separator + 1) };
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function statusOf(state: string): JobStatus {
30
+ if (state === "active" || state === "completed" || state === "failed") return state;
31
+ return "waiting";
32
+ }
33
+
34
+ export class BullMqJobQueue implements JobQueue {
35
+ private readonly queues = new Map<string, Queue>();
36
+ private readonly workers = new Map<string, Worker>();
37
+
38
+ async enqueue(
39
+ name: string,
40
+ payload: unknown,
41
+ options: EnqueueOptions = {},
42
+ ): Promise<JobReference> {
43
+ const queue = this.queue(name);
44
+ const job = await queue.add(name, payload, {
45
+ ...(options.delayMs === undefined ? {} : { delay: options.delayMs }),
46
+ ...(options.attempts === undefined ? {} : { attempts: options.attempts }),
47
+ ...(options.deduplicationKey === undefined
48
+ ? {}
49
+ : { deduplication: { id: options.deduplicationKey } }),
50
+ });
51
+ if (job.id === undefined) throw new Error("BullMQ did not assign a job id");
52
+ return { id: externalJobId(name, job.id), name };
53
+ }
54
+
55
+ async get(externalId: string): Promise<JobSnapshot | null> {
56
+ const parsed = parseExternalJobId(externalId);
57
+ if (!parsed) return null;
58
+ const job = await this.queue(parsed.name).getJob(parsed.id);
59
+ if (!job) return null;
60
+ return {
61
+ id: externalId,
62
+ name: parsed.name,
63
+ status: statusOf(await job.getState()),
64
+ attempts: job.attemptsMade,
65
+ createdAt: job.timestamp,
66
+ updatedAt: job.finishedOn ?? job.processedOn ?? job.timestamp,
67
+ ...(job.returnvalue === undefined ? {} : { result: job.returnvalue as unknown }),
68
+ ...(job.failedReason ? { error: job.failedReason } : {}),
69
+ };
70
+ }
71
+
72
+ process(name: string, handler: JobHandler): Promise<Unsubscribe> {
73
+ if (this.workers.has(name)) throw new Error(`A handler is already registered for job ${name}`);
74
+ const worker = new Worker(
75
+ name,
76
+ async (job): Promise<void> => {
77
+ await handler(job.data as unknown, {
78
+ id: externalJobId(name, String(job.id)),
79
+ name,
80
+ });
81
+ },
82
+ { connection: redisConnection() },
83
+ );
84
+ this.workers.set(name, worker);
85
+ return Promise.resolve(async () => {
86
+ this.workers.delete(name);
87
+ await worker.close();
88
+ });
89
+ }
90
+
91
+ async close(): Promise<void> {
92
+ await Promise.all([
93
+ ...[...this.workers.values()].map((worker) => worker.close()),
94
+ ...[...this.queues.values()].map((queue) => queue.close()),
95
+ ]);
96
+ this.workers.clear();
97
+ this.queues.clear();
98
+ }
99
+
100
+ private queue(name: string): Queue {
101
+ const current = this.queues.get(name);
102
+ if (current) return current;
103
+ const queue = new Queue(name, { connection: redisConnection() });
104
+ this.queues.set(name, queue);
105
+ return queue;
106
+ }
107
+ }
@@ -1,16 +1,16 @@
1
1
  import { AppException } from "@podosoft/podokit-contracts";
2
- import { Queue } from "bullmq";
2
+ import { JOBS } from "@podosoft/podokit-runtime";
3
3
  import { Elysia, t } from "elysia";
4
- import type { AppPlugin, PodokitModule, ServiceKey } from "../core/services";
5
- import { DEMO_QUEUE, redisConnection } from "./queue";
6
-
7
- export const JOB_QUEUE = Symbol("job-queue") as ServiceKey<Queue>;
4
+ import { PROVIDERS } from "../config/providers";
5
+ import type { AppPlugin, PodokitModule } from "../core/services";
6
+ import { BullMqJobQueue } from "./bullmq-job.queue";
8
7
 
9
8
  const jobsPlugin: AppPlugin = ({ services }) => {
10
- const queue = services.resolve(JOB_QUEUE);
9
+ if (PROVIDERS.jobs !== "bullmq") return new Elysia({ name: "podokit.jobs.inactive" });
10
+ const queue = services.resolve(JOBS);
11
11
  return new Elysia({ name: "podokit.jobs" })
12
12
  .post("/jobs", async ({ body, set }) => {
13
- const job = await queue.add("demo", { text: body.text });
13
+ const job = await queue.enqueue("demo", { text: body.text });
14
14
  set.status = 201;
15
15
  return { id: job.id };
16
16
  }, {
@@ -18,9 +18,9 @@ const jobsPlugin: AppPlugin = ({ services }) => {
18
18
  detail: { tags: ["jobs"], summary: "Enqueue a job" },
19
19
  })
20
20
  .get("/jobs/:id", async ({ params }) => {
21
- const job = await queue.getJob(params.id);
21
+ const job = await queue.get(params.id);
22
22
  if (!job) throw new AppException("JOB_NOT_FOUND", `Job ${params.id} not found`, 404);
23
- return { id: job.id, state: await job.getState(), result: job.returnvalue ?? null };
23
+ return { id: job.id, state: job.status, result: job.result ?? null };
24
24
  }, {
25
25
  params: t.Object({ id: t.String({ minLength: 1 }) }),
26
26
  detail: { tags: ["jobs"], summary: "Read job status" },
@@ -30,8 +30,9 @@ const jobsPlugin: AppPlugin = ({ services }) => {
30
30
  export const jobsModule: PodokitModule = {
31
31
  name: "bullmq",
32
32
  configure: (_env, services): void => {
33
- const queue = new Queue(DEMO_QUEUE, { connection: redisConnection() });
34
- services.register(JOB_QUEUE, queue, () => queue.close());
33
+ if (PROVIDERS.jobs !== "bullmq") return;
34
+ const queue = new BullMqJobQueue();
35
+ services.register(JOBS, queue, () => queue.close());
35
36
  },
36
37
  plugin: jobsPlugin,
37
38
  };
@@ -1,6 +1,11 @@
1
1
  import "dotenv/config";
2
+ import { PROVIDERS } from "./config/providers";
2
3
  import { startWorkers } from "./jobs/worker.module";
3
4
 
5
+ if (PROVIDERS.jobs !== "bullmq") {
6
+ throw new Error("The BullMQ worker cannot start unless jobs=bullmq is selected");
7
+ }
8
+
4
9
  const workers = startWorkers();
5
10
 
6
11
  for (const worker of workers) {
@@ -1,10 +1,17 @@
1
1
  {
2
+ "manifestVersion": 3,
2
3
  "name": "bullmq",
3
4
  "description": "Background jobs with BullMQ: a demo queue, a separate worker process, and enqueue/status endpoints (needs Redis).",
4
5
  "requires": [],
6
+ "provides": { "jobs": "bullmq" },
5
7
  "requiredProjectFiles": [
8
+ "apps/api/src/config/providers.ts",
6
9
  "apps/api/src/config/redis.connection.ts"
7
10
  ],
11
+ "managedGlobs": [
12
+ "apps/api/src/jobs/**",
13
+ "apps/api/src/main-worker.ts"
14
+ ],
8
15
  "targetApp": "api",
9
16
  "dependencies": {
10
17
  "bullmq": "^5.34.0"
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { MemoryCacheStore } from "@podosoft/podokit-runtime";
3
+
4
+ describe("memory cache provider", () => {
5
+ it("expires values and enforces a fixed-window limit", async () => {
6
+ let now = 1_000;
7
+ const cache = new MemoryCacheStore({ now: () => now });
8
+ await cache.set("short", "value", { ttlMs: 10 });
9
+ now += 10;
10
+ expect(await cache.get("short")).toBeNull();
11
+ expect((await cache.incrementFixedWindow("user", { windowMs: 1_000, limit: 1 })).allowed)
12
+ .toBe(true);
13
+ expect((await cache.incrementFixedWindow("user", { windowMs: 1_000, limit: 1 })).allowed)
14
+ .toBe(false);
15
+ });
16
+ });
@@ -0,0 +1,12 @@
1
+ import { CACHE, MemoryCacheStore } from "@podosoft/podokit-runtime";
2
+ import type { PodokitModule } from "../core/services";
3
+ import { PROVIDERS } from "../config/providers";
4
+
5
+ export const cacheMemoryModule: PodokitModule = {
6
+ name: "cache-memory",
7
+ configure: (_env, services): void => {
8
+ if (PROVIDERS.cache !== "memory") return;
9
+ const cache = new MemoryCacheStore();
10
+ services.register(CACHE, cache, () => cache.close());
11
+ },
12
+ };
@@ -0,0 +1,30 @@
1
+ {
2
+ "manifestVersion": 3,
3
+ "name": "cache-memory",
4
+ "description": "Bounded in-process cache with TTL and fixed-window counters for a single API process.",
5
+ "provides": { "cache": "memory" },
6
+ "runtimeConstraints": { "maxApiReplicas": 1 },
7
+ "requiredProjectFiles": [
8
+ "apps/api/src/config/providers.ts"
9
+ ],
10
+ "managedGlobs": [
11
+ "apps/api/src/cache-memory/**"
12
+ ],
13
+ "targetApp": "api",
14
+ "env": [],
15
+ "inject": [
16
+ {
17
+ "file": "apps/api/src/app.ts",
18
+ "marker": "// podokit:end:imports",
19
+ "text": "import { cacheMemoryModule } from \"./cache-memory/cache-memory.module\";"
20
+ },
21
+ {
22
+ "file": "apps/api/src/app.ts",
23
+ "marker": "// podokit:end:modules",
24
+ "text": "cacheMemoryModule,"
25
+ }
26
+ ],
27
+ "instructions": [
28
+ "Memory cache is process-local. Keep the API at one replica or select the Redis provider."
29
+ ]
30
+ }
@@ -0,0 +1,14 @@
1
+ import { EVENTS, MemoryEventBus } from "@podosoft/podokit-runtime";
2
+ import type { PodokitModule } from "../core/services";
3
+ import { PROVIDERS } from "../config/providers";
4
+
5
+ export const eventsMemoryModule: PodokitModule = {
6
+ name: "events-memory",
7
+ configure: (_env, services): void => {
8
+ if (PROVIDERS.events !== "memory") return;
9
+ const events = new MemoryEventBus({
10
+ maxEventBytes: Number(process.env.SSE_MAX_EVENT_BYTES ?? 65_536),
11
+ });
12
+ services.register(EVENTS, events, () => events.close());
13
+ },
14
+ };
@@ -0,0 +1,30 @@
1
+ {
2
+ "manifestVersion": 3,
3
+ "name": "events-memory",
4
+ "description": "In-process event bus for desktop and single API process applications.",
5
+ "provides": { "events": "memory" },
6
+ "runtimeConstraints": { "maxApiReplicas": 1 },
7
+ "requiredProjectFiles": [
8
+ "apps/api/src/config/providers.ts"
9
+ ],
10
+ "managedGlobs": [
11
+ "apps/api/src/events-provider-memory/**"
12
+ ],
13
+ "targetApp": "api",
14
+ "env": [],
15
+ "inject": [
16
+ {
17
+ "file": "apps/api/src/app.ts",
18
+ "marker": "// podokit:end:imports",
19
+ "text": "import { eventsMemoryModule } from \"./events-provider-memory/events-memory.module\";"
20
+ },
21
+ {
22
+ "file": "apps/api/src/app.ts",
23
+ "marker": "// podokit:end:modules",
24
+ "text": "eventsMemoryModule,"
25
+ }
26
+ ],
27
+ "instructions": [
28
+ "Memory events are process-local. Keep the API at one replica or select the Redis events provider."
29
+ ]
30
+ }
@@ -0,0 +1,16 @@
1
+ import { EVENTS } from "@podosoft/podokit-runtime";
2
+ import type { PodokitModule } from "../core/services";
3
+ import { PROVIDERS } from "../config/providers";
4
+ import { RedisEventBus } from "./redis-event.bus";
5
+
6
+ export const eventsRedisModule: PodokitModule = {
7
+ name: "events-redis",
8
+ configure: (_env, services): void => {
9
+ if (PROVIDERS.events !== "redis") return;
10
+ const events = new RedisEventBus();
11
+ services.register(EVENTS, events, async () => {
12
+ await events.close();
13
+ });
14
+ services.onStart(() => events.ready());
15
+ },
16
+ };
@@ -0,0 +1,75 @@
1
+ import { RedisClient } from "bun";
2
+ import type { EventBus, EventHandler, Unsubscribe } from "@podosoft/podokit-runtime";
3
+ import { redisConnectionUrl } from "../config/redis.connection";
4
+
5
+ export class RedisEventBus implements EventBus {
6
+ private readonly publisher: RedisClient;
7
+ private readonly subscriber: RedisClient;
8
+ private readonly channel: string;
9
+ private readonly maxEventBytes: number;
10
+ private readonly handlers = new Map<string, Set<EventHandler>>();
11
+ private listener?: (message: string) => void;
12
+
13
+ constructor(env: NodeJS.ProcessEnv = process.env) {
14
+ this.publisher = new RedisClient(redisConnectionUrl(env), { connectionTimeout: 5_000 });
15
+ this.subscriber = new RedisClient(redisConnectionUrl(env), { connectionTimeout: 5_000 });
16
+ this.channel = env.EVENTS_REDIS_CHANNEL?.trim() || "podokit:events";
17
+ this.maxEventBytes = Number(env.SSE_MAX_EVENT_BYTES ?? 65_536);
18
+ if (!Number.isSafeInteger(this.maxEventBytes) || this.maxEventBytes < 1) {
19
+ throw new Error("SSE_MAX_EVENT_BYTES must be a positive integer");
20
+ }
21
+ }
22
+
23
+ async connect(): Promise<void> {
24
+ if (this.listener) return;
25
+ await Promise.all([this.publisher.connect(), this.subscriber.connect()]);
26
+ this.listener = (message: string): void => {
27
+ if (Buffer.byteLength(message, "utf8") > this.maxEventBytes) return;
28
+ try {
29
+ const envelope = JSON.parse(message) as unknown;
30
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return;
31
+ const record = envelope as Record<string, unknown>;
32
+ if (typeof record.topic !== "string") return;
33
+ for (const handler of this.handlers.get(record.topic) ?? []) {
34
+ void Promise.resolve(handler(record.event)).catch(() => undefined);
35
+ }
36
+ } catch {
37
+ process.stderr.write("Discard malformed event transport message\n");
38
+ }
39
+ };
40
+ await this.subscriber.subscribe(this.channel, this.listener);
41
+ }
42
+
43
+ async ready(): Promise<void> {
44
+ await this.connect();
45
+ await this.publisher.ping();
46
+ }
47
+
48
+ async publish(topic: string, event: unknown): Promise<void> {
49
+ const message = JSON.stringify({ topic, event });
50
+ if (message === undefined) throw new Error("Event must be JSON serializable");
51
+ if (Buffer.byteLength(message, "utf8") > this.maxEventBytes) {
52
+ throw new Error(`Event exceeds maxEventBytes (${this.maxEventBytes})`);
53
+ }
54
+ await this.publisher.publish(this.channel, message);
55
+ }
56
+
57
+ async subscribe(topic: string, handler: EventHandler): Promise<Unsubscribe> {
58
+ await this.connect();
59
+ const handlers = this.handlers.get(topic) ?? new Set<EventHandler>();
60
+ handlers.add(handler);
61
+ this.handlers.set(topic, handlers);
62
+ return () => {
63
+ handlers.delete(handler);
64
+ if (handlers.size === 0) this.handlers.delete(topic);
65
+ };
66
+ }
67
+
68
+ async close(): Promise<void> {
69
+ if (this.listener) await this.subscriber.unsubscribe(this.channel, this.listener);
70
+ this.handlers.clear();
71
+ this.listener = undefined;
72
+ this.publisher.close();
73
+ this.subscriber.close();
74
+ }
75
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "manifestVersion": 3,
3
+ "name": "events-redis",
4
+ "description": "Redis publish/subscribe event bus for delivery across API replicas.",
5
+ "provides": { "events": "redis" },
6
+ "requiredProjectFiles": [
7
+ "apps/api/src/config/providers.ts",
8
+ "apps/api/src/config/redis.connection.ts"
9
+ ],
10
+ "managedGlobs": [
11
+ "apps/api/src/events-provider-redis/**"
12
+ ],
13
+ "targetApp": "api",
14
+ "env": [
15
+ "EVENTS_REDIS_CHANNEL=podokit:events"
16
+ ],
17
+ "inject": [
18
+ {
19
+ "file": "apps/api/src/app.ts",
20
+ "marker": "// podokit:end:imports",
21
+ "text": "import { eventsRedisModule } from \"./events-provider-redis/events-redis.module\";"
22
+ },
23
+ {
24
+ "file": "apps/api/src/app.ts",
25
+ "marker": "// podokit:end:modules",
26
+ "text": "eventsRedisModule,"
27
+ }
28
+ ],
29
+ "instructions": [
30
+ "Redis events use REDIS_URL or the shared individual Redis settings."
31
+ ]
32
+ }
@@ -1,22 +1,26 @@
1
1
  import { Elysia, t } from "elysia";
2
+ import { OBJECT_STORAGE } from "@podosoft/podokit-runtime";
2
3
  import type { AppPlugin, PodokitModule } from "../core/services";
3
- import { STORAGE } from "../storage/storage.module";
4
4
 
5
5
  const filesPlugin: AppPlugin = ({ services }) => {
6
- const storage = services.resolve(STORAGE);
6
+ const storage = services.resolve(OBJECT_STORAGE);
7
7
  return new Elysia({ name: "podokit.files" })
8
8
  .post("/files", async ({ body, set }) => {
9
9
  const safeName = body.file.name.replace(/[^\w.-]+/g, "_");
10
10
  const key = `uploads/${crypto.randomUUID()}-${safeName}`;
11
- await storage.put(key, new Uint8Array(await body.file.arrayBuffer()), body.file.type);
11
+ await storage.put(
12
+ key,
13
+ new Uint8Array(await body.file.arrayBuffer()),
14
+ { contentType: body.file.type },
15
+ );
12
16
  set.status = 201;
13
- return { key, url: await storage.presignedGetUrl(key) };
17
+ return { key, url: await storage.getDownloadUrl(key) };
14
18
  }, {
15
19
  body: t.Object({ file: t.File() }),
16
20
  detail: { tags: ["files"], summary: "Upload a file" },
17
21
  })
18
22
  .get("/files/:key/url", async ({ params }) => ({
19
- url: await storage.presignedGetUrl(params.key),
23
+ url: await storage.getDownloadUrl(params.key),
20
24
  }), {
21
25
  params: t.Object({ key: t.String({ minLength: 1 }) }),
22
26
  detail: { tags: ["files"], summary: "Presign a file download" },
@@ -16,7 +16,7 @@ test("file upload: multipart upload returns a key and url @smoke", async ({ play
16
16
  multipart: { file: { name: "hello.txt", mimeType: "text/plain", buffer: Buffer.from("hi there") } },
17
17
  });
18
18
  const responseText = await res.text();
19
- test.skip(res.status() >= 500, "object storage (MinIO/S3) not reachable");
19
+ test.skip(res.status() >= 500, "selected object storage is not reachable");
20
20
  expect(res.ok(), `upload returned ${res.status()}: ${responseText}`).toBeTruthy();
21
21
  const body = JSON.parse(responseText) as unknown;
22
22
  expect(typeof body).toBe("object");
@@ -1,7 +1,8 @@
1
1
  {
2
+ "manifestVersion": 3,
2
3
  "name": "file-upload",
3
4
  "description": "Multipart file upload endpoint that stores files via object storage and returns a presigned URL.",
4
- "requires": ["object-storage-s3"],
5
+ "requiresCapabilities": ["object-storage"],
5
6
  "targetApp": "api",
6
7
  "env": [],
7
8
  "inject": [
@@ -9,8 +10,8 @@
9
10
  { "file": "apps/api/src/app.ts", "marker": "// podokit:end:modules", "text": "filesModule," }
10
11
  ],
11
12
  "instructions": [
12
- "Start MinIO (or configure AWS S3): docker compose -f infra/docker/docker-compose.yml -f infra/docker/minio.compose.yml up -d",
13
+ "Configure the selected object-storage provider before uploading files.",
13
14
  "Upload a file: curl -F 'file=@./some.png' localhost:5002/files",
14
- "The response includes { key, url } — a presigned download URL."
15
+ "The response includes { key, url } — an S3 presigned URL or a local download URL."
15
16
  ]
16
17
  }
@@ -1,19 +1,39 @@
1
- import { Queue } from "bullmq";
1
+ import { JOBS, type JobQueue, type Unsubscribe } from "@podosoft/podokit-runtime";
2
2
  import { Elysia, t } from "elysia";
3
3
  import type { AppPlugin, PodokitModule, ServiceKey } from "../core/services";
4
- import { EVENTS } from "../events/events.module";
5
- import { redisConnection } from "../jobs/queue";
6
- import { REDIS } from "../redis/redis.module";
7
- import { ProgressBridge } from "./progress.bridge";
4
+ import { SSE_EVENTS } from "../events/events.module";
5
+ import { processProgress } from "./progress.processor";
8
6
 
9
- const PROGRESS_QUEUE = Symbol("progress-queue") as ServiceKey<Queue>;
10
- const PROGRESS_BRIDGE = Symbol("progress-bridge") as ServiceKey<ProgressBridge>;
7
+ class ProgressWorker {
8
+ private unsubscribe?: Unsubscribe;
9
+
10
+ constructor(
11
+ private readonly queue: JobQueue,
12
+ private readonly publish: (event: unknown) => Promise<void>,
13
+ ) {}
14
+
15
+ async connect(): Promise<void> {
16
+ this.unsubscribe = await this.queue.process("progress", async (payload, job) => {
17
+ await processProgress(payload, (progress) => this.publish({
18
+ type: "job-progress",
19
+ jobId: job.id,
20
+ progress,
21
+ }));
22
+ });
23
+ }
24
+
25
+ async close(): Promise<void> {
26
+ await this.unsubscribe?.();
27
+ }
28
+ }
29
+
30
+ const PROGRESS_WORKER = Symbol("progress-worker") as ServiceKey<ProgressWorker>;
11
31
 
12
32
  const progressPlugin: AppPlugin = ({ services }) => {
13
- const queue = services.resolve(PROGRESS_QUEUE);
33
+ const queue = services.resolve(JOBS);
14
34
  return new Elysia({ name: "podokit.job-progress" })
15
35
  .post("/progress", async ({ body, set }) => {
16
- const job = await queue.add("progress", { steps: body.steps ?? 5 });
36
+ const job = await queue.enqueue("progress", { steps: body.steps ?? 5 });
17
37
  set.status = 201;
18
38
  return { jobId: job.id };
19
39
  }, {
@@ -25,11 +45,13 @@ const progressPlugin: AppPlugin = ({ services }) => {
25
45
  export const jobProgressModule: PodokitModule = {
26
46
  name: "job-progress",
27
47
  configure: (_env, services): void => {
28
- const queue = new Queue("progress", { connection: redisConnection() });
29
- const bridge = new ProgressBridge(services.resolve(REDIS), services.resolve(EVENTS));
30
- services.register(PROGRESS_QUEUE, queue, () => queue.close());
31
- services.register(PROGRESS_BRIDGE, bridge, () => bridge.close());
32
- services.onStart(() => bridge.connect());
48
+ const events = services.resolve(SSE_EVENTS);
49
+ const worker = new ProgressWorker(
50
+ services.resolve(JOBS),
51
+ (event) => events.publishAsync(event),
52
+ );
53
+ services.register(PROGRESS_WORKER, worker, () => worker.close());
54
+ services.onStart(() => worker.connect());
33
55
  },
34
56
  plugin: progressPlugin,
35
57
  };
@@ -1,36 +1,13 @@
1
1
  import { describe, expect, it, mock } from "bun:test";
2
- import type { EventsService } from "../events/events.service";
3
- import type { RedisService } from "../redis/redis.service";
4
- import { ProgressBridge } from "./progress.bridge";
5
- import { PROGRESS_CHANNEL } from "./progress.processor";
2
+ import { processProgress } from "./progress.processor";
6
3
 
7
- describe("ProgressBridge", () => {
8
- it("delivers worker fan-out locally without republishing it", async () => {
9
- let handler: ((message: string) => void) | undefined;
10
- const unsubscribe = mock<() => Promise<void>>(() => Promise.resolve());
11
- const subscribe = mock(
12
- (channel: string, listener: (message: string) => void): Promise<() => Promise<void>> => {
13
- expect(channel).toBe(PROGRESS_CHANNEL);
14
- handler = listener;
15
- return Promise.resolve(unsubscribe);
16
- },
17
- );
18
- const publishLocal = mock<(data: unknown) => void>();
19
- const redis = { subscribe } as unknown as RedisService;
20
- const events = { publishLocal } as unknown as EventsService;
21
- const bridge = new ProgressBridge(redis, events);
22
-
23
- await bridge.connect();
24
- handler?.('{"jobId":"1","progress":50}');
25
- handler?.("invalid");
26
-
27
- expect(publishLocal).toHaveBeenCalledTimes(1);
28
- expect(publishLocal).toHaveBeenCalledWith({
29
- type: "job-progress",
30
- jobId: "1",
31
- progress: 50,
32
- });
33
- await bridge.close();
34
- expect(unsubscribe).toHaveBeenCalledTimes(1);
4
+ describe("progress processor", () => {
5
+ it("publishes provider-neutral progress updates", async () => {
6
+ const values: number[] = [];
7
+ const publish = mock(async (value: number): Promise<void> => { values.push(value); });
8
+ const delay = mock(async (): Promise<void> => undefined);
9
+ await expect(processProgress({ steps: 4 }, publish, delay)).resolves.toEqual({ done: true });
10
+ expect(values).toEqual([25, 50, 75, 100]);
11
+ expect(delay).toHaveBeenCalledTimes(4);
35
12
  });
36
13
  });
@@ -1,18 +1,23 @@
1
- import { RedisClient } from "bun";
2
- import type { Job } from "bullmq";
3
- import { redisConnectionUrl } from "../config/redis.connection";
4
-
5
- export const PROGRESS_CHANNEL = "job:progress";
1
+ export interface ProgressPayload {
2
+ steps?: number;
3
+ }
6
4
 
7
- const redis = new RedisClient(redisConnectionUrl());
5
+ function payload(value: unknown): ProgressPayload {
6
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
7
+ const steps = (value as Record<string, unknown>).steps;
8
+ return typeof steps === "number" ? { steps } : {};
9
+ }
8
10
 
9
- export async function processProgressJob(job: Job<{ steps?: number }>): Promise<{ done: true }> {
10
- const steps = job.data.steps ?? 5;
11
+ export async function processProgress(
12
+ value: unknown,
13
+ publish: (progress: number) => Promise<void>,
14
+ delay: (milliseconds: number) => Promise<unknown> = Bun.sleep,
15
+ ): Promise<{ done: true }> {
16
+ const steps = payload(value).steps ?? 5;
11
17
  for (let index = 1; index <= steps; index += 1) {
12
- await Bun.sleep(400);
18
+ await delay(400);
13
19
  const progress = Math.round((index / steps) * 100);
14
- await job.updateProgress(progress);
15
- await redis.publish(PROGRESS_CHANNEL, JSON.stringify({ jobId: job.id, progress }));
20
+ await publish(progress);
16
21
  }
17
22
  return { done: true };
18
23
  }