@podosoft/podokit 1.3.0 → 1.4.1

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 (154) hide show
  1. package/README.md +1 -1
  2. package/dist/add.d.ts +10 -1
  3. package/dist/add.js +20 -7
  4. package/dist/api-paths.d.ts +34 -0
  5. package/dist/api-paths.js +103 -0
  6. package/dist/deploy-compose-profile.d.ts +4 -0
  7. package/dist/deploy-compose-profile.js +13 -2
  8. package/dist/deploy-compose-render.d.ts +1 -1
  9. package/dist/deploy-compose-render.js +23 -7
  10. package/dist/deploy-compose.js +2 -1
  11. package/dist/deploy-profile.d.ts +3 -0
  12. package/dist/deploy-profile.js +12 -4
  13. package/dist/deploy-render.js +54 -8
  14. package/dist/deploy.js +21 -1
  15. package/dist/dev.d.ts +14 -0
  16. package/dist/dev.js +152 -10
  17. package/dist/index.js +15 -2
  18. package/dist/lib.d.ts +2 -0
  19. package/dist/lib.js +4 -2
  20. package/dist/lockfile.d.ts +3 -0
  21. package/dist/lockfile.js +11 -0
  22. package/dist/module-api-paths.d.ts +52 -0
  23. package/dist/module-api-paths.js +189 -0
  24. package/dist/remove.d.ts +3 -0
  25. package/dist/remove.js +12 -3
  26. package/dist/templates/fullstack/AGENTS.md +27 -5
  27. package/dist/templates/fullstack/README.md +28 -11
  28. package/dist/templates/fullstack/apps/api/package.json +2 -2
  29. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -0
  30. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
  31. package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
  32. package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
  33. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
  34. package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
  35. package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
  36. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
  37. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
  38. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
  39. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  40. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
  41. package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
  42. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +26 -1
  43. package/dist/templates/fullstack/apps/api/src/database/database.ts +52 -11
  44. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
  45. package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
  46. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +70 -0
  47. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
  48. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
  49. package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
  50. package/dist/templates/fullstack/apps/web/components.json +3 -1
  51. package/dist/templates/fullstack/apps/web/package.json +1 -1
  52. package/dist/templates/fullstack/apps/web/src/error.html +73 -0
  53. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
  54. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
  55. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
  56. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
  57. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
  58. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
  59. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  60. package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
  61. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
  62. package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
  63. package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
  64. package/dist/templates/fullstack/dot-env.example +10 -0
  65. package/dist/templates/fullstack/dot-gitignore +1 -0
  66. package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
  67. package/dist/templates/fullstack/infra/k3s/README.md +3 -1
  68. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
  69. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
  70. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
  75. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
  76. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
  77. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
  78. package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
  79. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  80. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
  85. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
  86. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
  87. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
  88. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
  89. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
  90. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
  91. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
  93. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
  94. package/dist/templates/modules/auth/module.manifest.json +0 -1
  95. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
  96. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
  97. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
  98. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
  99. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  100. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
  101. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
  102. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
  103. package/dist/templates/modules/events-redis/module.manifest.json +4 -0
  104. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
  105. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
  106. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
  107. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
  108. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
  109. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
  110. package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
  111. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
  112. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
  113. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +153 -0
  114. package/dist/templates/modules/file-upload/module.manifest.json +34 -6
  115. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
  116. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
  117. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
  118. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  119. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
  120. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
  121. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
  122. package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
  123. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
  124. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
  125. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
  126. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
  127. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
  128. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
  129. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
  130. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
  131. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
  132. package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
  133. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
  134. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
  135. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
  136. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
  137. package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
  138. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
  139. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
  140. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
  141. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
  142. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
  143. package/dist/templates/modules/redis/module.manifest.json +4 -0
  144. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
  145. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
  146. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
  147. package/dist/templates/todo/AGENTS.md +19 -4
  148. package/dist/templates/todo/README.md +27 -12
  149. package/dist/update.d.ts +5 -0
  150. package/dist/update.js +35 -0
  151. package/package.json +2 -2
  152. package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
  153. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
  154. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
@@ -35,6 +35,12 @@
35
35
  "file": "apps/api/src/app.ts",
36
36
  "marker": "// podokit:end:modules",
37
37
  "text": "rateLimitModule,"
38
+ },
39
+ {
40
+ "file": "AGENTS.md",
41
+ "marker": "<!-- podokit:end:agents-modules -->",
42
+ "optional": true,
43
+ "text": "### rate-limit\n- Every request counts against a per-caller budget (general, `/api/auth`, or runtime reads); exceeded limits return 429 `RATE_LIMIT_EXCEEDED` with `Retry-After`.\n- Give routes that legitimately send many requests, such as upload chunks, their own budget instead of raising `RATE_LIMIT_MAX`: `services.tryResolve(RATE_LIMIT_ROUTES)?.register({ method: \"PUT\", pathPrefix: \"/uploads\", profile: { name: \"uploads\", limit: 3000 } })` (`RATE_LIMIT_ROUTES` from `@podosoft/podokit-runtime`). Register from a module's `plugin` factory or `configureServices()` in `app.extensions.ts`; the most specific prefix wins, and `profile: \"exempt\"` skips limiting.\n- Prefixes are API paths (`/uploads`, not the web proxy's `/api/uploads`); Better Auth routes keep `/api/auth`.\n"
38
44
  }
39
45
  ],
40
46
  "instructions": [
@@ -47,6 +53,7 @@
47
53
  "Exceeded limits return RATE_LIMIT_EXCEEDED with HTTP 429 and Retry-After. Cache failures return RATE_LIMIT_UNAVAILABLE with HTTP 503.",
48
54
  "Register a RateLimitIdentityExtension under RATE_LIMIT_IDENTITY_EXTENSION in app.extensions.ts for another validated machine-key source.",
49
55
  "GET /health and GET /health/ready bypass request quotas so orchestrator probes remain reliable.",
56
+ "Give a route its own budget or exempt it with services.tryResolve(RATE_LIMIT_ROUTES)?.register({ method, pathPrefix, profile }) from a module's plugin factory or configureServices() in app.extensions.ts; the most specific prefix wins.",
50
57
  "Redis counters are shared across replicas; memory counters require one API replica."
51
58
  ]
52
59
  }
@@ -1,9 +1,11 @@
1
+ import type { RedisClient } from "bun";
1
2
  import { describe, expect, it, mock } from "bun:test";
2
3
  import {
3
4
  FIXED_WINDOW_SCRIPT,
4
5
  FIXED_WINDOW_SCRIPT_SHA,
5
6
  RedisCacheStore,
6
7
  } from "./redis-cache.store";
8
+ import { RedisCommandTimeoutError } from "../config/redis.connection";
7
9
  import { RedisService } from "./redis.service";
8
10
 
9
11
  const redisUrl = process.env.REDIS_TEST_URL;
@@ -13,7 +15,7 @@ type Send = (command: string, args: string[]) => Promise<unknown>;
13
15
  type SetValue = (key: string, value: string, ...options: Array<string | number>) => Promise<"OK">;
14
16
 
15
17
  function storeWith(client: { send?: Send; set?: SetValue }): RedisCacheStore {
16
- return new RedisCacheStore({ client } as unknown as RedisService);
18
+ return new RedisCacheStore(new RedisService(() => client as unknown as RedisClient));
17
19
  }
18
20
 
19
21
  function redisError(message: string): Error {
@@ -75,6 +77,17 @@ describe("RedisCacheStore set", () => {
75
77
  ]);
76
78
  });
77
79
 
80
+ it("fails a write Redis does not answer within the command timeout", async () => {
81
+ const set = mock<SetValue>(() => new Promise<"OK">(() => undefined));
82
+ const close = mock(() => undefined);
83
+ const store = new RedisCacheStore(
84
+ new RedisService(() => ({ set, close }) as unknown as RedisClient, 50),
85
+ );
86
+
87
+ await expect(store.set("key", "value")).rejects.toBeInstanceOf(RedisCommandTimeoutError);
88
+ expect(close).toHaveBeenCalledTimes(1);
89
+ });
90
+
78
91
  it("rejects an invalid TTL before writing", async () => {
79
92
  const set = mock<SetValue>(async () => "OK");
80
93
  const store = storeWith({ set });
@@ -1,5 +1,4 @@
1
1
  import { createHash } from "node:crypto";
2
- import type { RedisClient } from "bun";
3
2
  import type {
4
3
  CacheSetOptions,
5
4
  CacheStore,
@@ -30,26 +29,24 @@ function resultNumber(value: unknown): number | undefined {
30
29
  return undefined;
31
30
  }
32
31
 
32
+ /** Every command is bounded by `REDIS_COMMAND_TIMEOUT_MS` through `RedisService.command`. */
33
33
  export class RedisCacheStore implements CacheStore {
34
- private readonly client: RedisClient;
35
-
36
- constructor(private readonly redis: RedisService) {
37
- this.client = redis.client;
38
- }
34
+ constructor(private readonly redis: RedisService) {}
39
35
 
40
36
  get(key: string): Promise<string | null> {
41
37
  return this.redis.get(key);
42
38
  }
43
39
 
44
40
  async set(key: string, value: string, options: CacheSetOptions = {}): Promise<void> {
45
- if (options.ttlMs === undefined) {
46
- await this.client.set(key, value);
41
+ const ttlMs = options.ttlMs;
42
+ if (ttlMs === undefined) {
43
+ await this.redis.command((client) => client.set(key, value));
47
44
  return;
48
45
  }
49
- if (!Number.isSafeInteger(options.ttlMs) || options.ttlMs < 1) {
46
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) {
50
47
  throw new Error("ttlMs must be a positive integer");
51
48
  }
52
- await this.client.set(key, value, "PX", options.ttlMs);
49
+ await this.redis.command((client) => client.set(key, value, "PX", ttlMs));
53
50
  }
54
51
 
55
52
  async delete(key: string): Promise<boolean> {
@@ -84,11 +81,11 @@ export class RedisCacheStore implements CacheStore {
84
81
  private async runFixedWindow(key: string, windowMs: number): Promise<unknown> {
85
82
  const args = ["1", key, String(windowMs)];
86
83
  try {
87
- return await this.client.send("EVALSHA", [FIXED_WINDOW_SCRIPT_SHA, ...args]);
84
+ return await this.redis.command((client) => client.send("EVALSHA", [FIXED_WINDOW_SCRIPT_SHA, ...args]));
88
85
  } catch (error) {
89
86
  // Redis forgets scripts on restart, failover, or SCRIPT FLUSH; EVAL reloads it.
90
87
  if (!isMissingScript(error)) throw error;
91
- return this.client.send("EVAL", [FIXED_WINDOW_SCRIPT, ...args]);
88
+ return this.redis.command((client) => client.send("EVAL", [FIXED_WINDOW_SCRIPT, ...args]));
92
89
  }
93
90
  }
94
91
 
@@ -1,3 +1,4 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
1
2
  import { Elysia, t } from "elysia";
2
3
  import { CACHE } from "@podosoft/podokit-runtime";
3
4
  import { PROVIDERS } from "../config/providers";
@@ -12,12 +13,22 @@ import { RedisCacheStore } from "./redis-cache.store";
12
13
 
13
14
  export const REDIS = Symbol("redis") as ServiceKey<RedisService>;
14
15
 
16
+ /** Answers 503 CACHE_UNAVAILABLE while Redis cannot be reached; other Redis errors stay 500. */
17
+ async function withCache<T>(redis: RedisService, run: () => Promise<T>): Promise<T> {
18
+ try {
19
+ return await run();
20
+ } catch (error) {
21
+ if (!redis.isUnavailable(error)) throw error;
22
+ throw new AppException("CACHE_UNAVAILABLE", "The cache is temporarily unavailable.", 503);
23
+ }
24
+ }
25
+
15
26
  const cachePlugin: AppPlugin = ({ services }) => {
16
27
  if (PROVIDERS.cache !== "redis") return new Elysia({ name: "podokit.redis.inactive" });
17
28
  const redis = services.resolve(REDIS);
18
29
  return new Elysia({ name: "podokit.redis" })
19
30
  .put("/cache/:key", async ({ params, body }) => {
20
- await redis.set(params.key, body.value, body.ttl);
31
+ await withCache(redis, () => redis.set(params.key, body.value, body.ttl));
21
32
  return { key: params.key };
22
33
  }, {
23
34
  params: t.Object({ key: t.String({ minLength: 1 }) }),
@@ -29,7 +40,7 @@ const cachePlugin: AppPlugin = ({ services }) => {
29
40
  })
30
41
  .get("/cache/:key", async ({ params }) => ({
31
42
  key: params.key,
32
- value: await redis.get(params.key),
43
+ value: await withCache(redis, () => redis.get(params.key)),
33
44
  }), {
34
45
  params: t.Object({ key: t.String({ minLength: 1 }) }),
35
46
  detail: { tags: ["cache"], summary: "Read a cache value" },
@@ -40,10 +51,11 @@ export const redisModule: PodokitModule = {
40
51
  name: "redis",
41
52
  configure: (_env, services): void => {
42
53
  if (PROVIDERS.cache !== "redis") return;
43
- const redis = new RedisService(services.resolve(READINESS));
54
+ const redis = new RedisService();
44
55
  services.register(REDIS, redis, () => redis.close());
45
56
  services.register(CACHE, new RedisCacheStore(redis));
46
- services.onStart(() => redis.connect());
57
+ services.resolve(READINESS).register("redis", () => redis.ping());
58
+ services.supervise("redis", redis);
47
59
  },
48
60
  plugin: cachePlugin,
49
61
  };
@@ -1,23 +1,230 @@
1
+ import type { RedisClient } from "bun";
1
2
  import { describe, expect, it } from "bun:test";
2
- import { ReadinessService } from "../health/readiness.service";
3
+ import { RedisCommandTimeoutError } from "../config/redis.connection";
3
4
  import { RedisService } from "./redis.service";
4
5
 
6
+ type Listener = (message: string, channel: string) => void;
7
+
8
+ /**
9
+ * One Redis server shared by fake clients: it can go down, and it drops every
10
+ * connection when it does. While paused it keeps connections open and answers nothing.
11
+ */
12
+ class FakeRedis {
13
+ up = true;
14
+ paused = false;
15
+ readonly values = new Map<string, string>();
16
+ readonly clients: FakeClient[] = [];
17
+
18
+ client(): RedisClient {
19
+ const client = new FakeClient(this);
20
+ this.clients.push(client);
21
+ return client as unknown as RedisClient;
22
+ }
23
+
24
+ stop(): void {
25
+ this.up = false;
26
+ for (const client of this.clients) client.drop();
27
+ }
28
+
29
+ publish(channel: string, message: string): number {
30
+ let receivers = 0;
31
+ for (const client of this.clients) {
32
+ if (!client.connected || !client.channels.has(channel)) continue;
33
+ receivers += 1;
34
+ client.listener?.(message, channel);
35
+ }
36
+ return receivers;
37
+ }
38
+ }
39
+
40
+ class FakeClient {
41
+ connected = false;
42
+ onclose: ((error: Error) => void) | null = null;
43
+ readonly channels = new Set<string>();
44
+ listener?: Listener;
45
+ private readonly pending = new Set<(error: Error) => void>();
46
+
47
+ constructor(private readonly server: FakeRedis) {}
48
+
49
+ /** Answers with `value` unless disconnected, or never while the server is paused; closing fails it. */
50
+ private reply<T>(value: () => T): Promise<T> {
51
+ if (!this.connected) return Promise.reject(new Error("Connection closed"));
52
+ if (!this.server.paused) return Promise.resolve(value());
53
+ return new Promise<T>((_resolve, reject) => {
54
+ this.pending.add(reject);
55
+ });
56
+ }
57
+
58
+ get(key: string): Promise<string | null> {
59
+ return this.reply(() => this.server.values.get(key) ?? null);
60
+ }
61
+
62
+ set(key: string, value: string): Promise<"OK"> {
63
+ return this.reply(() => {
64
+ this.server.values.set(key, value);
65
+ return "OK" as const;
66
+ });
67
+ }
68
+
69
+ connect(): Promise<void> {
70
+ if (!this.server.up) {
71
+ this.onclose?.(new Error("Connection closed"));
72
+ return Promise.reject(new Error("Connection closed"));
73
+ }
74
+ this.connected = true;
75
+ return Promise.resolve();
76
+ }
77
+
78
+ ping(): Promise<string> {
79
+ return this.reply(() => "PONG");
80
+ }
81
+
82
+ subscribe(channels: string | string[], listener: Listener): Promise<number> {
83
+ if (!this.connected) return Promise.reject(new Error("Connection closed"));
84
+ for (const channel of [channels].flat()) this.channels.add(channel);
85
+ this.listener = listener;
86
+ return Promise.resolve(this.channels.size);
87
+ }
88
+
89
+ unsubscribe(channel: string): Promise<void> {
90
+ this.channels.delete(channel);
91
+ return Promise.resolve();
92
+ }
93
+
94
+ drop(): void {
95
+ if (!this.connected) return;
96
+ // Bun forgets the server subscription but keeps its listener.
97
+ this.connected = false;
98
+ this.channels.clear();
99
+ this.onclose?.(new Error("Connection closed"));
100
+ }
101
+
102
+ close(): void {
103
+ if (!this.connected) return;
104
+ this.connected = false;
105
+ for (const reject of this.pending) reject(new Error("Connection closed"));
106
+ this.pending.clear();
107
+ this.onclose?.(new Error("Connection closed"));
108
+ }
109
+ }
110
+
111
+ describe("RedisService commands", () => {
112
+ it("fails a command Redis does not answer and hands the connection to the supervisor", async () => {
113
+ const server = new FakeRedis();
114
+ const service = new RedisService(() => server.client(), 50);
115
+ const lost: unknown[] = [];
116
+ service.onLost((reason) => { lost.push(reason); });
117
+ await service.connect();
118
+ await service.set("greeting", "hi");
119
+
120
+ server.paused = true;
121
+ const started = Date.now();
122
+ const error = await service.get("greeting").then(() => undefined, (reason: unknown) => reason);
123
+
124
+ expect(error).toBeInstanceOf(RedisCommandTimeoutError);
125
+ expect(Date.now() - started).toBeLessThan(1_000);
126
+ expect(service.isUnavailable(error)).toBe(true);
127
+ expect(service.client.connected).toBe(false);
128
+ expect(lost.length).toBe(1);
129
+ await expect(service.get("greeting")).rejects.toThrow("Connection closed");
130
+
131
+ server.paused = false;
132
+ await service.connect();
133
+ await expect(service.get("greeting")).resolves.toBe("hi");
134
+ expect(service.isUnavailable(new Error("WRONGTYPE Operation against a key"))).toBe(false);
135
+ service.close();
136
+ });
137
+
138
+ it("fails a reconnect attempt that Redis accepts but does not answer", async () => {
139
+ const server = new FakeRedis();
140
+ server.paused = true;
141
+ const service = new RedisService(() => server.client(), 50);
142
+
143
+ await expect(service.connect()).rejects.toBeInstanceOf(RedisCommandTimeoutError);
144
+ expect(service.client.connected).toBe(false);
145
+ service.close();
146
+ });
147
+ });
148
+
149
+ describe("RedisService subscriptions", () => {
150
+ it("subscribes once Redis becomes reachable after starting without it", async () => {
151
+ const server = new FakeRedis();
152
+ server.up = false;
153
+ const service = new RedisService(() => server.client());
154
+ const received: string[] = [];
155
+ await service.subscribe("orders", (message) => { received.push(message); });
156
+
157
+ await expect(service.connect()).rejects.toThrow("Connection closed");
158
+ server.up = true;
159
+ await service.connect();
160
+
161
+ expect(server.publish("orders", "created")).toBe(1);
162
+ expect(received).toEqual(["created"]);
163
+ service.close();
164
+ });
165
+
166
+ it("reports a dropped connection and restores every subscription on reconnect", async () => {
167
+ const server = new FakeRedis();
168
+ const service = new RedisService(() => server.client());
169
+ const lost: unknown[] = [];
170
+ service.onLost((reason) => { lost.push(reason); });
171
+ await service.connect();
172
+ const received: string[] = [];
173
+ await service.subscribe("orders", (message) => { received.push(`orders:${message}`); });
174
+ await service.subscribe("users", (message) => { received.push(`users:${message}`); });
175
+
176
+ server.stop();
177
+ expect(lost.length).toBeGreaterThan(0);
178
+ await expect(service.connect()).rejects.toThrow("Connection closed");
179
+ server.up = true;
180
+ await service.connect();
181
+
182
+ expect(server.publish("orders", "1")).toBe(1);
183
+ expect(server.publish("users", "2")).toBe(1);
184
+ expect(received).toEqual(["orders:1", "users:2"]);
185
+ service.close();
186
+ });
187
+
188
+ it("stops delivering after the last handler of a channel unsubscribes", async () => {
189
+ const server = new FakeRedis();
190
+ const service = new RedisService(() => server.client());
191
+ await service.connect();
192
+ const received: string[] = [];
193
+ const unsubscribe = await service.subscribe("orders", (message) => { received.push(message); });
194
+
195
+ await unsubscribe();
196
+ expect(server.publish("orders", "late")).toBe(0);
197
+ expect(received).toEqual([]);
198
+ service.close();
199
+ });
200
+
201
+ it("does not report connections it closes itself", async () => {
202
+ const server = new FakeRedis();
203
+ const service = new RedisService(() => server.client());
204
+ const lost: unknown[] = [];
205
+ service.onLost((reason) => { lost.push(reason); });
206
+ await service.connect();
207
+
208
+ service.close();
209
+ expect(lost).toEqual([]);
210
+ });
211
+ });
212
+
5
213
  const redisUrl = process.env.REDIS_TEST_URL;
6
214
  const integrationTest = redisUrl ? it : it.skip;
7
215
 
8
216
  describe("RedisService integration", () => {
9
- integrationTest("uses authenticated settings and registers readiness", async () => {
217
+ integrationTest("uses authenticated settings and answers pings", async () => {
10
218
  const previousUrl = process.env.REDIS_URL;
11
219
  process.env.REDIS_URL = redisUrl;
12
- const readiness = new ReadinessService();
13
- const service = new RedisService(readiness);
220
+ const service = new RedisService();
14
221
  const key = `podokit:test:${Date.now()}:${Math.random().toString(36).slice(2)}`;
15
222
 
16
223
  try {
17
224
  await service.connect();
18
225
  await service.set(key, "value", 10);
19
226
  await expect(service.get(key)).resolves.toBe("value");
20
- await expect(readiness.run()).resolves.toEqual({ redis: "up" });
227
+ await expect(service.ping()).resolves.toBeUndefined();
21
228
  await service.del(key);
22
229
  } finally {
23
230
  service.close();
@@ -25,4 +232,25 @@ describe("RedisService integration", () => {
25
232
  else process.env.REDIS_URL = previousUrl;
26
233
  }
27
234
  });
235
+
236
+ integrationTest("delivers a published message to a subscriber", async () => {
237
+ const previousUrl = process.env.REDIS_URL;
238
+ process.env.REDIS_URL = redisUrl;
239
+ const service = new RedisService();
240
+ const channel = `podokit:test:${Date.now()}:${Math.random().toString(36).slice(2)}`;
241
+
242
+ try {
243
+ await service.connect();
244
+ const received = new Promise<string>((resolve) => {
245
+ void service.subscribe(channel, resolve);
246
+ });
247
+ await Bun.sleep(50);
248
+ await service.publish(channel, "hello");
249
+ await expect(received).resolves.toBe("hello");
250
+ } finally {
251
+ service.close();
252
+ if (previousUrl === undefined) delete process.env.REDIS_URL;
253
+ else process.env.REDIS_URL = previousUrl;
254
+ }
255
+ });
28
256
  });
@@ -1,62 +1,164 @@
1
1
  import { RedisClient } from "bun";
2
- import { redisConnectionUrl } from "../config/redis.connection";
3
- import { ReadinessService } from "../health/readiness.service";
2
+ import {
3
+ RedisCommandTimeoutError,
4
+ redisCommandTimeoutMs,
5
+ redisConnectionUrl,
6
+ withReplyTimeout,
7
+ } from "../config/redis.connection";
8
+ import type { SupervisedConnection } from "../core/services";
4
9
 
5
- export class RedisService {
10
+ export type RedisMessageHandler = (message: string) => void;
11
+
12
+ /**
13
+ * Commands fail at once while the client is disconnected instead of queueing,
14
+ * and the service's supervisor reconnects instead of Bun, which gives up after
15
+ * a fixed number of attempts and never restores subscriptions.
16
+ */
17
+ export function createRedisClient(): RedisClient {
18
+ return new RedisClient(redisConnectionUrl(), {
19
+ connectionTimeout: 5_000,
20
+ autoReconnect: false,
21
+ enableOfflineQueue: false,
22
+ });
23
+ }
24
+
25
+ export class RedisService implements SupervisedConnection {
6
26
  readonly client: RedisClient;
7
- private readonly subscribers: RedisClient[] = [];
8
- private unregisterReadiness?: () => void;
27
+ private readonly channels = new Map<string, Set<RedisMessageHandler>>();
28
+ // Channels the current subscriber connection holds on the server.
29
+ private readonly active = new Set<string>();
30
+ private subscriber?: RedisClient;
31
+ private opening?: Promise<RedisClient>;
32
+ private lostListener?: (reason: unknown) => void;
33
+ private closed = false;
34
+
35
+ constructor(
36
+ private readonly createClient: () => RedisClient = createRedisClient,
37
+ private readonly commandTimeoutMs = redisCommandTimeoutMs(),
38
+ ) {
39
+ this.client = createClient();
40
+ this.client.onclose = (error) => this.report(error);
41
+ }
9
42
 
10
- constructor(private readonly readiness?: ReadinessService) {
11
- this.client = new RedisClient(redisConnectionUrl(), {
12
- connectionTimeout: 5_000,
13
- enableOfflineQueue: false,
14
- maxRetries: 1,
15
- });
43
+ onLost(listener: (reason: unknown) => void): void {
44
+ this.lostListener = listener;
16
45
  }
17
46
 
47
+ /** Opens the command connection and restores every subscription. */
18
48
  async connect(): Promise<void> {
19
49
  if (!this.client.connected) await this.client.connect();
20
- await this.client.ping();
21
- this.unregisterReadiness ??= this.readiness?.register("redis", async () => {
22
- await this.client.ping();
23
- });
50
+ await this.ping();
51
+ if (this.channels.size > 0) await this.listen();
52
+ }
53
+
54
+ /**
55
+ * Runs `send` on the command connection. When Redis does not answer within
56
+ * `REDIS_COMMAND_TIMEOUT_MS`, it fails with `RedisCommandTimeoutError` and the
57
+ * connection is closed and reopened by the supervisor. Use it for commands
58
+ * this service does not wrap, instead of calling `client` directly.
59
+ */
60
+ async command<T>(send: (client: RedisClient) => Promise<T>): Promise<T> {
61
+ return withReplyTimeout(send(this.client), this.commandTimeoutMs, () => this.client.close());
62
+ }
63
+
64
+ /** Whether a failed command means Redis could not be reached, rather than that it refused the command. */
65
+ isUnavailable(error: unknown): boolean {
66
+ return error instanceof RedisCommandTimeoutError || !this.client.connected;
67
+ }
68
+
69
+ async ping(): Promise<void> {
70
+ await this.command((client) => client.ping());
24
71
  }
25
72
 
26
73
  get(key: string): Promise<string | null> {
27
- return this.client.get(key);
74
+ return this.command((client) => client.get(key));
28
75
  }
29
76
 
30
77
  async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
31
- await this.client.set(key, value);
32
- if (ttlSeconds !== undefined) await this.client.expire(key, ttlSeconds);
78
+ await this.command((client) => client.set(key, value));
79
+ if (ttlSeconds !== undefined) await this.command((client) => client.expire(key, ttlSeconds));
33
80
  }
34
81
 
35
82
  del(key: string): Promise<number> {
36
- return this.client.del(key);
83
+ return this.command((client) => client.del(key));
37
84
  }
38
85
 
39
86
  publish(channel: string, message: string): Promise<number> {
40
- return this.client.publish(channel, message);
87
+ return this.command((client) => client.publish(channel, message));
41
88
  }
42
89
 
43
- async subscribe(channel: string, handler: (message: string) => void): Promise<() => Promise<void>> {
44
- const subscriber = await this.client.duplicate();
45
- await subscriber.connect();
46
- const listener = (message: string): void => handler(message);
47
- await subscriber.subscribe(channel, listener);
48
- this.subscribers.push(subscriber);
90
+ /**
91
+ * Calls `handler` for every message on `channel`. The subscription does not
92
+ * need Redis to be reachable now: it starts once Redis is, and comes back
93
+ * after every reconnect. Messages published while disconnected are lost.
94
+ */
95
+ async subscribe(channel: string, handler: RedisMessageHandler): Promise<() => Promise<void>> {
96
+ const handlers = this.channels.get(channel) ?? new Set<RedisMessageHandler>();
97
+ handlers.add(handler);
98
+ this.channels.set(channel, handlers);
99
+ if (this.client.connected) {
100
+ // A failure reaches the supervisor through onclose or below, and the
101
+ // next reconnect subscribes again.
102
+ await this.listen().catch((error: unknown) => this.report(error));
103
+ }
49
104
  return async () => {
50
- const index = this.subscribers.indexOf(subscriber);
51
- if (index >= 0) this.subscribers.splice(index, 1);
52
- await subscriber.unsubscribe(channel, listener);
53
- subscriber.close();
105
+ handlers.delete(handler);
106
+ if (handlers.size > 0 || this.channels.get(channel) !== handlers) return;
107
+ this.channels.delete(channel);
108
+ if (!this.active.delete(channel)) return;
109
+ await this.subscriber?.unsubscribe(channel).catch(() => undefined);
54
110
  };
55
111
  }
56
112
 
57
113
  close(): void {
58
- this.unregisterReadiness?.();
59
- for (const subscriber of this.subscribers) subscriber.close();
114
+ this.closed = true;
115
+ this.subscriber?.close();
116
+ this.subscriber = undefined;
117
+ this.active.clear();
60
118
  this.client.close();
61
119
  }
120
+
121
+ private readonly dispatch = (message: string, channel: string): void => {
122
+ for (const handler of this.channels.get(channel) ?? []) handler(message);
123
+ };
124
+
125
+ private async listen(): Promise<void> {
126
+ const subscriber = this.subscriber ?? await (this.opening ??= this.openSubscriber());
127
+ const missing = [...this.channels.keys()].filter((channel) => !this.active.has(channel));
128
+ if (missing.length === 0) return;
129
+ await withReplyTimeout(
130
+ subscriber.subscribe(missing, this.dispatch),
131
+ this.commandTimeoutMs,
132
+ () => subscriber.close(),
133
+ );
134
+ if (this.subscriber !== subscriber) return;
135
+ for (const channel of missing) this.active.add(channel);
136
+ }
137
+
138
+ /** A fresh connection, because Bun keeps the listeners of a dropped one without the server subscription. */
139
+ private async openSubscriber(): Promise<RedisClient> {
140
+ try {
141
+ const subscriber = this.createClient();
142
+ subscriber.onclose = (error) => {
143
+ if (this.subscriber !== subscriber) return;
144
+ this.subscriber = undefined;
145
+ this.active.clear();
146
+ this.report(error);
147
+ };
148
+ try {
149
+ await subscriber.connect();
150
+ } catch (error) {
151
+ subscriber.close();
152
+ throw error;
153
+ }
154
+ this.subscriber = subscriber;
155
+ return subscriber;
156
+ } finally {
157
+ this.opening = undefined;
158
+ }
159
+ }
160
+
161
+ private report(reason: unknown): void {
162
+ if (!this.closed) this.lostListener?.(reason);
163
+ }
62
164
  }
@@ -8,6 +8,10 @@
8
8
  "apps/api/src/config/redis.connection.ts",
9
9
  "apps/api/src/health/readiness.service.ts"
10
10
  ],
11
+ "requiredProjectContents": {
12
+ "apps/api/src/core/services.ts": ["supervise(name: string, connection: SupervisedConnection): void {"],
13
+ "apps/api/src/config/redis.connection.ts": ["export async function withReplyTimeout<T>("]
14
+ },
11
15
  "managedGlobs": [
12
16
  "apps/api/src/redis/**",
13
17
  "tests/api/redis.api.spec.ts"
@@ -76,11 +76,18 @@ export class EventStream {
76
76
  offer(data: string, id: string | undefined): void {
77
77
  if (this.closed) return;
78
78
  if (this.queue.length >= this.options.queueLimit) {
79
- this.queue.length = 0;
80
- this.resyncPending = true;
81
- } else {
82
- this.queue.push({ kind: "event", id: id ?? String(++this.sequence), data });
79
+ this.resync();
80
+ return;
83
81
  }
82
+ this.queue.push({ kind: "event", id: id ?? String(++this.sequence), data });
83
+ this.wake();
84
+ }
85
+
86
+ /** Drops the backlog and sends one `resync` frame, telling the client to reload its state. */
87
+ resync(): void {
88
+ if (this.closed) return;
89
+ this.queue.length = 0;
90
+ this.resyncPending = true;
84
91
  this.wake();
85
92
  }
86
93