@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/cmd-routes.ts
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
// The rows are `@ultimat3/render`'s own `describeRoutes()`: the CLI prints the route table, it
|
|
5
5
|
// does not keep a second one.
|
|
6
6
|
|
|
7
|
-
import type { RouteDescriptor } from '@ultimat3/render';
|
|
8
|
-
import { describeRoutes } from '@ultimat3/render';
|
|
7
|
+
import type { RouteDescriptor, Surface } from '@ultimat3/render';
|
|
8
|
+
import { describeRoutes, SURFACES } from '@ultimat3/render';
|
|
9
9
|
import { loadApp } from './app-load';
|
|
10
10
|
import { requireAppRoot } from './app-root';
|
|
11
11
|
import type { CliCommand, CommandContext } from './command';
|
|
12
|
+
import { BadFlagError } from './errors';
|
|
12
13
|
import { msg } from './messages';
|
|
13
14
|
import type { CommandResult, JsonValue } from './output';
|
|
14
15
|
import { flagString } from './parse';
|
|
@@ -43,18 +44,40 @@ const routeJson = (routes: readonly RouteDescriptor[]): JsonValue =>
|
|
|
43
44
|
budget: { js: route.budgetJs, lcp: route.budgetLcp },
|
|
44
45
|
}));
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A closed set, because the filter was a bare `===`: `x routes --surface App` and `--surface pages`
|
|
49
|
+
* matched no row and reported `0 routes` with exit 0, which is the same output an app with no
|
|
50
|
+
* routes gives — so a typo and an empty route table are indistinguishable, and only one of them is
|
|
51
|
+
* a bug the caller can see. `SURFACES` is `@ultimat3/render`'s own declaration of what a surface
|
|
52
|
+
* is; a list restated here would be a second answer to it (`x g --surface` is `generate-kinds.ts`'s
|
|
53
|
+
* narrower question — which surface to SCAFFOLD onto — and takes site|app alone).
|
|
54
|
+
*/
|
|
55
|
+
export function readSurfaceFilter(raw: string | undefined): Surface | undefined {
|
|
56
|
+
const surfaces: readonly string[] = SURFACES;
|
|
57
|
+
if (raw === undefined) return undefined;
|
|
58
|
+
if (surfaces.includes(raw)) return raw as Surface;
|
|
59
|
+
throw new BadFlagError({
|
|
60
|
+
flag: 'surface',
|
|
61
|
+
command: 'routes',
|
|
62
|
+
reason: `"${raw}" is not a surface (known: ${SURFACES.join(', ')})`,
|
|
63
|
+
fix: 'x routes --surface app --json',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
46
67
|
export const routesCommand: CliCommand = {
|
|
47
68
|
spec: {
|
|
48
69
|
name: 'routes',
|
|
49
70
|
summary: 'the route table: path, surface, render mode, hydrate, offline',
|
|
50
|
-
usage: 'x routes [--surface site|app] [--json]',
|
|
71
|
+
usage: 'x routes [--surface site|app|api|shared] [--json]',
|
|
51
72
|
requiresApp: true,
|
|
52
73
|
flags: [{ name: 'surface', type: 'string', summary: 'filter by surface' }],
|
|
53
74
|
},
|
|
54
75
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
55
76
|
const root = requireAppRoot('routes', ctx.cwd).dir;
|
|
77
|
+
// Read before the app is loaded: a typo must not cost a boot to report, the rule `x mcp`'s
|
|
78
|
+
// `--transport` already follows.
|
|
79
|
+
const surface = readSurfaceFilter(flagString(ctx.args, 'surface'));
|
|
56
80
|
const { findings } = await loadApp(root);
|
|
57
|
-
const surface = flagString(ctx.args, 'surface');
|
|
58
81
|
const routes = describeRoutes().filter(
|
|
59
82
|
(route) => surface === undefined || route.surface === surface,
|
|
60
83
|
);
|
package/src/cmd-secrets.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { ENV_SCHEMA_EXPORT, loadEnvSchema } from './app-env';
|
|
|
32
32
|
import { requireAppRoot } from './app-root';
|
|
33
33
|
import type { CliCommand, CommandContext } from './command';
|
|
34
34
|
import {
|
|
35
|
-
|
|
35
|
+
MissingPositionalError,
|
|
36
36
|
SecretsEditFailedError,
|
|
37
37
|
SecretsEditorMissingError,
|
|
38
38
|
SecretsExistsError,
|
|
@@ -210,11 +210,11 @@ async function edit(ctx: CommandContext, io: SecretsIo): Promise<CommandResult>
|
|
|
210
210
|
async function set(ctx: CommandContext, io: SecretsIo): Promise<CommandResult> {
|
|
211
211
|
const name = ctx.args.positionals[0];
|
|
212
212
|
if (name === undefined) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
command: 'secrets',
|
|
216
|
-
|
|
217
|
-
|
|
213
|
+
// The name is the positional this command seals under, never a `--name` flag it does not have.
|
|
214
|
+
throw new MissingPositionalError({
|
|
215
|
+
command: 'secrets set',
|
|
216
|
+
positional: 'NAME',
|
|
217
|
+
example: 'printf %s "$TOKEN" | x secrets set STRIPE_KEY --json',
|
|
218
218
|
});
|
|
219
219
|
}
|
|
220
220
|
const { root, key } = open(ctx, 'set');
|
package/src/cmd-test.ts
CHANGED
|
@@ -9,9 +9,10 @@ import { readIntFlag } from './flag-number';
|
|
|
9
9
|
import type { CommandResult } from './output';
|
|
10
10
|
import type { ParsedArgs } from './parse';
|
|
11
11
|
import { flagString } from './parse';
|
|
12
|
+
import { quoteArg } from './shell-quote';
|
|
12
13
|
import { discoverTests, missingSelection, readSample, readType, sampleFiles } from './test-select';
|
|
13
|
-
import {
|
|
14
|
-
import { defaultWorkers } from './test-workers';
|
|
14
|
+
import { runShards } from './test-shards';
|
|
15
|
+
import { defaultWorkers, WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
|
|
15
16
|
import type { TestType } from './verify-tests';
|
|
16
17
|
import { TEST_TYPES } from './verify-tests';
|
|
17
18
|
|
|
@@ -25,6 +26,12 @@ const readIndex = (args: ParsedArgs, name: string, min: number): number | undefi
|
|
|
25
26
|
name,
|
|
26
27
|
command: 'test',
|
|
27
28
|
min,
|
|
29
|
+
// The ceiling the summary already claimed and the reader never enforced: `--workers 5000` was
|
|
30
|
+
// accepted, `planShards` clamps only to the file count, and `runParallel` `Promise.all`s them —
|
|
31
|
+
// one Bun process per test FILE, each with the framework module graph and a cloned database.
|
|
32
|
+
// `--worker` is an index into that split, so the same bound holds it (the exact upper index is
|
|
33
|
+
// `workers - 1`, refused a line below by the check that knows the real width).
|
|
34
|
+
max: WORKER_CEILING,
|
|
28
35
|
example: `x test --${name} ${Math.max(min, 1)}`,
|
|
29
36
|
});
|
|
30
37
|
|
|
@@ -54,7 +61,11 @@ export const testCommand: CliCommand = {
|
|
|
54
61
|
usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--workers N] [--worker I] [--json]`,
|
|
55
62
|
positionalChoices: TEST_TYPES,
|
|
56
63
|
flags: [
|
|
57
|
-
{
|
|
64
|
+
{
|
|
65
|
+
name: 'workers',
|
|
66
|
+
type: 'string',
|
|
67
|
+
summary: `process count (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
|
|
68
|
+
},
|
|
58
69
|
{
|
|
59
70
|
name: 'worker',
|
|
60
71
|
type: 'string',
|
package/src/cmd-verify.ts
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
MANIFEST_FILENAME,
|
|
14
14
|
verifyContract,
|
|
15
15
|
} from '@ultimat3/manifest';
|
|
16
|
+
import type { MetaIssue } from '@ultimat3/seo';
|
|
17
|
+
import { validateMeta } from '@ultimat3/seo';
|
|
16
18
|
import { checkAgentsMd } from './app-agents-md';
|
|
17
19
|
import { checkAppBoundaries } from './app-boundaries';
|
|
18
20
|
import { envExampleFindings } from './app-env';
|
|
@@ -24,13 +26,15 @@ import type { CliCommand, CommandContext } from './command';
|
|
|
24
26
|
import { checkDestructiveMigrations } from './db-destructive';
|
|
25
27
|
import { checkDocumentStyles, documentSurfaces } from './document-styles';
|
|
26
28
|
import { checkSourceDrift } from './drift';
|
|
27
|
-
import {
|
|
29
|
+
import { checkErrorFixReport } from './error-contract';
|
|
28
30
|
import { readIntFlag } from './flag-number';
|
|
29
31
|
import { guardFindings } from './guards';
|
|
30
32
|
import { msg } from './messages';
|
|
31
33
|
import type { CommandResult, Finding, StepResult } from './output';
|
|
32
34
|
import { findingFrom } from './output';
|
|
33
35
|
import type { ParsedArgs } from './parse';
|
|
36
|
+
import { scanSiteMeta } from './seo-meta';
|
|
37
|
+
import { WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
|
|
34
38
|
import {
|
|
35
39
|
floorProblemFindings,
|
|
36
40
|
floorRequires,
|
|
@@ -44,6 +48,9 @@ import { TEST_STEPS } from './verify-tests';
|
|
|
44
48
|
import { checkFileSizes, checkPackageShape, hasWorkspacePackages } from './workspace-checks';
|
|
45
49
|
|
|
46
50
|
/** The whole contract, in cost order. Every check the framework knows how to make lives here. */
|
|
51
|
+
/** The one file that makes the `roadmap` step answerable, and therefore what `applies` reads. */
|
|
52
|
+
const ROADMAP_FILE = join('docs', 'idea', '14-roadmap.md');
|
|
53
|
+
|
|
47
54
|
export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
48
55
|
{
|
|
49
56
|
name: 'typecheck',
|
|
@@ -111,8 +118,21 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
111
118
|
summary: 'every X_* code has a runnable fix and a docs page',
|
|
112
119
|
// The fix-line half runs anywhere source does. The docs half needs a reference page to check
|
|
113
120
|
// against, and which file that is belongs to the host repo — hence `hostFindings`.
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
//
|
|
122
|
+
// The coverage line rides in `output`, which `--json` carries verbatim: a scan without a
|
|
123
|
+
// parser cannot read every fix, and a step that reports only findings claims a completeness
|
|
124
|
+
// it does not have. "checked 412, could not read 27" is what a reader can act on.
|
|
125
|
+
async run(ctx) {
|
|
126
|
+
const report = await checkErrorFixReport(ctx.root);
|
|
127
|
+
const findings = [...report.findings, ...(await hostFindings(ctx, 'errors'))];
|
|
128
|
+
return {
|
|
129
|
+
...fromFindings(findings),
|
|
130
|
+
output: msg('cli.verify.fixCoverage', {
|
|
131
|
+
checked: report.checked,
|
|
132
|
+
unreadable: report.unreadable,
|
|
133
|
+
}),
|
|
134
|
+
};
|
|
135
|
+
},
|
|
116
136
|
},
|
|
117
137
|
...TEST_STEPS,
|
|
118
138
|
{
|
|
@@ -186,6 +206,30 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
186
206
|
]);
|
|
187
207
|
},
|
|
188
208
|
},
|
|
209
|
+
{
|
|
210
|
+
name: 'seo',
|
|
211
|
+
summary: 'every indexable site/ route has a title and a description a search result can render',
|
|
212
|
+
// The SEO checkers shipped in `@ultimat3/seo` with no caller anywhere — `validateMeta` and its
|
|
213
|
+
// asserts were reachable only by an app that called them itself, which is what
|
|
214
|
+
// `packages/seo/src/errors.ts`'s own header said. This is the caller.
|
|
215
|
+
//
|
|
216
|
+
// Its own step rather than a rider on `budgets`: that step asks what a document WEIGHS and
|
|
217
|
+
// this one asks what it SAYS, and a missing `<title>` reported under `budgets` would hand the
|
|
218
|
+
// reader a fix for the wrong question (axiom 4). It costs no second app load — `loadApp`
|
|
219
|
+
// imports each module once per process, so this runs on the registries `budgets` just filled.
|
|
220
|
+
applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
|
|
221
|
+
async run(ctx) {
|
|
222
|
+
const scan = await scanSiteMeta(ctx.root);
|
|
223
|
+
// No `baseUrl`: an app declares no base URL anywhere (`packages/core/src/config.ts` has no
|
|
224
|
+
// such key), so canonical checks are skipped rather than run against an origin this file
|
|
225
|
+
// invented. `seo-meta.ts` spells out why that is the honest half.
|
|
226
|
+
const report = validateMeta(scan.records);
|
|
227
|
+
return {
|
|
228
|
+
ok: scan.findings.length === 0 && report.ok,
|
|
229
|
+
findings: [...scan.findings, ...report.issues.map(seoFinding)],
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
},
|
|
189
233
|
{
|
|
190
234
|
name: 'manifest',
|
|
191
235
|
summary: 'the files an agent reads: generated facts, hand-written conventions, the env example',
|
|
@@ -224,8 +268,11 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
224
268
|
name: 'roadmap',
|
|
225
269
|
summary: "every roadmap milestone's status marker matches what is actually on disk",
|
|
226
270
|
// A generated app ships no `docs/idea/14-roadmap.md` — only the framework monorepo does, so
|
|
227
|
-
//
|
|
228
|
-
|
|
271
|
+
// the FILE is what decides. It keyed on `ctx.hostChecks?.roadmap` until `As of 2026-08`, which
|
|
272
|
+
// is a fact about the CALL: a caller of the exported `runVerify(VERIFY_STEPS, ctx)` passing no
|
|
273
|
+
// `hostChecks`, in a repo whose committed `x.verify.json` names `roadmap`, got
|
|
274
|
+
// `X_VERIFY_SUITE_VANISHED` — whose `fix:` is the command that had just failed.
|
|
275
|
+
applies: async (ctx) => existsSync(join(ctx.root, ROADMAP_FILE)),
|
|
229
276
|
run: async (ctx) => fromFindings(await hostFindings(ctx, 'roadmap')),
|
|
230
277
|
},
|
|
231
278
|
];
|
|
@@ -377,6 +424,18 @@ function findingOf(error: unknown, step: string): Finding {
|
|
|
377
424
|
};
|
|
378
425
|
}
|
|
379
426
|
|
|
427
|
+
/**
|
|
428
|
+
* One `MetaIssue` as the gate reports it. `at` is the route FILE and never the URL: every seo error
|
|
429
|
+
* already names the file in its cause, and `at` is what an agent opens.
|
|
430
|
+
*/
|
|
431
|
+
const seoFinding = (issue: MetaIssue): Finding => ({
|
|
432
|
+
code: issue.code,
|
|
433
|
+
cause: issue.cause,
|
|
434
|
+
fix: issue.fix,
|
|
435
|
+
docs: `https://ultimate.dev/errors/${issue.code}`,
|
|
436
|
+
at: issue.file,
|
|
437
|
+
});
|
|
438
|
+
|
|
380
439
|
export const verifyCommand: CliCommand = {
|
|
381
440
|
spec: {
|
|
382
441
|
name: 'verify',
|
|
@@ -390,7 +449,7 @@ export const verifyCommand: CliCommand = {
|
|
|
390
449
|
{
|
|
391
450
|
name: 'workers',
|
|
392
451
|
type: 'string',
|
|
393
|
-
summary:
|
|
452
|
+
summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
|
|
394
453
|
},
|
|
395
454
|
],
|
|
396
455
|
},
|
|
@@ -405,13 +464,24 @@ export const verifyCommand: CliCommand = {
|
|
|
405
464
|
},
|
|
406
465
|
};
|
|
407
466
|
|
|
408
|
-
/**
|
|
409
|
-
*
|
|
410
|
-
|
|
467
|
+
/**
|
|
468
|
+
* Both bounds are the constants the flag summary already names, so `x help verify` and the reader
|
|
469
|
+
* cannot disagree. Exported for the test that pins them: the command's `run` reaches this only
|
|
470
|
+
* after the whole gate would have started.
|
|
471
|
+
*
|
|
472
|
+
* `max` is the ceiling. Without it `--workers 5000` parsed, `planShards` clamped only to the file
|
|
473
|
+
* count, and `runParallel` `Promise.all`ed one Bun process per test file. `min` is `WORKER_FLOOR`,
|
|
474
|
+
* the same number `defaultWorkers` will not go below — the gate spreads or it does not shard, and
|
|
475
|
+
* `--workers 1` was a serial run the summary said was impossible. `x test --workers 1` stays legal
|
|
476
|
+
* and is deliberately NOT this reader: `runShards` clamps the width to the file count, so a
|
|
477
|
+
* one-file corpus makes `X_TEST_SHARD_FAILED`'s own `fix:` say `--workers 1`.
|
|
478
|
+
*/
|
|
479
|
+
export const readWorkers = (args: ParsedArgs): number | undefined =>
|
|
411
480
|
readIntFlag(args, {
|
|
412
481
|
name: 'workers',
|
|
413
482
|
command: 'verify',
|
|
414
|
-
min:
|
|
483
|
+
min: WORKER_FLOOR,
|
|
484
|
+
max: WORKER_CEILING,
|
|
415
485
|
example: 'x verify --workers 4',
|
|
416
486
|
});
|
|
417
487
|
|
package/src/command.ts
CHANGED
|
@@ -20,14 +20,22 @@ export interface CliCommand {
|
|
|
20
20
|
run(ctx: CommandContext): Promise<CommandResult>;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* `ok` is written AFTER the spread in both helpers, and that order is the whole contract: the
|
|
25
|
+
* function's NAME is the verdict, and `extra` may carry every other field. Spread last, a caller
|
|
26
|
+
* passing `{ ok: true }` to `failed()` got a result `exitCodeFor` exits 0 on while its own summary
|
|
27
|
+
* says it failed — a green CI over a red command. `command` and `summary` stay before the spread
|
|
28
|
+
* on purpose: those are arguments a caller may legitimately refine, and only the verdict is the
|
|
29
|
+
* helper's to keep.
|
|
30
|
+
*/
|
|
23
31
|
export const ok = (
|
|
24
32
|
command: string,
|
|
25
33
|
summary: string,
|
|
26
34
|
extra: Partial<CommandResult> = {},
|
|
27
|
-
): CommandResult => ({
|
|
35
|
+
): CommandResult => ({ command, summary, ...extra, ok: true });
|
|
28
36
|
|
|
29
37
|
export const failed = (
|
|
30
38
|
command: string,
|
|
31
39
|
summary: string,
|
|
32
40
|
extra: Partial<CommandResult> = {},
|
|
33
|
-
): CommandResult => ({
|
|
41
|
+
): CommandResult => ({ command, summary, ...extra, ok: false });
|
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}`),
|