create-nexwire 4.2.4 → 4.3.0

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 (113) hide show
  1. package/README.md +20 -8
  2. package/express/.env +1 -1
  3. package/express/.vscode/extensions.json +6 -0
  4. package/express/.vscode/settings.json +37 -26
  5. package/express/biome.json +99 -66
  6. package/express/package.json +31 -29
  7. package/express/src/config/database.ts +48 -29
  8. package/express/src/config/index.ts +83 -83
  9. package/express/src/config/openapi.ts +6 -24
  10. package/express/src/config/queue.ts +30 -30
  11. package/express/src/config/validate.ts +50 -40
  12. package/express/src/database/migrations/sqlite/0000_init.sql +47 -47
  13. package/express/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
  14. package/express/src/database/migrations/sqlite/meta/_journal.json +1 -1
  15. package/express/src/database/schema.ts +2 -2
  16. package/express/src/framework/cache/cache.ts +40 -8
  17. package/express/src/framework/circuit-breaker/cb.ts +88 -60
  18. package/express/src/framework/database/connection.ts +52 -27
  19. package/express/src/framework/events/dispatcher.ts +61 -49
  20. package/express/src/framework/facade.ts +46 -3
  21. package/express/src/framework/http/app.ts +28 -22
  22. package/express/src/framework/http/logger.ts +27 -16
  23. package/express/src/framework/http/openapi.ts +9 -11
  24. package/express/src/framework/modules/routes.ts +26 -21
  25. package/express/src/framework/notification/channels/broadcast.ts +15 -0
  26. package/express/src/framework/notification/channels/mail.ts +40 -0
  27. package/express/src/framework/notification/contracts.ts +29 -0
  28. package/express/src/framework/notification/index.ts +19 -94
  29. package/express/src/framework/notification/registry.ts +15 -0
  30. package/express/src/framework/providers/adapter.ts +27 -17
  31. package/express/src/framework/queue/queue.ts +43 -44
  32. package/express/src/framework/realtime/broadcast.ts +29 -5
  33. package/express/src/framework/realtime/socket.ts +149 -119
  34. package/express/src/framework/redis/client.ts +148 -151
  35. package/express/src/framework/runtime/server-adapter.ts +39 -34
  36. package/express/src/framework/scheduler/lock.ts +128 -116
  37. package/express/src/framework/server.ts +33 -49
  38. package/express/src/framework/session/session.ts +69 -14
  39. package/express/src/framework/storage/storage.ts +744 -785
  40. package/express/src/framework/support/mail.ts +15 -7
  41. package/express/src/providers/notification.ts +4 -0
  42. package/express/src/resources/src/components/Checkbox.vue +32 -29
  43. package/express/src/resources/src/components/Input.vue +243 -223
  44. package/express/src/resources/src/components/InputGroup.vue +147 -145
  45. package/express/src/resources/src/components/InputPasswordToggle.vue +229 -213
  46. package/express/src/resources/src/components/Modal.vue +71 -55
  47. package/express/src/resources/src/components/Refresh.vue +19 -0
  48. package/express/src/resources/src/helpers/assets.ts +51 -0
  49. package/express/src/resources/src/layouts/Layout/Header.vue +124 -105
  50. package/express/src/resources/src/main.ts +3 -1
  51. package/express/src/resources/src/plugins/gum.ts +1 -1
  52. package/express/src/resources/src/plugins/pulse.ts +157 -147
  53. package/express/src/resources/src/stores/auth.ts +184 -164
  54. package/express/src/resources/vite.config.ts +54 -36
  55. package/express/src/storage/database/nexwire.sqlite +0 -0
  56. package/express/src/storage/logs/app.log +0 -0
  57. package/express/src/storage/logs/fatal.log +0 -0
  58. package/express/src/types/global.d.ts +32 -26
  59. package/hono/.env +1 -1
  60. package/hono/.vscode/extensions.json +6 -0
  61. package/hono/.vscode/settings.json +37 -26
  62. package/hono/biome.json +25 -2
  63. package/hono/package.json +25 -23
  64. package/hono/src/config/database.ts +48 -29
  65. package/hono/src/config/index.ts +83 -83
  66. package/hono/src/config/openapi.ts +6 -24
  67. package/hono/src/config/queue.ts +30 -30
  68. package/hono/src/config/validate.ts +50 -40
  69. package/hono/src/database/migrations/sqlite/0000_init.sql +47 -47
  70. package/hono/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
  71. package/hono/src/database/migrations/sqlite/meta/_journal.json +1 -1
  72. package/hono/src/database/schema.ts +2 -2
  73. package/hono/src/framework/cache/cache.ts +40 -8
  74. package/hono/src/framework/circuit-breaker/cb.ts +88 -60
  75. package/hono/src/framework/database/connection.ts +52 -27
  76. package/hono/src/framework/events/dispatcher.ts +61 -49
  77. package/hono/src/framework/facade.ts +38 -1
  78. package/hono/src/framework/http/openapi.ts +7 -9
  79. package/hono/src/framework/notification/channels/broadcast.ts +15 -0
  80. package/hono/src/framework/notification/channels/mail.ts +40 -0
  81. package/hono/src/framework/notification/contracts.ts +29 -0
  82. package/hono/src/framework/notification/index.ts +19 -94
  83. package/hono/src/framework/notification/registry.ts +15 -0
  84. package/hono/src/framework/providers/adapter.ts +27 -17
  85. package/hono/src/framework/queue/queue.ts +43 -44
  86. package/hono/src/framework/realtime/broadcast.ts +29 -5
  87. package/hono/src/framework/realtime/socket.ts +217 -196
  88. package/hono/src/framework/redis/client.ts +148 -151
  89. package/hono/src/framework/scheduler/lock.ts +128 -116
  90. package/hono/src/framework/server.ts +27 -29
  91. package/hono/src/framework/session/session.ts +62 -14
  92. package/hono/src/framework/storage/storage.ts +744 -785
  93. package/hono/src/framework/support/mail.ts +56 -48
  94. package/hono/src/providers/notification.ts +4 -0
  95. package/hono/src/resources/src/components/Checkbox.vue +32 -29
  96. package/hono/src/resources/src/components/Input.vue +243 -234
  97. package/hono/src/resources/src/components/InputGroup.vue +147 -149
  98. package/hono/src/resources/src/components/InputPasswordToggle.vue +229 -222
  99. package/hono/src/resources/src/components/Modal.vue +71 -55
  100. package/hono/src/resources/src/components/Refresh.vue +19 -0
  101. package/hono/src/resources/src/helpers/assets.ts +51 -0
  102. package/hono/src/resources/src/layouts/Layout/Header.vue +124 -105
  103. package/hono/src/resources/src/main.ts +3 -1
  104. package/hono/src/resources/src/plugins/gum.ts +1 -1
  105. package/hono/src/resources/src/plugins/pulse.ts +157 -147
  106. package/hono/src/resources/src/stores/auth.ts +184 -164
  107. package/hono/src/resources/vite.config.ts +54 -36
  108. package/hono/src/storage/database/nexwire.sqlite +0 -0
  109. package/hono/src/storage/logs/app.log +0 -0
  110. package/hono/src/storage/logs/fatal.log +0 -0
  111. package/hono/src/types/global.d.ts +32 -26
  112. package/package.json +2 -2
  113. package/src/index.js +139 -14
@@ -8,46 +8,28 @@ import { appConfig } from "./app.js";
8
8
  * and the Scalar docs UI appearance.
9
9
  */
10
10
  export const openApiConfig = {
11
- /** OpenAPI spec version */
12
11
  version: "3.0.0",
13
-
14
- /** API title shown in docs and the /doc spec */
15
12
  title: `${appConfig.name} API`,
16
-
17
- /** API version shown in docs */
18
13
  apiVersion: "1.0.0",
19
-
20
- /** Optional description shown in the /doc spec */
21
14
  description: "",
22
15
 
23
16
  /** Scalar docs UI settings */
24
17
  scalar: {
25
- /** URL path to the OpenAPI JSON spec */
26
- specUrl: "/doc",
27
-
28
- /** URL path to the interactive docs UI */
29
- docsPath: "/api-docs",
30
-
31
- /** UI layout: "classic" or "modern" */
32
- layout: "classic" as const,
33
-
18
+ specUrl: "/doc", // URL path to the OpenAPI JSON spec
19
+ docsPath: "/api-docs", // URL path to the interactive docs UI
20
+ layout: "classic" as const, // UI layout: "classic" or "modern"
34
21
  /** UI theme: "default", "moon", "purple", "solarized", "bluePlanet", "fastify", "kepler", "mars", "nebula", "none" */
35
22
  theme: "moon" as const,
36
-
37
- /** HTML page title for the /api-docs page */
38
- pageTitle: `${appConfig.name} API`,
39
-
23
+ pageTitle: `${appConfig.name} API`, // HTML page title for the /api-docs page
40
24
  /** Favicon file (project-relative) served at /favicon.ico for the /api-docs page */
41
25
  favicon: "src/resources/src/assets/images/favicon/favicon.ico",
42
-
43
26
  /** Default HTTP client shown in code examples */
44
27
  defaultHttpClient: {
45
28
  targetKey: "js" as const,
46
29
  clientKey: "fetch" as const
47
30
  },
48
-
49
- /** Expand all tags on load */
50
- defaultOpenAllTags: true
31
+ defaultOpenAllTags: true, // Expand all tags on load
32
+ showDeveloperTools: "never"
51
33
  }
52
34
  } as const;
53
35
 
@@ -1,30 +1,30 @@
1
- import { redisConfig } from "./redis.js";
2
-
3
- /**
4
- * Why: Queue workers, concurrency, and BullMQ key namespaces.
5
- * When: The queue facade bootstraps workers and the dashboard connects.
6
- * Where: src/config/queue.ts.
7
- * How: `queues` lists which queue names get a worker by default (default,
8
- * mail, maintenance). `concurrency` caps parallel jobs per worker.
9
- * Key prefixes derive from the shared `REDIS_PREFIX`.
10
- */
11
- export const queueConfig = {
12
- queues: ["default", "mail", "maintenance"],
13
- concurrency: 10,
14
- /**
15
- * autoPruneQueues
16
- * Why: Automatically deletes phantom queues (Redis keys for queue names that
17
- * no longer exist in `queues`) when a queue worker boots. Safe because
18
- * only empty, unconfigured queues are removed, and BullMQ recreates a
19
- * queue's keys on demand if code still dispatches to it.
20
- * When: Set false to disable automatic pruning and rely on manual
21
- * `queue:clear` instead.
22
- */
23
- autoPruneQueues: true,
24
- prefix: `${redisConfig.prefix}:queue`,
25
- durablePrefix: `${redisConfig.prefix}:durable`,
26
- queueUi: "/queues",
27
- allowedEmails: ""
28
- };
29
-
30
- export type QueueConfig = typeof queueConfig;
1
+ import { redisConfig } from "./redis.js";
2
+
3
+ /**
4
+ * Why: Queue workers, concurrency, and BullMQ key namespaces.
5
+ * When: The queue facade bootstraps workers and the dashboard connects.
6
+ * Where: src/config/queue.ts.
7
+ * How: `queues` lists which queue names get a worker by default (default,
8
+ * mail, maintenance). `concurrency` caps parallel jobs per worker.
9
+ * Key prefixes derive from the shared `REDIS_PREFIX`.
10
+ */
11
+ export const queueConfig = {
12
+ queues: ["default", "mail", "maintenance"],
13
+ concurrency: 10,
14
+ /**
15
+ * autoPruneQueues
16
+ * Why: Automatically deletes phantom queues (Redis keys for queue names that
17
+ * no longer exist in `queues`) when a queue worker boots. Safe because
18
+ * only empty, unconfigured queues are removed, and BullMQ recreates a
19
+ * queue's keys on demand if code still dispatches to it.
20
+ * When: Set false to disable automatic pruning and rely on manual
21
+ * `queue:clear` instead.
22
+ */
23
+ autoPruneQueues: true,
24
+ prefix: `${redisConfig.prefix}:queue`,
25
+ durablePrefix: `${redisConfig.prefix}:durable`,
26
+ queueUi: "/queues",
27
+ allowedEmails: "" // Comma-separated list of email addresses allowed to access the queue UI. Empty means no restriction.
28
+ };
29
+
30
+ export type QueueConfig = typeof queueConfig;
@@ -1,40 +1,50 @@
1
- import { databaseConfig } from "./database.js";
2
- import { mailConfig } from "./mail.js";
3
- import { redisConfig } from "./redis.js";
4
- import { storageConfig } from "./storage.js";
5
-
6
- /**
7
- * Why: Validates that resolved configs are internally consistent at startup.
8
- * When: Called before any framework subsystem initializes.
9
- * Where: kernel.ts, before storage.init() and initRedis().
10
- * How: Checks cross-config dependencies and throws clear errors for inconsistencies.
11
- */
12
- export function validateConfig(): void {
13
- if (!databaseConfig.url) {
14
- throw new Error("DATABASE_URL is required but is empty");
15
- }
16
-
17
- if (storageConfig.driver === "s3") {
18
- if (!storageConfig.bucket) {
19
- throw new Error("STORAGE_BUCKET is required when storage driver is s3");
20
- }
21
- if (!storageConfig.accessKeyId || !storageConfig.secretAccessKey) {
22
- throw new Error("STORAGE_ACCESS_KEY_ID and STORAGE_SECRET_ACCESS_KEY are required when storage driver is s3");
23
- }
24
- }
25
-
26
- if (redisConfig.enabled) {
27
- try {
28
- const url = new URL(redisConfig.url);
29
- if (!url.hostname || url.port === "") {
30
- throw new Error(`REDIS_URL is invalid: "${redisConfig.url}" — must include host and port`);
31
- }
32
- } catch (error) {
33
- throw new Error(`REDIS_URL is invalid: "${redisConfig.url}" — ${error instanceof Error ? error.message : error}`);
34
- }
35
- }
36
-
37
- if (!mailConfig.failSilent && !mailConfig.username) {
38
- throw new Error("MAIL_USERNAME is required when MAIL_FAIL_SILENT is false");
39
- }
40
- }
1
+ import { databaseConfig } from "./database.js";
2
+ import { mailConfig } from "./mail.js";
3
+ import { redisConfig } from "./redis.js";
4
+ import { storageConfig } from "./storage.js";
5
+
6
+ /**
7
+ * Why: Validates that resolved configs are internally consistent at startup.
8
+ * When: Called before any framework subsystem initializes.
9
+ * Where: kernel.ts, before storage.init() and initRedis().
10
+ * How: Checks cross-config dependencies and throws clear errors for inconsistencies.
11
+ */
12
+ export function validateConfig(): void {
13
+ if (!databaseConfig.url) {
14
+ throw new Error("DATABASE_URL is required but is empty");
15
+ }
16
+
17
+ if (storageConfig.driver === "s3") {
18
+ if (!storageConfig.bucket) {
19
+ throw new Error("STORAGE_BUCKET is required when storage driver is s3");
20
+ }
21
+ if (!storageConfig.accessKeyId || !storageConfig.secretAccessKey) {
22
+ throw new Error("STORAGE_ACCESS_KEY_ID and STORAGE_SECRET_ACCESS_KEY are required when storage driver is s3");
23
+ }
24
+ }
25
+
26
+ if (redisConfig.enabled) {
27
+ try {
28
+ const url = new URL(redisConfig.url);
29
+
30
+ // Ensure it starts with redis:// or rediss://
31
+ if (url.protocol !== "redis:" && url.protocol !== "rediss:") {
32
+ throw new Error("URL must start with redis:// or rediss://");
33
+ }
34
+
35
+ // Hostname is mandatory (e.g., localhost, 127.0.0.1, redis-container)
36
+ if (!url.hostname) {
37
+ throw new Error("URL must include a valid hostname");
38
+ }
39
+
40
+ // Note: We do NOT check if the port is empty.
41
+ // ioredis automatically defaults to 6379 if no port is provided.
42
+ } catch (error) {
43
+ throw new Error(`REDIS_URL is invalid: "${redisConfig.url}" — ${error instanceof Error ? error.message : error}`);
44
+ }
45
+ }
46
+
47
+ if (!mailConfig.failSilent && !mailConfig.username) {
48
+ throw new Error("MAIL_USERNAME is required when MAIL_FAIL_SILENT is false");
49
+ }
50
+ }
@@ -1,48 +1,48 @@
1
- CREATE TABLE `roles` (
2
- `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3
- `name` text NOT NULL,
4
- `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL,
5
- `updated_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL
6
- );
7
- --> statement-breakpoint
8
- CREATE UNIQUE INDEX `roles_name_unique` ON `roles` (`name`);--> statement-breakpoint
9
- CREATE TABLE `email_verification_tokens` (
10
- `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
11
- `email` text NOT NULL,
12
- `token` text NOT NULL,
13
- `expires_at` integer NOT NULL,
14
- `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL
15
- );
16
- --> statement-breakpoint
17
- CREATE TABLE `password_reset_tokens` (
18
- `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
19
- `email` text NOT NULL,
20
- `token` text NOT NULL,
21
- `expires_at` integer NOT NULL,
22
- `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL
23
- );
24
- --> statement-breakpoint
25
- CREATE TABLE `refresh_tokens` (
26
- `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
27
- `user_id` integer,
28
- `jti` text NOT NULL,
29
- `revoked` integer DEFAULT false,
30
- `expires_at` integer NOT NULL,
31
- `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL,
32
- FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE set null
33
- );
34
- --> statement-breakpoint
35
- CREATE UNIQUE INDEX `refresh_tokens_jti_unique` ON `refresh_tokens` (`jti`);--> statement-breakpoint
36
- CREATE TABLE `users` (
37
- `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
38
- `name` text NOT NULL,
39
- `email` text NOT NULL,
40
- `password` text NOT NULL,
41
- `role_id` integer,
42
- `email_verified_at` integer,
43
- `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL,
44
- `updated_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL,
45
- FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE cascade ON DELETE set null
46
- );
47
- --> statement-breakpoint
1
+ CREATE TABLE `roles` (
2
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3
+ `name` text NOT NULL,
4
+ `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL,
5
+ `updated_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL
6
+ );
7
+ --> statement-breakpoint
8
+ CREATE UNIQUE INDEX `roles_name_unique` ON `roles` (`name`);--> statement-breakpoint
9
+ CREATE TABLE `email_verification_tokens` (
10
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
11
+ `email` text NOT NULL,
12
+ `token` text NOT NULL,
13
+ `expires_at` integer NOT NULL,
14
+ `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL
15
+ );
16
+ --> statement-breakpoint
17
+ CREATE TABLE `password_reset_tokens` (
18
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
19
+ `email` text NOT NULL,
20
+ `token` text NOT NULL,
21
+ `expires_at` integer NOT NULL,
22
+ `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL
23
+ );
24
+ --> statement-breakpoint
25
+ CREATE TABLE `refresh_tokens` (
26
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
27
+ `user_id` integer,
28
+ `jti` text NOT NULL,
29
+ `revoked` integer DEFAULT false,
30
+ `expires_at` integer NOT NULL,
31
+ `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL,
32
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE set null
33
+ );
34
+ --> statement-breakpoint
35
+ CREATE UNIQUE INDEX `refresh_tokens_jti_unique` ON `refresh_tokens` (`jti`);--> statement-breakpoint
36
+ CREATE TABLE `users` (
37
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
38
+ `name` text NOT NULL,
39
+ `email` text NOT NULL,
40
+ `password` text NOT NULL,
41
+ `role_id` integer,
42
+ `email_verified_at` integer,
43
+ `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL,
44
+ `updated_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL,
45
+ FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE cascade ON DELETE set null
46
+ );
47
+ --> statement-breakpoint
48
48
  CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": "6",
3
3
  "dialect": "sqlite",
4
- "id": "f0fe475e-8a3e-4c5c-9b5e-9ec41ddfde71",
4
+ "id": "af502348-be37-45c6-84e4-df4db9f31bfb",
5
5
  "prevId": "00000000-0000-0000-0000-000000000000",
6
6
  "tables": {
7
7
  "roles": {
@@ -5,7 +5,7 @@
5
5
  {
6
6
  "idx": 0,
7
7
  "version": "6",
8
- "when": 1792936160057,
8
+ "when": 1793719238470,
9
9
  "tag": "0000_init",
10
10
  "breakpoints": true
11
11
  }
@@ -1,2 +1,2 @@
1
- export * from "../modules/auth/database/models/role.js";
2
- export * from "../modules/auth/database/models/user.js";
1
+ export * from "../modules/auth/database/models/role.js";
2
+ export * from "../modules/auth/database/models/user.js";
@@ -1,10 +1,18 @@
1
1
  import { cacheConfig, redisConfig } from "@/config/index.js";
2
+ import { createCircuitBreaker, isCircuitBreakerError } from "@/framework/circuit-breaker/cb.js";
2
3
  import { redisClientIfReady } from "@/framework/redis/client.js";
3
4
 
4
5
  function cacheKey(key: string) {
5
6
  return `${cacheConfig.keyPrefix}:${key}`;
6
7
  }
7
8
 
9
+ // Initialize the circuit breaker specifically for Cache operations
10
+ const cacheBreaker = createCircuitBreaker({
11
+ name: "Redis Cache",
12
+ failureThreshold: 3,
13
+ resetTimeout: 30000
14
+ });
15
+
8
16
  /**
9
17
  * Why: Provides Redis-backed cache helpers with graceful fallback behavior.
10
18
  * When: Controllers/jobs need temporary computed data caching.
@@ -20,18 +28,35 @@ export const cache = {
20
28
  */
21
29
  async get<T>(key: string, fallback: T | null = null) {
22
30
  const client = redisClientIfReady();
31
+ // If the client isn't connected at all, fail fast to fallback
23
32
  if (!client) return fallback;
24
33
 
25
- const value = await client.get(cacheKey(key));
26
- return value ? (JSON.parse(value) as T) : fallback;
34
+ try {
35
+ return await cacheBreaker.exec(async () => {
36
+ const value = await client.get(cacheKey(key));
37
+ return value ? (JSON.parse(value) as T) : fallback;
38
+ });
39
+ } catch (error) {
40
+ // If the circuit is open, or a Redis error occurs, return the fallback safely
41
+ if (isCircuitBreakerError(error)) return fallback;
42
+ return fallback;
43
+ }
27
44
  },
28
45
 
29
46
  async set(key: string, value: unknown, ttl = cacheConfig.ttlSeconds) {
30
47
  const client = redisClientIfReady();
31
48
  if (!client) return false;
32
49
 
33
- await client.set(cacheKey(key), JSON.stringify(value), "EX", ttl);
34
- return true;
50
+ try {
51
+ return await cacheBreaker.exec(async () => {
52
+ await client.set(cacheKey(key), JSON.stringify(value), "EX", ttl);
53
+ return true;
54
+ });
55
+ } catch (error) {
56
+ // If the circuit is open, or a Redis error occurs, silently fail
57
+ if (isCircuitBreakerError(error)) return false;
58
+ return false;
59
+ }
35
60
  },
36
61
 
37
62
  /**
@@ -51,8 +76,15 @@ export const cache = {
51
76
  const client = redisClientIfReady();
52
77
  if (!client) return false;
53
78
 
54
- await client.del(cacheKey(key));
55
- return true;
79
+ try {
80
+ return await cacheBreaker.exec(async () => {
81
+ await client.del(cacheKey(key));
82
+ return true;
83
+ });
84
+ } catch (error) {
85
+ if (isCircuitBreakerError(error)) return false;
86
+ return false;
87
+ }
56
88
  },
57
89
 
58
90
  /**
@@ -66,7 +98,7 @@ export const cache = {
66
98
  if (cached !== null) return cached;
67
99
 
68
100
  const fresh = await callback();
69
- await this.put(key, fresh, ttl);
101
+ await this.set(key, fresh, ttl);
70
102
  return fresh;
71
103
  },
72
104
 
@@ -77,6 +109,6 @@ export const cache = {
77
109
  * How: Confirms REDIS flag and ready Redis client instance.
78
110
  */
79
111
  isAvailable() {
80
- return redisConfig.enabled && redisClientIfReady() !== null;
112
+ return redisConfig.enabled && redisClientIfReady() !== null && cacheBreaker.getState() !== "open";
81
113
  }
82
114
  };
@@ -1,60 +1,88 @@
1
- export class CircuitBreaker {
2
- private state: "closed" | "open" | "half-open" = "closed";
3
- private failureCount = 0;
4
- private readonly failureThreshold = 3;
5
- private readonly resetTimeout = 30000;
6
- private lastFailureTime = 0;
7
- private successCount = 0;
8
- private readonly successThreshold = 1;
9
-
10
- async exec<T>(fn: () => Promise<T>): Promise<T> {
11
- if (this.state === "open") {
12
- if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
13
- this.state = "half-open";
14
- this.successCount = 0;
15
- } else {
16
- throw new Error("Circuit breaker is open");
17
- }
18
- }
19
- try {
20
- const result = await fn();
21
- this.onSuccess();
22
- return result;
23
- } catch (error) {
24
- this.onFailure();
25
- throw error;
26
- }
27
- }
28
-
29
- getState(): "closed" | "open" | "half-open" {
30
- return this.state;
31
- }
32
-
33
- onSuccess(): void {
34
- if (this.state === "half-open") {
35
- this.successCount++;
36
- if (this.successCount >= this.successThreshold) {
37
- this.reset();
38
- }
39
- }
40
- this.failureCount = 0;
41
- }
42
-
43
- onFailure(): void {
44
- this.failureCount++;
45
- if (this.failureCount >= this.failureThreshold || this.state === "half-open") {
46
- this.trip();
47
- }
48
- }
49
-
50
- trip(): void {
51
- this.state = "open";
52
- this.lastFailureTime = Date.now();
53
- }
54
-
55
- reset(): void {
56
- this.state = "closed";
57
- this.failureCount = 0;
58
- this.successCount = 0;
59
- }
60
- }
1
+ // Factory for the error
2
+ export function createCircuitBreakerError(serviceName: string) {
3
+ const error = new Error(`Circuit breaker is open for ${serviceName}. Service is temporarily unavailable.`);
4
+ // Attach a custom property to identify it
5
+ Object.assign(error, { name: "CircuitBreakerOpenError", isCircuitBreakerError: true });
6
+ return error;
7
+ }
8
+
9
+ // Functional Type Guard
10
+ export function isCircuitBreakerError(error: unknown): error is Error {
11
+ return error instanceof Error && (error as any).isCircuitBreakerError === true;
12
+ }
13
+
14
+ export interface CircuitBreakerOptions {
15
+ name?: string;
16
+ failureThreshold?: number;
17
+ resetTimeout?: number;
18
+ successThreshold?: number;
19
+ }
20
+
21
+ export function createCircuitBreaker({
22
+ name = "Unknown Service",
23
+ failureThreshold = 3,
24
+ resetTimeout = 30000,
25
+ successThreshold = 1
26
+ }: CircuitBreakerOptions = {}) {
27
+ let state: "closed" | "open" | "half-open" = "closed";
28
+ let failureCount = 0;
29
+ let successCount = 0;
30
+ let lastFailureTime = 0;
31
+ let isHalfOpenTestRunning = false;
32
+
33
+ function trip() {
34
+ state = "open";
35
+ lastFailureTime = Date.now();
36
+ }
37
+
38
+ function reset() {
39
+ state = "closed";
40
+ failureCount = 0;
41
+ successCount = 0;
42
+ }
43
+
44
+ return {
45
+ async exec<T>(fn: () => Promise<T>): Promise<T> {
46
+ if (state === "open") {
47
+ if (Date.now() - lastFailureTime >= resetTimeout) {
48
+ state = "half-open";
49
+ successCount = 0;
50
+ isHalfOpenTestRunning = false;
51
+ } else {
52
+ throw createCircuitBreakerError(name);
53
+ }
54
+ }
55
+
56
+ if (state === "half-open") {
57
+ if (isHalfOpenTestRunning) {
58
+ throw createCircuitBreakerError(name);
59
+ }
60
+ isHalfOpenTestRunning = true;
61
+ }
62
+
63
+ try {
64
+ const result = await fn();
65
+ if (state === "half-open") {
66
+ successCount++;
67
+ if (successCount >= successThreshold) reset();
68
+ }
69
+ failureCount = 0;
70
+ return result;
71
+ } catch (error) {
72
+ failureCount++;
73
+ if (failureCount >= failureThreshold || state === "half-open") {
74
+ trip();
75
+ }
76
+ throw error;
77
+ } finally {
78
+ if (state === "half-open") isHalfOpenTestRunning = false;
79
+ }
80
+ },
81
+
82
+ getState() {
83
+ return state;
84
+ }
85
+ };
86
+ }
87
+
88
+ export type CircuitBreaker = ReturnType<typeof createCircuitBreaker>;