create-nexwire 4.2.5 → 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 (85) hide show
  1. package/README.md +20 -8
  2. package/express/.env +1 -1
  3. package/express/package.json +1 -1
  4. package/express/src/config/database.ts +48 -29
  5. package/express/src/config/index.ts +83 -83
  6. package/express/src/config/openapi.ts +6 -24
  7. package/express/src/config/queue.ts +30 -30
  8. package/express/src/config/validate.ts +50 -40
  9. package/express/src/database/migrations/sqlite/0000_init.sql +47 -47
  10. package/express/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
  11. package/express/src/database/migrations/sqlite/meta/_journal.json +1 -1
  12. package/express/src/framework/cache/cache.ts +40 -8
  13. package/express/src/framework/circuit-breaker/cb.ts +88 -60
  14. package/express/src/framework/database/connection.ts +52 -27
  15. package/express/src/framework/events/dispatcher.ts +61 -49
  16. package/express/src/framework/facade.ts +46 -3
  17. package/express/src/framework/http/app.ts +28 -22
  18. package/express/src/framework/http/logger.ts +27 -16
  19. package/express/src/framework/http/openapi.ts +9 -11
  20. package/express/src/framework/modules/routes.ts +26 -21
  21. package/express/src/framework/notification/channels/broadcast.ts +15 -0
  22. package/express/src/framework/notification/channels/mail.ts +40 -0
  23. package/express/src/framework/notification/contracts.ts +29 -0
  24. package/express/src/framework/notification/index.ts +19 -94
  25. package/express/src/framework/notification/registry.ts +15 -0
  26. package/express/src/framework/providers/adapter.ts +27 -17
  27. package/express/src/framework/queue/queue.ts +43 -44
  28. package/express/src/framework/realtime/broadcast.ts +29 -5
  29. package/express/src/framework/realtime/socket.ts +149 -119
  30. package/express/src/framework/redis/client.ts +148 -151
  31. package/express/src/framework/runtime/server-adapter.ts +39 -34
  32. package/express/src/framework/scheduler/lock.ts +128 -116
  33. package/express/src/framework/server.ts +33 -49
  34. package/express/src/framework/session/session.ts +69 -14
  35. package/express/src/framework/storage/storage.ts +744 -785
  36. package/express/src/framework/support/mail.ts +15 -7
  37. package/express/src/providers/notification.ts +4 -0
  38. package/express/src/resources/src/layouts/Layout/Header.vue +124 -105
  39. package/express/src/resources/src/plugins/pulse.ts +157 -147
  40. package/express/src/resources/vite.config.ts +54 -43
  41. package/express/src/storage/database/nexwire.sqlite +0 -0
  42. package/express/src/storage/logs/app.log +0 -0
  43. package/express/src/storage/logs/fatal.log +0 -0
  44. package/express/src/types/global.d.ts +32 -26
  45. package/hono/.env +1 -1
  46. package/hono/package.json +1 -1
  47. package/hono/src/config/database.ts +48 -29
  48. package/hono/src/config/index.ts +83 -83
  49. package/hono/src/config/openapi.ts +6 -24
  50. package/hono/src/config/queue.ts +30 -30
  51. package/hono/src/config/validate.ts +50 -40
  52. package/hono/src/database/migrations/sqlite/0000_init.sql +47 -47
  53. package/hono/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
  54. package/hono/src/database/migrations/sqlite/meta/_journal.json +1 -1
  55. package/hono/src/framework/cache/cache.ts +40 -8
  56. package/hono/src/framework/circuit-breaker/cb.ts +88 -60
  57. package/hono/src/framework/database/connection.ts +52 -27
  58. package/hono/src/framework/events/dispatcher.ts +61 -49
  59. package/hono/src/framework/facade.ts +38 -1
  60. package/hono/src/framework/http/openapi.ts +7 -9
  61. package/hono/src/framework/notification/channels/broadcast.ts +15 -0
  62. package/hono/src/framework/notification/channels/mail.ts +40 -0
  63. package/hono/src/framework/notification/contracts.ts +29 -0
  64. package/hono/src/framework/notification/index.ts +19 -94
  65. package/hono/src/framework/notification/registry.ts +15 -0
  66. package/hono/src/framework/providers/adapter.ts +27 -17
  67. package/hono/src/framework/queue/queue.ts +43 -44
  68. package/hono/src/framework/realtime/broadcast.ts +29 -5
  69. package/hono/src/framework/realtime/socket.ts +217 -196
  70. package/hono/src/framework/redis/client.ts +148 -151
  71. package/hono/src/framework/scheduler/lock.ts +128 -116
  72. package/hono/src/framework/server.ts +27 -29
  73. package/hono/src/framework/session/session.ts +62 -14
  74. package/hono/src/framework/storage/storage.ts +744 -785
  75. package/hono/src/framework/support/mail.ts +56 -48
  76. package/hono/src/providers/notification.ts +4 -0
  77. package/hono/src/resources/src/layouts/Layout/Header.vue +124 -105
  78. package/hono/src/resources/src/plugins/pulse.ts +157 -147
  79. package/hono/src/resources/vite.config.ts +54 -43
  80. package/hono/src/storage/database/nexwire.sqlite +0 -0
  81. package/hono/src/storage/logs/app.log +0 -0
  82. package/hono/src/storage/logs/fatal.log +0 -0
  83. package/hono/src/types/global.d.ts +32 -26
  84. package/package.json +2 -2
  85. package/src/index.js +139 -14
@@ -1,151 +1,148 @@
1
- import { Redis } from "ioredis";
2
- import { redisConfig } from "@/config/index.js";
3
- import { CircuitBreaker } from "@/framework/circuit-breaker/cb.js";
4
- import { logger } from "@/framework/support/logger.js";
5
-
6
- export type redis = Redis;
7
-
8
- let client: redis | null = null;
9
- let ready = false;
10
- let lastError: string | null = null;
11
- let redisBreaker: CircuitBreaker | null = null;
12
-
13
- if (redisConfig.enabled) {
14
- redisBreaker = new CircuitBreaker();
15
- }
16
-
17
- function safeRedisUrl() {
18
- try {
19
- const url = new URL(redisConfig.url);
20
- if (url.password) url.password = "***";
21
- return url.toString();
22
- } catch {
23
- return redisConfig.url;
24
- }
25
- }
26
-
27
- /**
28
- * Why: Initializes shared Redis connection for cache/queue/events/session.
29
- * When: Runtime bootstrap before Redis-backed features are used.
30
- * Where: Server, worker, scheduler startup paths.
31
- * How: Connects once, tracks readiness, and degrades gracefully if unavailable.
32
- */
33
- export async function initRedis() {
34
- if (client) return client;
35
-
36
- if (!redisConfig.enabled) {
37
- ready = false;
38
- lastError = "redis disabled in config";
39
- logger.info("Redis disabled; cache, session storage, queue, events, and cockpit unavailable");
40
- return null;
41
- }
42
-
43
- if (redisBreaker?.getState() === "open") {
44
- lastError = "redis circuit breaker is open";
45
- logger.warn("Redis circuit breaker is open; skipping connection");
46
- return null;
47
- }
48
-
49
- try {
50
- client = new Redis(redisConfig.url, {
51
- lazyConnect: true,
52
- enableReadyCheck: true,
53
- maxRetriesPerRequest: 0,
54
- retryStrategy: () => null
55
- });
56
-
57
- client.on("error", (error) => {
58
- lastError = error.message;
59
- if (ready) {
60
- logger.warn("Redis connection error", {
61
- redisUrl: safeRedisUrl(),
62
- error: error.message
63
- });
64
- }
65
- });
66
-
67
- await client.connect();
68
- await client.ping();
69
- ready = true;
70
- redisBreaker?.onSuccess();
71
- return client;
72
- } catch (error) {
73
- lastError ||= error instanceof Error ? error.message : String(error);
74
- client?.disconnect();
75
- client = null;
76
- ready = false;
77
- redisBreaker?.onFailure();
78
- logger.warn(`Redis unavailable; continuing without Redis features (${lastError})`);
79
- return null;
80
- }
81
- }
82
-
83
- /**
84
- * Why: Returns the current Redis client instance.
85
- * When: Internal helpers need direct Redis operations.
86
- * Where: Cache/session/queue/events modules.
87
- * How: Returns nullable singleton without creating a new connection.
88
- */
89
- export function redis() {
90
- return client;
91
- }
92
-
93
- /**
94
- * Why: Indicates whether Redis is connected and usable.
95
- * When: Features need to branch on Redis availability.
96
- * Where: Cache/session/queue/event internals.
97
- * How: Combines readiness flag with client presence.
98
- */
99
- export function redisReady() {
100
- return ready && client !== null;
101
- }
102
-
103
- /**
104
- * Why: Provides a single ready-checked Redis client accessor.
105
- * When: Redis-backed modules need quick availability guard.
106
- * Where: Cache/session/queue/events/BullBoard internals.
107
- * How: Returns connected client or null when Redis is unavailable.
108
- */
109
- export function redisClientIfReady() {
110
- if (redisBreaker?.getState() === "open") return null;
111
- return redisReady() ? client : null;
112
- }
113
-
114
- /**
115
- * Why: Exposes last Redis error for diagnostics.
116
- * When: Startup or health logs need failure detail.
117
- * Where: Runtime status output.
118
- * How: Returns cached error message string.
119
- */
120
- export function redisError() {
121
- return lastError;
122
- }
123
-
124
- /**
125
- * Why: Returns the Redis circuit breaker instance or null when disabled.
126
- * When: Health checks and diagnostics need breaker state.
127
- * Where: Runtime status and monitoring paths.
128
- * How: Returns the breaker if Redis is enabled, null otherwise.
129
- */
130
- export function getRedisBreaker() {
131
- return redisBreaker;
132
- }
133
-
134
- /**
135
- * Why: Closes Redis connection during graceful shutdown.
136
- * When: Process stop hooks for server/worker/scheduler.
137
- * Where: Runtime lifecycle files.
138
- * How: Attempts quit, falls back to disconnect, then resets state.
139
- */
140
- export async function closeRedis() {
141
- if (!client) return;
142
-
143
- try {
144
- await client.quit();
145
- } catch {
146
- client.disconnect();
147
- } finally {
148
- client = null;
149
- ready = false;
150
- }
151
- }
1
+ import { Redis } from "ioredis";
2
+ import { redisConfig } from "@/config/index.js";
3
+ import { createCircuitBreaker, isCircuitBreakerError } from "@/framework/circuit-breaker/cb.js";
4
+ import { logger } from "@/framework/support/logger.js";
5
+
6
+ export type redis = Redis;
7
+
8
+ let client: redis | null = null;
9
+ let ready = false;
10
+ let lastError: string | null = null;
11
+ let redisBreaker: any | null = null;
12
+
13
+ if (redisConfig.enabled) {
14
+ redisBreaker = createCircuitBreaker({ name: "Redis", failureThreshold: 3, resetTimeout: 30000 });
15
+ }
16
+
17
+ function safeRedisUrl() {
18
+ try {
19
+ const url = new URL(redisConfig.url);
20
+ if (url.password) url.password = "***";
21
+ return url.toString();
22
+ } catch {
23
+ return redisConfig.url;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Why: Initializes shared Redis connection for cache/queue/events/session.
29
+ * When: Runtime bootstrap before Redis-backed features are used.
30
+ * Where: Server, worker, scheduler startup paths.
31
+ * How: Connects once, tracks readiness, and degrades gracefully if unavailable.
32
+ */
33
+ export async function initRedis() {
34
+ if (client) return client;
35
+
36
+ if (!redisConfig.enabled) {
37
+ ready = false;
38
+ lastError = "redis disabled in config";
39
+ logger.debug("Redis disabled; cache, session storage, queue, events, and cockpit unavailable");
40
+ return null;
41
+ }
42
+
43
+ try {
44
+ return await redisBreaker.exec(async () => {
45
+ client = new Redis(redisConfig.url, {
46
+ lazyConnect: true,
47
+ enableReadyCheck: true,
48
+ maxRetriesPerRequest: 0,
49
+ retryStrategy: () => null
50
+ });
51
+
52
+ client.on("error", (error) => {
53
+ lastError = error.message;
54
+ if (ready) logger.warn("Redis connection error", { redisUrl: safeRedisUrl(), error: error.message });
55
+ });
56
+
57
+ await client.connect();
58
+ await client.ping();
59
+
60
+ ready = true;
61
+ logger.debug("Redis connected successfully");
62
+ return client;
63
+ });
64
+ } catch (error) {
65
+ // Use the functional guard
66
+ if (isCircuitBreakerError(error)) {
67
+ lastError = error.message;
68
+ logger.warn("Redis initialization blocked by circuit breaker");
69
+ } else {
70
+ lastError = error instanceof Error ? error.message : String(error);
71
+ logger.warn(`Redis unavailable; continuing without Redis features (${lastError})`);
72
+ }
73
+
74
+ client?.disconnect();
75
+ client = null;
76
+ ready = false;
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Why: Returns the current Redis client instance.
83
+ * When: Internal helpers need direct Redis operations.
84
+ * Where: Cache/session/queue/events modules.
85
+ * How: Returns nullable singleton without creating a new connection.
86
+ */
87
+ export function redis() {
88
+ return client;
89
+ }
90
+
91
+ /**
92
+ * Why: Indicates whether Redis is connected and usable.
93
+ * When: Features need to branch on Redis availability.
94
+ * Where: Cache/session/queue/event internals.
95
+ * How: Combines readiness flag with client presence.
96
+ */
97
+ export function redisReady() {
98
+ return ready && client !== null;
99
+ }
100
+
101
+ /**
102
+ * Why: Provides a single ready-checked Redis client accessor.
103
+ * When: Redis-backed modules need quick availability guard.
104
+ * Where: Cache/session/queue/events/BullBoard internals.
105
+ * How: Returns connected client or null when Redis is unavailable.
106
+ */
107
+ export function redisClientIfReady() {
108
+ return redisReady() ? client : null;
109
+ }
110
+
111
+ /**
112
+ * Why: Exposes last Redis error for diagnostics.
113
+ * When: Startup or health logs need failure detail.
114
+ * Where: Runtime status output.
115
+ * How: Returns cached error message string.
116
+ */
117
+ export function redisError() {
118
+ return lastError;
119
+ }
120
+
121
+ /**
122
+ * Why: Returns the Redis circuit breaker instance or null when disabled.
123
+ * When: Health checks and diagnostics need breaker state.
124
+ * Where: Runtime status and monitoring paths.
125
+ * How: Returns the breaker if Redis is enabled, null otherwise.
126
+ */
127
+ export function getRedisBreaker() {
128
+ return redisBreaker;
129
+ }
130
+
131
+ /**
132
+ * Why: Closes Redis connection during graceful shutdown.
133
+ * When: Process stop hooks for server/worker/scheduler.
134
+ * Where: Runtime lifecycle files.
135
+ * How: Attempts quit, falls back to disconnect, then resets state.
136
+ */
137
+ export async function closeRedis() {
138
+ if (!client) return;
139
+
140
+ try {
141
+ await client.quit();
142
+ } catch {
143
+ client.disconnect();
144
+ } finally {
145
+ client = null;
146
+ ready = false;
147
+ }
148
+ }
@@ -1,116 +1,128 @@
1
- import { randomUUID } from "node:crypto";
2
- import { redisConfig } from "@/config/index.js";
3
- import { databaseDialect, databasePool } from "@/framework/database/connection.js";
4
- import { redis, redisReady } from "@/framework/redis/client.js";
5
-
6
- type LockOptions = { ttlMs?: number; tableName?: string };
7
-
8
- function table(name?: string) {
9
- return name || "scheduler_locks";
10
- }
11
-
12
- async function ensureTable(tableName?: string) {
13
- const pool = databasePool();
14
- const dialect = databaseDialect();
15
- const lockTable = table(tableName);
16
-
17
- if (dialect === "mysql") {
18
- await pool.query(
19
- `CREATE TABLE IF NOT EXISTS \`${lockTable}\` (name VARCHAR(191) PRIMARY KEY, owner VARCHAR(191) NOT NULL, expires_at BIGINT NOT NULL)`
20
- );
21
- return;
22
- }
23
-
24
- if (dialect === "postgresql") {
25
- await pool.unsafe(
26
- `CREATE TABLE IF NOT EXISTS "${lockTable}" (name VARCHAR(191) PRIMARY KEY, owner VARCHAR(191) NOT NULL, expires_at BIGINT NOT NULL)`
27
- );
28
- return;
29
- }
30
-
31
- await pool.execute(`CREATE TABLE IF NOT EXISTS "${lockTable}" (name TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL)`);
32
- }
33
-
34
- async function acquireDbLock(name: string, owner: string, ttlMs: number, tableName?: string) {
35
- await ensureTable(tableName);
36
- const pool = databasePool();
37
- const dialect = databaseDialect();
38
- const lockTable = table(tableName);
39
- const now = Date.now();
40
- const expiresAt = now + ttlMs;
41
-
42
- if (dialect === "postgresql") {
43
- const rows = await pool.unsafe(
44
- `INSERT INTO "${lockTable}" (name, owner, expires_at) VALUES ($1, $2, $3) ON CONFLICT (name) DO UPDATE SET owner = EXCLUDED.owner, expires_at = EXCLUDED.expires_at WHERE "${lockTable}".expires_at < $4 RETURNING owner`,
45
- [name, owner, expiresAt, now]
46
- );
47
- return rows?.[0]?.owner === owner;
48
- }
49
-
50
- if (dialect === "mysql") {
51
- await pool.query(
52
- `INSERT INTO \`${lockTable}\` (name, owner, expires_at) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE owner = IF(expires_at < ?, VALUES(owner), owner), expires_at = IF(expires_at < ?, VALUES(expires_at), expires_at)`,
53
- [name, owner, expiresAt, now, now]
54
- );
55
- const [rows] = await pool.query(`SELECT owner FROM \`${lockTable}\` WHERE name = ?`, [name]);
56
- return rows?.[0]?.owner === owner;
57
- }
58
-
59
- await pool.execute({
60
- sql: `INSERT INTO "${lockTable}" (name, owner, expires_at) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at WHERE "${lockTable}".expires_at < ?`,
61
- args: [name, owner, expiresAt, now]
62
- });
63
- const result = await pool.execute({
64
- sql: `SELECT owner FROM "${lockTable}" WHERE name = ?`,
65
- args: [name]
66
- });
67
- return result.rows?.[0]?.owner === owner;
68
- }
69
-
70
- async function releaseDbLock(name: string, owner: string, tableName?: string) {
71
- const pool = databasePool();
72
- const dialect = databaseDialect();
73
- const lockTable = table(tableName);
74
-
75
- if (dialect === "postgresql") return await pool.unsafe(`DELETE FROM "${lockTable}" WHERE name = $1 AND owner = $2`, [name, owner]);
76
- if (dialect === "mysql") return await pool.query(`DELETE FROM \`${lockTable}\` WHERE name = ? AND owner = ?`, [name, owner]);
77
- await pool.execute({
78
- sql: `DELETE FROM "${lockTable}" WHERE name = ? AND owner = ?`,
79
- args: [name, owner]
80
- });
81
- }
82
-
83
- /**
84
- * Why: Runs a handler with distributed lock to prevent duplicate execution.
85
- * When: Scheduled tasks may run across multiple processes/instances.
86
- * Where: Scheduler runtime around each cron job.
87
- * How: Uses Redis NX lock when available, otherwise falls back to DB lock table.
88
- */
89
- export async function runWithLock<T>(name: string, handler: () => Promise<T>, options: LockOptions = {}) {
90
- const owner = randomUUID();
91
- const ttlMs = options.ttlMs || 60_000;
92
- const client = redis();
93
-
94
- if (redisReady() && client) {
95
- const key = `${redisConfig.prefix}:lock:${name}`;
96
- const ok = await client.set(key, owner, "PX", ttlMs, "NX");
97
- if (!ok) return { ran: false, backend: "redis" as const };
98
-
99
- try {
100
- const result = await handler();
101
- return { ran: true, backend: "redis" as const, result };
102
- } finally {
103
- await client.del(key);
104
- }
105
- }
106
-
107
- const ok = await acquireDbLock(name, owner, ttlMs, options.tableName);
108
- if (!ok) return { ran: false, backend: "db" as const };
109
-
110
- try {
111
- const result = await handler();
112
- return { ran: true, backend: "db" as const, result };
113
- } finally {
114
- await releaseDbLock(name, owner, options.tableName);
115
- }
116
- }
1
+ import { randomUUID } from "node:crypto";
2
+ import { redisConfig } from "@/config/index.js";
3
+ import { createCircuitBreaker, isCircuitBreakerError } from "@/framework/circuit-breaker/cb.js";
4
+ import { databaseDialect, databasePool } from "@/framework/database/connection.js";
5
+ import { redis, redisReady } from "@/framework/redis/client.js";
6
+ import { logger } from "@/framework/support/logger.js";
7
+
8
+ type LockOptions = { ttlMs?: number; tableName?: string };
9
+
10
+ // Circuit breaker for the Redis Lock
11
+ const lockBreaker = createCircuitBreaker({ name: "Redis Lock", failureThreshold: 3, resetTimeout: 30000 });
12
+
13
+ function table(name?: string) {
14
+ return name || "scheduler_locks";
15
+ }
16
+
17
+ async function ensureTable(tableName?: string) {
18
+ const pool = databasePool();
19
+ const dialect = databaseDialect();
20
+ const lockTable = table(tableName);
21
+
22
+ if (dialect === "mysql") {
23
+ await pool.query(
24
+ `CREATE TABLE IF NOT EXISTS \`${lockTable}\` (name VARCHAR(191) PRIMARY KEY, owner VARCHAR(191) NOT NULL, expires_at BIGINT NOT NULL)`
25
+ );
26
+ return;
27
+ }
28
+
29
+ if (dialect === "postgresql") {
30
+ await pool.unsafe(
31
+ `CREATE TABLE IF NOT EXISTS "${lockTable}" (name VARCHAR(191) PRIMARY KEY, owner VARCHAR(191) NOT NULL, expires_at BIGINT NOT NULL)`
32
+ );
33
+ return;
34
+ }
35
+
36
+ await pool.execute(`CREATE TABLE IF NOT EXISTS "${lockTable}" (name TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL)`);
37
+ }
38
+
39
+ async function acquireDbLock(name: string, owner: string, ttlMs: number, tableName?: string) {
40
+ await ensureTable(tableName);
41
+ const pool = databasePool();
42
+ const dialect = databaseDialect();
43
+ const lockTable = table(tableName);
44
+ const now = Date.now();
45
+ const expiresAt = now + ttlMs;
46
+
47
+ if (dialect === "postgresql") {
48
+ const rows = await pool.unsafe(
49
+ `INSERT INTO "${lockTable}" (name, owner, expires_at) VALUES ($1, $2, $3) ON CONFLICT (name) DO UPDATE SET owner = EXCLUDED.owner, expires_at = EXCLUDED.expires_at WHERE "${lockTable}".expires_at < $4 RETURNING owner`,
50
+ [name, owner, expiresAt, now]
51
+ );
52
+ return rows?.[0]?.owner === owner;
53
+ }
54
+
55
+ if (dialect === "mysql") {
56
+ await pool.query(
57
+ `INSERT INTO \`${lockTable}\` (name, owner, expires_at) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE owner = IF(expires_at < ?, VALUES(owner), owner), expires_at = IF(expires_at < ?, VALUES(expires_at), expires_at)`,
58
+ [name, owner, expiresAt, now, now]
59
+ );
60
+ const [rows] = await pool.query(`SELECT owner FROM \`${lockTable}\` WHERE name = ?`, [name]);
61
+ return rows?.[0]?.owner === owner;
62
+ }
63
+
64
+ await pool.execute({
65
+ sql: `INSERT INTO "${lockTable}" (name, owner, expires_at) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at WHERE "${lockTable}".expires_at < ?`,
66
+ args: [name, owner, expiresAt, now]
67
+ });
68
+ const result = await pool.execute({
69
+ sql: `SELECT owner FROM "${lockTable}" WHERE name = ?`,
70
+ args: [name]
71
+ });
72
+ return result.rows?.[0]?.owner === owner;
73
+ }
74
+
75
+ async function releaseDbLock(name: string, owner: string, tableName?: string) {
76
+ const pool = databasePool();
77
+ const dialect = databaseDialect();
78
+ const lockTable = table(tableName);
79
+
80
+ if (dialect === "postgresql") return await pool.unsafe(`DELETE FROM "${lockTable}" WHERE name = $1 AND owner = $2`, [name, owner]);
81
+ if (dialect === "mysql") return await pool.query(`DELETE FROM \`${lockTable}\` WHERE name = ? AND owner = ?`, [name, owner]);
82
+ await pool.execute({
83
+ sql: `DELETE FROM "${lockTable}" WHERE name = ? AND owner = ?`,
84
+ args: [name, owner]
85
+ });
86
+ }
87
+
88
+ export async function runWithLock<T>(name: string, handler: () => Promise<T>, options: LockOptions = {}) {
89
+ const owner = randomUUID();
90
+ const ttlMs = options.ttlMs || 60_000;
91
+ const client = redis();
92
+
93
+ if (redisReady() && client) {
94
+ try {
95
+ return await lockBreaker.exec(async () => {
96
+ const key = `${redisConfig.prefix}:lock:${name}`;
97
+ const ok = await client.set(key, owner, "PX", ttlMs, "NX");
98
+ if (!ok) return { ran: false, backend: "redis" as const };
99
+
100
+ try {
101
+ const result = await handler();
102
+ return { ran: true, backend: "redis" as const, result };
103
+ } finally {
104
+ await client.del(key);
105
+ }
106
+ });
107
+ } catch (error) {
108
+ // If the circuit breaker is open, we log it and fall back to DB lock
109
+ if (isCircuitBreakerError(error)) {
110
+ logger.warn(`Redis lock skipped due to circuit breaker for ${name}; falling back to DB`);
111
+ } else {
112
+ logger.error(`Redis lock error for ${name}; falling back to DB`, { error });
113
+ }
114
+ // Fall through to DB lock
115
+ }
116
+ }
117
+
118
+ // DB Lock Fallback
119
+ const ok = await acquireDbLock(name, owner, ttlMs, options.tableName);
120
+ if (!ok) return { ran: false, backend: "db" as const };
121
+
122
+ try {
123
+ const result = await handler();
124
+ return { ran: true, backend: "db" as const, result };
125
+ } finally {
126
+ await releaseDbLock(name, owner, options.tableName);
127
+ }
128
+ }
@@ -119,54 +119,52 @@ export async function startServer(): Promise<ServerHandle> {
119
119
  }
120
120
 
121
121
  const views = new Set(parseCsvOrFallback(process.env.NEXWIRE_DEV_VIEWS, []).map((view) => view.toLowerCase()));
122
- let redisWarnColor: ((text: string) => string) | null = null;
123
- console.log(`API Docs: ${serverUrl(server, "/api-docs")}`);
122
+
123
+ if (appConfig.openApiEnabled) {
124
+ console.log(`API Docs: ${serverUrl(server, "/api-docs")}`);
125
+ }
124
126
 
125
127
  if (appConfig.uiEnabled) {
126
- if (process.env.NEXWIRE_FRONTEND_URL) {
127
- console.log(`UI: ${process.env.NEXWIRE_FRONTEND_URL}`);
128
- } else {
129
- console.log("UI enabled");
130
- }
128
+ console.log(`UI: ${process.env.NEXWIRE_FRONTEND_URL}`);
131
129
  } else {
132
- console.log(chalk.gray("UI disabled (disabled in src/config/app.ts)"));
130
+ console.log(chalk.gray("UI: .env -> UI=false (API-only mode)"));
133
131
  }
134
132
 
135
- const bullboardLine = `${bullBoard.enabled ? "Queue Dashboard enabled" : "Queue Dashboard unavailable"}: ${serverUrl(server, bullBoard.basePath)}`;
136
- console.log(redisWarnColor ? redisWarnColor(bullboardLine) : bullboardLine);
133
+ if (redisConfig.enabled && bullBoard.enabled) {
134
+ console.log(`Queue: ${serverUrl(server, bullBoard.basePath)}`);
135
+ }
137
136
 
138
137
  if (views.has("maildev")) {
139
- const maildevLine = `MailDev: http://localhost:${mailConfig.maildev.webPort} (SMTP ${mailConfig.maildev.smtpPort})`;
140
- const line = `${maildevLine} (requested; see dev process status)`;
141
- console.log(redisWarnColor ? redisWarnColor(line) : line);
138
+ console.log(`MailDev: http://localhost:${mailConfig.maildev.webPort} (SMTP ${mailConfig.maildev.smtpPort})`);
142
139
  }
143
140
 
144
141
  if (views.has("studio")) {
145
- console.log("Drizzle Studio requested: https://local.drizzle.studio (see dev process status)");
142
+ console.log("Drizzle: https://local.drizzle.studio");
143
+ }
144
+
145
+ const wsUrl = serverUrl(server, WS_PATH).replace(/^http/, "ws");
146
+ if (!realtimeConfig.enabled) {
147
+ console.log(chalk.gray("Realtime (Socket.IO) disabled"));
148
+ } else if (realtime) {
149
+ if (socketAdmin.enabled) {
150
+ console.log(`Realtime: https://admin.socket.io | ${wsUrl}`);
151
+ } else {
152
+ console.log(`Realtime: ${wsUrl}`);
153
+ }
154
+ } else {
155
+ console.log(chalk.yellow("Realtime (Socket.IO) unavailable"));
146
156
  }
147
157
 
148
- const socketLine = !realtimeConfig.enabled
149
- ? "Realtime (Socket.IO) disabled"
150
- : realtime
151
- ? `Realtime (Socket.IO) enabled: ${socketAdmin.enabled ? "Admin UI: https://admin.socket.io" : ""}${socketAdmin.enabled ? " | " : " "}${serverUrl(server, WS_PATH).replace(/^http/, "ws")}`
152
- : "Realtime (Socket.IO) unavailable";
153
- console.log(!realtimeConfig.enabled ? chalk.gray(socketLine) : realtime ? chalk.green(socketLine) : chalk.yellow(socketLine));
154
-
155
- if (views.has("redis")) {
156
- const redisUiLine = `Redis UI: http://localhost:${redisConfig.commanderPort}`;
157
- const line = `${redisUiLine} (requested; see dev process status)`;
158
- console.log(redisWarnColor ? redisWarnColor(line) : line);
158
+ if (redisConfig.enabled && views.has("redis")) {
159
+ console.log(`Redis UI: http://localhost:${redisConfig.commanderPort}`);
159
160
  }
160
161
 
161
162
  if (!redisConfig.enabled) {
162
- redisWarnColor = chalk.cyan;
163
- console.log(chalk.cyan("Redis disabled (configured in src/config/redis.ts)"));
164
- console.log(chalk.cyan(`Redis-backed services disabled: ${redisBackedServices}`));
163
+ console.log(chalk.cyan(`Redis: .env -> REDIS=false (disabled ${redisBackedServices})`));
165
164
  } else if (redisReady()) {
166
165
  console.log(chalk.green(`Redis connected: ${redisConfig.url}`));
167
166
  console.log(chalk.green(`Redis-backed services enabled: ${redisBackedServices}`));
168
167
  } else {
169
- redisWarnColor = chalk.yellow;
170
168
  console.log(chalk.yellow(`Redis unavailable: ${redisError() || "not connected"}`));
171
169
  console.log(chalk.yellow(`Redis-backed services unavailable: ${redisBackedServices}`));
172
170
  }