@ultimat3/cli 1.2.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.
Files changed (141) hide show
  1. package/CLAUDE.md +761 -0
  2. package/README.md +42 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +134 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +219 -0
  13. package/src/cmd-db.ts +458 -153
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +92 -18
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +74 -10
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +14 -8
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +29 -24
  33. package/src/cmd-verify.ts +197 -25
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +269 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +144 -0
  39. package/src/db-seed.ts +294 -0
  40. package/src/db-snapshot.ts +24 -0
  41. package/src/dev-assets.ts +108 -23
  42. package/src/dev-cache.ts +122 -0
  43. package/src/dev-dashboard.ts +19 -4
  44. package/src/dev-hooks.ts +27 -2
  45. package/src/dev-n-plus-one.ts +191 -0
  46. package/src/dev-queue.ts +105 -19
  47. package/src/dev-render.ts +158 -26
  48. package/src/dev-roles-fixture.ts +67 -0
  49. package/src/dev-roles.ts +167 -78
  50. package/src/dev-runtime.ts +117 -40
  51. package/src/dev-services.ts +15 -0
  52. package/src/dev-storage.ts +247 -0
  53. package/src/dev-sync.ts +107 -0
  54. package/src/dev-traces.ts +37 -7
  55. package/src/dispatch.ts +4 -2
  56. package/src/document-styles.ts +54 -0
  57. package/src/drift.ts +78 -10
  58. package/src/error-catalog.ts +8 -18
  59. package/src/error-codes.ts +192 -0
  60. package/src/error-contract.ts +29 -7
  61. package/src/error-fixes.ts +114 -0
  62. package/src/errors.ts +201 -138
  63. package/src/exec.ts +42 -8
  64. package/src/fix-command.ts +268 -0
  65. package/src/flag-number.ts +67 -0
  66. package/src/framework-scope.ts +49 -0
  67. package/src/generate-kinds.ts +97 -0
  68. package/src/guards.ts +186 -0
  69. package/src/index.ts +92 -15
  70. package/src/island-bundle.ts +166 -0
  71. package/src/island-routes.ts +50 -0
  72. package/src/jobs-driver.ts +33 -0
  73. package/src/jobs-json.ts +24 -0
  74. package/src/jobs-report.ts +17 -4
  75. package/src/mcp-db-target.ts +52 -27
  76. package/src/mcp-errors.ts +128 -19
  77. package/src/mcp-host.ts +44 -25
  78. package/src/messages.ts +93 -2
  79. package/src/metrics-endpoint.ts +64 -16
  80. package/src/migrations.ts +37 -4
  81. package/src/otlp-export.ts +64 -0
  82. package/src/output.ts +46 -16
  83. package/src/parse.ts +41 -3
  84. package/src/policy-facts.ts +38 -6
  85. package/src/policy-fixture.ts +14 -7
  86. package/src/prerender.ts +111 -2
  87. package/src/registry.ts +21 -3
  88. package/src/runtime-overrides.ts +66 -0
  89. package/src/safe-url-label.ts +24 -0
  90. package/src/scaffold-fixture.ts +10 -0
  91. package/src/scaffold-typecheck.ts +16 -38
  92. package/src/serve.ts +185 -13
  93. package/src/shell-quote.ts +15 -0
  94. package/src/source-files.ts +4 -0
  95. package/src/statement-loop.ts +74 -0
  96. package/src/style-csp.ts +18 -0
  97. package/src/sync-authenticator.ts +59 -0
  98. package/src/templates/action.ts +15 -30
  99. package/src/templates/admin-page.ts +103 -0
  100. package/src/templates/admin.ts +11 -7
  101. package/src/templates/backfill.ts +212 -0
  102. package/src/templates/entity.ts +72 -31
  103. package/src/templates/guard.ts +143 -0
  104. package/src/templates/index.ts +12 -1
  105. package/src/templates/island.ts +67 -0
  106. package/src/templates/job.ts +53 -13
  107. package/src/templates/naming.ts +17 -1
  108. package/src/templates/policy.ts +35 -28
  109. package/src/templates/query.ts +24 -5
  110. package/src/templates/resource.ts +19 -11
  111. package/src/templates/route.ts +90 -15
  112. package/src/templates/scaffold-app.ts +142 -45
  113. package/src/templates/scaffold-claude-agents.ts +149 -0
  114. package/src/templates/scaffold-claude-commands.ts +221 -0
  115. package/src/templates/scaffold-claude.ts +134 -0
  116. package/src/templates/scaffold-container.ts +46 -2
  117. package/src/templates/scaffold-db-package.ts +91 -0
  118. package/src/templates/scaffold-docs.ts +24 -5
  119. package/src/templates/scaffold-domain-package.ts +90 -0
  120. package/src/templates/scaffold-env.ts +87 -0
  121. package/src/templates/scaffold-i18n.ts +4 -1
  122. package/src/templates/scaffold-mcp-package.ts +49 -0
  123. package/src/templates/scaffold-package-shape.ts +25 -4
  124. package/src/templates/scaffold-repo.ts +116 -257
  125. package/src/templates/scaffold-roles.ts +68 -0
  126. package/src/templates/scaffold-ui-package.ts +56 -0
  127. package/src/templates/slice-foundation.ts +88 -0
  128. package/src/templates/wrap.ts +95 -0
  129. package/src/test-counts.ts +35 -0
  130. package/src/test-select.ts +30 -15
  131. package/src/test-shards.ts +20 -11
  132. package/src/test-workers.ts +50 -0
  133. package/src/ts-scan.ts +284 -15
  134. package/src/tsconfig-references.ts +103 -0
  135. package/src/verify-floor.ts +133 -0
  136. package/src/verify-step.ts +19 -0
  137. package/src/verify-test-run.ts +72 -0
  138. package/src/verify-tests.ts +160 -71
  139. package/src/version-loader.ts +20 -3
  140. package/src/workspace-checks.ts +87 -16
  141. package/src/write-line.ts +34 -0
@@ -0,0 +1,29 @@
1
+ // The destructive-SQL rail at the gate: an `up` that drops, truncates or retypes must declare it.
2
+ // Files, not the database, so the rail fires in CI rather than first in a release phase.
3
+ // `@ultimat3/db` owns the classifier `x db gen` wrote the marker from — the generator and the gate
4
+ // cannot disagree about one file.
5
+
6
+ import { destructiveStatements, hasDestructiveMarker, migrationDestructive } from '@ultimat3/db';
7
+ import { MIGRATIONS_DIR, readMigrations } from './migrations';
8
+ import { type Finding, findingFrom } from './output';
9
+
10
+ /**
11
+ * One finding per migration, never one per statement: the marker declares the whole file, so a
12
+ * second finding would repeat the instruction the first already gave. The count still rides along
13
+ * in `cause`, because "and 3 more" is the difference between a typo and a rewrite.
14
+ *
15
+ * `readMigrations` is the reader `x db migrate` applies from, and only its `up` half is judged —
16
+ * a rail checking a list the migrator does not run, or SQL the migrator never sends, enforces
17
+ * nothing. An app with no migrations directory has nothing to declare and reports nothing.
18
+ */
19
+ export async function checkDestructiveMigrations(root: string): Promise<readonly Finding[]> {
20
+ const findings: Finding[] = [];
21
+ for (const migration of await readMigrations(root)) {
22
+ if (hasDestructiveMarker(migration.up)) continue;
23
+ const [first, ...rest] = destructiveStatements(migration.up);
24
+ if (first === undefined) continue;
25
+ const file = `${MIGRATIONS_DIR}/${migration.id}.sql`;
26
+ findings.push({ ...findingFrom(migrationDestructive(file, first, rest.length)), at: file });
27
+ }
28
+ return findings;
29
+ }
@@ -0,0 +1,28 @@
1
+ // One rule for turning a thrown value into a `Finding` on the `x db` path, shared by every
2
+ // subcommand: a framework error reaches the caller verbatim, and anything else is named by the
3
+ // step that failed. Its own module because `cmd-db.ts` and `cmd-db-branch.ts` both need it and
4
+ // neither may import the other.
5
+
6
+ import { renderThrowable } from '@ultimat3/core';
7
+ import type { Finding } from './output';
8
+ import { findingFrom, isUltimateErrorShape } from './output';
9
+
10
+ /**
11
+ * The engine names its own failures — `X_MIGRATION_CONFLICT` carries the ledger row that disagrees,
12
+ * `X_MIGRATION_IRREVERSIBLE` carries the exact `--allow-destructive` line to rerun, and
13
+ * `X_BRANCH_EXISTS` carries the `x db branch drop` that clears it — so those reach the caller
14
+ * verbatim. `X_DB_GEN_FAILED` / `X_DB_MIGRATE_FAILED` / `X_DB_BRANCH_FAILED` are what is left: the
15
+ * step failed for a reason no framework error claimed, and the raw message is all there is.
16
+ */
17
+ export const stepFinding = (error: unknown, code: string): Finding =>
18
+ isUltimateErrorShape(error)
19
+ ? findingFrom(error)
20
+ : {
21
+ code,
22
+ // The engine may throw a non-Error, and an Error whose `message` is a getter: core's
23
+ // `renderThrowable` reads both without trusting either, so the refusal cannot be lost to
24
+ // a TypeError raised while reporting it.
25
+ cause: renderThrowable(error),
26
+ fix: 'x doctor --json',
27
+ docs: `https://ultimate.dev/errors/${code}`,
28
+ };
@@ -0,0 +1,144 @@
1
+ // `x db gen`, on the framework's own engine. The app's registered entities diffed against what the
2
+ // migration files already declare, written as one reversible `.sql` plus the two sidecars the gate
3
+ // reads back — the snapshot the next generation diffs against, and the schema hash `drift` checks.
4
+
5
+ // `node:path` — Bun ships no path joiner of its own, and these paths are built, not opened.
6
+ import { join } from 'node:path';
7
+ import type { GeneratedMigration } from '@ultimat3/db';
8
+ import {
9
+ DESTRUCTIVE_MARKER,
10
+ declaredSchema,
11
+ generateMigration,
12
+ migrationSnapshotMissing,
13
+ snapshotJson,
14
+ } from '@ultimat3/db';
15
+ import { describeEntities } from '@ultimat3/entity';
16
+ import { loadApp } from './app-load';
17
+ import { reconcileSchemaHash, writeSchemaHash } from './drift';
18
+ import { hashFileName, MIGRATIONS_DIR, readMigrations, snapshotFileName } from './migrations';
19
+ import type { Finding } from './output';
20
+
21
+ export interface GenerateMigrationOptions {
22
+ readonly name: string;
23
+ /** `x db gen "…" --allow-destructive`. A DROP whose `down` cannot restore the rows. */
24
+ readonly allowDestructive?: boolean | undefined;
25
+ }
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
+
34
+ export interface GeneratedFiles {
35
+ readonly outcome: GenerateOutcome;
36
+ /** Absent unless `outcome` is `generated` — the other three write no migration. */
37
+ readonly migration?: GeneratedMigration | undefined;
38
+ /** App-root-relative paths written, in write order. Empty when there was nothing to write. */
39
+ readonly files: readonly string[];
40
+ /** What the source hashes to, whenever this run was in a position to record it. */
41
+ readonly schemaHash?: string | undefined;
42
+ /** Modules that would not load. Non-empty means nothing was generated. */
43
+ readonly findings: readonly Finding[];
44
+ }
45
+
46
+ /**
47
+ * The header every generated migration carries. It names the id rather than interpolating the
48
+ * caller's own message: a `name` is free text from argv, and a newline in it would end the comment
49
+ * and turn prose into SQL.
50
+ *
51
+ * A destructive `up` carries the marker `x verify` demands, written here rather than hand-added
52
+ * later: the marker is part of the SQL the checksum covers, so a migration that earns it after it
53
+ * has been applied is an edited migration and `X_MIGRATION_CONFLICT` — correctly — says so.
54
+ */
55
+ export function migrationSql(migration: GeneratedMigration): string {
56
+ return [
57
+ `-- ${migration.id}`,
58
+ "-- GENERATED by `x db gen` from the app's entities — do not edit.",
59
+ '-- Editing an applied migration changes its checksum: X_MIGRATION_CONFLICT on the next apply.',
60
+ ...(migration.destructive ? [DESTRUCTIVE_MARKER] : []),
61
+ '',
62
+ migration.up,
63
+ '',
64
+ '-- down',
65
+ migration.down,
66
+ '',
67
+ ].join('\n');
68
+ }
69
+
70
+ /**
71
+ * Generation reads source and writes files; it never opens a database. The previous migration's
72
+ * snapshot is what it diffs against (`declaredSchema`), so `x db gen` answers the same in CI, on a
73
+ * laptop with no Postgres running, and against a database three migrations behind.
74
+ *
75
+ * An app that would not load generates nothing. `describeEntities()` is the registry a failed
76
+ * import leaves short, and a diff against a short registry is a migration that drops the tables
77
+ * whose module happened to throw — the one destructive edit no `--allow-destructive` was asked for.
78
+ */
79
+ export async function generateAppMigration(
80
+ root: string,
81
+ options: GenerateMigrationOptions,
82
+ ): Promise<GeneratedFiles> {
83
+ const app = await loadApp(root);
84
+ if (app.findings.length > 0) return { outcome: 'blocked', files: [], findings: app.findings };
85
+
86
+ const migrations = await readMigrations(root);
87
+ const current = declaredSchema(migrations);
88
+ // Refused, never defaulted to the empty schema: with nothing to diff against, every table the
89
+ // database already holds looks new and the generated `up` is `create table` for all of them.
90
+ if (current === undefined) {
91
+ const newest = migrations[migrations.length - 1];
92
+ const id = newest?.id ?? '';
93
+ throw migrationSnapshotMissing(id, join(MIGRATIONS_DIR, snapshotFileName(id)));
94
+ }
95
+
96
+ const migration = generateMigration({
97
+ entities: describeEntities(),
98
+ current,
99
+ name: options.name,
100
+ ...(options.allowDestructive === true ? { allowDestructive: true } : {}),
101
+ });
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
+ }
127
+
128
+ const dir = join(root, MIGRATIONS_DIR);
129
+ const sql = `${migration.id}.sql`;
130
+ const snapshot = snapshotFileName(migration.id);
131
+ await Bun.write(join(dir, sql), migrationSql(migration));
132
+ // `snapshotJson`, never `JSON.stringify`: the sidecar is the one migration artefact a scaffolded
133
+ // app's `biome check .` reads, and its bytes carry their own trailing newline.
134
+ await Bun.write(join(dir, snapshot), snapshotJson(migration.snapshot));
135
+ const schemaHash = await writeSchemaHash(root, migration.id);
136
+
137
+ return {
138
+ outcome: 'generated',
139
+ migration,
140
+ schemaHash,
141
+ files: [sql, snapshot, hashFileName(migration.id)].map((file) => `${MIGRATIONS_DIR}/${file}`),
142
+ findings: [],
143
+ };
144
+ }
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
+ );
@@ -0,0 +1,24 @@
1
+ // `x db gen`'s own precondition, asked as a diagnostic rather than met as a throw: a newest
2
+ // migration with no `.snapshot.json` leaves the next generation nothing to diff against, so it
3
+ // refuses before writing anything. `@ultimat3/db` owns both the condition (`declaredSchema`) and
4
+ // the wording, so this check and that refusal cannot disagree about one directory.
5
+
6
+ import { declaredSchema, migrationSnapshotMissing } from '@ultimat3/db';
7
+ import { MIGRATIONS_DIR, readMigrations, snapshotFileName } from './migrations';
8
+ import { type Finding, findingFrom } from './output';
9
+
10
+ /**
11
+ * Empty result = the next `x db gen` has something to start from. An app with no migrations at all
12
+ * reports nothing: `declaredSchema([])` is the empty schema, which is a real answer and the state a
13
+ * freshly scaffolded app is in before its first generation.
14
+ *
15
+ * One finding, never one per file. Only the NEWEST snapshot is what a diff starts from, so an older
16
+ * migration missing one is a fact about history and not something the author can act on.
17
+ */
18
+ export async function checkMigrationSnapshots(root: string): Promise<readonly Finding[]> {
19
+ const migrations = await readMigrations(root);
20
+ if (declaredSchema(migrations) !== undefined) return [];
21
+ const id = migrations[migrations.length - 1]?.id ?? '';
22
+ const file = `${MIGRATIONS_DIR}/${snapshotFileName(id)}`;
23
+ return [{ ...findingFrom(migrationSnapshotMissing(id, file)), at: file }];
24
+ }