@ultimat3/cli 2.0.0 → 3.0.0
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 +40 -3
- package/README.md +1 -0
- package/package.json +24 -24
- package/src/budgets.ts +23 -3
- package/src/cmd-db-branch.ts +6 -2
- package/src/cmd-db.ts +138 -10
- package/src/cmd-dev.ts +9 -2
- package/src/cmd-doctor.ts +16 -7
- package/src/cmd-new.ts +1 -1
- package/src/cmd-test.ts +14 -3
- package/src/cmd-verify.ts +25 -7
- package/src/db-branch.ts +18 -0
- package/src/db-generate.ts +38 -6
- package/src/db-seed.ts +294 -0
- package/src/dev-assets.ts +22 -3
- package/src/dev-roles.ts +5 -3
- package/src/dev-storage.ts +6 -4
- package/src/dev-traces.ts +26 -4
- package/src/drift.ts +41 -1
- package/src/error-catalog.ts +1 -0
- package/src/error-codes.ts +6 -0
- package/src/exec.ts +42 -8
- package/src/flag-number.ts +11 -0
- package/src/index.ts +11 -4
- package/src/mcp-errors.ts +8 -0
- package/src/messages.ts +12 -0
- package/src/metrics-endpoint.ts +60 -13
- package/src/serve.ts +15 -3
- package/src/shell-quote.ts +15 -0
- package/src/test-shards.ts +1 -10
- package/src/test-workers.ts +4 -1
- package/src/ts-scan.ts +13 -2
- package/src/tsconfig-references.ts +27 -2
package/src/cmd-verify.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { msg } from './messages';
|
|
|
31
31
|
import type { CommandResult, Finding, StepResult } from './output';
|
|
32
32
|
import { findingFrom } from './output';
|
|
33
33
|
import type { ParsedArgs } from './parse';
|
|
34
|
+
import { WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
|
|
34
35
|
import {
|
|
35
36
|
floorProblemFindings,
|
|
36
37
|
floorRequires,
|
|
@@ -44,6 +45,9 @@ import { TEST_STEPS } from './verify-tests';
|
|
|
44
45
|
import { checkFileSizes, checkPackageShape, hasWorkspacePackages } from './workspace-checks';
|
|
45
46
|
|
|
46
47
|
/** The whole contract, in cost order. Every check the framework knows how to make lives here. */
|
|
48
|
+
/** The one file that makes the `roadmap` step answerable, and therefore what `applies` reads. */
|
|
49
|
+
const ROADMAP_FILE = join('docs', 'idea', '14-roadmap.md');
|
|
50
|
+
|
|
47
51
|
export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
48
52
|
{
|
|
49
53
|
name: 'typecheck',
|
|
@@ -224,8 +228,11 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
224
228
|
name: 'roadmap',
|
|
225
229
|
summary: "every roadmap milestone's status marker matches what is actually on disk",
|
|
226
230
|
// A generated app ships no `docs/idea/14-roadmap.md` — only the framework monorepo does, so
|
|
227
|
-
//
|
|
228
|
-
|
|
231
|
+
// the FILE is what decides. It keyed on `ctx.hostChecks?.roadmap` until `As of 2026-08`, which
|
|
232
|
+
// is a fact about the CALL: a caller of the exported `runVerify(VERIFY_STEPS, ctx)` passing no
|
|
233
|
+
// `hostChecks`, in a repo whose committed `x.verify.json` names `roadmap`, got
|
|
234
|
+
// `X_VERIFY_SUITE_VANISHED` — whose `fix:` is the command that had just failed.
|
|
235
|
+
applies: async (ctx) => existsSync(join(ctx.root, ROADMAP_FILE)),
|
|
229
236
|
run: async (ctx) => fromFindings(await hostFindings(ctx, 'roadmap')),
|
|
230
237
|
},
|
|
231
238
|
];
|
|
@@ -390,7 +397,7 @@ export const verifyCommand: CliCommand = {
|
|
|
390
397
|
{
|
|
391
398
|
name: 'workers',
|
|
392
399
|
type: 'string',
|
|
393
|
-
summary:
|
|
400
|
+
summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
|
|
394
401
|
},
|
|
395
402
|
],
|
|
396
403
|
},
|
|
@@ -405,13 +412,24 @@ export const verifyCommand: CliCommand = {
|
|
|
405
412
|
},
|
|
406
413
|
};
|
|
407
414
|
|
|
408
|
-
/**
|
|
409
|
-
*
|
|
410
|
-
|
|
415
|
+
/**
|
|
416
|
+
* Both bounds are the constants the flag summary already names, so `x help verify` and the reader
|
|
417
|
+
* cannot disagree. Exported for the test that pins them: the command's `run` reaches this only
|
|
418
|
+
* after the whole gate would have started.
|
|
419
|
+
*
|
|
420
|
+
* `max` is the ceiling. Without it `--workers 5000` parsed, `planShards` clamped only to the file
|
|
421
|
+
* count, and `runParallel` `Promise.all`ed one Bun process per test file. `min` is `WORKER_FLOOR`,
|
|
422
|
+
* the same number `defaultWorkers` will not go below — the gate spreads or it does not shard, and
|
|
423
|
+
* `--workers 1` was a serial run the summary said was impossible. `x test --workers 1` stays legal
|
|
424
|
+
* and is deliberately NOT this reader: `runShards` clamps the width to the file count, so a
|
|
425
|
+
* one-file corpus makes `X_TEST_SHARD_FAILED`'s own `fix:` say `--workers 1`.
|
|
426
|
+
*/
|
|
427
|
+
export const readWorkers = (args: ParsedArgs): number | undefined =>
|
|
411
428
|
readIntFlag(args, {
|
|
412
429
|
name: 'workers',
|
|
413
430
|
command: 'verify',
|
|
414
|
-
min:
|
|
431
|
+
min: WORKER_FLOOR,
|
|
432
|
+
max: WORKER_CEILING,
|
|
415
433
|
example: 'x verify --workers 4',
|
|
416
434
|
});
|
|
417
435
|
|
package/src/db-branch.ts
CHANGED
|
@@ -48,6 +48,24 @@ export function isBranchName(value: string): boolean {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The database a connection URL names. `url.split('/').at(-1)` took the query string with it, so
|
|
53
|
+
* a refusal built from it named `postly?sslmode=require_branch_x` — a database that does not exist
|
|
54
|
+
* — in a message whose whole point is that a reader can check it. `pathname` is the one part that
|
|
55
|
+
* IS the database, and it arrives percent-encoded, which `pg_database` does not.
|
|
56
|
+
*
|
|
57
|
+
* Falls back rather than throwing: the caller is already reporting a failure, and a second throw
|
|
58
|
+
* from the reporter replaces a checkable refusal with a stack trace.
|
|
59
|
+
*/
|
|
60
|
+
export function databaseNameOf(url: string, fallback = 'postgres'): string {
|
|
61
|
+
try {
|
|
62
|
+
const name = decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
|
|
63
|
+
return name === '' ? fallback : name;
|
|
64
|
+
} catch {
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
/** Where a branch's app answers once something serves it — the preview half of the design. */
|
|
52
70
|
export const previewUrl = (branch: string, port: number): string =>
|
|
53
71
|
`http://${branch}.localhost:${port}`;
|
package/src/db-generate.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from '@ultimat3/db';
|
|
15
15
|
import { describeEntities } from '@ultimat3/entity';
|
|
16
16
|
import { loadApp } from './app-load';
|
|
17
|
-
import { writeSchemaHash } from './drift';
|
|
17
|
+
import { reconcileSchemaHash, writeSchemaHash } from './drift';
|
|
18
18
|
import { hashFileName, MIGRATIONS_DIR, readMigrations, snapshotFileName } from './migrations';
|
|
19
19
|
import type { Finding } from './output';
|
|
20
20
|
|
|
@@ -24,11 +24,20 @@ export interface GenerateMigrationOptions {
|
|
|
24
24
|
readonly allowDestructive?: boolean | undefined;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* What this run actually did — four different things, and `--json` has to tell them apart. A
|
|
29
|
+
* `hash-recorded` run wrote a file and generated no migration; reporting it as `generated` would
|
|
30
|
+
* claim a migration nobody can apply, and reporting it as `unchanged` would hide the one write.
|
|
31
|
+
*/
|
|
32
|
+
export type GenerateOutcome = 'generated' | 'hash-recorded' | 'unchanged' | 'blocked';
|
|
33
|
+
|
|
27
34
|
export interface GeneratedFiles {
|
|
28
|
-
|
|
35
|
+
readonly outcome: GenerateOutcome;
|
|
36
|
+
/** Absent unless `outcome` is `generated` — the other three write no migration. */
|
|
29
37
|
readonly migration?: GeneratedMigration | undefined;
|
|
30
38
|
/** App-root-relative paths written, in write order. Empty when there was nothing to write. */
|
|
31
39
|
readonly files: readonly string[];
|
|
40
|
+
/** What the source hashes to, whenever this run was in a position to record it. */
|
|
32
41
|
readonly schemaHash?: string | undefined;
|
|
33
42
|
/** Modules that would not load. Non-empty means nothing was generated. */
|
|
34
43
|
readonly findings: readonly Finding[];
|
|
@@ -72,7 +81,7 @@ export async function generateAppMigration(
|
|
|
72
81
|
options: GenerateMigrationOptions,
|
|
73
82
|
): Promise<GeneratedFiles> {
|
|
74
83
|
const app = await loadApp(root);
|
|
75
|
-
if (app.findings.length > 0) return { files: [], findings: app.findings };
|
|
84
|
+
if (app.findings.length > 0) return { outcome: 'blocked', files: [], findings: app.findings };
|
|
76
85
|
|
|
77
86
|
const migrations = await readMigrations(root);
|
|
78
87
|
const current = declaredSchema(migrations);
|
|
@@ -90,9 +99,31 @@ export async function generateAppMigration(
|
|
|
90
99
|
name: options.name,
|
|
91
100
|
...(options.allowDestructive === true ? { allowDestructive: true } : {}),
|
|
92
101
|
});
|
|
93
|
-
// An empty diff writes
|
|
94
|
-
//
|
|
95
|
-
|
|
102
|
+
// An empty diff writes no MIGRATION — one with no statement still takes a ledger row, a checksum
|
|
103
|
+
// and a place in the apply order, a permanent record that nothing changed. It re-records the
|
|
104
|
+
// sidecar instead, and that is what makes `X_DB_DRIFT`'s `fix:` a real instruction: the hash
|
|
105
|
+
// covers every non-test file under `packages/db/src`, so editing a seed or a helper moves it with
|
|
106
|
+
// no DDL behind it, and the command the error names used to write nothing at all.
|
|
107
|
+
//
|
|
108
|
+
// Nothing is masked, because of what has already been proved above: `loadApp` reported no
|
|
109
|
+
// findings, so the registry is whole rather than short; `declaredSchema` returned a real snapshot
|
|
110
|
+
// rather than `undefined`, so the diff had something to run against; and the emptiness is the
|
|
111
|
+
// generator's OWN verdict — the same call, the same classifier — as the written path. A DDL
|
|
112
|
+
// change that reaches here is a `generateMigration` that missed it, and the sidecar was never the
|
|
113
|
+
// thing that caught that: an author following the fix simply stayed red with nothing left to run.
|
|
114
|
+
if (migration.up.trim().length === 0) {
|
|
115
|
+
const newest = migrations[migrations.length - 1];
|
|
116
|
+
// No migration to record against, which in this branch means no entity is declared either —
|
|
117
|
+
// a registry against zero migrations is `create table` for all of it, never an empty diff.
|
|
118
|
+
if (newest === undefined) return { outcome: 'unchanged', files: [], findings: [] };
|
|
119
|
+
const reconciled = await reconcileSchemaHash(root, newest.id);
|
|
120
|
+
return {
|
|
121
|
+
outcome: reconciled.written ? 'hash-recorded' : 'unchanged',
|
|
122
|
+
schemaHash: reconciled.hash,
|
|
123
|
+
files: reconciled.written ? [`${MIGRATIONS_DIR}/${hashFileName(newest.id)}`] : [],
|
|
124
|
+
findings: [],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
96
127
|
|
|
97
128
|
const dir = join(root, MIGRATIONS_DIR);
|
|
98
129
|
const sql = `${migration.id}.sql`;
|
|
@@ -104,6 +135,7 @@ export async function generateAppMigration(
|
|
|
104
135
|
const schemaHash = await writeSchemaHash(root, migration.id);
|
|
105
136
|
|
|
106
137
|
return {
|
|
138
|
+
outcome: 'generated',
|
|
107
139
|
migration,
|
|
108
140
|
schemaHash,
|
|
109
141
|
files: [sql, snapshot, hashFileName(migration.id)].map((file) => `${MIGRATIONS_DIR}/${file}`),
|
package/src/db-seed.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// `x db seed`, everything except the argv: where a seed is declared, which tier this environment
|
|
2
|
+
// takes, and what one pass reports. A driver plus plain strings in, plain rows out — the
|
|
3
|
+
// `db-backfill.ts` split repeated, so every rule here is testable with no `ParsedArgs` and no boot.
|
|
4
|
+
//
|
|
5
|
+
// The decisions a seed itself owns are `@ultimat3/entity`'s: `seedTiersFor` is the one table saying
|
|
6
|
+
// which tiers an environment runs, and two copies of "may this seed run" would be two answers.
|
|
7
|
+
|
|
8
|
+
// `node:path` for the joiner and the app-root-relative spelling every finding is keyed by; Bun
|
|
9
|
+
// exposes neither.
|
|
10
|
+
import { relative, sep } from 'node:path';
|
|
11
|
+
import type { Environment } from '@ultimat3/core';
|
|
12
|
+
import { UltimateError } from '@ultimat3/core';
|
|
13
|
+
import type { Driver, Seed, SeedTier } from '@ultimat3/entity';
|
|
14
|
+
import { isSeed, SEED_TIERS, seedTiersFor } from '@ultimat3/entity';
|
|
15
|
+
import { docsFor } from './error-codes';
|
|
16
|
+
import { BadFlagError } from './errors';
|
|
17
|
+
import type { Finding, JsonValue } from './output';
|
|
18
|
+
import { findingFrom } from './output';
|
|
19
|
+
import { renderTable } from './table';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where an app keeps seeds: a `seeds` directory in a package, or a `seed*.ts` beside its entities —
|
|
23
|
+
* the two layouts the tracked apps already use, and nothing wider. `loadApp`'s whole-src glob was
|
|
24
|
+
* the alternative and it is the wrong tool here: importing every module of every package to find a
|
|
25
|
+
* fixture graph makes an unrelated module that will not import into a failed seed run.
|
|
26
|
+
* `apps/` is deliberately absent: a fixture graph is data, and data lives in a package.
|
|
27
|
+
*/
|
|
28
|
+
export const SEED_GLOBS = ['packages/*/seeds/**/*.ts', 'packages/*/src/seed*.ts'] as const;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `x db seed <name>` named a seed no module declared. `X_DECLARATION_UNKNOWN` is the code the
|
|
32
|
+
* registries already answer this with — a seed is a declaration, and a second code for "no such
|
|
33
|
+
* name" is the synonym the registry exists to prevent. The known names ARE listed, unlike
|
|
34
|
+
* `DeclarationUnknownError`'s count: an app has one to five seeds, not two hundred actions, and
|
|
35
|
+
* picking another one is the entire remedy.
|
|
36
|
+
*
|
|
37
|
+
* Both classes live HERE rather than in `errors.ts` for one reason, stated so nobody has to guess:
|
|
38
|
+
* that file is at the 500-line ceiling `x verify`'s `filesize` step enforces, and these two are
|
|
39
|
+
* `x db seed`'s alone. The codes stay CLI-owned in `error-codes.ts`, as every code does.
|
|
40
|
+
*/
|
|
41
|
+
export class SeedUnknownError extends UltimateError {
|
|
42
|
+
constructor(input: { name: string; known: readonly string[] }) {
|
|
43
|
+
super({
|
|
44
|
+
code: 'X_DECLARATION_UNKNOWN',
|
|
45
|
+
cause:
|
|
46
|
+
input.known.length === 0
|
|
47
|
+
? `no seed named "${input.name}" — this app declares none (a seed is an exported defineSeed() in packages/<pkg>/seeds or packages/<pkg>/src/seed.ts)`
|
|
48
|
+
: `no seed named "${input.name}" is declared (known: ${input.known.join(', ')})`,
|
|
49
|
+
// A dry run, never a bare `x db seed`: the command that answers "which seeds are there" must
|
|
50
|
+
// not be the command that writes them.
|
|
51
|
+
fix: 'x db seed --dry-run --json',
|
|
52
|
+
docs: docsFor('X_DECLARATION_UNKNOWN'),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The seed's tier is not one this environment runs. `dev` fixtures reaching production is the one
|
|
59
|
+
* irreversible mistake `x db seed` can make, so it is refused rather than confirmed.
|
|
60
|
+
*
|
|
61
|
+
* `X_SEED_ENVIRONMENT` is its own code, not `X_CLI_BAD_FLAG`: the argv was well formed and the
|
|
62
|
+
* answer is still no. A flag code says "you typed it wrong" and sends the reader to `x help`; this
|
|
63
|
+
* says "this environment does not run that tier", whose one remedy is naming the tier. The env var
|
|
64
|
+
* is named in the cause and not in the `fix:`, because a `fix:` is one pasteable line and a
|
|
65
|
+
* container with a fixed command line is the case that needs the other half.
|
|
66
|
+
*/
|
|
67
|
+
export class SeedEnvironmentError extends UltimateError {
|
|
68
|
+
constructor(input: {
|
|
69
|
+
seed: string;
|
|
70
|
+
tier: string;
|
|
71
|
+
environment: string;
|
|
72
|
+
tiers: readonly string[];
|
|
73
|
+
}) {
|
|
74
|
+
super({
|
|
75
|
+
code: 'X_SEED_ENVIRONMENT',
|
|
76
|
+
cause: `seed "${input.seed}" is tier ${input.tier} and ULTIMATE_ENV resolved ${input.environment}, where x db seed runs ${input.tiers.join(', ')} — ULTIMATE_SEED_TIER=${input.tier} says this deploy takes it anyway`,
|
|
77
|
+
fix: `x db seed ${input.seed} --tier ${input.tier} --json`,
|
|
78
|
+
docs: docsFor('X_SEED_ENVIRONMENT'),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface DiscoveredSeed {
|
|
84
|
+
readonly seed: Seed;
|
|
85
|
+
/** App-root-relative POSIX path of the module that declared it. */
|
|
86
|
+
readonly file: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SeedDiscovery {
|
|
90
|
+
readonly seeds: readonly DiscoveredSeed[];
|
|
91
|
+
/** Modules that would not import. Reported, never swallowed: one of them may hold the seed. */
|
|
92
|
+
readonly findings: readonly Finding[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Every seed the app declares, by importing the modules that declare them — the same rule
|
|
97
|
+
* `loadApp` follows, because importing IS the declaration. Sorted by file, so a run's order is the
|
|
98
|
+
* one a reader can predict from the tree (`01_orgs.ts` before `02_posts.ts`) rather than the one a
|
|
99
|
+
* glob happened to yield.
|
|
100
|
+
*/
|
|
101
|
+
export async function discoverSeeds(root: string): Promise<SeedDiscovery> {
|
|
102
|
+
const seeds: DiscoveredSeed[] = [];
|
|
103
|
+
const findings: Finding[] = [];
|
|
104
|
+
const seen = new Set<string>();
|
|
105
|
+
for (const pattern of SEED_GLOBS) {
|
|
106
|
+
for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
|
|
107
|
+
if (absolute.includes('node_modules') || absolute.includes('.test.')) continue;
|
|
108
|
+
if (seen.has(absolute)) continue;
|
|
109
|
+
seen.add(absolute);
|
|
110
|
+
const file = relative(root, absolute).split(sep).join('/');
|
|
111
|
+
let module: Record<string, unknown>;
|
|
112
|
+
try {
|
|
113
|
+
module = (await import(absolute)) as Record<string, unknown>;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
findings.push({ ...findingFrom(error), at: file });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
for (const value of Object.values(module)) {
|
|
119
|
+
if (isSeed(value)) seeds.push({ seed: value, file });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
seeds: seeds.toSorted((left, right) => left.file.localeCompare(right.file)),
|
|
125
|
+
findings,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `--tier`, or `ULTIMATE_SEED_TIER` for a container whose command line is fixed. */
|
|
130
|
+
export function parseSeedTierFlag(value: string | undefined): SeedTier | undefined {
|
|
131
|
+
if (value === undefined || value === '') return undefined;
|
|
132
|
+
if ((SEED_TIERS as readonly string[]).includes(value)) return value as SeedTier;
|
|
133
|
+
throw new BadFlagError({
|
|
134
|
+
flag: 'tier',
|
|
135
|
+
command: 'db seed',
|
|
136
|
+
reason: `unknown tier "${value}" (known: ${SEED_TIERS.join(', ')})`,
|
|
137
|
+
fix: 'x db seed --dry-run --json',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface SeedSelection {
|
|
142
|
+
readonly discovered: readonly DiscoveredSeed[];
|
|
143
|
+
/** The positional. Absent runs every seed whose tier this environment takes. */
|
|
144
|
+
readonly name?: string | undefined;
|
|
145
|
+
readonly environment: Environment;
|
|
146
|
+
readonly requested?: SeedTier | undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Which seeds this invocation runs, and the two refusals that are not a run.
|
|
151
|
+
*
|
|
152
|
+
* The environment check is HERE and again in `cmd-db.ts` before the driver is booted, on purpose:
|
|
153
|
+
* seeding is the one irreversible thing this command does, and the layer that boots a connection
|
|
154
|
+
* to production must not be the only layer that decided it was allowed to.
|
|
155
|
+
*/
|
|
156
|
+
export function selectSeeds(input: SeedSelection): readonly DiscoveredSeed[] {
|
|
157
|
+
const tiers = seedTiersFor(input.environment, input.requested);
|
|
158
|
+
const known = input.discovered.map((entry) => entry.seed.name);
|
|
159
|
+
if (input.name === undefined) {
|
|
160
|
+
return input.discovered.filter((entry) => tiers.includes(entry.seed.tier));
|
|
161
|
+
}
|
|
162
|
+
const chosen = input.discovered.filter((entry) => entry.seed.name === input.name);
|
|
163
|
+
const first = chosen[0];
|
|
164
|
+
if (first === undefined) throw new SeedUnknownError({ name: input.name, known });
|
|
165
|
+
if (chosen.length > 1) {
|
|
166
|
+
throw new BadFlagError({
|
|
167
|
+
flag: 'name',
|
|
168
|
+
command: 'db seed',
|
|
169
|
+
reason: `"${input.name}" names ${chosen.length} seeds (${chosen.map((entry) => entry.file).join(', ')}) — a seed name is how a run is asked for, so two of them make the ask unanswerable`,
|
|
170
|
+
fix: 'x db seed --dry-run --json',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (!tiers.includes(first.seed.tier)) {
|
|
174
|
+
throw new SeedEnvironmentError({
|
|
175
|
+
seed: first.seed.name,
|
|
176
|
+
tier: first.seed.tier,
|
|
177
|
+
environment: input.environment,
|
|
178
|
+
tiers,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return chosen;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type SeedStatus = 'ok' | 'failed';
|
|
185
|
+
|
|
186
|
+
export interface SeedPassRow {
|
|
187
|
+
readonly file: string;
|
|
188
|
+
readonly name: string;
|
|
189
|
+
readonly tier: SeedTier;
|
|
190
|
+
readonly status: SeedStatus;
|
|
191
|
+
readonly ms: number;
|
|
192
|
+
readonly inserted: number;
|
|
193
|
+
readonly updated: number;
|
|
194
|
+
readonly skipped: number;
|
|
195
|
+
readonly finding: Finding | null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface SeedPassOptions {
|
|
199
|
+
readonly seeds: readonly DiscoveredSeed[];
|
|
200
|
+
readonly driver: Driver;
|
|
201
|
+
readonly dryRun: boolean;
|
|
202
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
203
|
+
/**
|
|
204
|
+
* One transaction PER SEED, never one around the run: a seed that fails must not roll back the
|
|
205
|
+
* ones that already succeeded, and a fixture graph half-written is worse than one not written.
|
|
206
|
+
* Injected so this stays testable with no database; `cmd-db.ts` passes `withTransaction`.
|
|
207
|
+
*/
|
|
208
|
+
readonly transaction: <T>(work: () => Promise<T>) => Promise<T>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Each seed, in file order, each isolated from the next. Never throws — a failure is a row. */
|
|
212
|
+
export async function runSeeds(options: SeedPassOptions): Promise<readonly SeedPassRow[]> {
|
|
213
|
+
const rows: SeedPassRow[] = [];
|
|
214
|
+
for (const entry of options.seeds) {
|
|
215
|
+
const started = Bun.nanoseconds();
|
|
216
|
+
const elapsed = (): number => Math.round((Bun.nanoseconds() - started) / 1_000_000);
|
|
217
|
+
try {
|
|
218
|
+
const run = await options.transaction(() =>
|
|
219
|
+
entry.seed.run({ driver: options.driver, dryRun: options.dryRun, env: options.env }),
|
|
220
|
+
);
|
|
221
|
+
rows.push({
|
|
222
|
+
file: entry.file,
|
|
223
|
+
name: run.name,
|
|
224
|
+
tier: run.tier,
|
|
225
|
+
status: 'ok',
|
|
226
|
+
ms: elapsed(),
|
|
227
|
+
...run.metrics,
|
|
228
|
+
finding: null,
|
|
229
|
+
});
|
|
230
|
+
} catch (error) {
|
|
231
|
+
rows.push({
|
|
232
|
+
file: entry.file,
|
|
233
|
+
name: entry.seed.name,
|
|
234
|
+
tier: entry.seed.tier,
|
|
235
|
+
status: 'failed',
|
|
236
|
+
ms: elapsed(),
|
|
237
|
+
inserted: 0,
|
|
238
|
+
updated: 0,
|
|
239
|
+
skipped: 0,
|
|
240
|
+
finding: { ...findingFrom(error), at: entry.file },
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return rows;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface SeedTotals {
|
|
248
|
+
readonly inserted: number;
|
|
249
|
+
readonly updated: number;
|
|
250
|
+
readonly skipped: number;
|
|
251
|
+
readonly failed: number;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export const seedTotals = (rows: readonly SeedPassRow[]): SeedTotals => ({
|
|
255
|
+
inserted: rows.reduce((sum, row) => sum + row.inserted, 0),
|
|
256
|
+
updated: rows.reduce((sum, row) => sum + row.updated, 0),
|
|
257
|
+
skipped: rows.reduce((sum, row) => sum + row.skipped, 0),
|
|
258
|
+
failed: rows.filter((row) => row.status === 'failed').length,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Slowest first, in both renderers: a seed run that got slow is diagnosed by which FILE took the
|
|
263
|
+
* time, and a list in run order buries that under whatever happens to be alphabetically first.
|
|
264
|
+
*/
|
|
265
|
+
const slowestFirst = (rows: readonly SeedPassRow[]): readonly SeedPassRow[] =>
|
|
266
|
+
rows.toSorted((left, right) => right.ms - left.ms);
|
|
267
|
+
|
|
268
|
+
export const seedPassToJson = (rows: readonly SeedPassRow[]): JsonValue => ({
|
|
269
|
+
seeds: slowestFirst(rows).map((row) => ({
|
|
270
|
+
file: row.file,
|
|
271
|
+
name: row.name,
|
|
272
|
+
tier: row.tier,
|
|
273
|
+
status: row.status,
|
|
274
|
+
ms: row.ms,
|
|
275
|
+
inserted: row.inserted,
|
|
276
|
+
updated: row.updated,
|
|
277
|
+
skipped: row.skipped,
|
|
278
|
+
})),
|
|
279
|
+
totals: { ...seedTotals(rows) },
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
export const renderSeedTable = (rows: readonly SeedPassRow[]): readonly string[] =>
|
|
283
|
+
renderTable(
|
|
284
|
+
['seed', 'tier', 'status', 'ms', 'inserted', 'updated', 'skipped'],
|
|
285
|
+
slowestFirst(rows).map((row) => [
|
|
286
|
+
row.name,
|
|
287
|
+
row.tier,
|
|
288
|
+
row.status,
|
|
289
|
+
String(row.ms),
|
|
290
|
+
String(row.inserted),
|
|
291
|
+
String(row.updated),
|
|
292
|
+
String(row.skipped),
|
|
293
|
+
]),
|
|
294
|
+
);
|
package/src/dev-assets.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { applyCacheHeaders } from '@ultimat3/http';
|
|
|
14
14
|
import type { IconPlan } from '@ultimat3/pwa';
|
|
15
15
|
import { BuiltinImagePipeline, PwaIconMissingError, planIcons } from '@ultimat3/pwa';
|
|
16
16
|
import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
|
|
17
|
-
import { builtinImageDriver, parseImageQuery } from '@ultimat3/seo';
|
|
17
|
+
import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery } from '@ultimat3/seo';
|
|
18
18
|
import type { ImageFormat, ImageTransform, Storage } from '@ultimat3/storage';
|
|
19
19
|
import { IMAGE_FORMATS, isTenantScoped, variantKey } from '@ultimat3/storage';
|
|
20
20
|
import {
|
|
@@ -49,6 +49,24 @@ export const MEDIA_BASE_PATH = '/media';
|
|
|
49
49
|
*/
|
|
50
50
|
const IMMUTABLE_IMAGE: CacheHint = { mode: 'immutable' };
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Whether a variant is one the framework itself can MINT, and therefore one worth storing.
|
|
54
|
+
*
|
|
55
|
+
* The cache key is built entirely from caller-supplied query values, and a signed-in reader
|
|
56
|
+
* holding `storage:read` may ask for any of them on their own objects — so `?w=1`, `?w=2`, … each
|
|
57
|
+
* wrote a new object to the app's only disk. `@ultimat3/seo`'s `MAX_IMAGE_WIDTH` (8192) bounds the
|
|
58
|
+
* blast radius and does not close it: 8192 stored objects per source, per format, is amplification
|
|
59
|
+
* a tenant drives with a `for` loop.
|
|
60
|
+
*
|
|
61
|
+
* The set is `DEFAULT_WIDTHS` **plus the source's intrinsic width**, which is exactly what
|
|
62
|
+
* `usableWidths` puts in a `srcset` — clamping to the constant alone would refuse the widest entry
|
|
63
|
+
* of every image whose intrinsic width is not one of the eight, a URL the framework mints itself.
|
|
64
|
+
* Anything outside it is still SERVED: this decides what is written, not what is answered, so no
|
|
65
|
+
* caller gains a new 4xx and the disk stops growing on a stranger's key.
|
|
66
|
+
*/
|
|
67
|
+
const isMintableWidth = (width: number | undefined, intrinsic: number): boolean =>
|
|
68
|
+
width === undefined || width === intrinsic || DEFAULT_WIDTHS.includes(width);
|
|
69
|
+
|
|
52
70
|
const imageResponse = (bytes: Uint8Array, contentType: string, cache: CacheHint): Response =>
|
|
53
71
|
applyCacheHeaders(
|
|
54
72
|
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
@@ -112,6 +130,7 @@ async function transformedVariant(
|
|
|
112
130
|
}
|
|
113
131
|
|
|
114
132
|
const source = await disk.get(key);
|
|
133
|
+
const intrinsic = probeImage(source.bytes).width;
|
|
115
134
|
// The seam is WHICH driver transforms, not who resolves the bytes: `TransformRequest.width` is
|
|
116
135
|
// required, and a request with no `?w=` gets its width from the source's own header — so the
|
|
117
136
|
// read happens either way and a supplied driver is handed the same resolved request the builtin
|
|
@@ -122,11 +141,11 @@ async function transformedVariant(
|
|
|
122
141
|
src: key,
|
|
123
142
|
// A header read, not a decode: `?f=webp` alone still needs a width, and the source's own is
|
|
124
143
|
// the only one that does not resize an image the caller never asked to resize.
|
|
125
|
-
width: query.width ??
|
|
144
|
+
width: query.width ?? intrinsic,
|
|
126
145
|
...(query.format === undefined ? {} : { format: query.format }),
|
|
127
146
|
...(query.quality === undefined ? {} : { quality: query.quality }),
|
|
128
147
|
});
|
|
129
|
-
if (cached !== undefined) {
|
|
148
|
+
if (cached !== undefined && isMintableWidth(query.width, intrinsic)) {
|
|
130
149
|
await disk.put(cached, variant.bytes, { contentType: variant.contentType });
|
|
131
150
|
}
|
|
132
151
|
return imageResponse(variant.bytes, variant.contentType, cache);
|
package/src/dev-roles.ts
CHANGED
|
@@ -294,9 +294,11 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
294
294
|
// — every enqueue in a request handler silently becomes a job that never runs.
|
|
295
295
|
//
|
|
296
296
|
// On `worker`, and on `worker` alone: it is the role that exists wherever jobs run at all, and
|
|
297
|
-
// a relay is safe to duplicate
|
|
298
|
-
//
|
|
299
|
-
//
|
|
297
|
+
// a relay is safe to duplicate — the claim is a LEASE taken in the statement that locks the
|
|
298
|
+
// row, so two relays never hold one batch — but pointless to spread. The idempotency key is
|
|
299
|
+
// not the reason and never was: its conflict target is a partial index over live states, so it
|
|
300
|
+
// collapses a repeat only while the first job is still live. A deployment with no `worker` has
|
|
301
|
+
// no one to run the jobs either way.
|
|
300
302
|
const relay: OutboxRelay | null = selected.includes('worker')
|
|
301
303
|
? createOutboxRelay({ store: options.runtime.outbox, driver: options.runtime.jobs })
|
|
302
304
|
: null;
|
package/src/dev-storage.ts
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// owns keys, bytes and the tenant boundary and owns no `Response`; `@ultimat3/policy` owns the
|
|
3
3
|
// one authz decision; this file is where those two meet a `Route` — the same shape `dev-assets.ts`
|
|
4
4
|
// uses for `/icons` and `/media`, so `x dev` and `apps/web/server.ts` mount one read path, not two.
|
|
5
|
+
//
|
|
6
|
+
// The base path is `@ultimat3/storage`'s `DEFAULT_SIGNED_URL_BASE`, imported and never restated:
|
|
7
|
+
// `localDriver` SIGNS `/_storage/<disk>/<key>`, so a local `'/_storage'` here is a second statement
|
|
8
|
+
// of one constant — and a signer and a reader that disagree serve 404 for every signed URL.
|
|
5
9
|
|
|
6
10
|
import { actorOf } from '@ultimat3/action';
|
|
7
11
|
import type { Actor } from '@ultimat3/core';
|
|
@@ -12,15 +16,13 @@ import { can, codeOf, evaluate, forbidden, reasonOf } from '@ultimat3/policy';
|
|
|
12
16
|
import type { Storage, StorageRead } from '@ultimat3/storage';
|
|
13
17
|
import {
|
|
14
18
|
assertSafeKey,
|
|
19
|
+
DEFAULT_SIGNED_URL_BASE,
|
|
15
20
|
isTenantScoped,
|
|
16
21
|
isWithinOrg,
|
|
17
22
|
objectNotFound,
|
|
18
23
|
orgMismatch,
|
|
19
24
|
} from '@ultimat3/storage';
|
|
20
25
|
|
|
21
|
-
/** `localDriver` signs `/_storage/<disk>/<key>`, so the read half hangs off the same base. */
|
|
22
|
-
export const STORAGE_BASE_PATH = '/_storage';
|
|
23
|
-
|
|
24
26
|
/**
|
|
25
27
|
* The one capability that gates reading a stored object, on every disk. A permission and not a
|
|
26
28
|
* per-disk family: `disk` is in the policy's `input`, so an app that wants a per-disk rule writes
|
|
@@ -223,7 +225,7 @@ export function storageRoutes(options: StorageRoutesOptions): readonly Route[] {
|
|
|
223
225
|
return [
|
|
224
226
|
{
|
|
225
227
|
method: 'GET',
|
|
226
|
-
path: `${
|
|
228
|
+
path: `${DEFAULT_SIGNED_URL_BASE}/:disk/*key`,
|
|
227
229
|
meta: {
|
|
228
230
|
name: 'storage.read',
|
|
229
231
|
auth: 'required',
|
package/src/dev-traces.ts
CHANGED
|
@@ -71,9 +71,27 @@ function requestFacts(root: ReadableSpan): { method: string; path: string } {
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** `http.request_id` is stamped by the pipeline's root and by nothing else; the name is a fallback. */
|
|
74
75
|
const isHttpRoot = (span: ReadableSpan): boolean =>
|
|
75
|
-
span.
|
|
76
|
-
|
|
76
|
+
span.attributes['http.request_id'] !== undefined || /^[A-Z]+ \//.test(span.name);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The request's own span among a trace's. `parentSpanId === undefined` was a third CONDITION
|
|
80
|
+
* until `As of 2026-08`, and it dropped every request that arrived with an inbound
|
|
81
|
+
* `traceparent`: `pipeline.ts` passes `parent: correlation.parent`, so the root has a defined
|
|
82
|
+
* `parentSpanId`, `spans.find(isHttpRoot)` answered `undefined`, and the whole trace vanished
|
|
83
|
+
* from `/_x/timeline` for any caller behind an instrumented client, an ingress or a service
|
|
84
|
+
* mesh. It survives as the TIE-BREAK: the outermost candidate is the one whose parent is not
|
|
85
|
+
* itself in this recording, so a nested candidate can never outrank the request's own span.
|
|
86
|
+
*/
|
|
87
|
+
function httpRootOf(spans: readonly ReadableSpan[]): ReadableSpan | undefined {
|
|
88
|
+
const candidates = spans.filter(isHttpRoot);
|
|
89
|
+
const recorded = new Set(spans.map((span) => span.context.spanId));
|
|
90
|
+
const outermost = candidates.find(
|
|
91
|
+
(span) => span.parentSpanId === undefined || !recorded.has(span.parentSpanId),
|
|
92
|
+
);
|
|
93
|
+
return outermost ?? candidates[0];
|
|
94
|
+
}
|
|
77
95
|
|
|
78
96
|
function toTrace(root: ReadableSpan, spans: readonly ReadableSpan[]): RequestTrace {
|
|
79
97
|
const { method, path } = requestFacts(root);
|
|
@@ -121,7 +139,11 @@ export function createTraceRecorder(options: { limit?: number } = {}): TraceReco
|
|
|
121
139
|
const spans = byTrace.get(traceId);
|
|
122
140
|
if (spans === undefined) {
|
|
123
141
|
byTrace.set(traceId, [span]);
|
|
124
|
-
// Bounded by
|
|
142
|
+
// Bounded by TRACE, not by span: dropping half a request would leave a flame with holes.
|
|
143
|
+
// The cost is stated rather than capped — one trace's span array has no bound of its own, so
|
|
144
|
+
// a request issuing 50k statements holds 50k `ReadableSpan`s until it is evicted. That is a
|
|
145
|
+
// dev-only recorder (`serve.ts` installs none), and a per-trace cap would silently produce
|
|
146
|
+
// the holed flame this bound exists to prevent.
|
|
125
147
|
while (byTrace.size > limit) {
|
|
126
148
|
const oldest = byTrace.keys().next();
|
|
127
149
|
if (oldest.done === true) break;
|
|
@@ -137,7 +159,7 @@ export function createTraceRecorder(options: { limit?: number } = {}): TraceReco
|
|
|
137
159
|
traces(): readonly RequestTrace[] {
|
|
138
160
|
const traces: RequestTrace[] = [];
|
|
139
161
|
for (const spans of byTrace.values()) {
|
|
140
|
-
const root = spans
|
|
162
|
+
const root = httpRootOf(spans);
|
|
141
163
|
if (root !== undefined) traces.push(toTrace(root, spans));
|
|
142
164
|
}
|
|
143
165
|
return traces.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|