create-stitchkit 0.4.0 → 0.4.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/CHANGELOG.md +141 -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 +115 -98
- package/template/package.json +9 -8
- 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
|
@@ -1,611 +0,0 @@
|
|
|
1
|
-
// GENERATED FILE — do not edit.
|
|
2
|
-
//
|
|
3
|
-
// Copied verbatim from the framework's `stitchkit/declaration` source by
|
|
4
|
-
// `scripts/sync-template-declaration.ts`. Edit `packages/core/src/declaration.ts`
|
|
5
|
-
// and re-run `bun run gen:template-declaration`; the gate refuses a copy that
|
|
6
|
-
// has fallen behind.
|
|
7
|
-
//
|
|
8
|
-
// This file disappears once the template's catalog targets a release that
|
|
9
|
-
// publishes `stitchkit/declaration` — at that point the schema is imported, not
|
|
10
|
-
// mirrored. → ADR 0104
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* The project declaration — the one machine-readable statement a repository
|
|
14
|
-
* chooses to make about itself.
|
|
15
|
-
*
|
|
16
|
-
* The schema lives in the published framework, not in the repository that fills
|
|
17
|
-
* it in, because it has readers that must never disagree: the project itself,
|
|
18
|
-
* the scaffolder that writes the first copy, and whatever binds an artifact
|
|
19
|
-
* of it into a deployment. A copy on any of those sides is a fork rather than a contract —
|
|
20
|
-
* it diverges silently and nothing fails — so the declaration ships as one
|
|
21
|
-
* versioned surface instead.
|
|
22
|
-
*
|
|
23
|
-
* The boundary rule the schema exists to hold:
|
|
24
|
-
*
|
|
25
|
-
* > A declaration must be complete and meaningful **when no machine exists**. A
|
|
26
|
-
* > field that cannot be filled in without knowing where the code will run is a
|
|
27
|
-
* > binding supplied by the deployment, not a declaration made by the
|
|
28
|
-
* > repository.
|
|
29
|
-
*
|
|
30
|
-
* What holds it, stated at its real strength:
|
|
31
|
-
*
|
|
32
|
-
* 1. **Structure.** There is nowhere a value MUST go. A command is `executable`
|
|
33
|
-
* plus an `args` array — no shell string, no pipe, no redirect — and no part
|
|
34
|
-
* may be an absolute path or an assignment in any form, so `--port=8080` and
|
|
35
|
-
* `--config=/srv/…` have to be written as separate arguments where the same
|
|
36
|
-
* checks reach them. Paths are repository-relative. Bindings are named by
|
|
37
|
-
* variable, never valued, and a listener's variables must exist in the env
|
|
38
|
-
* contract with the right shapes.
|
|
39
|
-
* 2. **Hygiene.** Every remaining free string is checked against
|
|
40
|
-
* `namesAMachine` — a scheme, a protocol-relative host, an absolute or
|
|
41
|
-
* home-relative path, a Windows drive, a `host:port` pair, a bare IPv4
|
|
42
|
-
* literal — and a number after a port flag is refused.
|
|
43
|
-
*
|
|
44
|
-
* The second is a filter for known shapes, not a proof. A secret written as its
|
|
45
|
-
* own argument (`--token`, `sk-live-…`) and a hostname written as a plain word
|
|
46
|
-
* (`db.internal`) are indistinguishable from any other argument, and no schema
|
|
47
|
-
* makes them distinguishable. This is not a secret scanner and does not claim to
|
|
48
|
-
* be one: the guarantee is that nothing here REQUIRES a value of the place, so a
|
|
49
|
-
* complete declaration can be written before any machine exists.
|
|
50
|
-
*
|
|
51
|
-
* **Declaring is optional, and that is a contract rather than a gap.** A
|
|
52
|
-
* project with no declaration is a complete project: nothing else in the
|
|
53
|
-
* framework imports this module, no build, test or start path looks for a
|
|
54
|
-
* `project.json`, and the absence of one is never an error or a warning. A
|
|
55
|
-
* check that demanded a declaration "for convenience" would turn a repository
|
|
56
|
-
* into something only one tool can bring up — which is a fork, not a
|
|
57
|
-
* dependency, and is the outcome this whole surface exists to avoid.
|
|
58
|
-
*
|
|
59
|
-
* Unknown keys are **refused**, not stripped. A declaration is a contract
|
|
60
|
-
* between programs that never meet; a key one side does not recognise is a
|
|
61
|
-
* disagreement, and silently discarding it is how a partially understood
|
|
62
|
-
* declaration becomes a running, wrong deployment.
|
|
63
|
-
*/
|
|
64
|
-
import { z } from 'zod';
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* The declaration format this build understands.
|
|
68
|
-
*
|
|
69
|
-
* A reader that does not recognise the version a repository declares refuses
|
|
70
|
-
* the repository rather than interpreting it partially. Because unknown keys
|
|
71
|
-
* are refused too, the version is what a reader consults when the *shape*
|
|
72
|
-
* changed — and every added field is a version bump, not a silent widening.
|
|
73
|
-
*/
|
|
74
|
-
export const PROJECT_DECLARATION_SCHEMA_VERSION = 1;
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Does this string name a particular machine?
|
|
78
|
-
*
|
|
79
|
-
* Every pattern here is something that cannot be true of code alone. Kept in
|
|
80
|
-
* one place because the previous version of this schema checked only `://`, in
|
|
81
|
-
* only one of the four fields a human writes freely — and a connection string
|
|
82
|
-
* with credentials, a secret and an absolute path all passed.
|
|
83
|
-
*/
|
|
84
|
-
const MACHINE_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
|
|
85
|
-
[/:\/\//, 'an absolute address'],
|
|
86
|
-
[/^\/\//, 'a protocol-relative host'],
|
|
87
|
-
[/^[~]/, 'a home-relative path'],
|
|
88
|
-
[/^[A-Za-z]:[\\/]/, 'a Windows drive path'],
|
|
89
|
-
[/\\/, 'a Windows path separator'],
|
|
90
|
-
[/(?:^|[\s=:])\d{1,3}(?:\.\d{1,3}){3}(?![\d.])/, 'an IP address'],
|
|
91
|
-
[/(?:^|[\s=])[A-Za-z][\w.-]*:\d{2,5}(?![\w.])/, 'a host and port'],
|
|
92
|
-
];
|
|
93
|
-
|
|
94
|
-
/** The reason this string names a machine, or `undefined` when it does not. */
|
|
95
|
-
export function namesAMachine(value: string): string | undefined {
|
|
96
|
-
for (const [pattern, reason] of MACHINE_PATTERNS) {
|
|
97
|
-
if (pattern.test(value)) return reason;
|
|
98
|
-
}
|
|
99
|
-
return undefined;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function refuseMachineNames(label: string) {
|
|
103
|
-
return (schema: z.ZodString) =>
|
|
104
|
-
schema.refine((value) => namesAMachine(value) === undefined, {
|
|
105
|
-
error: (issue) =>
|
|
106
|
-
`${label} names a machine — ${namesAMachine(String(issue.input)) ?? 'a value of the deployment'} is supplied by the deployment, not written in the code`,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Lowercase, hyphen-separated identity. Everything named after the project —
|
|
112
|
-
* process names, derived resource names — is derived from it, so it is the one
|
|
113
|
-
* identity field with a machine-checkable shape.
|
|
114
|
-
*/
|
|
115
|
-
export const ProjectSlugSchema = z
|
|
116
|
-
.string()
|
|
117
|
-
.min(1)
|
|
118
|
-
.max(64)
|
|
119
|
-
.regex(
|
|
120
|
-
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
121
|
-
'Use lowercase letters, numbers and single hyphens (for example: talk-control)',
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Human description keyed by locale tag. Which locales a project speaks is the
|
|
126
|
-
* project's own business — the framework only insists that it speaks one.
|
|
127
|
-
*
|
|
128
|
-
* To fix an exact set, replace this field entirely when composing a stricter
|
|
129
|
-
* declaration; it is a record, so it has no `extend`.
|
|
130
|
-
*/
|
|
131
|
-
export const ProjectDescriptionSchema = z
|
|
132
|
-
.record(
|
|
133
|
-
refuseMachineNames('A locale tag')(z.string().min(1)),
|
|
134
|
-
refuseMachineNames('A description')(z.string().trim().min(1)),
|
|
135
|
-
)
|
|
136
|
-
.refine(
|
|
137
|
-
(value) => Object.keys(value).length > 0,
|
|
138
|
-
'Describe the project in at least one locale',
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
/** Who the project is. True of the code with no machine in existence. */
|
|
142
|
-
export const ProjectIdentitySchema = z
|
|
143
|
-
.object({
|
|
144
|
-
slug: ProjectSlugSchema,
|
|
145
|
-
name: refuseMachineNames('A project name')(z.string().trim().min(1).max(80)),
|
|
146
|
-
version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Use a semantic version such as 0.1.0'),
|
|
147
|
-
description: ProjectDescriptionSchema,
|
|
148
|
-
})
|
|
149
|
-
.strict();
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* A path inside the source. Repository-relative on purpose: a path is code
|
|
153
|
-
* only while it is relative to the source — the moment it is absolute, climbs
|
|
154
|
-
* out, or names a drive, it names a machine.
|
|
155
|
-
*/
|
|
156
|
-
export const RepositoryPathSchema = z
|
|
157
|
-
.string()
|
|
158
|
-
.min(1)
|
|
159
|
-
.refine((value) => !value.startsWith('/'), 'Use a path relative to the repository root')
|
|
160
|
-
.refine((value) => namesAMachine(value) === undefined, {
|
|
161
|
-
error: (issue) =>
|
|
162
|
-
`A path may not contain ${namesAMachine(String(issue.input)) ?? 'a machine name'}`,
|
|
163
|
-
})
|
|
164
|
-
.refine(
|
|
165
|
-
(value) => !value.split('/').includes('..'),
|
|
166
|
-
'A path may not climb out of the repository',
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
/** The name of a variable a deployment will fill in. A name, never a value. */
|
|
170
|
-
export const BindingVariableSchema = z
|
|
171
|
-
.string()
|
|
172
|
-
.regex(/^[A-Z][A-Z0-9_]*$/, 'Name an environment variable, for example API_PORT');
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* How a role is reached over the network.
|
|
176
|
-
*
|
|
177
|
-
* Absent means the role has no listener at all — a queue consumer, a bot, a
|
|
178
|
-
* scheduler. That is a legitimate role, not an incomplete one, so nothing here
|
|
179
|
-
* is required of it.
|
|
180
|
-
*
|
|
181
|
-
* Both bindings are named, never valued: a deployment supplies the port and the
|
|
182
|
-
* interface under these names. There is deliberately no way to say "the port
|
|
183
|
-
* arrives as a command-line argument" — a reader would then have to implement
|
|
184
|
-
* two injection forms forever, and a role that needs an argument builds it from
|
|
185
|
-
* the variable inside its own process.
|
|
186
|
-
*/
|
|
187
|
-
export const ProjectListenerSchema = z
|
|
188
|
-
.object({
|
|
189
|
-
portVariable: BindingVariableSchema,
|
|
190
|
-
bindVariable: BindingVariableSchema,
|
|
191
|
-
/**
|
|
192
|
-
* Path that answers once this role is ready to serve. `/` is a real answer.
|
|
193
|
-
* Checked against `namesAMachine` because `//host/health` is a host, not a
|
|
194
|
-
* path, and `.startsWith('/')` alone cannot tell them apart.
|
|
195
|
-
*/
|
|
196
|
-
readinessPath: refuseMachineNames('A readiness path')(
|
|
197
|
-
z.string().startsWith('/', 'Readiness is a path, for example /health'),
|
|
198
|
-
),
|
|
199
|
-
})
|
|
200
|
-
.strict();
|
|
201
|
-
|
|
202
|
-
/** The run modes a role declares commands for. */
|
|
203
|
-
export const ProjectRunModeSchema = z.enum(['development', 'production']);
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* How to run a role: the program and its arguments, never a shell string.
|
|
207
|
-
*
|
|
208
|
-
* argv rather than a command line for two reasons that are both defects the
|
|
209
|
-
* previous shape had. A string has to be split to be executed, and splitting on
|
|
210
|
-
* spaces destroys quoted arguments and paths with spaces; and a string is a
|
|
211
|
-
* place to hide `--port 8080`, `API_TOKEN=…`, a pipe or a redirect, which is
|
|
212
|
-
* exactly what the boundary rule forbids. With argv there is nothing to split
|
|
213
|
-
* and each member is checked on its own.
|
|
214
|
-
*
|
|
215
|
-
* **Start the role's process, not a launcher that starts it.** Measured under
|
|
216
|
-
* PM2: with a launcher in between, the supervisor's signal reaches both, the
|
|
217
|
-
* launcher forwards its copy, and the role sees two presses in two turns —
|
|
218
|
-
* which every well-behaved shutdown treats as "stop waiting, force it now". A
|
|
219
|
-
* declared 15s drain collapsed to 1.3ms that way, and the only visible trace
|
|
220
|
-
* was a non-zero exit code. A workspace-filtering launcher is worse still: the
|
|
221
|
-
* signal never arrives at all. `PROJECT_LAUNCHERS` refuses the shape.
|
|
222
|
-
*/
|
|
223
|
-
/**
|
|
224
|
-
* A package-script runner, as a PAIR: the executable and the verb that makes it
|
|
225
|
-
* one.
|
|
226
|
-
*
|
|
227
|
-
* As a bare list of executables this refused `deno run x.ts`, which is a direct
|
|
228
|
-
* runtime invocation and exactly the shape the rule wants. `deno task` is the
|
|
229
|
-
* launcher; `deno run` is not. `npx` and `bunx` are launchers whatever follows.
|
|
230
|
-
*/
|
|
231
|
-
const PROJECT_SCRIPT_LAUNCHERS: ReadonlyArray<readonly [RegExp, RegExp | null]> = [
|
|
232
|
-
[/^(?:bun|npm|pnpm|yarn)$/, /^run$/],
|
|
233
|
-
[/^deno$/, /^task$/],
|
|
234
|
-
[/^(?:npx|bunx|pnpx)$/, null],
|
|
235
|
-
];
|
|
236
|
-
|
|
237
|
-
function launchesAScript(executable: string, firstArgument: string | undefined): boolean {
|
|
238
|
-
for (const [runner, verb] of PROJECT_SCRIPT_LAUNCHERS) {
|
|
239
|
-
if (!runner.test(executable)) continue;
|
|
240
|
-
if (verb === null) return true;
|
|
241
|
-
if (firstArgument !== undefined && verb.test(firstArgument)) return true;
|
|
242
|
-
}
|
|
243
|
-
return false;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Flags whose next argument is a port, for the one heuristic that remains.
|
|
248
|
-
*
|
|
249
|
-
* `-p` is included knowingly: it means "port" in almost every server CLI, and
|
|
250
|
-
* refusing `['--port', '8080']` while accepting `['-p', '8080']` would make the
|
|
251
|
-
* rule decorative. It costs a false refusal for the tools where `-p` means
|
|
252
|
-
* something else and takes a number, which the message tells you how to rewrite.
|
|
253
|
-
*/
|
|
254
|
-
const PORT_FLAG = /^(?:-p|-{1,2}(?:port|listen))$/i;
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* A command part carries neither a machine name nor an inline value.
|
|
258
|
-
*
|
|
259
|
-
* Two of these are STRUCTURAL and one is hygiene, and the difference matters
|
|
260
|
-
* because the texts around this schema used to claim all three were structural.
|
|
261
|
-
*
|
|
262
|
-
* Structural: a part may not be an absolute path, and a part may not be an
|
|
263
|
-
* ASSIGNMENT in any form. `--flag=value` was the hole — `--port=8080`,
|
|
264
|
-
* `--token=…` and `--config=/srv/app/config.json` all parsed clean, because
|
|
265
|
-
* every per-part check looks at the part and the value was hiding inside one.
|
|
266
|
-
* Writing the value as its own argv member is what puts it back under the same
|
|
267
|
-
* checks: `['--port', '8080']` is refused as a port, `['--config', '/srv/…']`
|
|
268
|
-
* as an absolute path.
|
|
269
|
-
*
|
|
270
|
-
* Hygiene: a bare number directly after a flag that names a port. It is a
|
|
271
|
-
* heuristic and is scoped like one — `['--workers', '12']` is a legitimate
|
|
272
|
-
* command and used to be refused. What this cannot do is recognise a secret or
|
|
273
|
-
* a hostname written as a plain word: `sk-live-…` and `db.internal` are
|
|
274
|
-
* indistinguishable from any other argument, and no schema will change that.
|
|
275
|
-
* The boundary is held by having NOWHERE for a value to be required; it is not
|
|
276
|
-
* a proof that nobody wrote one.
|
|
277
|
-
*/
|
|
278
|
-
const CommandPartSchema = refuseMachineNames('A command part')(z.string().min(1))
|
|
279
|
-
.refine(
|
|
280
|
-
(value) => !value.startsWith('/'),
|
|
281
|
-
'A command part may not be an absolute path — paths are relative to the source',
|
|
282
|
-
)
|
|
283
|
-
.refine(
|
|
284
|
-
// Anything up to the first `=` that is not itself a `=`. The earlier
|
|
285
|
-
// `^-{0,2}[A-Za-z][\w.-]*=` described the shapes its author thought of and
|
|
286
|
-
// let three through — `_API_TOKEN=secret` (a leading underscore is a legal
|
|
287
|
-
// env name), `--set:key=value` and `--opt[k]=v` — while the text beside it
|
|
288
|
-
// said "an assignment in any form".
|
|
289
|
-
(value) => !/^[^=\s]+=/.test(value),
|
|
290
|
-
'A command part may not carry an inline value — write the flag and its value as separate arguments, so the value is checked like every other one',
|
|
291
|
-
);
|
|
292
|
-
|
|
293
|
-
const CommandArgumentsSchema = z
|
|
294
|
-
.array(CommandPartSchema)
|
|
295
|
-
.refine(
|
|
296
|
-
(args) =>
|
|
297
|
-
args.every(
|
|
298
|
-
(value, index) => !(/^\d{1,5}$/.test(value) && PORT_FLAG.test(args[index - 1] ?? '')),
|
|
299
|
-
),
|
|
300
|
-
'A number after a port flag is a port — name the variable that carries it and let the role read it',
|
|
301
|
-
);
|
|
302
|
-
|
|
303
|
-
export const ProjectCommandSchema = z
|
|
304
|
-
.object({
|
|
305
|
-
executable: CommandPartSchema,
|
|
306
|
-
args: CommandArgumentsSchema,
|
|
307
|
-
})
|
|
308
|
-
.strict();
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* A command run under a supervisor, which additionally may not be a launcher.
|
|
312
|
-
*
|
|
313
|
-
* The rule applies to ROLES and not to `build`: a build is not signalled, so a
|
|
314
|
-
* script runner in front of it costs nothing. A supervised role is signalled,
|
|
315
|
-
* and there the launcher is the defect measured above.
|
|
316
|
-
*/
|
|
317
|
-
export const ProjectRoleCommandSchema = ProjectCommandSchema.refine(
|
|
318
|
-
(value) => !launchesAScript(value.executable, value.args[0]),
|
|
319
|
-
'Start the role process itself, not a script runner: a launcher between the supervisor and the role duplicates the shutdown signal and forces the drain',
|
|
320
|
-
).refine(
|
|
321
|
-
(value) => !value.args.includes('--filter'),
|
|
322
|
-
'A workspace filter puts a launcher between the supervisor and the role, and the shutdown signal never reaches it',
|
|
323
|
-
);
|
|
324
|
-
|
|
325
|
-
export const ProjectRoleSchema = z
|
|
326
|
-
.object({
|
|
327
|
-
name: ProjectSlugSchema,
|
|
328
|
-
/**
|
|
329
|
-
* Where this role's commands run, relative to the repository root. Omitted
|
|
330
|
-
* means the root itself.
|
|
331
|
-
*
|
|
332
|
-
* Part of the role rather than of the supervisor because a command is only
|
|
333
|
-
* meaningful together with the directory it runs in — and because a
|
|
334
|
-
* supervisor reaching into a workspace from the root adds the very launcher
|
|
335
|
-
* `ProjectCommandSchema` refuses.
|
|
336
|
-
*/
|
|
337
|
-
workingDirectory: RepositoryPathSchema.optional(),
|
|
338
|
-
/** How to run this role, per mode. The key enum makes every mode required. */
|
|
339
|
-
commands: z.record(ProjectRunModeSchema, ProjectRoleCommandSchema),
|
|
340
|
-
listener: ProjectListenerSchema.optional(),
|
|
341
|
-
/**
|
|
342
|
-
* The FLOOR: how long this role may need to drain cleanly, in milliseconds.
|
|
343
|
-
*
|
|
344
|
-
* A property of the code — it follows from what the role has to finish, not
|
|
345
|
-
* from how long a deployment is willing to wait. Whatever supervises the
|
|
346
|
-
* process must allow at least this much, plus whatever the role spends
|
|
347
|
-
* forcing and cleaning up, before killing it.
|
|
348
|
-
*/
|
|
349
|
-
drainFloorMs: z.number().int().nonnegative(),
|
|
350
|
-
})
|
|
351
|
-
.strict();
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Data the build is allowed to read, named and frozen.
|
|
355
|
-
*
|
|
356
|
-
* The boundary rule separates two things — code, and the values of a place.
|
|
357
|
-
* There is a third that is neither: **data read while building**. Pages
|
|
358
|
-
* prerendered from a database depend on bytes that are not in the source and
|
|
359
|
-
* are not a binding, and no amount of moving environment variables makes such a
|
|
360
|
-
* build portable. Left unnamed, the dependency is invisible: the build succeeds
|
|
361
|
-
* on the machine that happens to have the database, and the artifact silently
|
|
362
|
-
* stops being a function of the source.
|
|
363
|
-
*
|
|
364
|
-
* Declaring an input is what makes it legitimate. `path` points at a frozen
|
|
365
|
-
* export inside the source, `digest` pins its bytes, and a build that reads
|
|
366
|
-
* anything else is a build nobody declared. The other two legitimate answers
|
|
367
|
-
* need no field here at all: render at runtime, or generate the bytes as a
|
|
368
|
-
* release step on the way to the deployment.
|
|
369
|
-
*/
|
|
370
|
-
export const ProjectBuildInputSchema = z
|
|
371
|
-
.object({
|
|
372
|
-
/** How the build refers to this input — and how a failure names it. */
|
|
373
|
-
name: ProjectSlugSchema,
|
|
374
|
-
/** The frozen export, inside the source. Never a live source of data. */
|
|
375
|
-
path: RepositoryPathSchema,
|
|
376
|
-
/**
|
|
377
|
-
* The bytes, pinned. Without it the field records a filename and promises
|
|
378
|
-
* nothing: the contents could change between two builds of one source and
|
|
379
|
-
* both would look declared.
|
|
380
|
-
*/
|
|
381
|
-
digest: z
|
|
382
|
-
.string()
|
|
383
|
-
.regex(/^sha256:[0-9a-f]{64}$/, 'Use a lowercase sha256 digest, as "sha256:<64 hex>"'),
|
|
384
|
-
})
|
|
385
|
-
.strict();
|
|
386
|
-
|
|
387
|
-
export type ProjectBuildInput = z.infer<typeof ProjectBuildInputSchema>;
|
|
388
|
-
|
|
389
|
-
/** What building the source produces. More than one path is the normal case. */
|
|
390
|
-
export const ProjectBuildSchema = z
|
|
391
|
-
.object({
|
|
392
|
-
command: ProjectCommandSchema,
|
|
393
|
-
artifacts: z.array(RepositoryPathSchema).min(1),
|
|
394
|
-
/**
|
|
395
|
-
* Absent is the normal case, and it means something exact: this build reads
|
|
396
|
-
* no data. It does not mean "unknown".
|
|
397
|
-
*/
|
|
398
|
-
inputs: z.array(ProjectBuildInputSchema).optional(),
|
|
399
|
-
})
|
|
400
|
-
.strict()
|
|
401
|
-
.refine((build) => {
|
|
402
|
-
const names = (build.inputs ?? []).map((input) => input.name);
|
|
403
|
-
return new Set(names).size === names.length;
|
|
404
|
-
}, 'Two build inputs share a name — a failure could then name either of them');
|
|
405
|
-
|
|
406
|
-
/** When a requirement has to be there. */
|
|
407
|
-
export const ProjectRequirementPhaseSchema = z.enum(['release', 'start']);
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Something the code needs that the code does not provide.
|
|
411
|
-
*
|
|
412
|
-
* `phases` says when: `release` is needed once while bringing a deployment to
|
|
413
|
-
* this source, `start` is needed by every process every time it starts. One
|
|
414
|
-
* requirement can be both, and saying so once beats naming it twice.
|
|
415
|
-
*/
|
|
416
|
-
export const ProjectRequirementSchema = z
|
|
417
|
-
.object({
|
|
418
|
-
name: ProjectSlugSchema,
|
|
419
|
-
phases: z.array(ProjectRequirementPhaseSchema).min(1),
|
|
420
|
-
})
|
|
421
|
-
.strict();
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* What a migration IS, declared as a fact rather than as a command to run.
|
|
425
|
-
*
|
|
426
|
-
* The repository says which bytes are migrations; whatever brings a deployment
|
|
427
|
-
* to this source reads them and decides — exact contents, admission verdict,
|
|
428
|
-
* whether a preflight can be skipped because nothing touches the database. A
|
|
429
|
-
* free list of shell commands would take that decision away from the side that
|
|
430
|
-
* is able to make it, and hand it to the side that cannot see the deployment.
|
|
431
|
-
*
|
|
432
|
-
* `engine` is a name, checked like every other free string: a connection string
|
|
433
|
-
* fits in a name-shaped field otherwise, credentials and all.
|
|
434
|
-
*/
|
|
435
|
-
export const ProjectMigrationsSchema = z
|
|
436
|
-
.object({
|
|
437
|
-
engine: refuseMachineNames('A migration engine name')(z.string().min(1).max(64)),
|
|
438
|
-
root: RepositoryPathSchema,
|
|
439
|
-
lockfile: RepositoryPathSchema,
|
|
440
|
-
})
|
|
441
|
-
.strict();
|
|
442
|
-
|
|
443
|
-
/** What must happen once, before any role starts, to reach this source. */
|
|
444
|
-
export const ProjectReleaseSchema = z
|
|
445
|
-
.object({ migrations: ProjectMigrationsSchema.optional() })
|
|
446
|
-
.strict();
|
|
447
|
-
|
|
448
|
-
/** The value shapes a declaration can describe without naming a value. */
|
|
449
|
-
export const ProjectEnvShapeSchema = z.enum(['string', 'integer', 'boolean', 'url', 'enum']);
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* A variable a deployment must supply.
|
|
453
|
-
*
|
|
454
|
-
* `members` is required for `enum` and forbidden otherwise: a reader without a
|
|
455
|
-
* TypeScript runtime learns nothing from "one of an unnamed set", which is the
|
|
456
|
-
* only thing this list exists to tell it.
|
|
457
|
-
*/
|
|
458
|
-
export const ProjectEnvVariableSchema = z
|
|
459
|
-
.object({
|
|
460
|
-
name: BindingVariableSchema,
|
|
461
|
-
shape: ProjectEnvShapeSchema,
|
|
462
|
-
required: z.boolean(),
|
|
463
|
-
members: z
|
|
464
|
-
.array(refuseMachineNames('An enum member')(z.string().min(1)))
|
|
465
|
-
.min(1)
|
|
466
|
-
.optional(),
|
|
467
|
-
})
|
|
468
|
-
.strict()
|
|
469
|
-
.refine(
|
|
470
|
-
(value) => (value.shape === 'enum') === (value.members !== undefined),
|
|
471
|
-
'An enum variable lists its members; every other shape has none',
|
|
472
|
-
);
|
|
473
|
-
|
|
474
|
-
/** The declaration itself. Every field here is true about the code alone. */
|
|
475
|
-
export const ProjectDeclarationSchema = z
|
|
476
|
-
.object({
|
|
477
|
-
schemaVersion: z.literal(PROJECT_DECLARATION_SCHEMA_VERSION),
|
|
478
|
-
kind: z.enum(['library', 'application']),
|
|
479
|
-
identity: ProjectIdentitySchema,
|
|
480
|
-
roles: z.array(ProjectRoleSchema),
|
|
481
|
-
build: ProjectBuildSchema.optional(),
|
|
482
|
-
requires: z.array(ProjectRequirementSchema),
|
|
483
|
-
release: ProjectReleaseSchema,
|
|
484
|
-
/**
|
|
485
|
-
* The variables a deployment must supply, by name and shape.
|
|
486
|
-
*
|
|
487
|
-
* Names only. A default, a coercion or an error message belongs to the
|
|
488
|
-
* project's own validation, which stays the source — this list is derived
|
|
489
|
-
* from it so that a reader without a TypeScript runtime can still see what
|
|
490
|
-
* the project needs.
|
|
491
|
-
*/
|
|
492
|
-
env: z.object({ variables: z.array(ProjectEnvVariableSchema) }).strict(),
|
|
493
|
-
})
|
|
494
|
-
.strict()
|
|
495
|
-
.refine(
|
|
496
|
-
(value) =>
|
|
497
|
-
value.kind === 'application' ? value.roles.length > 0 : value.roles.length === 0,
|
|
498
|
-
'An application declares at least one role; a library declares none',
|
|
499
|
-
)
|
|
500
|
-
.refine(
|
|
501
|
-
(value) => new Set(value.roles.map((role) => role.name)).size === value.roles.length,
|
|
502
|
-
'Role names must be unique',
|
|
503
|
-
)
|
|
504
|
-
.refine(
|
|
505
|
-
(value) =>
|
|
506
|
-
new Set(value.requires.map((entry) => entry.name)).size === value.requires.length,
|
|
507
|
-
'Name each requirement once and list its phases',
|
|
508
|
-
)
|
|
509
|
-
.refine(
|
|
510
|
-
(value) =>
|
|
511
|
-
value.requires.every((entry) => new Set(entry.phases).size === entry.phases.length),
|
|
512
|
-
'List each phase of a requirement once',
|
|
513
|
-
)
|
|
514
|
-
.refine(
|
|
515
|
-
(value) =>
|
|
516
|
-
new Set(value.env.variables.map((entry) => entry.name)).size ===
|
|
517
|
-
value.env.variables.length,
|
|
518
|
-
'Declare each environment variable once',
|
|
519
|
-
)
|
|
520
|
-
/**
|
|
521
|
-
* A listener names two bindings. The env contract must contain both, with the
|
|
522
|
-
* right shapes, and they must not be the same variable.
|
|
523
|
-
*
|
|
524
|
-
* Without this a declaration can be internally contradictory and still parse:
|
|
525
|
-
* a reader outside the tree is told a role listens on `API_PORT`, looks for it
|
|
526
|
-
* in the contract that lists what a deployment must supply, and does not find
|
|
527
|
-
* it. Nothing fails — the deployment simply starts without a port. The
|
|
528
|
-
* repository's own fixture demonstrated exactly that shape before this check.
|
|
529
|
-
*/
|
|
530
|
-
.refine((value) => listenerBindingProblem(value) === undefined, {
|
|
531
|
-
error: (issue) =>
|
|
532
|
-
listenerBindingProblem(issue.input) ?? 'Listener bindings are inconsistent',
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
interface ListenerBindingSubject {
|
|
536
|
-
roles: ReadonlyArray<{
|
|
537
|
-
name: string;
|
|
538
|
-
listener?: { portVariable: string; bindVariable: string };
|
|
539
|
-
}>;
|
|
540
|
-
env: { variables: ReadonlyArray<{ name: string; shape: string }> };
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function isListenerBindingSubject(value: unknown): value is ListenerBindingSubject {
|
|
544
|
-
return typeof value === 'object' && value !== null && 'roles' in value && 'env' in value;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/** Why a listener's bindings disagree with the env contract, or `undefined`. */
|
|
548
|
-
function listenerBindingProblem(value: unknown): string | undefined {
|
|
549
|
-
if (!isListenerBindingSubject(value)) return undefined;
|
|
550
|
-
const shapes = new Map(value.env.variables.map((entry) => [entry.name, entry.shape]));
|
|
551
|
-
for (const role of value.roles) {
|
|
552
|
-
const listener = role.listener;
|
|
553
|
-
if (!listener) continue;
|
|
554
|
-
if (listener.portVariable === listener.bindVariable) {
|
|
555
|
-
return `Role "${role.name}" points its port and its bind address at the same variable "${listener.portVariable}"`;
|
|
556
|
-
}
|
|
557
|
-
const expected: ReadonlyArray<readonly [string, string]> = [
|
|
558
|
-
[listener.portVariable, 'integer'],
|
|
559
|
-
[listener.bindVariable, 'string'],
|
|
560
|
-
];
|
|
561
|
-
for (const [name, shape] of expected) {
|
|
562
|
-
const declared = shapes.get(name);
|
|
563
|
-
if (declared === undefined) {
|
|
564
|
-
return `Role "${role.name}" listens on "${name}", which env.variables does not declare — a deployment reading this cannot know it has to supply it`;
|
|
565
|
-
}
|
|
566
|
-
if (declared !== shape) {
|
|
567
|
-
return `Role "${role.name}" listens on "${name}", declared as "${declared}" where a ${shape} is needed`;
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
return undefined;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
export type ProjectDeclaration = z.infer<typeof ProjectDeclarationSchema>;
|
|
575
|
-
export type ProjectRole = z.infer<typeof ProjectRoleSchema>;
|
|
576
|
-
export type ProjectCommand = z.infer<typeof ProjectCommandSchema>;
|
|
577
|
-
export type ProjectRoleCommand = z.infer<typeof ProjectRoleCommandSchema>;
|
|
578
|
-
export type ProjectEnvVariable = z.infer<typeof ProjectEnvVariableSchema>;
|
|
579
|
-
export type ProjectIdentity = z.infer<typeof ProjectIdentitySchema>;
|
|
580
|
-
|
|
581
|
-
/** The declared version, or `undefined` when the source does not carry one. */
|
|
582
|
-
const VersionProbeSchema = z.object({ schemaVersion: z.unknown() }).loose();
|
|
583
|
-
|
|
584
|
-
/**
|
|
585
|
-
* Parse a declaration, refusing an unknown schema version **before** any field
|
|
586
|
-
* is read.
|
|
587
|
-
*
|
|
588
|
-
* Order matters. The object is strict, so a newer declaration would otherwise
|
|
589
|
-
* report as a list of unrecognised keys — which reads like a broken file rather
|
|
590
|
-
* than a version this build is too old to serve. The version check is
|
|
591
|
-
* fail-closed: an unrecognised version is refused, never assumed compatible.
|
|
592
|
-
*/
|
|
593
|
-
export function parseProjectDeclaration(source: unknown): ProjectDeclaration {
|
|
594
|
-
const probe = VersionProbeSchema.safeParse(source);
|
|
595
|
-
const declared = probe.success ? probe.data.schemaVersion : undefined;
|
|
596
|
-
if (declared !== undefined && declared !== PROJECT_DECLARATION_SCHEMA_VERSION) {
|
|
597
|
-
throw new Error(
|
|
598
|
-
`Project declaration schema version ${JSON.stringify(declared)} is not supported — ` +
|
|
599
|
-
`this build understands version ${PROJECT_DECLARATION_SCHEMA_VERSION}.`,
|
|
600
|
-
);
|
|
601
|
-
}
|
|
602
|
-
return ProjectDeclarationSchema.parse(source);
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
/** The role with this name, or `undefined`. */
|
|
606
|
-
export function findProjectRole(
|
|
607
|
-
declaration: ProjectDeclaration,
|
|
608
|
-
name: string,
|
|
609
|
-
): ProjectRole | undefined {
|
|
610
|
-
return declaration.roles.find((role) => role.name === name);
|
|
611
|
-
}
|