@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.
Files changed (47) hide show
  1. package/CLAUDE.md +108 -6
  2. package/package.json +29 -29
  3. package/src/app-boundaries.ts +11 -2
  4. package/src/app-load.ts +5 -1
  5. package/src/budgets.ts +17 -6
  6. package/src/cmd-dev.ts +29 -39
  7. package/src/cmd-doctor.ts +61 -23
  8. package/src/cmd-generate.ts +5 -2
  9. package/src/cmd-i18n.ts +10 -3
  10. package/src/cmd-jobs.ts +56 -10
  11. package/src/cmd-test.ts +15 -10
  12. package/src/db-seed.ts +2 -1
  13. package/src/dev-queue.ts +16 -2
  14. package/src/dev-reload.ts +46 -0
  15. package/src/dev-runtime.ts +4 -1
  16. package/src/dev-sync.ts +11 -3
  17. package/src/dev-watch-tree.ts +226 -0
  18. package/src/dev-watch.ts +59 -37
  19. package/src/doctor-offline.ts +122 -0
  20. package/src/error-catalog.ts +4 -5
  21. package/src/fix-command.ts +40 -1
  22. package/src/fix-path.ts +10 -11
  23. package/src/flag-number.ts +15 -0
  24. package/src/generate-kinds.ts +54 -4
  25. package/src/generate-write.ts +25 -2
  26. package/src/gitignore.ts +145 -0
  27. package/src/hold.ts +50 -17
  28. package/src/index.ts +1 -1
  29. package/src/island-bundle.ts +2 -1
  30. package/src/island-states-load.ts +2 -1
  31. package/src/jobs-driver.ts +4 -1
  32. package/src/mcp-host.ts +18 -9
  33. package/src/parse.ts +17 -0
  34. package/src/path-segments.ts +14 -0
  35. package/src/prerender.ts +46 -20
  36. package/src/retry-memo.ts +37 -0
  37. package/src/serve.ts +5 -1
  38. package/src/source-files.ts +3 -1
  39. package/src/sw-artifacts.ts +71 -7
  40. package/src/templates/admin-page.ts +49 -1
  41. package/src/templates/scaffold-container.ts +12 -0
  42. package/src/templates/scaffold-repo.ts +7 -2
  43. package/src/test-passes.ts +79 -0
  44. package/src/test-shards.ts +110 -36
  45. package/src/verify-checks.ts +6 -6
  46. package/src/verify-step.ts +4 -4
  47. package/src/verify-tests.ts +14 -2
@@ -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 runs INSIDE the drain's own deadline, and that is not a detail. `drain()` ABANDONS a hook
37
- * that overruns `ShutdownReason.deadlineAt` — the process is meant to exit without it — and
38
- * `release` here re-enters the very same teardown one call later: `app.stop()` ->
39
- * `startRoles().stop()` -> `worker.stop()`, memoised in the package that owns it, so awaiting it
40
- * is awaiting the promise the drain just walked away from. Unbounded, that hangs forever and the
41
- * deadline buys nothing.
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 this budget IS the drain's budget
52
- // rather than a second one that happens to be the same length.
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()` raced against what is left of the drain's budget.
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
- const budget = Math.max(0, budgetMs);
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'), budget);
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
- cause: `the "${name}" release was still running ${budget}ms after the drain finished and has been ABANDONED — the process exits without it, so anything it held may not be closed`,
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, OFFLINE_FALLBACK, probeFor, runDoctor } from './cmd-doctor';
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';
@@ -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.includes('node_modules')) continue;
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.includes('node_modules')) continue;
41
+ if (hasPathSegment(absolute, 'node_modules')) continue;
41
42
  files.push(relative(root, absolute).split(sep).join('/'));
42
43
  }
43
44
  return files.sort();
@@ -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
- const queue = await startQueue(services);
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
- let started: Promise<RunningServices> | undefined;
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
- started ??= startServices(services, input.env);
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
- // A boot that rejected has nothing to stop, and close() must not throw on the way out.
114
- await (await started?.catch(() => undefined))?.stop();
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
- let readOnlyRole: Promise<string | null> | undefined;
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
- readOnlyRole ??= ensureReadOnlyRole(db);
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 { SERVICE_WORKER_PATH, SW_REGISTER_PATH, serviceWorkerArtifacts } from './sw-artifacts';
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 worker and its registration script, written as FILES. A static host runs no route table,
178
- // so a `<script src="/x-sw-register.js">` in every document is a 404 unless the bytes are in the
179
- // artifact — the same promise `favicon.ico` and the icons above keep, for the asset that decides
180
- // whether the export works offline at all.
181
- const serviceWorker =
182
- pwa === undefined
183
- ? undefined
184
- : serviceWorkerArtifacts({
185
- pwa,
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 + (serviceWorker?.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(resolveServices(options.root, options.env), options.runtime);
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({
@@ -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.includes('node_modules') || path.includes('/dist/') || path.startsWith('dist/');
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');