@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.
- package/README.md +2 -2
- package/package.json +3 -3
- package/src/index.js +415 -150
- package/src/utils.js +36 -0
- package/src/utils.test.js +63 -0
- package/templates/base-project/.cursor/rules/quark.mdc +172 -0
- package/templates/base-project/.github/copilot-instructions.md +55 -0
- package/templates/base-project/.github/workflows/release.yml +37 -8
- package/templates/base-project/CLAUDE.md +273 -0
- package/templates/base-project/README.md +72 -30
- package/templates/base-project/apps/web/next.config.js +5 -1
- package/templates/base-project/apps/web/package.json +7 -5
- package/templates/base-project/apps/web/public/quark.svg +46 -0
- package/templates/base-project/apps/web/railway.json +2 -2
- package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
- package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
- package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
- package/templates/base-project/apps/web/src/app/api/health/route.js +56 -17
- package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
- package/templates/base-project/apps/web/src/app/global-error.js +53 -0
- package/templates/base-project/apps/web/src/app/globals.css +121 -15
- package/templates/base-project/apps/web/src/app/icon.svg +46 -0
- package/templates/base-project/apps/web/src/app/layout.js +1 -0
- package/templates/base-project/apps/web/src/app/not-found.js +35 -0
- package/templates/base-project/apps/web/src/app/page.js +38 -5
- package/templates/base-project/apps/web/src/lib/theme.js +23 -0
- package/templates/base-project/apps/web/src/proxy.js +10 -2
- package/templates/base-project/package.json +16 -1
- package/templates/base-project/packages/db/package.json +4 -4
- package/templates/base-project/packages/db/src/client.js +6 -1
- package/templates/base-project/packages/db/src/index.js +1 -0
- package/templates/base-project/packages/db/src/ping.js +66 -0
- package/templates/base-project/scripts/doctor.js +261 -0
- package/templates/base-project/turbo.json +2 -1
- package/templates/config/package.json +1 -0
- package/templates/config/src/index.js +1 -3
- package/templates/config/src/validate-env.js +79 -3
- package/templates/jobs/package.json +2 -1
- package/templates/ui/README.md +67 -0
- package/templates/ui/package.json +1 -0
- package/templates/ui/src/badge.js +32 -0
- package/templates/ui/src/badge.test.js +42 -0
- package/templates/ui/src/button.js +64 -15
- package/templates/ui/src/button.test.js +34 -5
- package/templates/ui/src/card.js +58 -0
- package/templates/ui/src/card.test.js +59 -0
- package/templates/ui/src/checkbox.js +35 -0
- package/templates/ui/src/checkbox.test.js +35 -0
- package/templates/ui/src/dialog.js +139 -0
- package/templates/ui/src/dialog.test.js +15 -0
- package/templates/ui/src/index.js +16 -0
- package/templates/ui/src/input.js +15 -0
- package/templates/ui/src/input.test.js +27 -0
- package/templates/ui/src/label.js +14 -0
- package/templates/ui/src/label.test.js +22 -0
- package/templates/ui/src/select.js +42 -0
- package/templates/ui/src/select.test.js +27 -0
- package/templates/ui/src/skeleton.js +14 -0
- package/templates/ui/src/skeleton.test.js +22 -0
- package/templates/ui/src/table.js +75 -0
- package/templates/ui/src/table.test.js +69 -0
- package/templates/ui/src/textarea.js +15 -0
- package/templates/ui/src/textarea.test.js +27 -0
- package/templates/ui/src/theme-constants.js +24 -0
- package/templates/ui/src/theme.js +132 -0
- package/templates/ui/src/toast.js +229 -0
- package/templates/ui/src/toast.test.js +23 -0
- package/templates/{base-project/apps/worker → worker}/package.json +2 -2
- package/templates/{base-project/apps/worker → worker}/src/index.js +38 -23
- package/templates/{base-project/apps/worker → worker}/src/index.test.js +19 -20
- package/templates/base-project/apps/web/public/file.svg +0 -1
- package/templates/base-project/apps/web/public/globe.svg +0 -1
- package/templates/base-project/apps/web/public/next.svg +0 -1
- package/templates/base-project/apps/web/public/vercel.svg +0 -1
- package/templates/base-project/apps/web/public/window.svg +0 -1
- /package/templates/{base-project/apps/worker → worker}/README.md +0 -0
- /package/templates/{base-project/apps/worker → worker}/railway.json +0 -0
- /package/templates/{base-project/apps/worker → worker}/src/handlers/email.js +0 -0
- /package/templates/{base-project/apps/worker → worker}/src/handlers/files.js +0 -0
- /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
|
-
--
|
|
5
|
-
--
|
|
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
|
-
@
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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(--
|
|
24
|
-
|
|
25
|
-
|
|
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>
|
|
@@ -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
|
|
4
|
-
|
|
5
|
-
<
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
24
|
+
"prisma": "^7.4.2"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@prisma/adapter-pg": "^7.4.
|
|
28
|
-
"@prisma/client": "^7.4.
|
|
29
|
-
"pg": "^8.
|
|
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
|
-
|
|
50
|
+
const client = getPrismaClient();
|
|
51
|
+
const value = client[prop];
|
|
52
|
+
return typeof value === "function" ? value.bind(client) : value;
|
|
48
53
|
},
|
|
49
54
|
});
|
|
50
55
|
|
|
@@ -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
|
+
}
|