@techstream/quark-create-app 1.6.0 → 1.8.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.
@@ -1,24 +1,7 @@
1
1
  import { PrismaPg } from "@prisma/adapter-pg";
2
+ import { getConnectionString } from "./connection.js";
2
3
  import { PrismaClient } from "./generated/prisma/client.ts";
3
4
 
4
- /**
5
- * Builds a Postgres connection string from individual env vars (mirrors prisma.config.ts).
6
- * Throws if any required variable is missing — but only when actually called,
7
- * so the module can be safely imported at build time (e.g. during `next build`).
8
- */
9
- function getConnectionString() {
10
- const user = process.env.POSTGRES_USER;
11
- if (!user) throw new Error("POSTGRES_USER environment variable is required");
12
- const password = process.env.POSTGRES_PASSWORD;
13
- if (!password)
14
- throw new Error("POSTGRES_PASSWORD environment variable is required");
15
- const host = process.env.POSTGRES_HOST || "localhost";
16
- const port = process.env.POSTGRES_PORT || "5432";
17
- const db = process.env.POSTGRES_DB;
18
- if (!db) throw new Error("POSTGRES_DB environment variable is required");
19
- return `postgresql://${user}:${password}@${host}:${port}/${db}?schema=public`;
20
- }
21
-
22
5
  /**
23
6
  * Returns the connection pool configuration for the `pg` driver.
24
7
  *
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared connection string builder for PostgreSQL.
3
+ * Used by both the Prisma client (client.js) and Prisma CLI (prisma.config.ts).
4
+ *
5
+ * Priority: DATABASE_URL env var → assembled from POSTGRES_* env vars.
6
+ *
7
+ * @param {{ throwOnMissing?: boolean }} [options]
8
+ * - throwOnMissing=true (default) — runtime: throws if credentials are missing.
9
+ * - throwOnMissing=false — CLI/CI: returns a placeholder URL so `prisma generate` works.
10
+ * @returns {string} PostgreSQL connection string
11
+ */
12
+ export function getConnectionString({ throwOnMissing = true } = {}) {
13
+ // 1. Prefer an explicit DATABASE_URL if set
14
+ if (process.env.DATABASE_URL) {
15
+ return process.env.DATABASE_URL;
16
+ }
17
+
18
+ // 2. Assemble from individual POSTGRES_* vars
19
+ const user = process.env.POSTGRES_USER;
20
+ const password = process.env.POSTGRES_PASSWORD;
21
+ const host = process.env.POSTGRES_HOST || "localhost";
22
+ const port = process.env.POSTGRES_PORT || "5432";
23
+ const db = process.env.POSTGRES_DB;
24
+
25
+ const hasCredentials = user && password && db;
26
+
27
+ if (!hasCredentials) {
28
+ if (throwOnMissing) {
29
+ const missing = [];
30
+ if (!user) missing.push("POSTGRES_USER");
31
+ if (!password) missing.push("POSTGRES_PASSWORD");
32
+ if (!db) missing.push("POSTGRES_DB");
33
+ throw new Error(
34
+ `Missing required database environment variables: ${missing.join(", ")}. ` +
35
+ "Set DATABASE_URL or the individual POSTGRES_* variables.",
36
+ );
37
+ }
38
+
39
+ // Placeholder for prisma generate / CI — will fail at connect time, not at config time.
40
+ return "postgresql://placeholder:placeholder@localhost:5432/placeholder?schema=public";
41
+ }
42
+
43
+ return `postgresql://${user}:${password}@${host}:${port}/${db}?schema=public`;
44
+ }
@@ -0,0 +1,119 @@
1
+ import assert from "node:assert";
2
+ import { afterEach, beforeEach, describe, test } from "node:test";
3
+ import { getConnectionString } from "./connection.js";
4
+
5
+ describe("getConnectionString", () => {
6
+ const originalEnv = { ...process.env };
7
+
8
+ beforeEach(() => {
9
+ // Clear all DB-related env vars before each test
10
+ delete process.env.DATABASE_URL;
11
+ delete process.env.POSTGRES_USER;
12
+ delete process.env.POSTGRES_PASSWORD;
13
+ delete process.env.POSTGRES_HOST;
14
+ delete process.env.POSTGRES_PORT;
15
+ delete process.env.POSTGRES_DB;
16
+ });
17
+
18
+ afterEach(() => {
19
+ // Restore original env
20
+ process.env = { ...originalEnv };
21
+ });
22
+
23
+ test("returns DATABASE_URL when set", () => {
24
+ process.env.DATABASE_URL =
25
+ "postgresql://user:pass@remote:5433/mydb?schema=public";
26
+ const url = getConnectionString();
27
+ assert.strictEqual(url, process.env.DATABASE_URL);
28
+ });
29
+
30
+ test("DATABASE_URL takes priority over individual POSTGRES_* vars", () => {
31
+ process.env.DATABASE_URL =
32
+ "postgresql://url_user:url_pass@remote:5433/url_db?schema=public";
33
+ process.env.POSTGRES_USER = "individual_user";
34
+ process.env.POSTGRES_PASSWORD = "individual_pass";
35
+ process.env.POSTGRES_DB = "individual_db";
36
+
37
+ const url = getConnectionString();
38
+ assert.strictEqual(url, process.env.DATABASE_URL);
39
+ });
40
+
41
+ test("assembles from POSTGRES_* vars when DATABASE_URL is not set", () => {
42
+ process.env.POSTGRES_USER = "test_user";
43
+ process.env.POSTGRES_PASSWORD = "test_pass";
44
+ process.env.POSTGRES_DB = "test_db";
45
+
46
+ const url = getConnectionString();
47
+ assert.strictEqual(
48
+ url,
49
+ "postgresql://test_user:test_pass@localhost:5432/test_db?schema=public",
50
+ );
51
+ });
52
+
53
+ test("uses custom host and port when provided", () => {
54
+ process.env.POSTGRES_USER = "user";
55
+ process.env.POSTGRES_PASSWORD = "pass";
56
+ process.env.POSTGRES_HOST = "db.example.com";
57
+ process.env.POSTGRES_PORT = "5433";
58
+ process.env.POSTGRES_DB = "mydb";
59
+
60
+ const url = getConnectionString();
61
+ assert.strictEqual(
62
+ url,
63
+ "postgresql://user:pass@db.example.com:5433/mydb?schema=public",
64
+ );
65
+ });
66
+
67
+ test("throws when POSTGRES_USER is missing (throwOnMissing=true)", () => {
68
+ process.env.POSTGRES_PASSWORD = "pass";
69
+ process.env.POSTGRES_DB = "db";
70
+
71
+ assert.throws(() => getConnectionString(), {
72
+ message: /POSTGRES_USER/,
73
+ });
74
+ });
75
+
76
+ test("throws when POSTGRES_PASSWORD is missing (throwOnMissing=true)", () => {
77
+ process.env.POSTGRES_USER = "user";
78
+ process.env.POSTGRES_DB = "db";
79
+
80
+ assert.throws(() => getConnectionString(), {
81
+ message: /POSTGRES_PASSWORD/,
82
+ });
83
+ });
84
+
85
+ test("throws when POSTGRES_DB is missing (throwOnMissing=true)", () => {
86
+ process.env.POSTGRES_USER = "user";
87
+ process.env.POSTGRES_PASSWORD = "pass";
88
+
89
+ assert.throws(() => getConnectionString(), {
90
+ message: /POSTGRES_DB/,
91
+ });
92
+ });
93
+
94
+ test("lists all missing vars in error message", () => {
95
+ assert.throws(() => getConnectionString(), {
96
+ message: /POSTGRES_USER.*POSTGRES_PASSWORD.*POSTGRES_DB/,
97
+ });
98
+ });
99
+
100
+ test("returns placeholder when throwOnMissing=false and vars missing", () => {
101
+ const url = getConnectionString({ throwOnMissing: false });
102
+ assert.strictEqual(
103
+ url,
104
+ "postgresql://placeholder:placeholder@localhost:5432/placeholder?schema=public",
105
+ );
106
+ });
107
+
108
+ test("still assembles correctly with throwOnMissing=false when vars present", () => {
109
+ process.env.POSTGRES_USER = "user";
110
+ process.env.POSTGRES_PASSWORD = "pass";
111
+ process.env.POSTGRES_DB = "db";
112
+
113
+ const url = getConnectionString({ throwOnMissing: false });
114
+ assert.strictEqual(
115
+ url,
116
+ "postgresql://user:pass@localhost:5432/db?schema=public",
117
+ );
118
+ });
119
+ });
@@ -1,6 +1,8 @@
1
1
  export const config = {
2
- appName: "Quark",
3
- appDescription: "A modern monorepo with Next.js, React, and Prisma",
2
+ appName: process.env.APP_NAME || "Quark",
3
+ appDescription:
4
+ process.env.APP_DESCRIPTION ||
5
+ "A modern monorepo with Next.js, React, and Prisma",
4
6
  };
5
7
 
6
8
  export { getAllowedOrigins, getAppUrl, syncNextAuthUrl } from "./app-url.js";
@@ -11,3 +13,4 @@ export {
11
13
  resolveEnvironment,
12
14
  } from "./environment.js";
13
15
  export { getConfig, loadConfig, resetConfig } from "./load-config.js";
16
+ export { loadEnv } from "./validate-env.js";
@@ -36,7 +36,7 @@ export function loadConfig(overrides = {}, options = {}) {
36
36
  }
37
37
 
38
38
  // Step 1: Validate environment variables
39
- const validated = validateEnv();
39
+ const { validated } = validateEnv();
40
40
 
41
41
  // Step 2: Resolve environment and get defaults
42
42
  const envConfig = getEnvironmentConfig();
@@ -23,7 +23,6 @@ const envSchema = {
23
23
  REDIS_PORT: { required: false, description: "Redis port" },
24
24
 
25
25
  // Mail (local SMTP — Mailpit in dev)
26
- MAIL_SMTP_URL: { required: false, description: "Mail SMTP URL" },
27
26
  MAIL_HOST: { required: false, description: "Mail host" },
28
27
  MAIL_SMTP_PORT: { required: false, description: "Mail SMTP port" },
29
28
  MAIL_UI_PORT: { required: false, description: "Mail UI port" },
@@ -55,6 +54,10 @@ const envSchema = {
55
54
  },
56
55
 
57
56
  // Application
57
+ APP_NAME: {
58
+ required: false,
59
+ description: "Application name — used in metadata, emails, and page titles",
60
+ },
58
61
  APP_URL: {
59
62
  required: false,
60
63
  description:
@@ -84,18 +87,30 @@ const envSchema = {
84
87
  };
85
88
 
86
89
  /**
87
- * Validates environment variables against schema
90
+ * Validates environment variables against schema.
91
+ *
92
+ * @param {"web" | "worker"} [service="web"] — The service being validated.
93
+ * Worker skips web-only checks (e.g. NEXTAUTH_SECRET).
88
94
  * @throws {Error} If required environment variables are missing
89
- * @returns {Object} Validated environment object
95
+ * @returns {{ validated: Object, warnings: string[] }}
90
96
  */
91
- export function validateEnv() {
97
+ export function validateEnv(service = "web") {
92
98
  const errors = [];
99
+ const warnings = [];
93
100
  const validated = {};
101
+ const isTest = process.env.NODE_ENV === "test";
102
+
103
+ // Web-only required fields that workers can skip
104
+ const webOnlyRequired = new Set(["NEXTAUTH_SECRET"]);
94
105
 
95
106
  for (const [key, config] of Object.entries(envSchema)) {
96
107
  const value = process.env[key];
97
108
 
98
- if (config.required && !value) {
109
+ // Skip web-only required checks for worker service
110
+ const isRequired =
111
+ config.required && !(service === "worker" && webOnlyRequired.has(key));
112
+
113
+ if (isRequired && !value) {
99
114
  errors.push(
100
115
  `Missing required environment variable: ${key} (${config.description})`,
101
116
  );
@@ -112,6 +127,30 @@ export function validateEnv() {
112
127
  }
113
128
  }
114
129
 
130
+ // --- Cross-field validation ---
131
+
132
+ // Database: either DATABASE_URL or POSTGRES_USER must be set (skip in test)
133
+ if (!isTest) {
134
+ const hasDbUrl = !!process.env.DATABASE_URL;
135
+ const hasPostgresUser = !!process.env.POSTGRES_USER;
136
+ if (!hasDbUrl && !hasPostgresUser) {
137
+ errors.push(
138
+ "Database not configured: set DATABASE_URL or POSTGRES_USER + POSTGRES_PASSWORD + POSTGRES_DB",
139
+ );
140
+ }
141
+ }
142
+
143
+ // Redis: warn if not configured (defaults to localhost in dev, will fail in prod)
144
+ if (
145
+ !process.env.REDIS_URL &&
146
+ !process.env.REDIS_HOST &&
147
+ process.env.NODE_ENV === "production"
148
+ ) {
149
+ warnings.push(
150
+ "Redis not configured: set REDIS_URL or REDIS_HOST (defaults to localhost)",
151
+ );
152
+ }
153
+
115
154
  // Conditional: S3 storage requires bucket + credentials
116
155
  if (process.env.STORAGE_PROVIDER === "s3") {
117
156
  for (const key of [
@@ -132,29 +171,39 @@ export function validateEnv() {
132
171
  errors.push("Missing RESEND_API_KEY — required when EMAIL_PROVIDER=resend");
133
172
  }
134
173
 
174
+ // Log warnings (non-fatal)
175
+ for (const warning of warnings) {
176
+ console.warn(`[env] ⚠️ ${warning}`);
177
+ }
178
+
135
179
  if (errors.length > 0) {
136
180
  const errorMessage = `Environment Validation Failed:\n${errors.join("\n")}`;
137
181
  throw new Error(errorMessage);
138
182
  }
139
183
 
140
184
  // Ensure NEXTAUTH_URL is derived from APP_URL when not explicitly set
141
- syncNextAuthUrl();
185
+ if (service === "web") {
186
+ syncNextAuthUrl();
142
187
 
143
- // Include the (possibly derived) NEXTAUTH_URL in the validated object
144
- if (process.env.NEXTAUTH_URL && !validated.NEXTAUTH_URL) {
145
- validated.NEXTAUTH_URL = process.env.NEXTAUTH_URL;
188
+ // Include the (possibly derived) NEXTAUTH_URL in the validated object
189
+ if (process.env.NEXTAUTH_URL && !validated.NEXTAUTH_URL) {
190
+ validated.NEXTAUTH_URL = process.env.NEXTAUTH_URL;
191
+ }
146
192
  }
147
193
 
148
- return validated;
194
+ return { validated, warnings };
149
195
  }
150
196
 
151
197
  /**
152
- * Loads and validates environment variables
153
- * Call this function at application startup
198
+ * Loads and validates environment variables.
199
+ * Call this function at application startup.
200
+ *
201
+ * @param {"web" | "worker"} [service="web"]
154
202
  */
155
- export function loadEnv() {
203
+ export function loadEnv(service = "web") {
156
204
  try {
157
- return validateEnv();
205
+ const { validated } = validateEnv(service);
206
+ return validated;
158
207
  } catch (error) {
159
208
  console.error(error.message);
160
209
  process.exit(1);
@@ -3,6 +3,6 @@
3
3
  "version": "1.0.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "bullmq": "^5.67.3"
6
+ "bullmq": "^5.69.3"
7
7
  }
8
8
  }
@@ -3,9 +3,9 @@
3
3
  "version": "1.0.0",
4
4
  "type": "module",
5
5
  "devDependencies": {
6
- "@types/react": "^19.2.13",
6
+ "@types/react": "^19.2.14",
7
7
  "@types/react-dom": "^19.2.3",
8
- "react": "19.2.0",
9
- "react-dom": "19.2.0"
8
+ "react": "19.2.4",
9
+ "react-dom": "19.2.4"
10
10
  }
11
11
  }