@ultimat3/cli 2.0.0 → 4.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 +109 -13
- package/README.md +1 -0
- package/package.json +24 -24
- package/src/budgets.ts +31 -8
- package/src/cmd-db-branch.ts +6 -2
- package/src/cmd-db.ts +138 -10
- package/src/cmd-deploy.ts +42 -14
- package/src/cmd-dev.ts +9 -2
- package/src/cmd-docs.ts +7 -3
- package/src/cmd-doctor.ts +16 -7
- package/src/cmd-fix.ts +15 -3
- package/src/cmd-generate.ts +29 -4
- package/src/cmd-help.ts +25 -4
- package/src/cmd-i18n.ts +8 -5
- package/src/cmd-jobs.ts +6 -5
- package/src/cmd-mcp.ts +16 -12
- package/src/cmd-new.ts +10 -14
- package/src/cmd-planned.ts +13 -0
- package/src/cmd-policy.ts +8 -6
- package/src/cmd-registries.ts +7 -6
- package/src/cmd-routes.ts +27 -4
- package/src/cmd-secrets.ts +6 -6
- package/src/cmd-test.ts +14 -3
- package/src/cmd-verify.ts +80 -10
- package/src/command.ts +10 -2
- 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-cache.ts +9 -9
- package/src/dev-render.ts +6 -1
- package/src/dev-roles.ts +5 -3
- package/src/dev-runtime.ts +2 -2
- package/src/dev-storage.ts +6 -4
- package/src/dev-traces.ts +26 -4
- package/src/dispatch.ts +33 -4
- package/src/drift.ts +41 -1
- package/src/error-catalog.ts +1 -0
- package/src/error-codes.ts +11 -0
- package/src/error-contract.ts +31 -4
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +9 -2
- package/src/fix-imports.ts +118 -0
- package/src/fix-scan.ts +251 -0
- package/src/flag-number.ts +11 -0
- package/src/flag-reads.ts +114 -0
- package/src/i18n-audit.ts +2 -1
- package/src/index.ts +19 -5
- package/src/jobs-drain.ts +6 -1
- package/src/mcp-errors.ts +13 -0
- package/src/mcp-host.ts +4 -2
- package/src/messages.ts +15 -0
- package/src/metrics-endpoint.ts +60 -13
- package/src/otlp-export.ts +14 -0
- package/src/parse.ts +6 -1
- package/src/seo-meta.ts +105 -0
- package/src/serve.ts +15 -3
- package/src/shell-quote.ts +15 -0
- package/src/templates/action.ts +39 -7
- package/src/templates/backfill.ts +3 -1
- package/src/templates/index.ts +10 -1
- package/src/templates/job.ts +6 -2
- package/src/templates/query.ts +6 -1
- package/src/templates/route.ts +18 -9
- package/src/templates/scaffold-api.ts +100 -0
- package/src/templates/scaffold-app.ts +8 -48
- package/src/templates/scaffold-container.ts +44 -9
- package/src/templates/scaffold-helm-templates.ts +327 -0
- package/src/templates/scaffold-helm.ts +144 -0
- package/src/templates/scaffold-repo.ts +25 -8
- package/src/test-shards.ts +1 -10
- package/src/test-workers.ts +4 -1
- package/src/ts-scan.ts +25 -176
- package/src/tsconfig-references.ts +27 -2
- package/src/verify-step.ts +5 -0
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-cache.ts
CHANGED
|
@@ -79,25 +79,25 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
|
|
|
79
79
|
registerInvalidationBroadcast(async (wireTags) => {
|
|
80
80
|
await options.transport.publish(CACHE_INVALIDATE_SUBJECT, JSON.stringify(wireTags));
|
|
81
81
|
});
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
82
|
+
// Not awaited HERE: the boot must not block on a subscribe, and a bus that refuses one is a
|
|
83
|
+
// process that misses peer invalidations, never a process that fails to start. The PROMISE is
|
|
84
|
+
// held rather than a handle assigned inside a `.then`, because the release ran first whenever
|
|
85
|
+
// `stop()` beat the round trip — a NATS bus plus a boot that throws in `bootRoles`, or a test
|
|
86
|
+
// that boots and stops immediately — and the subscription that landed afterwards was live with
|
|
87
|
+
// nobody left holding it. `mcp-host.ts`'s lazy `started` is the same shape.
|
|
88
|
+
const subscribing: Promise<TransportSubscription | undefined> = options.transport
|
|
86
89
|
.subscribe(CACHE_INVALIDATE_SUBJECT, (payload: string) => {
|
|
87
90
|
void applyBroadcast(payload);
|
|
88
91
|
})
|
|
89
|
-
.then((handle) => {
|
|
90
|
-
subscription = handle;
|
|
91
|
-
})
|
|
92
92
|
.catch((error: unknown) => {
|
|
93
93
|
logger.warn('cache.broadcast.subscribe-failed', { error: messageOf(error) });
|
|
94
|
+
return undefined;
|
|
94
95
|
});
|
|
95
96
|
|
|
96
97
|
// `resetTiers()` drops the registry AND the broadcast in one call: this boot is the only thing
|
|
97
98
|
// that registers either, and a tier left behind would purge for a process that has stopped.
|
|
98
99
|
return async () => {
|
|
99
|
-
|
|
100
|
-
subscription = undefined;
|
|
100
|
+
(await subscribing)?.unsubscribe();
|
|
101
101
|
resetTiers();
|
|
102
102
|
};
|
|
103
103
|
}
|
package/src/dev-render.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
createIsrController,
|
|
25
25
|
headFromMeta,
|
|
26
26
|
hydrateRuntime,
|
|
27
|
+
isrKey,
|
|
27
28
|
metaContextFor,
|
|
28
29
|
renderComponent,
|
|
29
30
|
renderHead,
|
|
@@ -185,7 +186,11 @@ async function resultFor(
|
|
|
185
186
|
return { status: 200, headers: staticHeaders(contentHash(body), options.buildId), body };
|
|
186
187
|
}
|
|
187
188
|
case 'isr': {
|
|
188
|
-
|
|
189
|
+
// `isrKey(url)`, never `url.pathname`: the query is part of what was rendered — this
|
|
190
|
+
// route's own `meta` reads `data.url` — so two URLs differing only in their query are two
|
|
191
|
+
// documents. Keyed on the pathname alone, the first render answered every later query
|
|
192
|
+
// string (#171). Render owns the derivation so no second caller can invent another.
|
|
193
|
+
const served = await isr.serve(isrKey(url), () =>
|
|
189
194
|
documentFrom(entry, request, data, options),
|
|
190
195
|
);
|
|
191
196
|
return served.result;
|
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-runtime.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { mkdirSync } from 'node:fs';
|
|
7
7
|
import type { PurgeDriver } from '@ultimat3/cache';
|
|
8
8
|
import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
|
|
9
|
-
import { isLocal, resolveEnvironment } from '@ultimat3/core';
|
|
9
|
+
import { isLocal, renderThrowable, resolveEnvironment } from '@ultimat3/core';
|
|
10
10
|
import type { EventBus, JobDriver, OutboxStore } from '@ultimat3/jobs';
|
|
11
11
|
import type { MailDriver } from '@ultimat3/mail';
|
|
12
12
|
import {
|
|
@@ -167,7 +167,7 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
|
|
|
167
167
|
try {
|
|
168
168
|
mkdirSync(root, { recursive: true });
|
|
169
169
|
} catch (cause) {
|
|
170
|
-
const detail =
|
|
170
|
+
const detail = renderThrowable(cause);
|
|
171
171
|
throw new StorageUnwritableError(
|
|
172
172
|
`the embedded storage disk needs ${root} and it could not be created: ${detail}`,
|
|
173
173
|
`mount a writable volume at ${root}, or set S3_ENDPOINT and S3_BUCKET to use object storage instead`,
|
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));
|
package/src/dispatch.ts
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// path as results, so a failure is machine-readable exactly like a success.
|
|
4
4
|
|
|
5
5
|
import { isAbsolute, resolve } from 'node:path';
|
|
6
|
-
import { requireBunVersion } from './app-root';
|
|
6
|
+
import { requireAppRoot, requireBunVersion } from './app-root';
|
|
7
7
|
import { createHelpCommand } from './cmd-help';
|
|
8
|
+
import { plannedCommandFor } from './cmd-planned';
|
|
8
9
|
import type { CommandContext } from './command';
|
|
9
|
-
import { UnknownCommandError } from './errors';
|
|
10
|
+
import { CliNotImplementedError, UnknownCommandError } from './errors';
|
|
10
11
|
import type { Runner } from './exec';
|
|
11
12
|
import { exec } from './exec';
|
|
12
13
|
import type { CommandResult } from './output';
|
|
@@ -42,14 +43,33 @@ const errorResult = (command: string, error: unknown): CommandResult => ({
|
|
|
42
43
|
* end without terminating the test runner.
|
|
43
44
|
*/
|
|
44
45
|
export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
45
|
-
|
|
46
|
+
// Its own branch, ahead of the parse: an unsupported Bun is a fact about the environment and
|
|
47
|
+
// outranks anything argv says, including the planned pre-empt below.
|
|
46
48
|
try {
|
|
47
49
|
requireBunVersion(options.bunVersion);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
options.write(render(errorResult('x', error), wantsJson(options.argv)));
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let args: ParsedArgs;
|
|
56
|
+
try {
|
|
48
57
|
args = parseArgs(options.argv, SPECS);
|
|
49
58
|
} catch (error) {
|
|
59
|
+
// A PLANNED command is not built, so every invocation of one must say that and nothing else.
|
|
60
|
+
// The parser refuses an undeclared flag before any `run` is reached and a planned command
|
|
61
|
+
// declares only the four globals, so `x logs tail --follow` reported X_CLI_BAD_FLAG — a flag
|
|
62
|
+
// list for a command that does not exist yet — while `x logs tail` reported the honest
|
|
63
|
+
// X_NOT_IMPLEMENTED with a runnable fix. Substituted here rather than in `parse.ts`, which is
|
|
64
|
+
// pure and knows nothing about what a command means; the precedent is the help swap below.
|
|
65
|
+
const planned = plannedCommandFor(commandFor(options.argv[0] ?? '')?.spec.name);
|
|
66
|
+
const failure =
|
|
67
|
+
planned === undefined
|
|
68
|
+
? error
|
|
69
|
+
: new CliNotImplementedError({ feature: `x ${planned.name}`, fix: planned.fix });
|
|
50
70
|
// `wantsJson`, not `includes('--json')`: a typo'd flag or a typo'd command is exactly the case
|
|
51
71
|
// an agent hits while always passing `-j`, and the short form rendered prose it then parsed.
|
|
52
|
-
const result = errorResult('x',
|
|
72
|
+
const result = errorResult(planned?.name ?? 'x', failure);
|
|
53
73
|
options.write(render(result, wantsJson(options.argv)));
|
|
54
74
|
return 1;
|
|
55
75
|
}
|
|
@@ -85,6 +105,15 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
85
105
|
};
|
|
86
106
|
|
|
87
107
|
try {
|
|
108
|
+
// The reader `CommandSpec.requiresApp` never had. Its doc said "the dispatcher enforces it" and
|
|
109
|
+
// `dispatch` did not read the field at all: the guarantee held only because all 17 declaring
|
|
110
|
+
// commands happen to call `requireAppRoot` themselves, so a new command that declares it and
|
|
111
|
+
// forgets the call ran outside an app with no refusal. Each of those 17 calls stays — they are
|
|
112
|
+
// what hands a command the root it works in, and several name a subcommand this cannot see
|
|
113
|
+
// (`env init`, `secrets set`) — but the DECLARATION is now what decides, ahead of any check a
|
|
114
|
+
// command makes about its own arguments. `--help` is exempt because `target` is then the help
|
|
115
|
+
// command, which declares nothing: usage for a command must be readable from anywhere.
|
|
116
|
+
if (target.spec.requiresApp === true) requireAppRoot(target.spec.name, ctx.cwd);
|
|
88
117
|
const result = await target.run(ctx);
|
|
89
118
|
options.write(render(result, args.json, args.flags.get('verbose') === true));
|
|
90
119
|
// `x dev` and `x mcp serve --transport http` are still listening here: report first, so the
|
package/src/drift.ts
CHANGED
|
@@ -62,6 +62,41 @@ export async function writeSchemaHash(root: string, migrationId: string): Promis
|
|
|
62
62
|
return hash;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* "Some migration recorded this hash" — the one predicate, read by the check below AND by the
|
|
67
|
+
* reconcile above it. Two spellings would let `x db gen` report the sidecar written while
|
|
68
|
+
* `x verify` still reports drift over the same two files.
|
|
69
|
+
*/
|
|
70
|
+
const isRecorded = (records: readonly MigrationRecord[], hash: string): boolean =>
|
|
71
|
+
records.some((record) => record.hash === hash);
|
|
72
|
+
|
|
73
|
+
export interface HashReconciliation {
|
|
74
|
+
readonly hash: string;
|
|
75
|
+
/** False when a sidecar already held this hash — nothing was written, and nothing needed to be. */
|
|
76
|
+
readonly written: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Re-record the sidecar for a migration that is already the right one. `SCHEMA_GLOB` covers every
|
|
81
|
+
* non-test file under `packages/db/src`, not only the ones that imply DDL, so editing a seed or a
|
|
82
|
+
* helper moves the hash with no diff behind it — and `X_DB_DRIFT`'s `fix:` has to have somewhere to
|
|
83
|
+
* land or the instruction is unfollowable. The caller owes the proof that the DDL genuinely did not
|
|
84
|
+
* move (`db-generate.ts` reaches this only on an empty diff off a fully loaded registry); this
|
|
85
|
+
* function decides only whether a write is needed.
|
|
86
|
+
*
|
|
87
|
+
* A hash an OLDER migration recorded is left alone: `checkSourceDrift` already answers clean on it,
|
|
88
|
+
* and stamping the newest sidecar would claim that migration produced a schema it did not.
|
|
89
|
+
*/
|
|
90
|
+
export async function reconcileSchemaHash(
|
|
91
|
+
root: string,
|
|
92
|
+
migrationId: string,
|
|
93
|
+
): Promise<HashReconciliation> {
|
|
94
|
+
const hash = await schemaHash(root);
|
|
95
|
+
if (isRecorded(await recordedHashes(root), hash)) return { hash, written: false };
|
|
96
|
+
await Bun.write(join(root, MIGRATIONS_DIR, hashFileName(migrationId)), `${hash}\n`);
|
|
97
|
+
return { hash, written: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
65
100
|
/**
|
|
66
101
|
* How many entities the app declares. Injected so this module's own tests need no app on disk, and
|
|
67
102
|
* so a caller that has already loaded the app can answer without loading it twice.
|
|
@@ -90,6 +125,11 @@ export async function checkSourceDrift(
|
|
|
90
125
|
// `x db gen "initial"`, which has an empty diff there, writes no `.hash`, and exits ok: a fix
|
|
91
126
|
// that succeeds and changes nothing. Drift resumes the moment the author declares an entity,
|
|
92
127
|
// and by then the fix genuinely writes one.
|
|
128
|
+
//
|
|
129
|
+
// An empty diff still writes no `.hash` HERE, and must: `reconcileSchemaHash` needs a migration
|
|
130
|
+
// id to record against and there is no migration at all in this branch. With an entity declared
|
|
131
|
+
// the diff is never empty — a registry against zero migrations is `create table` for all of
|
|
132
|
+
// it — so this branch's fix stays the real generation it always was.
|
|
93
133
|
if ((await declaredEntities()) === 0) return [];
|
|
94
134
|
return [
|
|
95
135
|
{
|
|
@@ -101,7 +141,7 @@ export async function checkSourceDrift(
|
|
|
101
141
|
},
|
|
102
142
|
];
|
|
103
143
|
}
|
|
104
|
-
if (records
|
|
144
|
+
if (isRecorded(records, current)) return [];
|
|
105
145
|
return [
|
|
106
146
|
{
|
|
107
147
|
code: 'X_DB_DRIFT',
|