immortal-js 1.0.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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +577 -0
  3. package/docs/CHANGELOG.md +21 -0
  4. package/docs/CONTRIBUTING.md +41 -0
  5. package/docs/api/chaos.md +179 -0
  6. package/docs/api/dashboard.md +109 -0
  7. package/docs/api/isolation.md +191 -0
  8. package/docs/api/lifecycle.md +187 -0
  9. package/docs/api/monitoring.md +313 -0
  10. package/docs/api/plugins.md +217 -0
  11. package/docs/api/recovery.md +267 -0
  12. package/docs/api/safe-zone.md +236 -0
  13. package/docs/api/supervision.md +285 -0
  14. package/docs/guides/configuration.md +168 -0
  15. package/docs/guides/express.md +171 -0
  16. package/docs/guides/fastify.md +188 -0
  17. package/docs/guides/koa.md +102 -0
  18. package/docs/guides/nestjs.md +182 -0
  19. package/docs/guides/testing.md +91 -0
  20. package/examples/express-basic/index.ts +462 -0
  21. package/examples/express-basic/package.json +21 -0
  22. package/examples/express-basic/tsconfig.json +24 -0
  23. package/examples/fastify-microservice/index.ts +342 -0
  24. package/examples/fastify-microservice/package.json +22 -0
  25. package/examples/fastify-microservice/tsconfig.json +24 -0
  26. package/examples/invoice-service/data/invoices.db +0 -0
  27. package/examples/invoice-service/data/invoices.db-shm +0 -0
  28. package/examples/invoice-service/data/invoices.db-wal +0 -0
  29. package/examples/invoice-service/package.json +25 -0
  30. package/examples/invoice-service/public/index.html +5025 -0
  31. package/examples/invoice-service/src/db.ts +608 -0
  32. package/examples/invoice-service/src/pdf.ts +358 -0
  33. package/examples/invoice-service/src/server.ts +527 -0
  34. package/examples/invoice-service/src/store.ts +159 -0
  35. package/examples/invoice-service/src/types.ts +193 -0
  36. package/examples/invoice-service/tsconfig.json +23 -0
  37. package/examples/nestjs-enterprise/app.module.ts +561 -0
  38. package/examples/nestjs-enterprise/main.ts +67 -0
  39. package/examples/nestjs-enterprise/package.json +26 -0
  40. package/examples/nestjs-enterprise/tsconfig.json +27 -0
  41. package/immortal-js-1.0.0.tgz +0 -0
  42. package/package.json +33 -0
  43. package/packages/adapter-express/package.json +34 -0
  44. package/packages/adapter-express/src/index.ts +349 -0
  45. package/packages/adapter-express/tsconfig.json +14 -0
  46. package/packages/adapter-fastify/package.json +56 -0
  47. package/packages/adapter-fastify/src/plugin.ts +226 -0
  48. package/packages/adapter-fastify/tsconfig.json +14 -0
  49. package/packages/adapter-koa/package.json +55 -0
  50. package/packages/adapter-koa/src/index.ts +207 -0
  51. package/packages/adapter-koa/tsconfig.json +14 -0
  52. package/packages/adapter-nestjs/package.json +61 -0
  53. package/packages/adapter-nestjs/src/immortal.module.ts +313 -0
  54. package/packages/adapter-nestjs/src/index.ts +14 -0
  55. package/packages/adapter-nestjs/tsconfig.json +16 -0
  56. package/packages/core/package.json +56 -0
  57. package/packages/core/src/chaos/ChaosEngine.ts +249 -0
  58. package/packages/core/src/config/defaults.ts +200 -0
  59. package/packages/core/src/config/schema.ts +199 -0
  60. package/packages/core/src/event-bus.ts +168 -0
  61. package/packages/core/src/index.ts +164 -0
  62. package/packages/core/src/isolation/BulkheadPool.ts +279 -0
  63. package/packages/core/src/isolation/WorkerSandbox.ts +306 -0
  64. package/packages/core/src/isolation/index.ts +8 -0
  65. package/packages/core/src/lifecycle/GracefulShutdown.ts +161 -0
  66. package/packages/core/src/logger.ts +104 -0
  67. package/packages/core/src/monitoring/DiagnosticsChannel.ts +248 -0
  68. package/packages/core/src/monitoring/HealthMonitor.ts +191 -0
  69. package/packages/core/src/monitoring/MemoryLeakGuard.ts +340 -0
  70. package/packages/core/src/monitoring/MetricsCollector.ts +219 -0
  71. package/packages/core/src/monitoring/index.ts +10 -0
  72. package/packages/core/src/plugins/BuiltinPlugins.ts +269 -0
  73. package/packages/core/src/recovery/CircuitBreaker.ts +334 -0
  74. package/packages/core/src/recovery/FallbackCache.ts +328 -0
  75. package/packages/core/src/recovery/RetryEngine.ts +225 -0
  76. package/packages/core/src/recovery/Timeout.ts +97 -0
  77. package/packages/core/src/recovery/index.ts +11 -0
  78. package/packages/core/src/runtime.ts +242 -0
  79. package/packages/core/src/safe-zone/AsyncBoundary.ts +114 -0
  80. package/packages/core/src/safe-zone/ErrorTrap.ts +347 -0
  81. package/packages/core/src/safe-zone/SafeWrapper.ts +317 -0
  82. package/packages/core/src/safe-zone/index.ts +23 -0
  83. package/packages/core/src/supervision/ClusterManager.ts +243 -0
  84. package/packages/core/src/supervision/RestartStrategy.ts +68 -0
  85. package/packages/core/src/supervision/Supervisor.ts +311 -0
  86. package/packages/core/src/supervision/index.ts +11 -0
  87. package/packages/core/src/types.ts +470 -0
  88. package/packages/core/test/bulkhead.test.ts +310 -0
  89. package/packages/core/test/circuit-breaker.test.ts +153 -0
  90. package/packages/core/test/memory-guard.test.ts +213 -0
  91. package/packages/core/test/retry.test.ts +110 -0
  92. package/packages/core/test/safe-zone.test.ts +271 -0
  93. package/packages/core/test/supervisor.test.ts +310 -0
  94. package/packages/core/tsconfig.json +13 -0
  95. package/packages/dashboard/package.json +56 -0
  96. package/packages/dashboard/server/DashboardServer.ts +454 -0
  97. package/packages/dashboard/tsconfig.json +14 -0
  98. package/tsconfig.json +25 -0
@@ -0,0 +1,527 @@
1
+ /**
2
+ * @file src/server.ts
3
+ * @description Invoice Service Pro v3 — Ultra Pro Fastify Server
4
+ *
5
+ * Frontend-is-Backend Pattern:
6
+ * Fastify serves static SPA from public/ (dark/light theme, Lucide icons)
7
+ * AND a full REST API at /api/*
8
+ *
9
+ * Resilience (Immortal.js):
10
+ * CircuitBreaker — external email/storage calls
11
+ * BulkheadPool — PDF rendering + API concurrency
12
+ * FallbackCache — dashboard stats (15s TTL), invoice list (30s TTL)
13
+ * withRetry — transient storage errors
14
+ * withTimeout — email + external ops
15
+ * GracefulShutdown — SIGTERM/SIGINT → drain → close
16
+ * MemoryLeakGuard — restart at 1500 MB
17
+ * HealthMonitor — event loop + memory tracking
18
+ *
19
+ * Routes:
20
+ * GET /api/ — API welcome + endpoint map
21
+ * GET /api/health — full system health
22
+ * GET /api/metrics — Prometheus text
23
+ * GET /api/dashboard — real SQLite stats (KPI, monthly, top clients)
24
+ * GET /api/invoices — paginated invoice list (SQLite, filtered, sorted)
25
+ * POST /api/invoices — create invoice
26
+ * GET /api/invoices/:id — get invoice
27
+ * PATCH /api/invoices/:id — update invoice (status, notes, etc.)
28
+ * DELETE /api/invoices/:id — delete invoice
29
+ * GET /api/invoices/:id/pdf — print-ready PDF HTML
30
+ * GET /api/invoices/:id/activity — audit log
31
+ * GET /api/clients — list clients
32
+ * POST /api/clients — create client
33
+ * GET /api/clients/:id — get client
34
+ * PATCH /api/clients/:id — update client
35
+ * DELETE /api/clients/:id — delete client
36
+ * GET /api/stats — quick stats for sidebar KPIs
37
+ */
38
+
39
+ import path from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+ import type { FastifyRequest, FastifyReply } from "fastify";
42
+ import Fastify from "fastify";
43
+ import fastifyStatic from "@fastify/static";
44
+
45
+ import {
46
+ CircuitBreaker, BulkheadPool, FallbackCache, GracefulShutdown,
47
+ MetricsCollector, HealthMonitor, MemoryLeakGuard, ErrorTrap,
48
+ withRetry, withTimeout, getEventBus, mergeWithDefaults,
49
+ } from "@immortal/core";
50
+
51
+ import { InvoiceStore, ClientStore, newId, newItemId, nextNumber, now, today, addDays } from "./db.js";
52
+ import { generatePdfHtml, calculateTotals } from "./pdf.js";
53
+ import type { Invoice, CreateInvoiceDto, UpdateInvoiceDto, CreateClientDto, InvoiceListQuery, InvoiceCurrency } from "./types.js";
54
+
55
+ // ─── Paths ────────────────────────────────────────────────────────────────────
56
+
57
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
58
+ const PUBLIC_DIR = path.resolve(__dirname, "..", "public");
59
+
60
+ // ─── Error helper ─────────────────────────────────────────────────────────────
61
+
62
+ function sendError(reply: FastifyReply, status: number, message: string, code = "ERROR"): FastifyReply {
63
+ return reply.status(status).send({ error: message, code, timestamp: now() });
64
+ }
65
+
66
+ // ─── Immortal.js Runtime ──────────────────────────────────────────────────────
67
+
68
+ const config = mergeWithDefaults({});
69
+ const bus = getEventBus();
70
+
71
+ new ErrorTrap(bus).install();
72
+
73
+ const metrics = new MetricsCollector();
74
+ const healthMonitor = new HealthMonitor(config.healthMonitor, bus, metrics);
75
+ const memoryGuard = new MemoryLeakGuard({ checkIntervalMs: 30_000, restartThresholdMb: 1_500 }, bus);
76
+
77
+ // Circuit breakers
78
+ const emailCircuit = new CircuitBreaker("email-service", { threshold: 3, cooldownMs: 30_000, slidingWindowSize: 10 });
79
+ const storageCircuit = new CircuitBreaker("storage-service", { threshold: 5, cooldownMs: 15_000, slidingWindowSize: 20 });
80
+
81
+ // Bulkhead pools
82
+ const pdfBulkhead = new BulkheadPool("pdf-rendering", { maxConcurrent: 5, maxQueueSize: 20, queueTimeoutMs: 30_000 });
83
+ const apiBulkhead = new BulkheadPool("api-requests", { maxConcurrent: 50, maxQueueSize: 200, queueTimeoutMs: 10_000 });
84
+
85
+ // Fallback caches (JSON-serialized values)
86
+ const dashboardCache = new FallbackCache<string>({ maxSize: 10, defaultTtlMs: 15_000 });
87
+ const listCache = new FallbackCache<string>({ maxSize: 100, defaultTtlMs: 30_000 });
88
+ const statsCache = new FallbackCache<string>({ maxSize: 5, defaultTtlMs: 10_000 });
89
+
90
+ function bustCaches(): void {
91
+ dashboardCache.invalidate("dashboard");
92
+ statsCache.invalidate("stats");
93
+ for (const k of ["all","draft","sent","paid","overdue","cancelled","refunded"]) {
94
+ listCache.invalidate(`list:${k}`);
95
+ }
96
+ }
97
+
98
+ // Graceful shutdown
99
+ const shutdown = new GracefulShutdown({
100
+ timeoutMs: 20_000,
101
+ signals: ["SIGTERM", "SIGINT"],
102
+ onShutdown: [
103
+ async () => { pdfBulkhead.drain(); },
104
+ async () => { healthMonitor.stop(); memoryGuard.stop(); },
105
+ async () => { await fastify.close(); },
106
+ ],
107
+ }, bus);
108
+ void shutdown;
109
+
110
+ // ─── Fastify ──────────────────────────────────────────────────────────────────
111
+
112
+ const fastify = Fastify({ logger: false, bodyLimit: 2 * 1024 * 1024 });
113
+
114
+ await fastify.register(fastifyStatic, { root: PUBLIC_DIR, prefix: "/" });
115
+
116
+ // CORS
117
+ fastify.addHook("onRequest", async (_req: FastifyRequest, reply: FastifyReply) => {
118
+ reply.header("Access-Control-Allow-Origin", "*");
119
+ reply.header("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
120
+ reply.header("Access-Control-Allow-Headers", "Content-Type,Authorization");
121
+ reply.header("X-Powered-By", "Immortal.js Invoice Service Pro v3");
122
+ });
123
+ fastify.options("/*", async (_req, reply) => reply.status(204).send());
124
+
125
+ // ─── /api/ ────────────────────────────────────────────────────────────────────
126
+
127
+ fastify.get("/api/", async (_req: FastifyRequest, reply: FastifyReply) => {
128
+ return reply.send({
129
+ service: "Immortal.js Invoice Service Pro",
130
+ version: "3.0.0",
131
+ status: "running",
132
+ timestamp: now(),
133
+ database: "SQLite (better-sqlite3 WAL mode)",
134
+ endpoints: {
135
+ "GET /api/": "API welcome",
136
+ "GET /api/health": "System health",
137
+ "GET /api/metrics": "Prometheus metrics",
138
+ "GET /api/dashboard": "Dashboard KPIs from SQLite",
139
+ "GET /api/stats": "Quick sidebar stats",
140
+ "GET /api/invoices": "List invoices (paginated, filtered, sorted)",
141
+ "POST /api/invoices": "Create invoice",
142
+ "GET /api/invoices/:id": "Get invoice by ID",
143
+ "PATCH /api/invoices/:id": "Update invoice",
144
+ "DELETE /api/invoices/:id": "Delete invoice",
145
+ "GET /api/invoices/:id/pdf": "Render PDF-ready HTML",
146
+ "GET /api/invoices/:id/activity": "Audit log",
147
+ "GET /api/clients": "List clients",
148
+ "POST /api/clients": "Create client",
149
+ "GET /api/clients/:id": "Get client",
150
+ "PATCH /api/clients/:id": "Update client",
151
+ "DELETE /api/clients/:id": "Delete client",
152
+ },
153
+ });
154
+ });
155
+
156
+ // ─── /api/health ──────────────────────────────────────────────────────────────
157
+
158
+ fastify.get("/api/health", async (_req: FastifyRequest, reply: FastifyReply) => {
159
+ const snap = metrics.getSnapshot();
160
+ const pdfBk = pdfBulkhead.getStatus();
161
+ const apiBk = apiBulkhead.getStatus();
162
+ return reply.send({
163
+ status: "healthy",
164
+ uptime: Math.round(process.uptime()),
165
+ version: "3.0.0",
166
+ memoryMb: {
167
+ heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
168
+ heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
169
+ rss: Math.round(process.memoryUsage().rss / 1024 / 1024),
170
+ },
171
+ eventLoop: snap.eventLoopLag,
172
+ invoiceCount: InvoiceStore.count(),
173
+ clientCount: ClientStore.count(),
174
+ circuits: {
175
+ "email-service": emailCircuit.getStatus(),
176
+ "storage-service": storageCircuit.getStatus(),
177
+ },
178
+ bulkheads: {
179
+ "pdf-rendering": { active: pdfBk.active, queued: pdfBk.queued, max: 5 },
180
+ "api-requests": { active: apiBk.active, queued: apiBk.queued, max: 50 },
181
+ },
182
+ });
183
+ });
184
+
185
+ // ─── /api/metrics ─────────────────────────────────────────────────────────────
186
+
187
+ fastify.get("/api/metrics", async (_req: FastifyRequest, reply: FastifyReply) => {
188
+ const snap = metrics.getSnapshot();
189
+ const emailS = emailCircuit.getStatus();
190
+ const strgS = storageCircuit.getStatus();
191
+ const pdfBk = pdfBulkhead.getStatus();
192
+ const apiBk = apiBulkhead.getStatus();
193
+ const stateNum = (s: string): number => s === "CLOSED" ? 1 : s === "HALF_OPEN" ? 2 : 3;
194
+ return reply.header("Content-Type", "text/plain; version=0.0.4").send([
195
+ `immortal_circuit_state{name="email-service"} ${stateNum(emailS.state)}`,
196
+ `immortal_circuit_state{name="storage-service"} ${stateNum(strgS.state)}`,
197
+ `immortal_bulkhead_active{pool="pdf-rendering"} ${pdfBk.active}`,
198
+ `immortal_bulkhead_active{pool="api-requests"} ${apiBk.active}`,
199
+ `immortal_heap_used_mb ${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}`,
200
+ `immortal_eventloop_lag_p99_ms ${snap.eventLoopLag.p99.toFixed(2)}`,
201
+ `invoice_service_total ${InvoiceStore.count()}`,
202
+ `invoice_client_total ${ClientStore.count()}`,
203
+ ].join("\n"));
204
+ });
205
+
206
+ // ─── /api/dashboard ───────────────────────────────────────────────────────────
207
+
208
+ fastify.get("/api/dashboard", async (_req: FastifyRequest, reply: FastifyReply) => {
209
+ return apiBulkhead.run(async () => {
210
+ const cached = dashboardCache.get("dashboard");
211
+ if (cached) {
212
+ return reply.header("X-Cache", "HIT").header("Content-Type", "application/json").send(cached);
213
+ }
214
+ const stats = InvoiceStore.dashboard();
215
+ const payload = JSON.stringify(stats);
216
+ dashboardCache.set("dashboard", payload);
217
+ return reply.header("X-Cache", "MISS").send(stats);
218
+ }, "medium");
219
+ });
220
+
221
+ // ─── /api/stats ───────────────────────────────────────────────────────────────
222
+
223
+ fastify.get("/api/stats", async (_req: FastifyRequest, reply: FastifyReply) => {
224
+ const cached = statsCache.get("stats");
225
+ if (cached) return reply.header("X-Cache", "HIT").header("Content-Type", "application/json").send(cached);
226
+ const stats = InvoiceStore.dashboard();
227
+ const quick = {
228
+ total: stats.totalInvoices,
229
+ revenue: stats.totalRevenueCents,
230
+ outstanding: stats.outstandingCents,
231
+ overdue: stats.overdueCents,
232
+ drafts: stats.draftCount,
233
+ clients: stats.totalClients,
234
+ paid: stats.paidCount,
235
+ sent: stats.sentCount,
236
+ };
237
+ statsCache.set("stats", JSON.stringify(quick));
238
+ return reply.send(quick);
239
+ });
240
+
241
+ // ─── /api/invoices (GET — list) ───────────────────────────────────────────────
242
+
243
+ fastify.get(
244
+ "/api/invoices",
245
+ async (
246
+ request: FastifyRequest<{ Querystring: Record<string, string> }>,
247
+ reply: FastifyReply
248
+ ) => {
249
+ return apiBulkhead.run(async () => {
250
+ const q = request.query;
251
+ // Build query object without undefined optional fields (exactOptionalPropertyTypes)
252
+ const query: InvoiceListQuery = {};
253
+ query.page = q["page"] ? parseInt(q["page"], 10) : 1;
254
+ query.limit = q["limit"] ? parseInt(q["limit"], 10) : 25;
255
+ query.sortDir = q["sortDir"] === "asc" ? "asc" : "desc";
256
+ if (q["status"]) query.status = q["status"] as NonNullable<InvoiceListQuery["status"]>;
257
+ if (q["currency"]) query.currency = q["currency"] as NonNullable<InvoiceListQuery["currency"]>;
258
+ if (q["search"]) query.search = q["search"];
259
+ if (q["sortBy"]) query.sortBy = q["sortBy"] as NonNullable<InvoiceListQuery["sortBy"]>;
260
+ if (q["dateFrom"]) query.dateFrom = q["dateFrom"];
261
+ if (q["dateTo"]) query.dateTo = q["dateTo"];
262
+
263
+ const cacheKey = `list:${JSON.stringify(query)}`;
264
+ const cached = listCache.get(cacheKey);
265
+ if (cached) {
266
+ return reply.header("X-Cache", "HIT").header("Content-Type", "application/json").send(cached);
267
+ }
268
+
269
+ const result = await storageCircuit.execute(
270
+ async () => {
271
+ await simulateStorageLatency();
272
+ return InvoiceStore.list(query);
273
+ },
274
+ () => InvoiceStore.list(query)
275
+ );
276
+
277
+ listCache.set(cacheKey, JSON.stringify(result));
278
+ return reply.header("X-Cache", "MISS").send(result);
279
+ }, "medium");
280
+ }
281
+ );
282
+
283
+ // ─── /api/invoices (POST — create) ────────────────────────────────────────────
284
+
285
+ fastify.post(
286
+ "/api/invoices",
287
+ async (request: FastifyRequest, reply: FastifyReply) => {
288
+ return apiBulkhead.run(async () => {
289
+ const dto = request.body as CreateInvoiceDto;
290
+
291
+ if (!dto?.from?.name || !dto?.to?.name || !dto?.to?.email) {
292
+ return sendError(reply, 400, "from.name, to.name, to.email are required", "VALIDATION_ERROR");
293
+ }
294
+ if (!dto.lineItems?.length) {
295
+ return sendError(reply, 400, "At least one line item is required", "VALIDATION_ERROR");
296
+ }
297
+
298
+ const invoice = await withRetry(async () => {
299
+ await simulateStorageLatency();
300
+ const n = now();
301
+ const newInv: Invoice = {
302
+ id: newId(),
303
+ number: nextNumber(),
304
+ status: "draft",
305
+ currency: (dto.currency ?? "USD") as InvoiceCurrency,
306
+ issuedAt: dto.issuedAt ?? today(),
307
+ dueAt: dto.dueAt ?? addDays(30),
308
+ from: dto.from,
309
+ to: dto.to,
310
+ lineItems: dto.lineItems.map(li => ({ ...li, id: newItemId() })),
311
+ createdAt: n,
312
+ updatedAt: n,
313
+ ...(dto.notes !== undefined && { notes: dto.notes }),
314
+ ...(dto.terms !== undefined && { terms: dto.terms }),
315
+ ...(dto.discount !== undefined && { discount: dto.discount }),
316
+ ...(dto.reference !== undefined && { reference: dto.reference }),
317
+ ...(dto.tags?.length && { tags: dto.tags }),
318
+ };
319
+ return InvoiceStore.create(newInv);
320
+ }, { maxAttempts: 2, baseDelayMs: 100 });
321
+
322
+ bustCaches();
323
+
324
+ // Non-blocking email
325
+ void emailCircuit.execute(
326
+ () => withTimeout(
327
+ () => simulateEmailSend(invoice.to.email, invoice.number),
328
+ 5_000, "email"
329
+ ),
330
+ () => undefined
331
+ ).catch(() => undefined);
332
+
333
+ return reply.status(201).send(invoice);
334
+ }, "high");
335
+ }
336
+ );
337
+
338
+ // ─── /api/invoices/:id (GET) ──────────────────────────────────────────────────
339
+
340
+ fastify.get(
341
+ "/api/invoices/:id",
342
+ async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
343
+ const { id } = request.params;
344
+ const invoice = InvoiceStore.get(id);
345
+ if (!invoice) return sendError(reply, 404, `Invoice '${id}' not found`, "NOT_FOUND");
346
+ return reply.send(invoice);
347
+ }
348
+ );
349
+
350
+ // ─── /api/invoices/:id (PATCH) ────────────────────────────────────────────────
351
+
352
+ fastify.patch(
353
+ "/api/invoices/:id",
354
+ async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
355
+ const { id } = request.params;
356
+ const dto = request.body as UpdateInvoiceDto;
357
+
358
+ if (dto.status) {
359
+ const valid = ["draft", "sent", "paid", "overdue", "cancelled", "refunded"];
360
+ if (!valid.includes(dto.status)) {
361
+ return sendError(reply, 400, `Invalid status. Valid: ${valid.join(", ")}`, "VALIDATION_ERROR");
362
+ }
363
+ if (dto.status === "paid" && !dto.paidAt) {
364
+ dto.paidAt = today();
365
+ }
366
+ }
367
+
368
+ const updated = InvoiceStore.update(id, dto);
369
+ if (!updated) return sendError(reply, 404, `Invoice '${id}' not found`, "NOT_FOUND");
370
+ bustCaches();
371
+ return reply.send(updated);
372
+ }
373
+ );
374
+
375
+ // ─── /api/invoices/:id (DELETE) ───────────────────────────────────────────────
376
+
377
+ fastify.delete(
378
+ "/api/invoices/:id",
379
+ async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
380
+ const { id } = request.params;
381
+ if (!InvoiceStore.get(id)) return sendError(reply, 404, `Invoice '${id}' not found`, "NOT_FOUND");
382
+ InvoiceStore.delete(id);
383
+ bustCaches();
384
+ return reply.status(204).send();
385
+ }
386
+ );
387
+
388
+ // ─── /api/invoices/:id/pdf ────────────────────────────────────────────────────
389
+
390
+ fastify.get(
391
+ "/api/invoices/:id/pdf",
392
+ async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
393
+ const { id } = request.params;
394
+ return pdfBulkhead.run(async () => {
395
+ const invoice = InvoiceStore.get(id);
396
+ if (!invoice) return sendError(reply, 404, `Invoice '${id}' not found`, "NOT_FOUND");
397
+
398
+ await sleep(20 + Math.random() * 50);
399
+ const html = generatePdfHtml(invoice);
400
+ const totals = calculateTotals(invoice);
401
+
402
+ InvoiceStore.addActivity(id, "viewed", "PDF rendered");
403
+
404
+ return reply
405
+ .header("Content-Type", "text/html; charset=utf-8")
406
+ .header("X-Invoice-Total", totals.total)
407
+ .header("X-Invoice-Number", invoice.number)
408
+ .send(html);
409
+ }, "high");
410
+ }
411
+ );
412
+
413
+ // ─── /api/invoices/:id/activity ───────────────────────────────────────────────
414
+
415
+ fastify.get(
416
+ "/api/invoices/:id/activity",
417
+ async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
418
+ const { id } = request.params;
419
+ if (!InvoiceStore.get(id)) return sendError(reply, 404, `Invoice '${id}' not found`, "NOT_FOUND");
420
+ return reply.send(InvoiceStore.getActivity(id));
421
+ }
422
+ );
423
+
424
+ // ─── /api/clients ─────────────────────────────────────────────────────────────
425
+
426
+ fastify.get("/api/clients", async (_req: FastifyRequest, reply: FastifyReply) => {
427
+ return reply.send(ClientStore.list());
428
+ });
429
+
430
+ fastify.post("/api/clients", async (request: FastifyRequest, reply: FastifyReply) => {
431
+ const dto = request.body as CreateClientDto;
432
+ if (!dto?.name || !dto?.email) {
433
+ return sendError(reply, 400, "name and email are required", "VALIDATION_ERROR");
434
+ }
435
+ const client = ClientStore.create(dto);
436
+ bustCaches();
437
+ return reply.status(201).send(client);
438
+ });
439
+
440
+ fastify.get("/api/clients/:id",
441
+ async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
442
+ const client = ClientStore.get(request.params.id);
443
+ if (!client) return sendError(reply, 404, "Client not found", "NOT_FOUND");
444
+ return reply.send(client);
445
+ }
446
+ );
447
+
448
+ fastify.patch("/api/clients/:id",
449
+ async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
450
+ const updated = ClientStore.update(request.params.id, request.body as Partial<{ name: string; email: string; phone: string; address: string; city: string; country: string; taxId: string; website: string; notes: string }>);
451
+ if (!updated) return sendError(reply, 404, "Client not found", "NOT_FOUND");
452
+ return reply.send(updated);
453
+ }
454
+ );
455
+
456
+ fastify.delete("/api/clients/:id",
457
+ async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
458
+ if (!ClientStore.delete(request.params.id)) return sendError(reply, 404, "Client not found", "NOT_FOUND");
459
+ return reply.status(204).send();
460
+ }
461
+ );
462
+
463
+ // ─── SPA Fallback ─────────────────────────────────────────────────────────────
464
+
465
+ fastify.setNotFoundHandler(async (request: FastifyRequest, reply: FastifyReply) => {
466
+ if (request.url.startsWith("/api")) {
467
+ return reply.status(404).send({ error: `Route not found: ${request.method} ${request.url}`, code: "NOT_FOUND", timestamp: now() });
468
+ }
469
+ return reply.sendFile("index.html");
470
+ });
471
+
472
+ // ─── Global Error Handler ─────────────────────────────────────────────────────
473
+
474
+ fastify.setErrorHandler(async (error, _req: FastifyRequest, reply: FastifyReply) => {
475
+ const status = (error as { statusCode?: number }).statusCode ?? 500;
476
+ console.error(`[ERROR] ${error.name}: ${error.message}`);
477
+ return reply.status(status).send({
478
+ error: status >= 500 ? "Internal Server Error" : error.message,
479
+ code: error.name,
480
+ timestamp: now(),
481
+ });
482
+ });
483
+
484
+ // ─── Simulators ───────────────────────────────────────────────────────────────
485
+
486
+ function sleep(ms: number): Promise<void> { return new Promise(r => setTimeout(r, ms)); }
487
+
488
+ async function simulateStorageLatency(): Promise<void> {
489
+ await sleep(2 + Math.random() * 8);
490
+ if (Math.random() < 0.01) throw new Error("Storage transient error");
491
+ }
492
+
493
+ async function simulateEmailSend(email: string, num: string): Promise<void> {
494
+ await sleep(60 + Math.random() * 100);
495
+ if (Math.random() < 0.04) throw new Error("Email service unavailable");
496
+ console.log(` [email] Confirmation → ${email} [${num}]`);
497
+ }
498
+
499
+ // ─── Start ────────────────────────────────────────────────────────────────────
500
+
501
+ healthMonitor.start();
502
+ memoryGuard.start();
503
+
504
+ const PORT = parseInt(process.env["PORT"] ?? "3000", 10);
505
+
506
+ try {
507
+ await fastify.listen({ port: PORT, host: "0.0.0.0" });
508
+ console.log(`
509
+ +----------------------------------------------------------------------+
510
+ | Immortal.js Invoice Service Pro v3 (Ultra Edition) |
511
+ +----------------------------------------------------------------------+
512
+ | Frontend --> http://localhost:${PORT}/ |
513
+ | API --> http://localhost:${PORT}/api/ |
514
+ | Dashboard --> http://localhost:${PORT}/api/dashboard |
515
+ | Health --> http://localhost:${PORT}/api/health |
516
+ | Metrics --> http://localhost:${PORT}/api/metrics |
517
+ +----------------------------------------------------------------------+
518
+ | DB: SQLite WAL | Invoices: ${InvoiceStore.count()} | Clients: ${ClientStore.count()} |
519
+ | Circuits: email-service, storage-service |
520
+ | Bulkheads: pdf-rendering(5), api-requests(50) |
521
+ | Caches: dashboard(15s), list(30s), stats(10s) |
522
+ +----------------------------------------------------------------------+
523
+ `);
524
+ } catch (err) {
525
+ console.error("Startup failed:", err);
526
+ process.exit(1);
527
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * @file src/store.ts
3
+ * @description In-memory invoice store with atomic operations.
4
+ * In production, replace with PostgreSQL / MongoDB.
5
+ */
6
+
7
+ import type { Invoice, InvoiceStatus, InvoiceCurrency } from "./types.js";
8
+
9
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
10
+
11
+ function newId(): string {
12
+ return `inv_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
13
+ }
14
+
15
+ function newItemId(): string {
16
+ return `li_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
17
+ }
18
+
19
+ let invoiceCounter = 1;
20
+ function nextNumber(): string {
21
+ const n = String(invoiceCounter++).padStart(4, "0");
22
+ return `INV-${new Date().getFullYear()}-${n}`;
23
+ }
24
+
25
+ // ─── Seed data ────────────────────────────────────────────────────────────────
26
+
27
+ function seedInvoices(): Map<string, Invoice> {
28
+ const now = new Date();
29
+ const due = new Date(now);
30
+ due.setDate(due.getDate() + 30);
31
+
32
+ const mkInvoice = (
33
+ status: InvoiceStatus,
34
+ currency: InvoiceCurrency,
35
+ clientName: string,
36
+ items: { desc: string; qty: number; unit: number; tax: number }[]
37
+ ): Invoice => {
38
+ const id = newId();
39
+ return {
40
+ id,
41
+ number: nextNumber(),
42
+ status,
43
+ currency,
44
+ issuedAt: now.toISOString().split("T")[0] ?? now.toISOString(),
45
+ dueAt: due.toISOString().split("T")[0] ?? due.toISOString(),
46
+ from: {
47
+ name: "Immortal Corp",
48
+ email: "billing@immortal.dev",
49
+ address: "1 Resilience Boulevard",
50
+ city: "San Francisco, CA 94102",
51
+ country: "US",
52
+ taxId: "US-TAX-99887766",
53
+ },
54
+ to: {
55
+ name: clientName,
56
+ email: `accounts@${clientName.toLowerCase().replace(/\s/g, "")}.com`,
57
+ address: "100 Client Street",
58
+ city: "New York, NY 10001",
59
+ country: "US",
60
+ },
61
+ lineItems: items.map((i) => ({
62
+ id: newItemId(),
63
+ description: i.desc,
64
+ quantity: i.qty,
65
+ unitPrice: i.unit,
66
+ taxRate: i.tax,
67
+ })),
68
+ notes: "Thank you for your business.",
69
+ terms: "Payment due within 30 days of invoice date.",
70
+ createdAt: now.toISOString(),
71
+ updatedAt: now.toISOString(),
72
+ };
73
+ };
74
+
75
+ const store = new Map<string, Invoice>();
76
+ const invoices = [
77
+ mkInvoice("paid", "USD", "Acme Technologies", [
78
+ { desc: "Enterprise License — Immortal.js Pro", qty: 1, unit: 299900, tax: 0.08 },
79
+ { desc: "Onboarding & Setup (4h)", qty: 4, unit: 20000, tax: 0.08 },
80
+ ]),
81
+ mkInvoice("sent", "EUR", "Munich Innovations GmbH", [
82
+ { desc: "Monthly SaaS Subscription", qty: 3, unit: 49900, tax: 0.19 },
83
+ { desc: "Priority Support (yearly)", qty: 1, unit: 120000, tax: 0.19 },
84
+ ]),
85
+ mkInvoice("overdue", "GBP", "London Fintech Ltd", [
86
+ { desc: "API Integration Consulting (8h)", qty: 8, unit: 25000, tax: 0.20 },
87
+ ]),
88
+ mkInvoice("draft", "AED", "Dubai Digital Solutions", [
89
+ { desc: "Cloud Infrastructure Setup", qty: 1, unit: 550000, tax: 0.05 },
90
+ { desc: "Custom Dashboard Development", qty: 1, unit: 180000, tax: 0.05 },
91
+ { desc: "Training Sessions (6h)", qty: 6, unit: 18000, tax: 0.05 },
92
+ ]),
93
+ mkInvoice("paid", "USD", "Startup Valley Inc", [
94
+ { desc: "Immortal.js Starter License", qty: 1, unit: 9900, tax: 0.08 },
95
+ ]),
96
+ ];
97
+
98
+ for (const inv of invoices) {
99
+ store.set(inv.id, inv);
100
+ }
101
+ return store;
102
+ }
103
+
104
+ // ─── Store ────────────────────────────────────────────────────────────────────
105
+
106
+ export class InvoiceStore {
107
+ private readonly invoices: Map<string, Invoice> = seedInvoices();
108
+
109
+ list(): Invoice[] {
110
+ return [...this.invoices.values()].sort(
111
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
112
+ );
113
+ }
114
+
115
+ get(id: string): Invoice | undefined {
116
+ return this.invoices.get(id);
117
+ }
118
+
119
+ create(invoice: Invoice): Invoice {
120
+ this.invoices.set(invoice.id, invoice);
121
+ return invoice;
122
+ }
123
+
124
+ update(id: string, patch: Partial<Invoice>): Invoice | undefined {
125
+ const existing = this.invoices.get(id);
126
+ if (!existing) return undefined;
127
+ const updated: Invoice = {
128
+ ...existing,
129
+ ...patch,
130
+ id, // never overwrite id
131
+ updatedAt: new Date().toISOString(),
132
+ };
133
+ this.invoices.set(id, updated);
134
+ return updated;
135
+ }
136
+
137
+ delete(id: string): boolean {
138
+ return this.invoices.delete(id);
139
+ }
140
+
141
+ count(): number {
142
+ return this.invoices.size;
143
+ }
144
+
145
+ newId(): string {
146
+ return newId();
147
+ }
148
+
149
+ newItemId(): string {
150
+ return newItemId();
151
+ }
152
+
153
+ nextNumber(): string {
154
+ return nextNumber();
155
+ }
156
+ }
157
+
158
+ // Singleton store
159
+ export const store = new InvoiceStore();