@ultimat3/cli 19.2.0 → 19.3.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/CLAUDE.md +108 -6
- package/package.json +29 -29
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +5 -1
- package/src/budgets.ts +17 -6
- package/src/cmd-dev.ts +29 -39
- package/src/cmd-doctor.ts +61 -23
- package/src/cmd-generate.ts +5 -2
- package/src/cmd-i18n.ts +10 -3
- package/src/cmd-jobs.ts +56 -10
- package/src/cmd-test.ts +15 -10
- package/src/db-seed.ts +2 -1
- package/src/dev-queue.ts +16 -2
- package/src/dev-reload.ts +46 -0
- package/src/dev-runtime.ts +4 -1
- package/src/dev-sync.ts +11 -3
- package/src/dev-watch-tree.ts +226 -0
- package/src/dev-watch.ts +59 -37
- package/src/doctor-offline.ts +122 -0
- package/src/error-catalog.ts +4 -5
- package/src/fix-command.ts +40 -1
- package/src/fix-path.ts +10 -11
- package/src/flag-number.ts +15 -0
- package/src/generate-kinds.ts +54 -4
- package/src/generate-write.ts +25 -2
- package/src/gitignore.ts +145 -0
- package/src/hold.ts +50 -17
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +2 -1
- package/src/island-states-load.ts +2 -1
- package/src/jobs-driver.ts +4 -1
- package/src/mcp-host.ts +18 -9
- package/src/parse.ts +17 -0
- package/src/path-segments.ts +14 -0
- package/src/prerender.ts +46 -20
- package/src/retry-memo.ts +37 -0
- package/src/serve.ts +5 -1
- package/src/source-files.ts +3 -1
- package/src/sw-artifacts.ts +71 -7
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +6 -6
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +14 -2
package/src/gitignore.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// What git ignores, as data. The one reader of a `.gitignore` in this package: `dev-watch.ts` asks
|
|
2
|
+
// it which directories `x dev` must not watch, and `fix-path.ts` asks it which citations name a
|
|
3
|
+
// file this repository never commits. A second reader would be a second answer, and the two would
|
|
4
|
+
// disagree on the first pattern neither author anticipated.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no synchronous file read and no synchronous existence primitive —
|
|
7
|
+
// `Bun.file(p).text()` is async, and the ignore set is rebuilt inside an `fs.watch` callback where
|
|
8
|
+
// an await opens a window for the next event. Delete when Bun ships sync equivalents.
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
// why: Bun exposes no path-join, dirname or relative primitive. The same necessity `fix-path.ts`
|
|
11
|
+
// already records for `join`.
|
|
12
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
13
|
+
|
|
14
|
+
/** One line of a `.gitignore`, read for what it states rather than for what a reader assumes. */
|
|
15
|
+
export interface IgnorePattern {
|
|
16
|
+
/** The glob, without its leading `/` or trailing `/`. */
|
|
17
|
+
readonly glob: string;
|
|
18
|
+
/** Held against the whole path relative to the file's own directory, never the basename. */
|
|
19
|
+
readonly anchored: boolean;
|
|
20
|
+
/** A trailing `/`: it matches a directory and never a file of that name. */
|
|
21
|
+
readonly directoryOnly: boolean;
|
|
22
|
+
/** A leading `!`: it re-includes what an earlier pattern ignored, at the same level. */
|
|
23
|
+
readonly negated: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One `.gitignore` file and the directory every one of its patterns is relative to. */
|
|
27
|
+
export interface IgnoreScope {
|
|
28
|
+
readonly base: string;
|
|
29
|
+
readonly patterns: readonly IgnorePattern[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A pattern is ANCHORED when it holds a `/` anywhere but at its end — git's own rule, and the one
|
|
34
|
+
* a hand-rolled reader gets wrong in both directions: `coverage/` ignores a `coverage` directory at
|
|
35
|
+
* any depth, while `/coverage/` and a pattern holding an inner slash only ever match where they
|
|
36
|
+
* are written.
|
|
37
|
+
*/
|
|
38
|
+
export function parseGitignore(text: string): readonly IgnorePattern[] {
|
|
39
|
+
const patterns: IgnorePattern[] = [];
|
|
40
|
+
for (const raw of text.split('\n')) {
|
|
41
|
+
// Trailing whitespace carries no rule unless escaped; a leading `#` is a comment, and `\#` is
|
|
42
|
+
// a filename that starts with one.
|
|
43
|
+
const line = raw.replace(/\\?\s+$/, (match) => (match.startsWith('\\') ? match : ''));
|
|
44
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
45
|
+
const negated = line.startsWith('!');
|
|
46
|
+
const body = (negated ? line.slice(1) : line).replace(/^\\(?=[#!])/, '');
|
|
47
|
+
if (body === '' || body === '/') continue;
|
|
48
|
+
const directoryOnly = body.endsWith('/');
|
|
49
|
+
const trimmed = directoryOnly ? body.slice(0, -1) : body;
|
|
50
|
+
const rooted = trimmed.startsWith('/');
|
|
51
|
+
const glob = rooted ? trimmed.slice(1) : trimmed;
|
|
52
|
+
if (glob === '') continue;
|
|
53
|
+
patterns.push({ glob, anchored: rooted || glob.includes('/'), directoryOnly, negated });
|
|
54
|
+
}
|
|
55
|
+
return patterns;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The patterns one directory's own `.gitignore` states, or none where it has no such file. */
|
|
59
|
+
export function readIgnoreFile(directory: string): readonly IgnorePattern[] {
|
|
60
|
+
const file = join(directory, '.gitignore');
|
|
61
|
+
return existsSync(file) ? parseGitignore(readFileSync(file, 'utf8')) : [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The `.gitignore` files that govern `root`: its own last, then every ancestor up to and including
|
|
66
|
+
* the directory holding `.git`. Outermost first, so a nearer file's pattern is read last and wins —
|
|
67
|
+
* `examples/dummy` carries no ignore file of its own and every rule about it lives in the
|
|
68
|
+
* repository root's, which is why reading one file made `touch tsconfig.tsbuildinfo` a full reload.
|
|
69
|
+
*/
|
|
70
|
+
export function ignoreScopes(root: string): readonly IgnoreScope[] {
|
|
71
|
+
const scopes: IgnoreScope[] = [];
|
|
72
|
+
let directory = root;
|
|
73
|
+
for (;;) {
|
|
74
|
+
const patterns = readIgnoreFile(directory);
|
|
75
|
+
if (patterns.length > 0) scopes.unshift({ base: directory, patterns });
|
|
76
|
+
if (existsSync(join(directory, '.git'))) break;
|
|
77
|
+
const parent = dirname(directory);
|
|
78
|
+
if (parent === directory) break;
|
|
79
|
+
directory = parent;
|
|
80
|
+
}
|
|
81
|
+
return scopes;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Compiled once per pattern: one `x dev` walk asks the same glob of every directory it meets. */
|
|
85
|
+
const globs = new Map<string, Bun.Glob>();
|
|
86
|
+
|
|
87
|
+
function globFor(pattern: string): Bun.Glob {
|
|
88
|
+
const known = globs.get(pattern);
|
|
89
|
+
if (known !== undefined) return known;
|
|
90
|
+
const glob = new Bun.Glob(pattern);
|
|
91
|
+
globs.set(pattern, glob);
|
|
92
|
+
return glob;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The path relative to `base`, POSIX-separated, or `undefined` when it is not under it. */
|
|
96
|
+
function relativeUnder(base: string, path: string): string | undefined {
|
|
97
|
+
const rel = relative(base, path).split(sep).join('/');
|
|
98
|
+
return rel === '' || rel.startsWith('../') || rel === '..' ? undefined : rel;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function matches(pattern: IgnorePattern, path: string, isDirectory: boolean): boolean {
|
|
102
|
+
if (pattern.directoryOnly && !isDirectory) return false;
|
|
103
|
+
const subject = pattern.anchored ? path : (path.split('/').at(-1) ?? path);
|
|
104
|
+
return globFor(pattern.glob).match(subject);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Last match wins, across every scope in order — git's rule, and the reason `!` works at all. */
|
|
108
|
+
function verdictFor(
|
|
109
|
+
scopes: readonly IgnoreScope[],
|
|
110
|
+
path: string,
|
|
111
|
+
isDirectory: boolean,
|
|
112
|
+
): boolean | undefined {
|
|
113
|
+
let verdict: boolean | undefined;
|
|
114
|
+
for (const scope of scopes) {
|
|
115
|
+
const rel = relativeUnder(scope.base, path);
|
|
116
|
+
if (rel === undefined) continue;
|
|
117
|
+
for (const pattern of scope.patterns) {
|
|
118
|
+
if (matches(pattern, rel, isDirectory)) verdict = !pattern.negated;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return verdict;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether git would ignore this absolute path. Every ancestor between the outermost scope and the
|
|
126
|
+
* path is judged as a DIRECTORY first: an ignored directory takes everything under it, which is how
|
|
127
|
+
* `coverage/` reaches `coverage/lcov.info` and the only reason a walk may prune at all.
|
|
128
|
+
*/
|
|
129
|
+
export function isGitIgnored(
|
|
130
|
+
scopes: readonly IgnoreScope[],
|
|
131
|
+
path: string,
|
|
132
|
+
isDirectory: boolean,
|
|
133
|
+
): boolean {
|
|
134
|
+
const outermost = scopes[0];
|
|
135
|
+
if (outermost === undefined) return false;
|
|
136
|
+
const rel = relativeUnder(outermost.base, path);
|
|
137
|
+
if (rel === undefined) return false;
|
|
138
|
+
const segments = rel.split('/');
|
|
139
|
+
for (let depth = 0; depth < segments.length; depth += 1) {
|
|
140
|
+
const prefix = join(outermost.base, ...segments.slice(0, depth + 1));
|
|
141
|
+
const last = depth === segments.length - 1;
|
|
142
|
+
if (verdictFor(scopes, prefix, last ? isDirectory : true) === true) return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
package/src/hold.ts
CHANGED
|
@@ -33,12 +33,15 @@ export interface HoldOptions {
|
|
|
33
33
|
* handler opened them against. It is the resources core never learned about: the embedded
|
|
34
34
|
* Postgres, the worker, the file watcher.
|
|
35
35
|
*
|
|
36
|
-
* It
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* `
|
|
40
|
-
*
|
|
41
|
-
*
|
|
36
|
+
* It is BOUNDED, and that is not a detail. `drain()` ABANDONS a hook that overruns
|
|
37
|
+
* `ShutdownReason.deadlineAt` — the process is meant to exit without it — and `release` here
|
|
38
|
+
* re-enters the very same teardown one call later: `app.stop()` -> `startRoles().stop()` ->
|
|
39
|
+
* `worker.stop()`, memoised in the package that owns it, so awaiting it is awaiting the promise
|
|
40
|
+
* the drain just walked away from. Unbounded, that hangs forever and the deadline buys nothing.
|
|
41
|
+
*
|
|
42
|
+
* The bound is what is LEFT of the drain's budget, under a floor of `MIN_RELEASE_MS` — see there
|
|
43
|
+
* for why the remainder alone answered `0` on every busy pod and abandoned the teardown before it
|
|
44
|
+
* closed anything.
|
|
42
45
|
*/
|
|
43
46
|
export function holdUntilShutdown(
|
|
44
47
|
name: string,
|
|
@@ -48,8 +51,8 @@ export function holdUntilShutdown(
|
|
|
48
51
|
const uninstall = installSignalHandlers({ exit: false });
|
|
49
52
|
let unregister = (): void => {};
|
|
50
53
|
// The hook's own `reason`, not a stopwatch of ours: `deadlineAt` is the instant core computed
|
|
51
|
-
// when the drain began, on the same real monotonic clock, so
|
|
52
|
-
//
|
|
54
|
+
// when the drain began, on the same real monotonic clock, so what is left of the drain's budget
|
|
55
|
+
// is read off the drain's own number and never off a second one of the same length.
|
|
53
56
|
const shuttingDown = new Promise<number>((resolve) => {
|
|
54
57
|
unregister = onShutdown(
|
|
55
58
|
`cli:${name}:hold`,
|
|
@@ -71,7 +74,7 @@ export function holdUntilShutdown(
|
|
|
71
74
|
await drain();
|
|
72
75
|
unregister();
|
|
73
76
|
uninstall();
|
|
74
|
-
await releaseWithin(name, release, deadlineAt - systemClock.monotonic());
|
|
77
|
+
await releaseWithin(name, release, releaseBudgetMs(deadlineAt - systemClock.monotonic()));
|
|
75
78
|
options.exit?.(0);
|
|
76
79
|
})();
|
|
77
80
|
return held;
|
|
@@ -79,33 +82,63 @@ export function holdUntilShutdown(
|
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
/**
|
|
82
|
-
* `release
|
|
85
|
+
* The floor under `release`'s budget, and the reason the drain's REMAINDER alone is the wrong one.
|
|
86
|
+
*
|
|
87
|
+
* The remainder is what is left of a budget that was spent on something else. A drain that used
|
|
88
|
+
* all of it — one request over budget is enough, and that is the ordinary shutdown on a busy pod —
|
|
89
|
+
* hands `release` a NEGATIVE number, `Math.max(0, …)` reads it as `0`, and `setTimeout(resolve, 0)`
|
|
90
|
+
* wins against any teardown whose first await is real work. Measured at `deadlineMs: 60` with one
|
|
91
|
+
* `beginWork()` outstanding: the release STARTED and was abandoned 0ms later, so `app.stop()` never
|
|
92
|
+
* reached the pool close, the NATS close, the cache tiers or the mail driver, and the outbox relay
|
|
93
|
+
* was abandoned somewhere between `driver.enqueue` and `markPublished` — a duplicate job on the
|
|
94
|
+
* next boot. The abandonment is not the harmless "exit slightly early" it was written as: it is
|
|
95
|
+
* every resource the process holds, left to the kernel.
|
|
96
|
+
*
|
|
97
|
+
* So `release` gets a budget of its own, floored, never a leftover. 5s, and the arithmetic is
|
|
98
|
+
* what makes it defensible rather than a feel: `DEFAULT_DEADLINE_MS` is 25s
|
|
99
|
+
* (`packages/core/src/lifecycle.ts`) and `docker/helm/templates/deployments.yaml` sets
|
|
100
|
+
* `terminationGracePeriodSeconds: 45`, so a drain that spends everything PLUS a release that
|
|
101
|
+
* spends everything is 30s — still inside the grace period, so the kubelet never SIGKILLs a
|
|
102
|
+
* process this floor kept alive. It stays a floor and not a clamp: an app that raised
|
|
103
|
+
* `deadlineMs` for a slow teardown keeps the bigger number.
|
|
104
|
+
*/
|
|
105
|
+
export const MIN_RELEASE_MS = 5_000;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* What `release` really gets. Non-finite is the floor rather than the input, for the reason
|
|
109
|
+
* `packages/core/src/lifecycle-bounds.test.ts` exists: `Math.max(n, NaN)` is `NaN` and
|
|
110
|
+
* `setTimeout(fn, NaN)` fires on the next tick, which is this whole defect a second time.
|
|
111
|
+
*/
|
|
112
|
+
export const releaseBudgetMs = (remainingMs: number): number =>
|
|
113
|
+
Number.isFinite(remainingMs) ? Math.max(MIN_RELEASE_MS, remainingMs) : MIN_RELEASE_MS;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `release()` raced against its own budget.
|
|
83
117
|
*
|
|
84
118
|
* A local race and not core's `settleWithin`, which is internal to `lifecycle-deadline.ts` and not
|
|
85
119
|
* on core's barrel. The semantics are deliberately the same, including the one that matters: a
|
|
86
120
|
* REJECTION still rejects — `dispatch` awaits the hold inside its own `try`, and an embedded
|
|
87
121
|
* database that would not close is a finding on the way out, never a clean exit over it.
|
|
88
|
-
*
|
|
89
|
-
* A budget already spent is `0`, and that abandons immediately by design: past `deadlineAt` the
|
|
90
|
-
* orchestrator is already counting down to SIGKILL, so the honest move is to say so and exit
|
|
91
|
-
* rather than to start a second grace period nobody granted.
|
|
92
122
|
*/
|
|
93
123
|
async function releaseWithin(
|
|
94
124
|
name: string,
|
|
95
125
|
release: () => Promise<void>,
|
|
96
126
|
budgetMs: number,
|
|
97
127
|
): Promise<void> {
|
|
98
|
-
|
|
128
|
+
// Screened by `releaseBudgetMs`, which is the one answer to "how long does a teardown get" —
|
|
129
|
+
// a second `Math.max` here would be a second, quieter one.
|
|
99
130
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
100
131
|
const abandoned = new Promise<'abandoned'>((resolve) => {
|
|
101
|
-
timer = setTimeout(() => resolve('abandoned'),
|
|
132
|
+
timer = setTimeout(() => resolve('abandoned'), budgetMs);
|
|
102
133
|
});
|
|
103
134
|
try {
|
|
104
135
|
const outcome = await Promise.race([release().then(() => 'released' as const), abandoned]);
|
|
105
136
|
if (outcome === 'released') return;
|
|
106
137
|
logger.warn('X_SHUTDOWN_TIMEOUT', {
|
|
107
138
|
code: 'X_SHUTDOWN_TIMEOUT',
|
|
108
|
-
|
|
139
|
+
// Rounded: `deadlineAt - monotonic()` is a float, and `4923.185900000001ms` in a shutdown
|
|
140
|
+
// log reads as a bug in the number rather than as the budget it is.
|
|
141
|
+
cause: `the "${name}" release was still running ${Math.round(budgetMs)}ms after the drain finished and has been ABANDONED — the process exits without it, so anything it held may not be closed`,
|
|
109
142
|
fix: 'raise the budget past the slowest teardown — configureLifecycle({ deadlineMs: 600_000 }) for a 10-minute one — and set terminationGracePeriodSeconds to at least as many seconds',
|
|
110
143
|
});
|
|
111
144
|
} finally {
|
package/src/index.ts
CHANGED
|
@@ -68,7 +68,7 @@ export { deployCommand, planDeploy } from './cmd-deploy';
|
|
|
68
68
|
export type { DevServer, StartDevOptions } from './cmd-dev';
|
|
69
69
|
export { devCommand, startDev } from './cmd-dev';
|
|
70
70
|
export type { DoctorProbe } from './cmd-doctor';
|
|
71
|
-
export { doctorCommand,
|
|
71
|
+
export { doctorCommand, ENV_DEVELOPMENT, probeFor, runDoctor } from './cmd-doctor';
|
|
72
72
|
export { ERRORS_SUBCOMMANDS, errorsCommand } from './cmd-errors';
|
|
73
73
|
export { FIX_SUBCOMMANDS, fixCommand } from './cmd-fix';
|
|
74
74
|
export type { GenerateOptions, Generator } from './cmd-generate';
|
package/src/island-bundle.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/
|
|
|
12
12
|
import { contentHash } from '@ultimat3/render/server';
|
|
13
13
|
import { IslandBuildFailedError } from './errors';
|
|
14
14
|
import { islandStylesPlugin } from './island-styles';
|
|
15
|
+
import { hasPathSegment } from './path-segments';
|
|
15
16
|
import { solidJsxPlugin } from './solid-loader';
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -59,7 +60,7 @@ export interface IslandBundle {
|
|
|
59
60
|
export async function discoverIslands(root: string): Promise<readonly string[]> {
|
|
60
61
|
const files: string[] = [];
|
|
61
62
|
for await (const absolute of new Bun.Glob(ISLAND_GLOB).scan({ cwd: root, absolute: true })) {
|
|
62
|
-
if (absolute
|
|
63
|
+
if (hasPathSegment(absolute, 'node_modules')) continue;
|
|
63
64
|
files.push(relative(root, absolute).split(sep).join('/'));
|
|
64
65
|
}
|
|
65
66
|
return files.sort();
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '@ultimat3/testing';
|
|
17
17
|
import { ISLAND_GLOB } from './island-bundle';
|
|
18
18
|
import { IslandStatesFileEmptyError } from './island-shot-errors';
|
|
19
|
+
import { hasPathSegment } from './path-segments';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* `.island.states.ts`, built from the two constants that own its halves and restated as neither:
|
|
@@ -37,7 +38,7 @@ export async function discoverIslandStates(root: string): Promise<readonly strin
|
|
|
37
38
|
const files: string[] = [];
|
|
38
39
|
const scan = new Bun.Glob(ISLAND_STATES_GLOB).scan({ cwd: root, absolute: true });
|
|
39
40
|
for await (const absolute of scan) {
|
|
40
|
-
if (absolute
|
|
41
|
+
if (hasPathSegment(absolute, 'node_modules')) continue;
|
|
41
42
|
files.push(relative(root, absolute).split(sep).join('/'));
|
|
42
43
|
}
|
|
43
44
|
return files.sort();
|
package/src/jobs-driver.ts
CHANGED
|
@@ -24,7 +24,10 @@ export async function withJobDriver(
|
|
|
24
24
|
const ambient = jobDriver();
|
|
25
25
|
if (ambient !== undefined) return fn(ambient);
|
|
26
26
|
const services = resolveServices(root, ctx.env);
|
|
27
|
-
|
|
27
|
+
// The command's own environment on both halves: `resolveServices` reads DATABASE_URL off it, so
|
|
28
|
+
// a queue that resolved its standby from `process.env` would talk to a pair the same call had
|
|
29
|
+
// just decided against.
|
|
30
|
+
const queue = await startQueue(services, undefined, ctx.env);
|
|
28
31
|
try {
|
|
29
32
|
return await fn(queue.jobs);
|
|
30
33
|
} finally {
|
package/src/mcp-host.ts
CHANGED
|
@@ -45,6 +45,7 @@ import { databaseTarget } from './mcp-db-target';
|
|
|
45
45
|
import { explainErrorCode } from './mcp-errors';
|
|
46
46
|
import { parseBunTest } from './mcp-test-output';
|
|
47
47
|
import { readMigrations } from './migrations';
|
|
48
|
+
import { retryMemo } from './retry-memo';
|
|
48
49
|
|
|
49
50
|
export interface DevHostInput {
|
|
50
51
|
readonly root: string;
|
|
@@ -91,7 +92,11 @@ export interface LazyServices {
|
|
|
91
92
|
*/
|
|
92
93
|
export function lazyServices(input: DevHostInput): LazyServices {
|
|
93
94
|
const services = resolveServices(input.root, input.env);
|
|
94
|
-
|
|
95
|
+
// `retryMemo`, not `??=`: a boot that REJECTED is not an answer to keep. A Postgres that refused
|
|
96
|
+
// one connection wedged every later tool call in the session with that first error, and the only
|
|
97
|
+
// way out was restarting the host — `startServices` unwinds everything it started before it
|
|
98
|
+
// rejects, so there is nothing left over for a second attempt to collide with.
|
|
99
|
+
const boot = retryMemo(() => startServices(services, input.env));
|
|
95
100
|
let closed = false;
|
|
96
101
|
return {
|
|
97
102
|
services,
|
|
@@ -104,14 +109,14 @@ export function lazyServices(input: DevHostInput): LazyServices {
|
|
|
104
109
|
fix: 'x mcp serve --transport stdio # keep the host open for the whole session',
|
|
105
110
|
});
|
|
106
111
|
}
|
|
107
|
-
|
|
108
|
-
return started;
|
|
112
|
+
return boot.get();
|
|
109
113
|
},
|
|
110
114
|
async close(): Promise<void> {
|
|
111
115
|
if (closed) return;
|
|
112
116
|
closed = true;
|
|
113
|
-
//
|
|
114
|
-
|
|
117
|
+
// `started()` and never `get()`: closing must not BOOT a database in order to stop one. A
|
|
118
|
+
// boot that rejected has nothing to stop, and close() must not throw on the way out.
|
|
119
|
+
await (await boot.started()?.catch(() => undefined))?.stop();
|
|
115
120
|
},
|
|
116
121
|
};
|
|
117
122
|
}
|
|
@@ -175,8 +180,13 @@ export async function readOnlyRows(
|
|
|
175
180
|
function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities {
|
|
176
181
|
const { root, runner } = input;
|
|
177
182
|
// Layer 1 is seven idempotent DDL statements, and `db.query` is a tool an agent calls in a
|
|
178
|
-
// loop — resolve the role once per process and reuse the answer, `null` included.
|
|
179
|
-
|
|
183
|
+
// loop — resolve the role once per process and reuse the answer, `null` included. A FAILED
|
|
184
|
+
// resolution is not an answer: `??=` kept the rejection, so a statement timeout on the DDL
|
|
185
|
+
// meant every later `db.query` in the session refused with it instead of trying again.
|
|
186
|
+
//
|
|
187
|
+
// It asks `lazy.running()` for the client rather than closing over one: this runs before any
|
|
188
|
+
// boot has happened, and the boot is memoised, so it is the same connection `runQuery` uses.
|
|
189
|
+
const readOnlyRole = retryMemo(async () => ensureReadOnlyRole((await lazy.running()).db));
|
|
180
190
|
|
|
181
191
|
return {
|
|
182
192
|
database: databaseTarget(lazy.services, input.env),
|
|
@@ -185,8 +195,7 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
|
|
|
185
195
|
const { db } = await lazy.running();
|
|
186
196
|
// A managed Postgres may refuse CREATE ROLE; `ensureReadOnlyRole` answers null and the
|
|
187
197
|
// layer is reported absent in `guards` rather than quietly assumed present.
|
|
188
|
-
|
|
189
|
-
return readOnlyRows(db, sql, limits, await readOnlyRole);
|
|
198
|
+
return readOnlyRows(db, sql, limits, await readOnlyRole.get());
|
|
190
199
|
},
|
|
191
200
|
|
|
192
201
|
async runMigrations(branch: string, dryRun: boolean) {
|
package/src/parse.ts
CHANGED
|
@@ -88,6 +88,15 @@ export interface CommandSpec {
|
|
|
88
88
|
* that declared it and forgot the call ran outside an app with no refusal.
|
|
89
89
|
*/
|
|
90
90
|
readonly requiresApp?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* The command hands everything after a bare `--` to another tool, so `ParsedArgs.passthrough`
|
|
93
|
+
* has a READER. Declared, because it did not until 2026-09 and nothing read it anywhere:
|
|
94
|
+
* `x test unit -- --coverage --bail` parsed both flags, carried them the whole way and dropped
|
|
95
|
+
* them, and every other command did the same in the same silence. A command that declares this
|
|
96
|
+
* forwards them; one that does not refuses the `--` (`X_CLI_BAD_FLAG`), which is the only way an
|
|
97
|
+
* argument that changes nothing becomes visible to the caller who typed it.
|
|
98
|
+
*/
|
|
99
|
+
readonly passthrough?: true;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
export interface ParsedArgs {
|
|
@@ -170,6 +179,14 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
|
|
|
170
179
|
}
|
|
171
180
|
|
|
172
181
|
const spec = resolveCommand(first, specs);
|
|
182
|
+
if (passthrough.length > 0 && spec.passthrough !== true) {
|
|
183
|
+
throw new BadFlagError({
|
|
184
|
+
flag: '',
|
|
185
|
+
command: spec.name,
|
|
186
|
+
reason: `hands nothing to another tool, so ${passthrough.join(' ')} would be dropped in silence`,
|
|
187
|
+
fix: `x ${spec.name} --help`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
173
190
|
const flags = defaults(spec);
|
|
174
191
|
const positionals: string[] = [];
|
|
175
192
|
// What argv actually SET, as against what `defaults()` seeded: a default is nobody's request,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// One answer to "does this path pass through a directory called X". Six modules asked it with
|
|
2
|
+
// `path.includes('node_modules')`, which is a SUBSTRING match: an app checked out under
|
|
3
|
+
// `~/dev/node_modules-experiments/myapp` answered true for every file it holds, so `loadApp`
|
|
4
|
+
// imported none of them and the app registered nothing at all.
|
|
5
|
+
|
|
6
|
+
/** Split on either separator: a rule that reads `a/b` and not `a\b` does not exist on Windows. */
|
|
7
|
+
export function pathSegments(path: string): readonly string[] {
|
|
8
|
+
return path.replaceAll('\\', '/').split('/');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Whether any whole segment of `path` is `segment` — never a substring of one. */
|
|
12
|
+
export function hasPathSegment(path: string, segment: string): boolean {
|
|
13
|
+
return pathSegments(path).includes(segment);
|
|
14
|
+
}
|
package/src/prerender.ts
CHANGED
|
@@ -22,7 +22,13 @@ import { loadPwaArtifacts, WEB_MANIFEST_PATH, writePwaIcons } from './pwa-artifa
|
|
|
22
22
|
import type { SkippedRoute, UnmeasuredRoute } from './static-report';
|
|
23
23
|
import { skippedRoute, skipReasonFor, writeStaticReport } from './static-report';
|
|
24
24
|
import { styleBundle, writeStyles } from './style-bundle';
|
|
25
|
-
import {
|
|
25
|
+
import type { RenderedDocument } from './sw-artifacts';
|
|
26
|
+
import {
|
|
27
|
+
SERVICE_WORKER_PATH,
|
|
28
|
+
SW_REGISTER_PATH,
|
|
29
|
+
serviceWorkerArtifacts,
|
|
30
|
+
serviceWorkerHead,
|
|
31
|
+
} from './sw-artifacts';
|
|
26
32
|
|
|
27
33
|
// Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
|
|
28
34
|
// carries it, and this file already imports that module.
|
|
@@ -141,6 +147,11 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
141
147
|
const skipped: SkippedRoute[] = [];
|
|
142
148
|
const routes: RouteStats[] = [];
|
|
143
149
|
const unmeasured: UnmeasuredRoute[] = [];
|
|
150
|
+
// What the loop below rendered, by the path it rendered — the service worker's precache
|
|
151
|
+
// revisions. A `Map` and not a record, so a route path spelling a prototype member cannot answer
|
|
152
|
+
// with one; insertion order is never read, because `pwaRoutes` walks the route table and
|
|
153
|
+
// `buildPrecacheManifest` sorts its own entries by code unit.
|
|
154
|
+
const documents = new Map<string, RenderedDocument>();
|
|
144
155
|
|
|
145
156
|
// Before the first document: a page's `data-x-entry` is a built chunk's URL, so the chunks have
|
|
146
157
|
// to exist to be named. Written into `out` too — a static export is served with no process
|
|
@@ -174,24 +185,15 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
174
185
|
// wiring exists to close. `undefined` when the app is not installable, and then no document
|
|
175
186
|
// names it either.
|
|
176
187
|
const pwa = await loadPwaArtifacts(options.root);
|
|
177
|
-
// The
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
buildId,
|
|
187
|
-
routes: describeRoutes(),
|
|
188
|
-
islands,
|
|
189
|
-
styles,
|
|
190
|
-
});
|
|
191
|
-
if (serviceWorker !== undefined) {
|
|
192
|
-
await Bun.write(join(options.out, SERVICE_WORKER_PATH.slice(1)), serviceWorker.source);
|
|
193
|
-
await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorker.register);
|
|
194
|
-
}
|
|
188
|
+
// The registration TAG now, the worker itself after the render loop — the two halves are wanted
|
|
189
|
+
// at different moments and used to be taken at the same one. Every document below has to name
|
|
190
|
+
// `/x-sw-register.js`, and the worker's precache manifest is built from the content hash of
|
|
191
|
+
// those same documents, which do not exist yet: emitted here, every route's revision was the
|
|
192
|
+
// BUILD ID and every route's byte count was 0, so a deploy of a byte-identical site re-fetched
|
|
193
|
+
// everything and the precache budget could not count one byte of HTML (`precache.ts`' own
|
|
194
|
+
// header). `serviceWorkerHead` is the one predicate behind both, so a page can never name a
|
|
195
|
+
// script the export does not carry.
|
|
196
|
+
const swHead = pwa === undefined ? undefined : serviceWorkerHead(pwa);
|
|
195
197
|
if (pwa !== undefined) {
|
|
196
198
|
await Bun.write(join(options.out, WEB_MANIFEST_PATH.slice(1)), pwa.body);
|
|
197
199
|
// And the icons that manifest NAMES. A static host runs no `assetRoutes()`, so every
|
|
@@ -224,7 +226,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
224
226
|
runWithContext(as, () =>
|
|
225
227
|
routeDocument(entry, data, {
|
|
226
228
|
resolveIsland: (file: string) => islands.resolverFor(file),
|
|
227
|
-
...(pwa === undefined ? {} : { pwaHead: pwa.head + (
|
|
229
|
+
...(pwa === undefined ? {} : { pwaHead: pwa.head + (swHead ?? '') }),
|
|
228
230
|
}),
|
|
229
231
|
);
|
|
230
232
|
const document = (entry: RouteEntry, data: { url: string; params: Record<string, string> }) =>
|
|
@@ -299,6 +301,11 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
299
301
|
hash: artifact.hash,
|
|
300
302
|
bytes,
|
|
301
303
|
});
|
|
304
|
+
// `artifact.hash` is `contentHash(html)` — the same identity that becomes this page's ETag,
|
|
305
|
+
// so the precache revision and the HTTP validator can never disagree about one document.
|
|
306
|
+
// Keyed by the FILLED path, which for a non-dynamic route is the declared one; a dynamic
|
|
307
|
+
// route is not precached as a single URL anyway (`buildPrecacheManifest` skips it).
|
|
308
|
+
documents.set(artifact.path, { revision: artifact.hash, bytes });
|
|
302
309
|
// Measured from the document that was just written, so the `budgets` step compares a
|
|
303
310
|
// declared budget against bytes that exist on disk rather than against a graph's estimate.
|
|
304
311
|
const measured = await measureDocumentJs(artifact.html, options.out);
|
|
@@ -312,6 +319,25 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
312
319
|
}
|
|
313
320
|
if (heaviest !== undefined) routes.push(heaviest);
|
|
314
321
|
}
|
|
322
|
+
// The worker, LAST: every document it precaches has now been rendered, hashed and weighed. A
|
|
323
|
+
// static host runs no route table, so both files go into the artifact — a
|
|
324
|
+
// `<script src="/x-sw-register.js">` in every document is a 404 otherwise, which is the same
|
|
325
|
+
// promise `favicon.ico` and the icons above keep.
|
|
326
|
+
const serviceWorker =
|
|
327
|
+
pwa === undefined
|
|
328
|
+
? undefined
|
|
329
|
+
: serviceWorkerArtifacts({
|
|
330
|
+
pwa,
|
|
331
|
+
buildId,
|
|
332
|
+
routes: describeRoutes(),
|
|
333
|
+
islands,
|
|
334
|
+
styles,
|
|
335
|
+
documents,
|
|
336
|
+
});
|
|
337
|
+
if (serviceWorker !== undefined) {
|
|
338
|
+
await Bun.write(join(options.out, SERVICE_WORKER_PATH.slice(1)), serviceWorker.source);
|
|
339
|
+
await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorker.register);
|
|
340
|
+
}
|
|
315
341
|
const stats = await writeBuildStats(options.root, { routes });
|
|
316
342
|
// Written LAST and by the same call that writes the stats, so an app whose `prerender.ts` does
|
|
317
343
|
// not reach `prerenderSite` produces neither — and `x verify`'s `budgets` step already reds that
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// One in-flight attempt, shared — and a FAILED one not kept. `started ??= boot()` caches the
|
|
2
|
+
// rejection with the value, so a Postgres that refused a connection once answered every later
|
|
3
|
+
// call in the session with that same error and no retry was possible short of restarting the host.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A lazily made attempt that may be made again.
|
|
7
|
+
*
|
|
8
|
+
* `started()` is the half `??=` has no spelling for: it answers the attempt already in flight or
|
|
9
|
+
* already made, and `undefined` when nobody has asked yet — so a `close()` can stop what was
|
|
10
|
+
* booted without BOOTING one in order to stop it.
|
|
11
|
+
*/
|
|
12
|
+
export interface RetryMemo<T> {
|
|
13
|
+
get(): Promise<T>;
|
|
14
|
+
started(): Promise<T> | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `start` runs at most once per SUCCESS. `@ultimat3/db`'s `createPgliteClient` states the rule this
|
|
19
|
+
* generalises — "a failed boot must not be cached" — and the clearing handler is attached at
|
|
20
|
+
* creation for the reason that matters: it therefore runs ahead of every caller's own `await`
|
|
21
|
+
* continuation, so by the time anyone sees the rejection the slot is already empty and the next
|
|
22
|
+
* call really does start a new attempt. Cleared inside the caller's `catch` instead, there is a
|
|
23
|
+
* window in which a second call is handed the dead promise.
|
|
24
|
+
*/
|
|
25
|
+
export function retryMemo<T>(start: () => Promise<T>): RetryMemo<T> {
|
|
26
|
+
let attempt: Promise<T> | undefined;
|
|
27
|
+
return {
|
|
28
|
+
get(): Promise<T> {
|
|
29
|
+
attempt ??= start().catch((error: unknown) => {
|
|
30
|
+
attempt = undefined;
|
|
31
|
+
throw error;
|
|
32
|
+
});
|
|
33
|
+
return attempt;
|
|
34
|
+
},
|
|
35
|
+
started: (): Promise<T> | undefined => attempt,
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/serve.ts
CHANGED
|
@@ -195,7 +195,11 @@ export type StartedApp = ServedApp | MigratedApp;
|
|
|
195
195
|
* is what fails on it.
|
|
196
196
|
*/
|
|
197
197
|
export async function runMigrations(options: ServeOptions): Promise<MigratedApp> {
|
|
198
|
-
const queue = await startQueue(
|
|
198
|
+
const queue = await startQueue(
|
|
199
|
+
resolveServices(options.root, options.env),
|
|
200
|
+
options.runtime,
|
|
201
|
+
options.env,
|
|
202
|
+
);
|
|
199
203
|
try {
|
|
200
204
|
const migrations = await readMigrations(options.root);
|
|
201
205
|
const report = await migrate({
|
package/src/source-files.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// and an app. One list for every step that walks source, because two steps scanning different sets
|
|
3
3
|
// means a finding one of them can never see.
|
|
4
4
|
|
|
5
|
+
import { hasPathSegment } from './path-segments';
|
|
6
|
+
|
|
5
7
|
export const SOURCE_GLOBS = [
|
|
6
8
|
'packages/*/src/**/*.{ts,tsx}',
|
|
7
9
|
// Three packages carry an `e2e` directory beside `src`. It is shipped source by every rule that
|
|
@@ -21,7 +23,7 @@ export const SOURCE_GLOBS = [
|
|
|
21
23
|
* root. `dist/` is build output: the sources that produced it are already in the set.
|
|
22
24
|
*/
|
|
23
25
|
export const isVendored = (path: string): boolean =>
|
|
24
|
-
path
|
|
26
|
+
hasPathSegment(path, 'node_modules') || hasPathSegment(path, 'dist');
|
|
25
27
|
|
|
26
28
|
/** Emitted declarations, not authored source — a rule about authored code cannot apply to them. */
|
|
27
29
|
export const isGenerated = (path: string): boolean => path.endsWith('.d.ts');
|