create-stitchkit 0.4.0 → 0.4.1
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/CHANGELOG.md +129 -0
- package/UPGRADING.md +117 -0
- package/dist/cli.js +5 -1
- package/examples/repository/scripts/runtime-smoke.ts +14 -2
- package/package.json +4 -2
- package/template/AGENTS.md +15 -5
- package/template/README.md +46 -8
- package/template/_env.example +8 -0
- package/template/_gitignore +1 -0
- package/template/biome.json +1 -1
- package/template/bun.lock +113 -96
- package/template/package.json +8 -7
- package/template/packages/backend/package.json +2 -2
- package/template/packages/backend/src/cleanup.ts +121 -0
- package/template/packages/backend/src/index.ts +12 -3
- package/template/packages/config/package.json +3 -1
- package/template/packages/config/src/declaration.ts +1 -1
- package/template/packages/config/src/shutdown.ts +20 -0
- package/template/packages/db/package.json +2 -2
- package/template/packages/frontend/package.json +11 -11
- package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
- package/template/packages/frontend/src/lib/seo/pages.ts +2 -2
- package/template/packages/shared/package.json +1 -1
- package/template/scripts/acceptance-database.test.ts +73 -0
- package/template/scripts/acceptance-database.ts +92 -0
- package/template/scripts/acceptance-local.ts +144 -0
- package/template/scripts/build-inputs.test.ts +1 -1
- package/template/scripts/build-inputs.ts +4 -3
- package/template/scripts/build-stamp.test.ts +151 -0
- package/template/scripts/build-stamp.ts +169 -0
- package/template/scripts/client-boundary.test.ts +117 -0
- package/template/scripts/client-boundary.ts +148 -0
- package/template/scripts/declaration.ts +10 -7
- package/template/scripts/deployment-preflight.ts +41 -0
- package/template/scripts/dev.ts +8 -6
- package/template/scripts/local-env.ts +9 -3
- package/template/scripts/readiness.ts +92 -0
- package/template/scripts/release-steps.ts +5 -1
- package/template/scripts/release.ts +8 -0
- package/template/scripts/runtime-smoke.test.ts +178 -0
- package/template/scripts/runtime-smoke.ts +15 -2
- package/template/scripts/shutdown-budget.fixture.ts +29 -0
- package/template/scripts/shutdown-budget.test.ts +164 -0
- package/template/scripts/surface-conformance.ts +8 -1
- package/template/scripts/tooling-env.ts +30 -1
- package/template/scripts/web-surface-smoke.ts +125 -14
- package/template/packages/config/src/project-declaration.generated.ts +0 -611
package/template/package.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"check": "bun run db:generate && bun run check:authored && bun x tsc -p tsconfig.json --noEmit && bun run --filter '*' check",
|
|
15
15
|
"check:authored": "bun scripts/check-authored.ts",
|
|
16
16
|
"test": "bun test scripts && bun run --filter '*' test",
|
|
17
|
-
"build": "bun scripts/build-inputs.ts && bun run db:generate && bun --filter @app/backend build && bun --filter @app/frontend build",
|
|
17
|
+
"build": "bun scripts/build-inputs.ts && bun run db:generate && bun --filter @app/backend build && bun --filter @app/frontend build && bun scripts/build-stamp.ts",
|
|
18
18
|
"start:api": "bun --filter @app/backend start",
|
|
19
19
|
"start:web": "bun --filter @app/frontend start",
|
|
20
20
|
"env:ensure": "bun scripts/local-env.ts",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"runtime:smoke": "bun scripts/runtime-smoke.ts",
|
|
29
29
|
"surface:snapshot": "bun scripts/surface-snapshot.ts",
|
|
30
30
|
"e2e": "playwright test",
|
|
31
|
+
"acceptance:local": "bun scripts/acceptance-local.ts",
|
|
31
32
|
"lint": "biome check --error-on-warnings .",
|
|
32
33
|
"lint:fix": "biome check --write .",
|
|
33
34
|
"pm2:dev": "bun scripts/dev.ts",
|
|
@@ -40,13 +41,13 @@
|
|
|
40
41
|
"devDependencies": {
|
|
41
42
|
"@app/config": "workspace:*",
|
|
42
43
|
"@app/shared": "workspace:*",
|
|
43
|
-
"@axe-core/playwright": "^4.
|
|
44
|
-
"@biomejs/biome": "^2.5.
|
|
44
|
+
"@axe-core/playwright": "^4.13.0",
|
|
45
|
+
"@biomejs/biome": "^2.5.10",
|
|
45
46
|
"@modelcontextprotocol/client": "^2.0.0",
|
|
46
|
-
"@playwright/test": "^1.
|
|
47
|
-
"@types/bun": "^1.
|
|
48
|
-
"@types/node": "^26.
|
|
49
|
-
"oxc-parser": "^0.
|
|
47
|
+
"@playwright/test": "^1.62.1",
|
|
48
|
+
"@types/bun": "^1.4.0",
|
|
49
|
+
"@types/node": "^26.3.0",
|
|
50
|
+
"oxc-parser": "^0.147.0",
|
|
50
51
|
"socket.io-client": "^4.8.3",
|
|
51
52
|
"stitchkit": "catalog:",
|
|
52
53
|
"typescript": "^7.0.2",
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
20
20
|
"@prisma/adapter-pg": "^7.9.1",
|
|
21
21
|
"@socket.io/bun-engine": "^0.1.1",
|
|
22
|
-
"ai": "^7.0.
|
|
22
|
+
"ai": "^7.0.78",
|
|
23
23
|
"socket.io": "^4.8.3",
|
|
24
24
|
"stitchkit": "catalog:",
|
|
25
25
|
"zod": "^4.4.3"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@types/bun": "^1.
|
|
28
|
+
"@types/bun": "^1.4.0",
|
|
29
29
|
"typescript": "^7.0.2"
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { CLEANUP_BUDGET_MS } from '@app/config/shutdown';
|
|
2
|
+
|
|
3
|
+
export interface CleanupStep {
|
|
4
|
+
name: string;
|
|
5
|
+
close: () => Promise<unknown>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CleanupFailure {
|
|
9
|
+
name: string;
|
|
10
|
+
cause: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CleanupResult {
|
|
14
|
+
/** Steps that did not finish inside the budget, in order. */
|
|
15
|
+
unfinished: string[];
|
|
16
|
+
/** Steps that finished by throwing, with what they threw. */
|
|
17
|
+
failed: CleanupFailure[];
|
|
18
|
+
durationMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** How one step ended: in time, in time but throwing, or not in time. */
|
|
22
|
+
type StepOutcome =
|
|
23
|
+
| { kind: 'finished' }
|
|
24
|
+
| { kind: 'threw'; cause: unknown }
|
|
25
|
+
| { kind: 'expired' };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Close what the role owns, and stop waiting when the budget is spent.
|
|
29
|
+
*
|
|
30
|
+
* Waiting forever is not the safe option it looks like: the supervisor's kill
|
|
31
|
+
* timeout is derived from this budget, so a close that hangs past it turns an
|
|
32
|
+
* orderly shutdown into a SIGKILL — which is the one ending that runs no
|
|
33
|
+
* cleanup at all. Better to leave one connection to the operating system and
|
|
34
|
+
* exit, saying which.
|
|
35
|
+
*
|
|
36
|
+
* The steps run in order and share one deadline, because they are one budget:
|
|
37
|
+
* a slow first close must not hand the second one a full budget of its own.
|
|
38
|
+
*
|
|
39
|
+
* The two ways a step can end are kept apart. Running out of time and throwing
|
|
40
|
+
* are different facts about a shutdown — one says a resource is still held, the
|
|
41
|
+
* other says closing it is broken — and collapsing them (this used to discard
|
|
42
|
+
* the rejection entirely) turned a failed shutdown into a clean exit with the
|
|
43
|
+
* reason gone.
|
|
44
|
+
*
|
|
45
|
+
* The clock is `performance.now()` and cannot be replaced. It used to be
|
|
46
|
+
* `Date.now()` behind an injectable parameter, and both halves of that were
|
|
47
|
+
* wrong for a deadline: a wall clock stepped backwards widens the very upper
|
|
48
|
+
* bound a supervisor's kill timeout was derived from, and an injected clock
|
|
49
|
+
* that does not advance hands every step a full budget — a shutdown budget that
|
|
50
|
+
* a test could switch off. Nothing needs to fake it: the regression that
|
|
51
|
+
* matters runs a real process.
|
|
52
|
+
*/
|
|
53
|
+
export async function closeWithinBudget(
|
|
54
|
+
steps: readonly CleanupStep[],
|
|
55
|
+
budgetMs: number = CLEANUP_BUDGET_MS,
|
|
56
|
+
): Promise<CleanupResult> {
|
|
57
|
+
const now = (): number => performance.now();
|
|
58
|
+
const startedAt = now();
|
|
59
|
+
const unfinished: string[] = [];
|
|
60
|
+
const failed: CleanupFailure[] = [];
|
|
61
|
+
const finished = (): StepOutcome => ({ kind: 'finished' });
|
|
62
|
+
const threw = (cause: unknown): StepOutcome => ({ kind: 'threw', cause });
|
|
63
|
+
for (const step of steps) {
|
|
64
|
+
const remaining = budgetMs - (now() - startedAt);
|
|
65
|
+
if (remaining <= 0) {
|
|
66
|
+
unfinished.push(step.name);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
70
|
+
const outcome = await Promise.race<StepOutcome>([
|
|
71
|
+
step.close().then(finished, threw),
|
|
72
|
+
new Promise<StepOutcome>((resolve) => {
|
|
73
|
+
timer = setTimeout(() => resolve({ kind: 'expired' }), remaining);
|
|
74
|
+
}),
|
|
75
|
+
]);
|
|
76
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
77
|
+
if (outcome.kind === 'expired') unfinished.push(step.name);
|
|
78
|
+
else if (outcome.kind === 'threw') failed.push({ name: step.name, cause: outcome.cause });
|
|
79
|
+
}
|
|
80
|
+
return { unfinished, failed, durationMs: Math.round(now() - startedAt) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Turn a bounded cleanup into an ending: say what happened, and make sure it
|
|
85
|
+
* actually ends.
|
|
86
|
+
*
|
|
87
|
+
* `process.exitCode` only decides the code the process reports *when it exits*.
|
|
88
|
+
* A step that ran out of time may still be holding a handle — that is what
|
|
89
|
+
* running out of time usually means — and a process holding one waits for the
|
|
90
|
+
* event loop to drain, which is precisely the wait the supervisor answers with
|
|
91
|
+
* SIGKILL. So an unfinished step is not a note to log on the way out; it is the
|
|
92
|
+
* reason to leave now.
|
|
93
|
+
*
|
|
94
|
+
* `exit` is a parameter so this decision can be exercised without ending the
|
|
95
|
+
* test runner — but the regression that matters runs a real process
|
|
96
|
+
* (`scripts/shutdown-budget.fixture.ts`), because "the promise resolved" was
|
|
97
|
+
* never the property in question.
|
|
98
|
+
*/
|
|
99
|
+
export function concludeShutdown(
|
|
100
|
+
result: CleanupResult,
|
|
101
|
+
drainWasClean: boolean,
|
|
102
|
+
exit: (code: number) => void = (code) => process.exit(code),
|
|
103
|
+
): void {
|
|
104
|
+
for (const failure of result.failed) {
|
|
105
|
+
console.error(`Shutdown could not close ${failure.name}:`, failure.cause);
|
|
106
|
+
}
|
|
107
|
+
if (result.unfinished.length > 0) {
|
|
108
|
+
console.error(
|
|
109
|
+
`Shutdown left ${result.unfinished.join(' and ')} unclosed after ${result.durationMs}ms — the cleanup budget is spent, exiting anyway.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const cleanupCompleted = result.unfinished.length === 0 && result.failed.length === 0;
|
|
113
|
+
const code = drainWasClean && cleanupCompleted ? 0 : 1;
|
|
114
|
+
process.exitCode = code;
|
|
115
|
+
// A cleanup that did not complete does not get to wait. Either a step ran out
|
|
116
|
+
// of time — so something is still held — or closing it threw, which says the
|
|
117
|
+
// same thing with less certainty about what. A shutdown that completed exits
|
|
118
|
+
// on its own, and letting it do so keeps the ordinary path ordinary.
|
|
119
|
+
// Last on purpose: nothing after this line runs.
|
|
120
|
+
if (!cleanupCompleted) exit(code);
|
|
121
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
openApiRoute,
|
|
9
9
|
} from 'stitchkit/server';
|
|
10
10
|
import { createMcpHandler, createMcpHttpRoute } from 'stitchkit/tools';
|
|
11
|
+
import { closeWithinBudget, concludeShutdown } from './cleanup';
|
|
11
12
|
import { prisma } from './lib/db';
|
|
12
13
|
import { createSurface } from './surface';
|
|
13
14
|
import { onError } from './transport/errors';
|
|
@@ -60,15 +61,23 @@ async function main(): Promise<void> {
|
|
|
60
61
|
// before sending SIGKILL, or the drain never finishes.
|
|
61
62
|
shutdown: { gracePeriodMs: apiRole.drainFloorMs },
|
|
62
63
|
onComplete: async (result) => {
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
// Bounded on purpose. The supervisor's kill timeout is derived from this
|
|
65
|
+
// budget, so a close that hangs past it turns an orderly shutdown into a
|
|
66
|
+
// SIGKILL — the one ending that runs no cleanup at all.
|
|
67
|
+
const cleanup = await closeWithinBudget([
|
|
68
|
+
{ name: 'MCP', close: () => mcp.close() },
|
|
69
|
+
{ name: 'database', close: () => prisma.$disconnect() },
|
|
70
|
+
]);
|
|
65
71
|
// Say how the drain ended. Without this an operator sees a process that
|
|
66
72
|
// vanished and an exit code, and cannot tell a clean drain from one the
|
|
67
73
|
// deadline or a second signal cut short.
|
|
68
74
|
console.log(
|
|
69
75
|
`Shutdown ${result.outcome}${result.reason ? ` (${result.reason})` : ''} in ${result.durationMs}ms — ${result.completedRequests} requests completed, ${result.abortedRequests} aborted, ${result.forcedWebSockets} sockets forced`,
|
|
70
76
|
);
|
|
71
|
-
|
|
77
|
+
// Sets the code, reports any close that threw, and — if a step is still
|
|
78
|
+
// holding something — ends the process here rather than waiting for a
|
|
79
|
+
// handle that already missed its deadline. Nothing follows it.
|
|
80
|
+
concludeShutdown(cleanup, result.outcome === 'clean');
|
|
72
81
|
},
|
|
73
82
|
onError: (phase, error) => {
|
|
74
83
|
console.error(`Shutdown failed during ${phase}`, error);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
".": "./src/server.ts",
|
|
8
8
|
"./variables": "./src/variables.ts",
|
|
9
9
|
"./declaration": "./src/declaration.ts",
|
|
10
|
+
"./shutdown": "./src/shutdown.ts",
|
|
10
11
|
"./app-identity": "./src/app-identity.generated.ts"
|
|
11
12
|
},
|
|
12
13
|
"scripts": {
|
|
@@ -16,10 +17,11 @@
|
|
|
16
17
|
"dependencies": {
|
|
17
18
|
"@t3-oss/env-core": "^0.13.11",
|
|
18
19
|
"dotenv": "^17.4.2",
|
|
20
|
+
"stitchkit": "catalog:",
|
|
19
21
|
"zod": "^4.4.3"
|
|
20
22
|
},
|
|
21
23
|
"devDependencies": {
|
|
22
|
-
"@types/bun": "^1.
|
|
24
|
+
"@types/bun": "^1.4.0",
|
|
23
25
|
"typescript": "^7.0.2"
|
|
24
26
|
}
|
|
25
27
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { findProjectRole, parseProjectDeclaration } from 'stitchkit/declaration';
|
|
1
2
|
import source from '../../../project.json' with { type: 'json' };
|
|
2
|
-
import { findProjectRole, parseProjectDeclaration } from './project-declaration.generated';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* What this repository says about itself — the one machine-readable statement
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of a shutdown that are NOT the drain, as one number each.
|
|
3
|
+
*
|
|
4
|
+
* `terminationBudgetMs` adds these to a role's drain floor and refuses a
|
|
5
|
+
* supervision policy that allows less. That only means something if each part
|
|
6
|
+
* is really bounded — and cleanup was not: the drain has a deadline, but the
|
|
7
|
+
* closes that run after it (an MCP session, a database pool) had none, so the
|
|
8
|
+
* "budget" was an estimate wearing the shape of an upper bound, and a role
|
|
9
|
+
* could still be killed mid-shutdown by a timeout the generator had approved.
|
|
10
|
+
*
|
|
11
|
+
* They live here rather than in the generator because two readers need them and
|
|
12
|
+
* a number in two places is two numbers: the generator, which tells a
|
|
13
|
+
* supervisor how long to wait, and the role itself, which must not take longer.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** After the drain deadline, how long a forced finish may take. */
|
|
17
|
+
export const FORCE_BUDGET_MS = 5_000;
|
|
18
|
+
|
|
19
|
+
/** After the server is done, how long the role's own closes may take. */
|
|
20
|
+
export const CLEANUP_BUDGET_MS = 5_000;
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
"@app/config": "workspace:*",
|
|
20
20
|
"@prisma/adapter-pg": "^7.9.1",
|
|
21
21
|
"@prisma/client": "^7.9.1",
|
|
22
|
-
"pg": "^8.
|
|
22
|
+
"pg": "^8.23.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@types/pg": "^8.
|
|
25
|
+
"@types/pg": "^8.23.1",
|
|
26
26
|
"prisma": "^7.9.1",
|
|
27
27
|
"typescript": "^7.0.2"
|
|
28
28
|
}
|
|
@@ -26,23 +26,23 @@
|
|
|
26
26
|
"@radix-ui/react-tooltip": "^1.2.16",
|
|
27
27
|
"@t3-oss/env-nextjs": "^0.13.11",
|
|
28
28
|
"@tabler/icons-react": "^3.46.0",
|
|
29
|
-
"@tanstack/react-query": "^5.
|
|
30
|
-
"@tanstack/react-table": "^9.1.
|
|
31
|
-
"@wrksz/themes": "^1.
|
|
29
|
+
"@tanstack/react-query": "^5.102.3",
|
|
30
|
+
"@tanstack/react-table": "^9.1.2",
|
|
31
|
+
"@wrksz/themes": "^1.2.0",
|
|
32
32
|
"class-variance-authority": "^0.7.1",
|
|
33
33
|
"clsx": "^2.1.1",
|
|
34
34
|
"date-fns": "^4.4.0",
|
|
35
35
|
"dotenv": "^17.4.2",
|
|
36
|
-
"framer-motion": "^13.
|
|
37
|
-
"next": "^16.3.
|
|
38
|
-
"next-intl": "^4.13.
|
|
36
|
+
"framer-motion": "^13.1.1",
|
|
37
|
+
"next": "^16.3.2",
|
|
38
|
+
"next-intl": "^4.13.7",
|
|
39
39
|
"react": "^19.2.8",
|
|
40
40
|
"react-dom": "^19.2.8",
|
|
41
41
|
"react-query-kit": "^3.3.4",
|
|
42
42
|
"recharts": "^3.10.1",
|
|
43
|
-
"shiki": "^4.4.
|
|
43
|
+
"shiki": "^4.4.3",
|
|
44
44
|
"socket.io-client": "^4.8.3",
|
|
45
|
-
"sonner": "^2.0.
|
|
45
|
+
"sonner": "^2.0.8",
|
|
46
46
|
"stitchkit": "catalog:",
|
|
47
47
|
"tailwind-merge": "^3.6.0",
|
|
48
48
|
"vaul": "^1.1.2",
|
|
@@ -50,10 +50,10 @@
|
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@tailwindcss/postcss": "^4.3.3",
|
|
53
|
-
"@types/bun": "^1.
|
|
54
|
-
"@types/node": "^26.
|
|
53
|
+
"@types/bun": "^1.4.0",
|
|
54
|
+
"@types/node": "^26.3.0",
|
|
55
55
|
"@types/react": "^19.2.18",
|
|
56
|
-
"@types/react-dom": "^19.2.
|
|
56
|
+
"@types/react-dom": "^19.2.5",
|
|
57
57
|
"babel-plugin-react-compiler": "^1.0.0",
|
|
58
58
|
"tailwindcss": "^4.3.3",
|
|
59
59
|
"tw-animate-css": "^1.4.0",
|
|
@@ -38,7 +38,10 @@ export const toast = {
|
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
export function Toaster() {
|
|
41
|
-
|
|
41
|
+
// No explicit type argument: since @wrksz/themes 1.2 the parameter describes
|
|
42
|
+
// the MAP, not the value, and it infers `const` — so naming the value union
|
|
43
|
+
// here made the result every member of `string` instead of narrowing it.
|
|
44
|
+
const theme = useThemeValue({
|
|
42
45
|
light: 'light',
|
|
43
46
|
dark: 'dark',
|
|
44
47
|
default: 'system',
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { appIdentity } from '@app/config/app-identity';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { AppLocale } from '@/i18n/locales';
|
|
4
4
|
|
|
5
|
-
export const SITE_NAME =
|
|
5
|
+
export const SITE_NAME = appIdentity.name;
|
|
6
6
|
export const StoryIdSchema = z.enum(['components', 'themes', 'blocks']);
|
|
7
7
|
export type StoryId = z.infer<typeof StoryIdSchema>;
|
|
8
8
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { resolveAcceptanceDatabase } from './acceptance-database';
|
|
3
|
+
|
|
4
|
+
const deployment = 'postgresql://app:secret@127.0.0.1:5432/starter';
|
|
5
|
+
|
|
6
|
+
describe('the acceptance gate writes only to a database of its own', () => {
|
|
7
|
+
test('a distinct database is accepted', () => {
|
|
8
|
+
expect(
|
|
9
|
+
resolveAcceptanceDatabase({
|
|
10
|
+
DATABASE_URL: deployment,
|
|
11
|
+
ACCEPTANCE_DATABASE_URL: 'postgresql://app:secret@127.0.0.1:5432/starter_acceptance',
|
|
12
|
+
}),
|
|
13
|
+
).toBe('postgresql://app:secret@127.0.0.1:5432/starter_acceptance');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('an unset variable is refused with the line to paste', () => {
|
|
17
|
+
// The gate used to inherit DATABASE_URL. Defaulting to it is exactly the
|
|
18
|
+
// behaviour this replaces, so absence has to be a refusal, not a fallback.
|
|
19
|
+
expect(() => resolveAcceptanceDatabase({ DATABASE_URL: deployment })).toThrow(
|
|
20
|
+
/ACCEPTANCE_DATABASE_URL=postgresql:\/\/app:secret@127\.0\.0\.1:5432\/starter_acceptance/,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('the same database written differently is still the same database', () => {
|
|
25
|
+
// Credentials and the scheme do not make it another database; host, port
|
|
26
|
+
// and name do. A check on the raw string would pass this and then migrate
|
|
27
|
+
// the deployment.
|
|
28
|
+
expect(() =>
|
|
29
|
+
resolveAcceptanceDatabase({
|
|
30
|
+
DATABASE_URL: deployment,
|
|
31
|
+
ACCEPTANCE_DATABASE_URL: 'postgres://someone:else@127.0.0.1/starter',
|
|
32
|
+
}),
|
|
33
|
+
).toThrow(/which is the one DATABASE_URL names/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a default port on one side and 5432 on the other is one database', () => {
|
|
37
|
+
expect(() =>
|
|
38
|
+
resolveAcceptanceDatabase({
|
|
39
|
+
DATABASE_URL: 'postgresql://app@db.internal/starter',
|
|
40
|
+
ACCEPTANCE_DATABASE_URL: 'postgresql://app@db.internal:5432/starter',
|
|
41
|
+
}),
|
|
42
|
+
).toThrow(/which is the one DATABASE_URL names/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('the same name on another hostname is refused, because a hostname proves nothing', () => {
|
|
46
|
+
// `localhost` and `127.0.0.1` are one server; so are two DNS names for the
|
|
47
|
+
// same PostgreSQL. A guard that compared `host:port/name` called these two
|
|
48
|
+
// different databases and let the gate migrate the deployment's own.
|
|
49
|
+
expect(() =>
|
|
50
|
+
resolveAcceptanceDatabase({
|
|
51
|
+
DATABASE_URL: 'postgresql://app@localhost:5432/starter',
|
|
52
|
+
ACCEPTANCE_DATABASE_URL: 'postgresql://app@127.0.0.1:5432/starter',
|
|
53
|
+
}),
|
|
54
|
+
).toThrow(/A different host is not proof of a different server/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('a different name on the same host is fine — the name is what decides', () => {
|
|
58
|
+
// The control. Without it the rule "refuse everything" would pass the case
|
|
59
|
+
// above, and no acceptance database would ever be accepted.
|
|
60
|
+
expect(
|
|
61
|
+
resolveAcceptanceDatabase({
|
|
62
|
+
DATABASE_URL: 'postgresql://app@localhost:5432/starter',
|
|
63
|
+
ACCEPTANCE_DATABASE_URL: 'postgresql://app@localhost:5432/starter_acceptance',
|
|
64
|
+
}),
|
|
65
|
+
).toContain('starter_acceptance');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a value that is not a URL is named, not silently used', () => {
|
|
69
|
+
expect(() =>
|
|
70
|
+
resolveAcceptanceDatabase({ ACCEPTANCE_DATABASE_URL: 'starter_acceptance' }),
|
|
71
|
+
).toThrow(/ACCEPTANCE_DATABASE_URL is not a valid URL/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The database the local acceptance gate is allowed to write to.
|
|
3
|
+
*
|
|
4
|
+
* `acceptance:local` creates and destroys its own deployment — its own PM2 home,
|
|
5
|
+
* its own ephemeral ports, its own host allowlist. The database was the one
|
|
6
|
+
* thing it still borrowed: it inherited `DATABASE_URL`, and the runtime gates
|
|
7
|
+
* WRITE. The repository example's smoke posts `/api/repository/refresh` twice,
|
|
8
|
+
* which upserts. So a gate a developer is told to run before handing work off
|
|
9
|
+
* wrote rows into whatever `.env` happened to name — including a production
|
|
10
|
+
* database, if that is what the machine was pointed at.
|
|
11
|
+
*
|
|
12
|
+
* Fail-closed on purpose. An acceptance database that is merely *probably*
|
|
13
|
+
* separate is the same defect one edit later, so an unset variable and one that
|
|
14
|
+
* names the deployment's own database are both refused before a role starts,
|
|
15
|
+
* and the refusal carries the line to paste.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The database NAME, which is all a refusal may rely on.
|
|
20
|
+
*
|
|
21
|
+
* Comparing `host:port/name` catches the ordinary mistake and misses the ones
|
|
22
|
+
* that matter: `localhost` and `127.0.0.1` are one server, and so are two DNS
|
|
23
|
+
* names pointing at the same PostgreSQL. A guard that reads those as different
|
|
24
|
+
* databases lets this gate migrate and write into the deployment's own.
|
|
25
|
+
*
|
|
26
|
+
* A hostname is not proof of a different server, so it does not take part in
|
|
27
|
+
* the decision. The cost is a false refusal when two genuinely separate servers
|
|
28
|
+
* host a database of the same name — answered by renaming the throwaway one,
|
|
29
|
+
* which the message asks for. That is the trade a fail-closed gate is supposed
|
|
30
|
+
* to make.
|
|
31
|
+
*/
|
|
32
|
+
function databaseName(url: URL): string {
|
|
33
|
+
return decodeURIComponent(url.pathname.replace(/^\//, '').replace(/\/+$/, ''));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parse(name: string, value: string): URL {
|
|
37
|
+
try {
|
|
38
|
+
return new URL(value);
|
|
39
|
+
} catch {
|
|
40
|
+
throw new Error(`${name} is not a valid URL.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `…/app` → `…/app_acceptance`, so the refusal can name a line worth pasting. */
|
|
45
|
+
function suggestionFrom(deploymentUrl: string | undefined): string {
|
|
46
|
+
if (!deploymentUrl) return 'postgresql://USER:PASSWORD@127.0.0.1:5432/acceptance';
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL(deploymentUrl);
|
|
49
|
+
url.pathname = `${url.pathname.replace(/\/$/, '')}_acceptance`;
|
|
50
|
+
return url.toString();
|
|
51
|
+
} catch {
|
|
52
|
+
return 'postgresql://USER:PASSWORD@127.0.0.1:5432/acceptance';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The acceptance database URL, or a refusal explaining exactly what to add.
|
|
58
|
+
*
|
|
59
|
+
* Takes the environment rather than reading it, so the rule that keeps a gate
|
|
60
|
+
* off the deployment's database is testable without one.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveAcceptanceDatabase(
|
|
63
|
+
environment: Record<string, string | undefined>,
|
|
64
|
+
): string {
|
|
65
|
+
const acceptance = environment.ACCEPTANCE_DATABASE_URL?.trim();
|
|
66
|
+
const deployment = environment.DATABASE_URL?.trim();
|
|
67
|
+
|
|
68
|
+
if (!acceptance) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
'ACCEPTANCE_DATABASE_URL is not set, and `bun run acceptance:local` will not write to the ' +
|
|
71
|
+
'database this deployment uses. Add a line naming a throwaway database to `.env`:\n' +
|
|
72
|
+
` ACCEPTANCE_DATABASE_URL=${suggestionFrom(deployment)}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const acceptanceUrl = parse('ACCEPTANCE_DATABASE_URL', acceptance);
|
|
77
|
+
if (deployment) {
|
|
78
|
+
const deploymentUrl = parse('DATABASE_URL', deployment);
|
|
79
|
+
if (databaseName(acceptanceUrl) === databaseName(deploymentUrl)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`ACCEPTANCE_DATABASE_URL uses the database name "${databaseName(acceptanceUrl)}", which is ` +
|
|
82
|
+
'the one DATABASE_URL names. A different host is not proof of a different server — ' +
|
|
83
|
+
'`localhost` and `127.0.0.1` are one, and so are two DNS names for the same ' +
|
|
84
|
+
'PostgreSQL — and this gate applies migrations and writes rows. Give it a name of ' +
|
|
85
|
+
'its own:\n' +
|
|
86
|
+
` ACCEPTANCE_DATABASE_URL=${suggestionFrom(deployment)}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return acceptanceUrl.toString();
|
|
92
|
+
}
|