@usequark/quark-create-app 1.25.1 → 1.25.3
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 +1 -1
- package/src/deploy/adapters/iac.js +37 -9
- package/src/index.js +4 -0
- package/templates/base-project/.railway/railway.ts +1 -1
- package/templates/base-project/MAIN.md +2 -3
- package/templates/base-project/README.md +1 -1
- package/templates/base-project/apps/web/src/app/api/health/route.js +19 -211
- package/templates/base-project/scripts/run-tests.mjs +30 -1
package/package.json
CHANGED
|
@@ -2,6 +2,29 @@ import { execa } from "execa";
|
|
|
2
2
|
|
|
3
3
|
const RAILWAY = "railway";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Every helper `railway/iac` exports that can appear in a generated body.
|
|
7
|
+
*
|
|
8
|
+
* The import list is derived from the helpers the body actually calls, rather
|
|
9
|
+
* than hardcoded. A helper that is called but not imported is a ReferenceError
|
|
10
|
+
* when the CLI evaluates the file, and that evaluation only happens later, in
|
|
11
|
+
* the user's `railway config apply` — so nothing in this repo ever surfaced it.
|
|
12
|
+
* Deriving the list makes the two impossible to drift apart.
|
|
13
|
+
*/
|
|
14
|
+
const IAC_HELPERS = [
|
|
15
|
+
"bucket",
|
|
16
|
+
"database",
|
|
17
|
+
"defineRailway",
|
|
18
|
+
"group",
|
|
19
|
+
"image",
|
|
20
|
+
"postgres",
|
|
21
|
+
"preserve",
|
|
22
|
+
"project",
|
|
23
|
+
"redis",
|
|
24
|
+
"service",
|
|
25
|
+
"volume",
|
|
26
|
+
];
|
|
27
|
+
|
|
5
28
|
/**
|
|
6
29
|
* Escapes a string for safe embedding in a TypeScript string literal.
|
|
7
30
|
* Handles backslashes, quotes, newlines, and template literal interpolation.
|
|
@@ -65,9 +88,6 @@ export async function generateIacFile({
|
|
|
65
88
|
const { default: fs } = await import("fs-extra");
|
|
66
89
|
const { dirname } = await import("node:path");
|
|
67
90
|
|
|
68
|
-
const imports = new Set();
|
|
69
|
-
imports.add(`import { defineRailway, project, service } from "railway/iac";`);
|
|
70
|
-
|
|
71
91
|
const resourceLines = [];
|
|
72
92
|
|
|
73
93
|
for (const svc of services) {
|
|
@@ -116,17 +136,25 @@ export async function generateIacFile({
|
|
|
116
136
|
|
|
117
137
|
const resources = services.map((s) => toIdentifier(s.name)).join(", ");
|
|
118
138
|
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
export default defineRailway(() => {
|
|
139
|
+
const body = `export default defineRailway(() => {
|
|
122
140
|
${resourceLines.join("\n\n")}
|
|
123
141
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
142
|
+
return project("${escapeTsString(projectName)}", {
|
|
143
|
+
resources: [${resources}],
|
|
144
|
+
});
|
|
127
145
|
});
|
|
128
146
|
`;
|
|
129
147
|
|
|
148
|
+
// Derive the import list from the body so a helper can never be called
|
|
149
|
+
// without being in scope. See IAC_HELPERS.
|
|
150
|
+
const used = IAC_HELPERS.filter((helper) =>
|
|
151
|
+
new RegExp(`\\b${helper}\\s*\\(`).test(body),
|
|
152
|
+
).sort();
|
|
153
|
+
|
|
154
|
+
const content = `import { ${used.join(", ")} } from "railway/iac";
|
|
155
|
+
|
|
156
|
+
${body}`;
|
|
157
|
+
|
|
130
158
|
await fs.ensureDir(dirname(iacPath));
|
|
131
159
|
await fs.writeFile(iacPath, content, "utf8");
|
|
132
160
|
}
|
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.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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",
|