@usequark/quark-create-app 1.25.1 → 1.25.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-create-app",
3
- "version": "1.25.1",
3
+ "version": "1.25.2",
4
4
  "description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
5
5
  "keywords": [
6
6
  "quark",
package/src/index.js CHANGED
@@ -1530,6 +1530,10 @@ STORAGE_PROVIDER=local
1530
1530
  __QUARK_SCOPE__: scope,
1531
1531
  __QUARK_PROJECT_NAME__: projectName,
1532
1532
  __QUARK_SCAFFOLD_DATE__: scaffoldDate,
1533
+ // The web port is resolved in Step 9 (findAvailablePort), so the
1534
+ // docs can point at the port the app will actually bind rather
1535
+ // than always claiming 3000.
1536
+ __QUARK_WEB_PORT__: String(webPort),
1533
1537
  __QUARK_OPTIONAL_APPS__: optionalAppLines,
1534
1538
  __QUARK_OPTIONAL_PACKAGES__: optionalBlock,
1535
1539
  __QUARK_FEATURE_ROWS__: featureRows,
@@ -6,8 +6,7 @@
6
6
 
7
7
  1. `CLAUDE.md` - project rules and conventions (keep this updated)
8
8
  2. `docs/` - guides and walkthroughs
9
- 3. `openapi.yaml` - API contract
10
- 4. `.opencode/skills/` - embedded skills (how to build bookings, CRM, CMS, AI, and more on this project)
9
+ 3. `.opencode/skills/` - embedded skills (how to build bookings, CRM, CMS, AI, and more on this project)
11
10
 
12
11
  ## Quick start
13
12
 
@@ -18,4 +17,4 @@ pnpm db:migrate # Apply migrations
18
17
  pnpm dev # Start the app (and worker, if included)
19
18
  ```
20
19
 
21
- Open [http://localhost:3000](http://localhost:3000).
20
+ Open [http://localhost:__QUARK_WEB_PORT__](http://localhost:__QUARK_WEB_PORT__).
@@ -19,7 +19,7 @@ pnpm db:seed # Seed the database (admin user + sample data)
19
19
  pnpm dev # Start the app (and worker, if included)
20
20
  ```
21
21
 
22
- Open [http://localhost:3000](http://localhost:3000)
22
+ Open [http://localhost:__QUARK_WEB_PORT__](http://localhost:__QUARK_WEB_PORT__)
23
23
 
24
24
  ## UI References
25
25
 
@@ -10,37 +10,39 @@
10
10
  * connection and produces a fresh connection storm on the dependency that was
11
11
  * already struggling. That turns a transient blip into a restart loop.
12
12
  * Callers that need a hard verdict should read `status`, not the status code.
13
+ *
14
+ * The probing itself lives in `@usequark/quark-core/health`. This route is the
15
+ * thin HTTP shell around it: it wires the app's probes in and renders the
16
+ * report. Projects hand-rolled their own copy of this orchestration, which is
17
+ * what the core module replaces.
13
18
  */
14
19
 
15
20
  import {
21
+ checkQueues,
22
+ checkStorage,
16
23
  createLogger,
17
- createStorage,
18
24
  getRegisteredQueues,
19
25
  pingRedis,
26
+ runHealthChecks,
20
27
  } from "@usequark/quark-core";
21
28
  import { pingDatabase } from "@usequark/quark-db";
22
29
  import { NextResponse } from "next/server";
23
30
 
24
31
  const logger = createLogger("health");
25
32
 
26
- /** Ceiling for the whole check (ms). */
27
- const HEALTH_CHECK_TIMEOUT = 5000;
28
-
29
- /** Ceiling for one dependency probe (ms). Clamped to the overall budget, so a
30
- * single probe can never outlive the request. */
31
- const PROBE_TIMEOUT = 3000;
32
-
33
- /** Message returned in place of a real error when NODE_ENV is production. */
34
- const GENERIC_MESSAGES = {
35
- database: "Database unavailable",
36
- redis: "Redis unavailable",
37
- storage: "Storage unavailable",
38
- queue: "Queue unavailable",
39
- };
40
-
41
33
  export async function GET() {
42
34
  try {
43
- const result = await runHealthChecks();
35
+ // `pingDatabase` is passed in rather than imported by the core module:
36
+ // `@usequark/quark-db` depends on `@usequark/quark-core`, so core cannot
37
+ // import it back without a cycle.
38
+ const result = await runHealthChecks({
39
+ probes: {
40
+ database: pingDatabase,
41
+ redis: pingRedis,
42
+ storage: checkStorage,
43
+ queues: () => checkQueues(getRegisteredQueues),
44
+ },
45
+ });
44
46
 
45
47
  // Always 200 — see the module comment. An orchestrator restarting this
46
48
  // service is what turns a dependency blip into an outage.
@@ -67,197 +69,3 @@ export async function GET() {
67
69
  );
68
70
  }
69
71
  }
70
-
71
- /**
72
- * Runs every dependency probe concurrently and aggregates the results.
73
- *
74
- * Concurrency matters as much as the deadlines: run sequentially, a 3s database
75
- * timeout plus a 3s Redis timeout exhausts the 5s budget before storage is even
76
- * attempted, and the whole request falls through to the give-up path.
77
- *
78
- * @returns {Promise<{ status: string, timestamp: string, durationMs: number, checks: Record<string, object> }>}
79
- */
80
- async function runHealthChecks() {
81
- const startedAt = performance.now();
82
-
83
- // Every probe is wrapped in settleProbe(), so this Promise.all cannot reject
84
- // and cannot outlast the deadline.
85
- const [database, redis, storage, queues] = await Promise.all([
86
- settleProbe("database", () => pingDatabase()),
87
- settleProbe("redis", () => pingRedis()),
88
- // `provider` rides along on the error result so a failure still names the
89
- // provider that failed — settleProbe owns the message redaction.
90
- settleProbe("storage", checkStorage, {
91
- provider: process.env.STORAGE_PROVIDER || "local",
92
- }),
93
- settleProbe("queues", checkQueues),
94
- ]);
95
-
96
- const checks = { database, redis, storage };
97
- if (queues) checks.queues = queues;
98
-
99
- return {
100
- // "degraded" means at least one dependency failed. Both this and "ok"
101
- // answer 200 — see the module comment.
102
- status: Object.values(checks).some(isFailing) ? "degraded" : "ok",
103
- timestamp: new Date().toISOString(),
104
- durationMs: Math.round(performance.now() - startedAt),
105
- checks,
106
- };
107
- }
108
-
109
- /**
110
- * Reports whether a check entry counts as a failure. Handles both a flat probe
111
- * result (`{ status: "error" }`) and the nested queue map, where the verdict
112
- * lives on each queue rather than on the map itself.
113
- *
114
- * @param {unknown} check
115
- * @returns {boolean}
116
- */
117
- function isFailing(check) {
118
- if (!check || typeof check !== "object") return false;
119
-
120
- // A flat probe result carries its own `status`. The nested queue map does
121
- // not, so fall through to its entries.
122
- if (typeof (/** @type {any} */ (check).status) === "string") {
123
- return /** @type {any} */ (check).status === "error";
124
- }
125
-
126
- return Object.values(/** @type {Record<string, any>} */ (check)).some(
127
- (entry) => entry?.status === "error",
128
- );
129
- }
130
-
131
- /**
132
- * Runs one dependency probe with its own deadline and normalises every failure
133
- * mode — rejection, timeout, or a probe that never settles — into a result
134
- * object. Never throws, so callers can rely on `Promise.all` resolving.
135
- *
136
- * @param {string} name - Probe name, also the key under `checks`.
137
- * @param {() => Promise<object>} probe
138
- * @param {object} [context] - Fields to merge into the error result.
139
- * @returns {Promise<{ status: string } & Record<string, unknown>>}
140
- */
141
- async function settleProbe(name, probe, context = {}) {
142
- // A probe that hangs must not hold up the aggregate, and must never outlive
143
- // the overall budget. The timer is always cleared, so it never leaks.
144
- let timer;
145
- const deadline = new Promise((resolve) => {
146
- timer = setTimeout(
147
- () => resolve({ status: "error", message: `${name} check timed out` }),
148
- Math.min(PROBE_TIMEOUT, HEALTH_CHECK_TIMEOUT),
149
- );
150
- });
151
-
152
- try {
153
- return withSafeMessage(name, await Promise.race([probe(), deadline]));
154
- } catch (/** @type {any} */ error) {
155
- return withSafeMessage(name, {
156
- ...context,
157
- status: "error",
158
- message: error?.message,
159
- });
160
- } finally {
161
- clearTimeout(timer);
162
- }
163
- }
164
-
165
- /**
166
- * Returns a check result whose message is safe to serve publicly.
167
- *
168
- * A probe can signal failure two ways — by rejecting, or by *resolving* with
169
- * `{ status: "error", message }`. Both are routed through here so neither can
170
- * bypass the redaction: `pingRedis` returned `{ status: "error" }` carrying the
171
- * raw `REDIS_URL`, so a normaliser that only handled rejections would have let
172
- * the password straight through.
173
- *
174
- * @param {string} name - Probe name.
175
- * @param {object} [result]
176
- * @returns {object}
177
- */
178
- function withSafeMessage(name, result) {
179
- if (result?.status !== "error") return result;
180
-
181
- const { message, ...rest } = result;
182
- return { ...rest, status: "error", message: safeMessage(name, message) };
183
- }
184
-
185
- /**
186
- * Reduces a failure to a message safe to return from an unauthenticated route.
187
- *
188
- * Driver errors carry the connection string they failed on, so the raw text is
189
- * only echoed outside production. `pingRedis`/`pingDatabase` redact at the
190
- * source regardless; this is the second layer.
191
- *
192
- * @param {string} name - Probe name.
193
- * @param {string|Error} [source] - The failure message or the error itself.
194
- * @returns {string}
195
- */
196
- function safeMessage(name, source) {
197
- if (process.env.NODE_ENV === "production") {
198
- return GENERIC_MESSAGES[name] ?? "Check unavailable";
199
- }
200
-
201
- return source?.message ?? source ?? "Unknown error";
202
- }
203
-
204
- /**
205
- * Verifies storage is reachable and writable by writing then deleting a
206
- * small sentinel object. Uses whichever provider is configured via
207
- * STORAGE_PROVIDER (defaults to "local" when unset).
208
- * @returns {Promise<{ status: string, provider: string }>}
209
- */
210
- async function checkStorage() {
211
- const provider = process.env.STORAGE_PROVIDER || "local";
212
- const storage = createStorage();
213
- const sentinelKey = ".health-check-sentinel";
214
-
215
- await storage.put(sentinelKey, Buffer.from("ok"), {
216
- contentType: "text/plain",
217
- });
218
- await storage.delete(sentinelKey);
219
-
220
- return { status: "ok", provider };
221
- }
222
-
223
- /**
224
- * Reports queue depths. Only meaningful in a process that registered the
225
- * queues — the web app registers none, so this resolves to null and the key is
226
- * omitted from the response rather than reported as a false pass.
227
- *
228
- * @returns {Promise<Record<string, { status: string, waiting?: number, active?: number, failed?: number, message?: string }> | null>}
229
- */
230
- async function checkQueues() {
231
- const registeredQueues = getRegisteredQueues();
232
- if (registeredQueues.size === 0) return null;
233
-
234
- const entries = [...registeredQueues.entries()];
235
- const results = await Promise.allSettled(
236
- entries.map(([, queue]) =>
237
- Promise.all([
238
- queue.getWaitingCount(),
239
- queue.getActiveCount(),
240
- queue.getFailedCount(),
241
- ]),
242
- ),
243
- );
244
-
245
- const queueChecks = {};
246
-
247
- for (let i = 0; i < entries.length; i++) {
248
- const [name] = entries[i];
249
- const result = results[i];
250
-
251
- if (result.status === "fulfilled") {
252
- const [waiting, active, failed] = result.value;
253
- queueChecks[name] = { status: "ok", waiting, active, failed };
254
- } else {
255
- queueChecks[name] = withSafeMessage("queue", {
256
- status: "error",
257
- message: result.reason?.message,
258
- });
259
- }
260
- }
261
-
262
- return queueChecks;
263
- }
@@ -60,6 +60,35 @@ function collectTests(targetPath) {
60
60
 
61
61
  const files = roots.flatMap((rootPath) => collectTests(path.resolve(rootPath)));
62
62
 
63
+ /**
64
+ * Escapes glob metacharacters so `node --test` treats the path as a literal
65
+ * path rather than a pattern.
66
+ *
67
+ * Next.js dynamic route segments are directories of the form `[id]` and
68
+ * `[...slug]`, and `node --test` matches its file arguments as globs. An
69
+ * unescaped `[id]` is read as a character class matching a single `i` or `d`,
70
+ * so the path matches nothing. The failure is silent — the runner reports
71
+ * zero tests and exits 0 — which meant every test co-located in a dynamic
72
+ * segment (`api/files/[id]/route.test.js`, and `users/[id]` and
73
+ * `[...nextauth]` which have none) was collected here and then silently
74
+ * dropped at the `node --test` boundary.
75
+ *
76
+ * Only `[` and `]` need escaping: a literal `*`, `?`, `{`, `}`, `+`, `@` or
77
+ * `!` in a directory name is resolved as itself once no glob metacharacter
78
+ * remains to give it meaning. `[[]` and `[]]` are the character-class escapes
79
+ * for the literal characters; a backslash is *not* honoured here, so it
80
+ * silently fails to escape anything.
81
+ *
82
+ * @param {string} filePath
83
+ * @returns {string}
84
+ */
85
+ function escapeGlobChars(filePath) {
86
+ // Single pass. Two `replaceAll` calls in sequence do not work: the `[` of
87
+ // `[[]` introduces a `]` that the second call then escapes again, yielding
88
+ // `[[[]]` for one literal `[`.
89
+ return filePath.replace(/[[\]]/g, (char) => (char === "[" ? "[[]" : "[]]"));
90
+ }
91
+
63
92
  if (files.length === 0) {
64
93
  // An empty suite is not a failure. `node --test` exits 0 when it matches no
65
94
  // files, so exiting 1 here made a freshly scaffolded project fail its own
@@ -75,7 +104,7 @@ const result = spawnSync(
75
104
  "tsx/esm",
76
105
  "--experimental-test-module-mocks",
77
106
  "--test",
78
- ...files,
107
+ ...files.map(escapeGlobChars),
79
108
  ],
80
109
  {
81
110
  stdio: "inherit",