@techstream/quark-create-app 1.8.0 → 1.10.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 (80) hide show
  1. package/README.md +2 -2
  2. package/package.json +3 -3
  3. package/src/index.js +415 -150
  4. package/src/utils.js +36 -0
  5. package/src/utils.test.js +63 -0
  6. package/templates/base-project/.cursor/rules/quark.mdc +172 -0
  7. package/templates/base-project/.github/copilot-instructions.md +55 -0
  8. package/templates/base-project/.github/workflows/release.yml +37 -8
  9. package/templates/base-project/CLAUDE.md +273 -0
  10. package/templates/base-project/README.md +72 -30
  11. package/templates/base-project/apps/web/next.config.js +5 -1
  12. package/templates/base-project/apps/web/package.json +7 -5
  13. package/templates/base-project/apps/web/public/quark.svg +46 -0
  14. package/templates/base-project/apps/web/railway.json +2 -2
  15. package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
  16. package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
  17. package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
  18. package/templates/base-project/apps/web/src/app/api/health/route.js +56 -17
  19. package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
  20. package/templates/base-project/apps/web/src/app/global-error.js +53 -0
  21. package/templates/base-project/apps/web/src/app/globals.css +121 -15
  22. package/templates/base-project/apps/web/src/app/icon.svg +46 -0
  23. package/templates/base-project/apps/web/src/app/layout.js +1 -0
  24. package/templates/base-project/apps/web/src/app/not-found.js +35 -0
  25. package/templates/base-project/apps/web/src/app/page.js +38 -5
  26. package/templates/base-project/apps/web/src/lib/theme.js +23 -0
  27. package/templates/base-project/apps/web/src/proxy.js +10 -2
  28. package/templates/base-project/package.json +16 -1
  29. package/templates/base-project/packages/db/package.json +4 -4
  30. package/templates/base-project/packages/db/src/client.js +6 -1
  31. package/templates/base-project/packages/db/src/index.js +1 -0
  32. package/templates/base-project/packages/db/src/ping.js +66 -0
  33. package/templates/base-project/scripts/doctor.js +261 -0
  34. package/templates/base-project/turbo.json +2 -1
  35. package/templates/config/package.json +1 -0
  36. package/templates/config/src/index.js +1 -3
  37. package/templates/config/src/validate-env.js +79 -3
  38. package/templates/jobs/package.json +2 -1
  39. package/templates/ui/README.md +67 -0
  40. package/templates/ui/package.json +1 -0
  41. package/templates/ui/src/badge.js +32 -0
  42. package/templates/ui/src/badge.test.js +42 -0
  43. package/templates/ui/src/button.js +64 -15
  44. package/templates/ui/src/button.test.js +34 -5
  45. package/templates/ui/src/card.js +58 -0
  46. package/templates/ui/src/card.test.js +59 -0
  47. package/templates/ui/src/checkbox.js +35 -0
  48. package/templates/ui/src/checkbox.test.js +35 -0
  49. package/templates/ui/src/dialog.js +139 -0
  50. package/templates/ui/src/dialog.test.js +15 -0
  51. package/templates/ui/src/index.js +16 -0
  52. package/templates/ui/src/input.js +15 -0
  53. package/templates/ui/src/input.test.js +27 -0
  54. package/templates/ui/src/label.js +14 -0
  55. package/templates/ui/src/label.test.js +22 -0
  56. package/templates/ui/src/select.js +42 -0
  57. package/templates/ui/src/select.test.js +27 -0
  58. package/templates/ui/src/skeleton.js +14 -0
  59. package/templates/ui/src/skeleton.test.js +22 -0
  60. package/templates/ui/src/table.js +75 -0
  61. package/templates/ui/src/table.test.js +69 -0
  62. package/templates/ui/src/textarea.js +15 -0
  63. package/templates/ui/src/textarea.test.js +27 -0
  64. package/templates/ui/src/theme-constants.js +24 -0
  65. package/templates/ui/src/theme.js +132 -0
  66. package/templates/ui/src/toast.js +229 -0
  67. package/templates/ui/src/toast.test.js +23 -0
  68. package/templates/{base-project/apps/worker → worker}/package.json +2 -2
  69. package/templates/{base-project/apps/worker → worker}/src/index.js +38 -23
  70. package/templates/{base-project/apps/worker → worker}/src/index.test.js +19 -20
  71. package/templates/base-project/apps/web/public/file.svg +0 -1
  72. package/templates/base-project/apps/web/public/globe.svg +0 -1
  73. package/templates/base-project/apps/web/public/next.svg +0 -1
  74. package/templates/base-project/apps/web/public/vercel.svg +0 -1
  75. package/templates/base-project/apps/web/public/window.svg +0 -1
  76. /package/templates/{base-project/apps/worker → worker}/README.md +0 -0
  77. /package/templates/{base-project/apps/worker → worker}/railway.json +0 -0
  78. /package/templates/{base-project/apps/worker → worker}/src/handlers/email.js +0 -0
  79. /package/templates/{base-project/apps/worker → worker}/src/handlers/files.js +0 -0
  80. /package/templates/{base-project/apps/worker → worker}/src/handlers/index.js +0 -0
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ export default function GlobalError({ reset }) {
4
+ return (
5
+ <html lang="en">
6
+ <body style={{ margin: 0, background: "#05070a" }}>
7
+ <main
8
+ className="fixed inset-0 flex flex-col items-center justify-center"
9
+ style={{ background: "#05070a" }}
10
+ >
11
+ <p
12
+ className="font-mono uppercase"
13
+ style={{
14
+ color: "#3a3a4a",
15
+ fontSize: "11px",
16
+ letterSpacing: "0.15em",
17
+ }}
18
+ >
19
+ 500
20
+ </p>
21
+ <p
22
+ className="font-mono mt-2"
23
+ style={{ color: "#2a2a3a", fontSize: "11px" }}
24
+ >
25
+ something went wrong
26
+ </p>
27
+ <nav
28
+ className="mt-6 flex items-center gap-2"
29
+ style={{ fontSize: "11px", fontFamily: "monospace" }}
30
+ >
31
+ <button
32
+ type="button"
33
+ onClick={reset}
34
+ className="quark-home-link"
35
+ style={{
36
+ background: "none",
37
+ border: "none",
38
+ padding: 0,
39
+ cursor: "pointer",
40
+ }}
41
+ >
42
+ try again
43
+ </button>
44
+ <span style={{ color: "#2a2a3a" }}>·</span>
45
+ <a href="/" className="quark-home-link">
46
+ ← home
47
+ </a>
48
+ </nav>
49
+ </main>
50
+ </body>
51
+ </html>
52
+ );
53
+ }
@@ -1,26 +1,132 @@
1
1
  @import "tailwindcss";
2
+ @source "../../../../packages/ui/src/**/*.{js,jsx}";
2
3
 
4
+ /*
5
+ * CSS custom properties for the home page shell.
6
+ * Automatically responds to the OS colour preference via prefers-color-scheme —
7
+ * no JavaScript required. Override these in your own stylesheet as needed.
8
+ */
3
9
  :root {
4
- --background: #ffffff;
5
- --foreground: #171717;
10
+ --quark-page-bg: #05070a;
11
+ --quark-home-muted: #3a3a4a;
12
+ --quark-home-sep: #2a2a3a;
13
+ --quark-home-link-idle: #4a4a6a;
14
+ --quark-home-link-hover: #377dff;
15
+ --quark-health-text: #4a4a6a;
16
+ --quark-health-sep: #2a2a3a;
17
+ --quark-health-dot-idle: #2a2a3a;
18
+ --quark-grid-line: rgba(255, 255, 255, 0.05);
6
19
  }
7
20
 
8
- @theme inline {
9
- --color-background: var(--background);
10
- --color-foreground: var(--foreground);
11
- --font-sans: var(--font-geist-sans);
12
- --font-mono: var(--font-geist-mono);
21
+ @media (prefers-color-scheme: light) {
22
+ /* OS says light AND the user hasn't explicitly pinned dark via the toggle */
23
+ :root:not([data-theme="dark"]) {
24
+ --quark-page-bg: #f7f8fa;
25
+ --quark-home-muted: #9ca3af;
26
+ --quark-home-sep: #d1d5db;
27
+ --quark-home-link-idle: #6b7280;
28
+ --quark-home-link-hover: #1a56db;
29
+ --quark-health-text: #9ca3af;
30
+ --quark-health-sep: #d1d5db;
31
+ --quark-health-dot-idle: #d1d5db;
32
+ --quark-grid-line: rgba(0, 0, 0, 0.07);
33
+ }
13
34
  }
14
35
 
15
- @media (prefers-color-scheme: dark) {
16
- :root {
17
- --background: #0a0a0a;
18
- --foreground: #ededed;
19
- }
36
+ /* User explicitly toggled to light (overrides OS dark preference) */
37
+ :root[data-theme="light"] {
38
+ --quark-page-bg: #f7f8fa;
39
+ --quark-home-muted: #9ca3af;
40
+ --quark-home-sep: #d1d5db;
41
+ --quark-home-link-idle: #6b7280;
42
+ --quark-home-link-hover: #1a56db;
43
+ --quark-health-text: #9ca3af;
44
+ --quark-health-sep: #d1d5db;
45
+ --quark-health-dot-idle: #d1d5db;
46
+ --quark-grid-line: rgba(0, 0, 0, 0.07);
20
47
  }
21
48
 
49
+ /* Pin the document background to match the app shell — prevents overscroll flash */
50
+ html,
22
51
  body {
23
- background: var(--background);
24
- color: var(--foreground);
25
- font-family: Arial, Helvetica, sans-serif;
52
+ background-color: var(--quark-page-bg);
53
+ }
54
+
55
+ .quark-home-main {
56
+ background-color: var(--quark-page-bg);
57
+ background-image:
58
+ linear-gradient(var(--quark-grid-line) 1px, transparent 1px),
59
+ linear-gradient(90deg, var(--quark-grid-line) 1px, transparent 1px);
60
+ background-size: 40px 40px;
61
+ }
62
+
63
+ /* Native dialog — browser default positions it at the top; force viewport-center */
64
+ dialog {
65
+ position: fixed;
66
+ inset: 0;
67
+ margin: auto;
68
+ }
69
+
70
+ /* Home page - footer fades in after animation takes the stage */
71
+ @keyframes quark-home-fade-in {
72
+ from {
73
+ opacity: 0;
74
+ }
75
+ to {
76
+ opacity: 1;
77
+ }
78
+ }
79
+
80
+ .quark-home-footer {
81
+ animation: quark-home-fade-in 0.6s ease-out 1.2s both;
82
+ }
83
+
84
+ .quark-home-label {
85
+ color: var(--quark-home-muted);
86
+ font-size: 11px;
87
+ letter-spacing: 0.15em;
88
+ }
89
+
90
+ .quark-home-sep {
91
+ color: var(--quark-home-sep);
92
+ font-family: monospace;
93
+ }
94
+
95
+ .quark-home-link {
96
+ color: var(--quark-home-link-idle);
97
+ text-decoration: none;
98
+ font-family: monospace;
99
+ transition: color 0.15s ease;
100
+ }
101
+
102
+ .quark-home-link:hover {
103
+ color: var(--quark-home-link-hover);
104
+ }
105
+
106
+ .quark-home-link:focus-visible {
107
+ outline: 1px solid var(--quark-home-link-hover);
108
+ outline-offset: 2px;
109
+ border-radius: 2px;
110
+ }
111
+
112
+ @keyframes quark-dialog-in {
113
+ from {
114
+ opacity: 0;
115
+ transform: scale(0.95) translateY(-8px);
116
+ }
117
+ to {
118
+ opacity: 1;
119
+ transform: scale(1) translateY(0);
120
+ }
121
+ }
122
+
123
+ @keyframes quark-dialog-out {
124
+ from {
125
+ opacity: 1;
126
+ transform: scale(1) translateY(0);
127
+ }
128
+ to {
129
+ opacity: 0;
130
+ transform: scale(0.95) translateY(-8px);
131
+ }
26
132
  }
@@ -0,0 +1,46 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none">
2
+ <!--
3
+ Quark logo — SVG master
4
+ Geometry mirrors QuarkAnimation.js constants (ring/dash thickness ratio ~0.20)
5
+
6
+ Ring: center (100,100), centerline radius 65, stroke-width 26
7
+ Gap: centred at 45.8° (dashAngle=0.8 rad), half-width 27.1° (0.472 rad)
8
+ Dash: radial at 45.8°, from radius 23.4 → 103.4 (proportional to animation dL)
9
+ stroke-width 28 (= dashThickness/ringThickness × 26 = 2.8/2.6 × 26)
10
+
11
+ Colors: #2d3436 (dark/top) · #377dff (blue/bottom-left) · #ff4757 (red/tail)
12
+ -->
13
+
14
+ <!-- Dark arc — upper semicircle: left (180°) → top (270°) → right (360°) -->
15
+ <path
16
+ d="M 35,100 A 65,65 0 0,1 165,100"
17
+ stroke="#2d3436"
18
+ stroke-width="26"
19
+ stroke-linecap="butt"
20
+ />
21
+
22
+ <!-- Blue arc — bottom-left: gap-end (72.9°) → left (180°) -->
23
+ <path
24
+ d="M 119.1,162.1 A 65,65 0 0,1 35,100"
25
+ stroke="#377dff"
26
+ stroke-width="26"
27
+ stroke-linecap="butt"
28
+ />
29
+
30
+ <!-- Red arc — tiny pre-gap sliver: right (0°) → gap-start (18.8°) -->
31
+ <path
32
+ d="M 165,100 A 65,65 0 0,1 161.5,121"
33
+ stroke="#ff4757"
34
+ stroke-width="26"
35
+ stroke-linecap="butt"
36
+ />
37
+
38
+ <!-- Red dash — radial tail through the gap -->
39
+ <line
40
+ x1="116.3" y1="116.8"
41
+ x2="172" y2="174.2"
42
+ stroke="#ff4757"
43
+ stroke-width="28"
44
+ stroke-linecap="butt"
45
+ />
46
+ </svg>
@@ -1,3 +1,4 @@
1
+ import "./globals.css";
1
2
  import { getSiteMetadata } from "../lib/seo/site-metadata.js";
2
3
 
3
4
  export const metadata = getSiteMetadata();
@@ -0,0 +1,35 @@
1
+ import Link from "next/link";
2
+
3
+ export const metadata = {
4
+ title: "404 — Not Found",
5
+ };
6
+
7
+ export default function NotFound() {
8
+ return (
9
+ <main
10
+ style={{ background: "#05070a" }}
11
+ className="fixed inset-0 flex flex-col items-center justify-center"
12
+ >
13
+ <p
14
+ className="font-mono uppercase"
15
+ style={{ color: "#3a3a4a", fontSize: "11px", letterSpacing: "0.15em" }}
16
+ >
17
+ 404
18
+ </p>
19
+ <p
20
+ className="font-mono mt-2"
21
+ style={{ color: "#2a2a3a", fontSize: "11px" }}
22
+ >
23
+ page not found
24
+ </p>
25
+ <nav
26
+ className="mt-6"
27
+ style={{ fontSize: "11px", fontFamily: "monospace" }}
28
+ >
29
+ <Link href="/" className="quark-home-link">
30
+ ← home
31
+ </Link>
32
+ </nav>
33
+ </main>
34
+ );
35
+ }
@@ -1,10 +1,43 @@
1
+ import HealthIndicator from "./_components/HealthIndicator.js";
2
+ import HomeThemeToggle from "./_components/HomeThemeToggle.js";
3
+ import QuarkAnimation from "./_components/QuarkAnimation.js";
4
+
1
5
  export default function Home() {
2
6
  return (
3
- <main className="min-h-screen flex flex-col items-center justify-center p-8">
4
- <h1 className="text-4xl font-bold text-center mb-4">Quark</h1>
5
- <p className="text-lg text-gray-600 text-center max-w-md">
6
- Welcome to the Quark monorepo. This is a Next.js 16 app with React 19.
7
- </p>
7
+ <main className="quark-home-main min-h-screen flex flex-col items-center justify-center">
8
+ {/* Hero: animated ASCII logo, centered */}
9
+ <QuarkAnimation />
10
+
11
+ {/* Footer — flows naturally below the animation */}
12
+ <div className="quark-home-footer flex flex-col items-center gap-2 pt-8 pb-8">
13
+ {/* Identity */}
14
+ <p className="quark-home-label font-mono uppercase">Your Quark App</p>
15
+
16
+ {/* Navigation */}
17
+ <nav className="flex items-center gap-2">
18
+ <a href="/api/health" className="quark-home-link">
19
+ health
20
+ </a>
21
+ <span className="quark-home-sep">·</span>
22
+ <a href="/playground" className="quark-home-link">
23
+ playground
24
+ </a>
25
+ <span className="quark-home-sep">·</span>
26
+ <a
27
+ href="https://www.npmjs.com/package/@techstream/quark-create-app"
28
+ target="_blank"
29
+ rel="noopener noreferrer"
30
+ className="quark-home-link"
31
+ >
32
+ npm
33
+ </a>
34
+ <span className="quark-home-sep">·</span>
35
+ <HomeThemeToggle />
36
+ </nav>
37
+
38
+ {/* Status — supplementary, last */}
39
+ <HealthIndicator />
40
+ </div>
8
41
  </main>
9
42
  );
10
43
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Theme system constants for the home page shell.
3
+ *
4
+ * These three values form the contract between HomeThemeToggle and any
5
+ * ThemeProvider that may be present in the tree (e.g. from @techstream/quark-ui).
6
+ *
7
+ * An identical copy lives in packages/ui/src/theme-constants.js — each
8
+ * package owns its own copy so there is no cross-package import. If you
9
+ * rename any of these, update both files and the CSS selectors in globals.css.
10
+ */
11
+
12
+ /** Key used to persist the user's explicit theme choice in localStorage. */
13
+ export const THEME_STORAGE_KEY = "quark-theme";
14
+
15
+ /**
16
+ * Attribute set on <html> so CSS custom properties can react to JS state.
17
+ * Use via element.setAttribute(THEME_ATTR, value) / element.getAttribute(THEME_ATTR).
18
+ * Referenced in globals.css as [data-theme="light"] / [data-theme="dark"].
19
+ */
20
+ export const THEME_ATTR = "data-theme";
21
+
22
+ /** CustomEvent name dispatched when the theme changes outside a ThemeProvider. */
23
+ export const THEME_CHANGE_EVENT = "quark-theme-change";
@@ -92,7 +92,16 @@ const CORS_CONFIG = {
92
92
 
93
93
  /**
94
94
  * Security headers configuration
95
+ *
96
+ * In development, Turbopack's hot-reload runtime uses eval() for module
97
+ * evaluation. 'unsafe-eval' is therefore added to script-src only when
98
+ * NODE_ENV is not 'production' — it must never reach a production build.
95
99
  */
100
+ const _scriptSrc =
101
+ process.env.NODE_ENV !== "production"
102
+ ? "script-src 'self' 'unsafe-inline' 'unsafe-eval'"
103
+ : "script-src 'self' 'unsafe-inline'";
104
+
96
105
  const SECURITY_HEADERS = {
97
106
  "X-DNS-Prefetch-Control": "on",
98
107
  "Strict-Transport-Security": "max-age=63072000; includeSubDomains",
@@ -100,8 +109,7 @@ const SECURITY_HEADERS = {
100
109
  "X-Content-Type-Options": "nosniff",
101
110
  "Referrer-Policy": "strict-origin-when-cross-origin",
102
111
  "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
103
- "Content-Security-Policy":
104
- "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self';",
112
+ "Content-Security-Policy": `default-src 'self'; ${_scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self';`,
105
113
  };
106
114
 
107
115
  /**
@@ -15,7 +15,20 @@
15
15
  "db:migrate": "turbo run db:migrate",
16
16
  "db:push": "turbo run db:push",
17
17
  "db:seed": "turbo run db:seed",
18
- "db:studio": "turbo run db:studio"
18
+ "db:studio": "turbo run db:studio",
19
+ "doctor": "node scripts/doctor.js",
20
+ "doctor:fix": "node scripts/doctor.js --fix",
21
+ "prepare": "simple-git-hooks"
22
+ },
23
+ "simple-git-hooks": {
24
+ "pre-commit": "pnpm nano-staged",
25
+ "pre-push": "pnpm test"
26
+ },
27
+ "nano-staged": {
28
+ "**/*.{js,mjs,ts,tsx,json,css}": [
29
+ "biome format --write",
30
+ "biome check --write"
31
+ ]
19
32
  },
20
33
  "keywords": [],
21
34
  "author": "",
@@ -39,6 +52,8 @@
39
52
  "@biomejs/biome": "^2.4.0",
40
53
  "@types/node": "^24.10.9",
41
54
  "dotenv-cli": "^11.0.0",
55
+ "nano-staged": "^0.9.0",
56
+ "simple-git-hooks": "^2.13.1",
42
57
  "tsx": "^4.21.0",
43
58
  "turbo": "^2.8.1"
44
59
  }
@@ -21,12 +21,12 @@
21
21
  "@faker-js/faker": "^10.3.0",
22
22
  "@techstream/quark-config": "workspace:*",
23
23
  "bcryptjs": "^3.0.3",
24
- "prisma": "^7.4.0"
24
+ "prisma": "^7.4.2"
25
25
  },
26
26
  "dependencies": {
27
- "@prisma/adapter-pg": "^7.4.0",
28
- "@prisma/client": "^7.4.0",
29
- "pg": "^8.18.0",
27
+ "@prisma/adapter-pg": "^7.4.2",
28
+ "@prisma/client": "^7.4.2",
29
+ "pg": "^8.20.0",
30
30
  "zod": "^4.3.6"
31
31
  }
32
32
  }
@@ -41,10 +41,15 @@ function getPrismaClient() {
41
41
  /**
42
42
  * Prisma client singleton. Lazily initialized on first use so the module
43
43
  * can be imported safely at build time (no DB env vars needed).
44
+ *
45
+ * Methods are bound to the real client so `this` inside Prisma internals is
46
+ * always the PrismaClient instance, never the Proxy wrapper.
44
47
  */
45
48
  export const prisma = new Proxy(/** @type {PrismaClient} */ ({}), {
46
49
  get(_target, prop) {
47
- return getPrismaClient()[prop];
50
+ const client = getPrismaClient();
51
+ const value = client[prop];
52
+ return typeof value === "function" ? value.bind(client) : value;
48
53
  },
49
54
  });
50
55
 
@@ -1,3 +1,4 @@
1
1
  export * from "./client.js";
2
+ export * from "./ping.js";
2
3
  export * from "./queries.js";
3
4
  export * from "./schemas.js";
@@ -0,0 +1,66 @@
1
+ import { getConnectionString } from "./connection.js";
2
+
3
+ /**
4
+ * Pings PostgreSQL to verify connectivity.
5
+ * Uses a raw `pg.Client` directly so the check is immune to Prisma
6
+ * adapter quirks — in particular, Prisma 7 + @prisma/adapter-pg surfaces
7
+ * connection failures as a misleading "Invalid invocation" error rather
8
+ * than a proper ECONNREFUSED.
9
+ *
10
+ * @param {object} [options]
11
+ * @param {number} [options.timeout=3000] - Connection/query timeout in milliseconds.
12
+ * @returns {Promise<{ status: "ok", latencyMs: number } | { status: "error", message: string }>}
13
+ */
14
+ export async function pingDatabase({ timeout = 3000 } = {}) {
15
+ /** @type {import("pg").Client | null} */
16
+ let client = null;
17
+ let connectionString;
18
+
19
+ try {
20
+ const { default: pg } = await import("pg");
21
+ connectionString = getConnectionString();
22
+
23
+ client = new pg.Client({
24
+ connectionString,
25
+ connectionTimeoutMillis: timeout,
26
+ query_timeout: timeout,
27
+ });
28
+
29
+ await client.connect();
30
+
31
+ const start = performance.now();
32
+ await client.query("SELECT 1");
33
+ const latencyMs = Math.round(performance.now() - start);
34
+
35
+ return { status: "ok", latencyMs };
36
+ } catch (/** @type {any} */ error) {
37
+ let message;
38
+ if (
39
+ error?.code === "MODULE_NOT_FOUND" ||
40
+ error?.code === "ERR_MODULE_NOT_FOUND"
41
+ ) {
42
+ message = "pg is not installed";
43
+ } else if (
44
+ error?.code === "ECONNREFUSED" ||
45
+ /ECONNREFUSED/i.test(error?.message ?? "")
46
+ ) {
47
+ try {
48
+ const { hostname, port } = new URL(connectionString);
49
+ message = `PostgreSQL unreachable at ${hostname}:${port || "5432"}`;
50
+ } catch {
51
+ message = "PostgreSQL unreachable";
52
+ }
53
+ } else if (error?.code === "ENOTFOUND") {
54
+ message = `PostgreSQL host not found: ${error.hostname ?? "unknown"}`;
55
+ } else {
56
+ message = error?.message ?? String(error);
57
+ }
58
+ return { status: "error", message };
59
+ } finally {
60
+ try {
61
+ await client?.end();
62
+ } catch {
63
+ // ignore disconnect errors
64
+ }
65
+ }
66
+ }