@podosoft/podokit 1.0.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +74 -6
  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 +1 -0
  9. package/dist/deploy-compose-render.js +46 -1
  10. package/dist/deploy-compose.js +2 -1
  11. package/dist/deploy-profile.js +18 -8
  12. package/dist/deploy.js +157 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +41 -0
  15. package/dist/lib.d.ts +2 -0
  16. package/dist/lib.js +8 -2
  17. package/dist/lockfile.d.ts +6 -1
  18. package/dist/lockfile.js +22 -4
  19. package/dist/provider.d.ts +24 -0
  20. package/dist/provider.js +142 -0
  21. package/dist/remove.js +6 -3
  22. package/dist/templates/base/AGENTS.md +9 -3
  23. package/dist/templates/base/README.md +12 -8
  24. package/dist/templates/fullstack/AGENTS.md +24 -5
  25. package/dist/templates/fullstack/README.md +75 -11
  26. package/dist/templates/fullstack/apps/api/package.json +4 -3
  27. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +1 -0
  28. package/dist/templates/fullstack/apps/api/src/config/providers.ts +9 -0
  29. package/dist/templates/fullstack/apps/api/src/core/services.ts +7 -2
  30. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +30 -8
  31. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +58 -0
  32. package/dist/templates/fullstack/apps/api/src/database/database.ts +58 -2
  33. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +24 -2
  34. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.spec.ts +50 -0
  35. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +134 -0
  36. package/dist/templates/fullstack/compose.dev.yaml +2 -2
  37. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  38. package/dist/templates/fullstack/dot-env.example +2 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +2 -2
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +10 -4
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +10 -4
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +10 -4
  43. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -5
  44. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +2 -5
  45. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +33 -0
  46. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +13 -0
  47. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +17 -8
  48. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +5 -4
  49. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  50. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +88 -0
  51. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +123 -0
  52. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +2 -2
  53. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -0
  54. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +2 -2
  55. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -4
  56. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +55 -10
  57. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +10 -14
  58. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +24 -6
  59. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +2 -1
  60. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +4 -3
  61. package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts +15 -4
  62. package/dist/templates/modules/auth/files/apps/api/src/settings/auth-config.migration.spec.ts +30 -0
  63. package/dist/templates/modules/auth/module.manifest.json +6 -6
  64. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +10 -0
  65. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +107 -0
  66. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +12 -11
  67. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +5 -0
  68. package/dist/templates/modules/bullmq/module.manifest.json +7 -0
  69. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.spec.ts +16 -0
  70. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.ts +12 -0
  71. package/dist/templates/modules/cache-memory/module.manifest.json +30 -0
  72. package/dist/templates/modules/events-memory/files/apps/api/src/events-provider-memory/events-memory.module.ts +14 -0
  73. package/dist/templates/modules/events-memory/module.manifest.json +30 -0
  74. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +16 -0
  75. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +75 -0
  76. package/dist/templates/modules/events-redis/module.manifest.json +32 -0
  77. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -5
  78. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  79. package/dist/templates/modules/file-upload/module.manifest.json +4 -3
  80. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +36 -14
  81. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +9 -32
  82. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +16 -11
  83. package/dist/templates/modules/job-progress/module.manifest.json +7 -8
  84. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +42 -0
  85. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +45 -0
  86. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +212 -0
  87. package/dist/templates/modules/jobs-local/module.manifest.json +34 -0
  88. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +7 -3
  89. package/dist/templates/modules/mailer/module.manifest.json +2 -4
  90. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +25 -0
  91. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +60 -0
  92. package/dist/templates/modules/object-storage-local/module.manifest.json +34 -0
  93. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +50 -0
  94. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +8 -0
  95. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +4 -4
  96. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +20 -12
  97. package/dist/templates/modules/object-storage-s3/module.manifest.json +4 -2
  98. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +14 -1
  99. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +18 -37
  100. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +12 -0
  101. package/dist/templates/modules/rate-limit/module.manifest.json +8 -6
  102. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +82 -0
  103. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +6 -0
  104. package/dist/templates/modules/redis/module.manifest.json +2 -0
  105. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +5 -6
  106. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +4 -4
  107. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +6 -5
  108. package/dist/templates/modules/sse/module.manifest.json +4 -6
  109. package/dist/templates/todo/AGENTS.md +25 -6
  110. package/dist/templates/todo/README.md +72 -14
  111. package/dist/templates/todo/apps/api/src/migrations/1720100000000-InitTodos.ts +11 -0
  112. package/dist/templates/todo/apps/api/src/todos/todo.repository.spec.ts +76 -0
  113. package/dist/templates/todo/apps/api/src/todos/todo.repository.ts +9 -4
  114. package/dist/templates/todo/tests/api/todo.api.spec.ts +32 -0
  115. package/dist/templates/todo/tests/ui/todo.ui.spec.ts +31 -0
  116. package/package.json +2 -1
  117. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +0 -32
  118. package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +0 -13
  119. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +0 -111
@@ -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
  }
@@ -1,20 +1,19 @@
1
1
  {
2
2
  "name": "job-progress",
3
- "description": "Live job progress: a worker reports progress over Redis pub/sub and the API relays it to SSE clients (combines bullmq + redis + sse).",
4
- "requires": ["bullmq", "sse", "redis"],
3
+ "manifestVersion": 3,
4
+ "description": "Live job progress over the selected jobs and events providers, streamed to clients with SSE.",
5
+ "requires": ["sse"],
6
+ "requiresCapabilities": ["jobs", "events"],
5
7
  "targetApp": "api",
6
8
  "env": [],
7
9
  "inject": [
8
10
  { "file": "apps/api/src/app.ts", "marker": "// podokit:end:imports", "text": "import { jobProgressModule } from \"./progress/job-progress.module\";" },
9
- { "file": "apps/api/src/app.ts", "marker": "// podokit:end:modules", "text": "jobProgressModule," },
10
- { "file": "apps/api/src/jobs/worker.module.ts", "marker": "// podokit:end:worker-imports", "text": "import { processProgressJob } from \"../progress/progress.processor\";" },
11
- { "file": "apps/api/src/jobs/worker.module.ts", "marker": "// podokit:end:worker-processors", "text": "{ queue: \"progress\", processor: processProgressJob }," }
11
+ { "file": "apps/api/src/app.ts", "marker": "// podokit:end:modules", "text": "jobProgressModule," }
12
12
  ],
13
13
  "instructions": [
14
- "Start Redis + Postgres: docker compose -f infra/docker/docker-compose.yml up -d",
15
- "Run API and worker in separate terminals: {{rootRun}} dev / {{apiRun}} dev:worker{{apiWorkspaceArg}}",
14
+ "Configure the selected jobs and events providers before starting the API.",
16
15
  "Watch progress: curl -N localhost:5002/events/stream",
17
16
  "Start a job: curl -XPOST localhost:5002/progress -H 'content-type: application/json' -d '{\"steps\":5}'",
18
- "The stream shows job-progress events 20 -> 40 -> ... -> 100 pushed from the worker via Redis."
17
+ "The stream shows job-progress events 20 -> 40 -> ... -> 100 through the selected events provider."
19
18
  ]
20
19
  }
@@ -0,0 +1,42 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
2
+ import { JOBS } from "@podosoft/podokit-runtime";
3
+ import { Elysia, t } from "elysia";
4
+ import { PROVIDERS } from "../config/providers";
5
+ import { DATABASE, type AppPlugin, type PodokitModule } from "../core/services";
6
+ import { LocalJobQueue } from "./local-job.queue";
7
+
8
+ const localJobsPlugin: AppPlugin = ({ services }) => {
9
+ if (PROVIDERS.jobs !== "local") return new Elysia({ name: "podokit.jobs-local.inactive" });
10
+ const queue = services.resolve(JOBS);
11
+ return new Elysia({ name: "podokit.jobs-local" })
12
+ .post("/jobs", async ({ body, set }) => {
13
+ const job = await queue.enqueue("demo", { text: body.text });
14
+ set.status = 201;
15
+ return { id: job.id };
16
+ }, {
17
+ body: t.Object({ text: t.String({ minLength: 1 }) }),
18
+ detail: { tags: ["jobs"], summary: "Enqueue a job" },
19
+ })
20
+ .get("/jobs/:id", async ({ params }) => {
21
+ const job = await queue.get(params.id);
22
+ if (!job) throw new AppException("JOB_NOT_FOUND", `Job ${params.id} not found`, 404);
23
+ return { id: job.id, state: job.status, error: job.error ?? null };
24
+ }, {
25
+ params: t.Object({ id: t.String({ minLength: 1 }) }),
26
+ detail: { tags: ["jobs"], summary: "Read job status" },
27
+ });
28
+ };
29
+
30
+ export const jobsLocalModule: PodokitModule = {
31
+ name: "jobs-local",
32
+ configure: (_env, services): void => {
33
+ if (PROVIDERS.jobs !== "local") return;
34
+ const queue = new LocalJobQueue(services.resolve(DATABASE).sql, {
35
+ pollIntervalMs: Number(process.env.LOCAL_JOBS_POLL_INTERVAL_MS ?? 250),
36
+ });
37
+ void queue.process("demo", () => undefined);
38
+ services.register(JOBS, queue, () => queue.close());
39
+ services.onStart(() => queue.start());
40
+ },
41
+ plugin: localJobsPlugin,
42
+ };
@@ -0,0 +1,45 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { LocalJobQueue } from "./local-job.queue";
4
+
5
+ let sql: SQL | undefined;
6
+
7
+ afterEach(async () => {
8
+ await sql?.close();
9
+ sql = undefined;
10
+ });
11
+
12
+ describe("local job queue", () => {
13
+ it("persists and processes a job", async () => {
14
+ sql = new SQL(":memory:");
15
+ const queue = new LocalJobQueue(sql, { pollIntervalMs: 10 });
16
+ const payloads: unknown[] = [];
17
+ await queue.process("demo", (payload) => { payloads.push(payload); });
18
+ const job = await queue.enqueue("demo", { text: "hello" });
19
+ expect(await queue.runOnce()).toBe(true);
20
+ expect(payloads).toEqual([{ text: "hello" }]);
21
+ await expect(queue.get(job.id)).resolves.toMatchObject({
22
+ status: "completed",
23
+ attempts: 1,
24
+ });
25
+ queue.close();
26
+ });
27
+
28
+ it("retries failures and records the final error", async () => {
29
+ sql = new SQL(":memory:");
30
+ let now = 1_000;
31
+ const queue = new LocalJobQueue(sql, { pollIntervalMs: 10, now: () => now });
32
+ await queue.process("fail", () => { throw new Error("expected failure"); });
33
+ const job = await queue.enqueue("fail", {}, { attempts: 2 });
34
+ expect(await queue.runOnce()).toBe(true);
35
+ expect((await queue.get(job.id))?.status).toBe("waiting");
36
+ now += 10;
37
+ expect(await queue.runOnce()).toBe(true);
38
+ await expect(queue.get(job.id)).resolves.toMatchObject({
39
+ status: "failed",
40
+ attempts: 2,
41
+ error: "expected failure",
42
+ });
43
+ queue.close();
44
+ });
45
+ });