@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.
- package/CLAUDE.md +761 -0
- package/README.md +42 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +134 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +219 -0
- package/src/cmd-db.ts +458 -153
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +92 -18
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +74 -10
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +14 -8
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +29 -24
- package/src/cmd-verify.ts +197 -25
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +269 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +144 -0
- package/src/db-seed.ts +294 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +108 -23
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +167 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +247 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +37 -7
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +78 -10
- package/src/error-catalog.ts +8 -18
- package/src/error-codes.ts +192 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +67 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +92 -15
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +128 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +93 -2
- package/src/metrics-endpoint.ts +64 -16
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +185 -13
- package/src/shell-quote.ts +15 -0
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +20 -11
- package/src/test-workers.ts +50 -0
- package/src/ts-scan.ts +284 -15
- package/src/tsconfig-references.ts +103 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
package/src/cmd-db.ts
CHANGED
|
@@ -1,187 +1,492 @@
|
|
|
1
|
-
// `x db gen|migrate|reset|studio|branch` — everything that touches the database
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// `x db gen|migrate|reset|seed|studio|branch|backfill` — everything that touches the database. One
|
|
2
|
+
// subcommand per line and no fall-through: a word this file does not know is refused, never
|
|
3
|
+
// re-read as an argument to the last branch. `branch` itself is `cmd-db-branch.ts`.
|
|
4
|
+
//
|
|
5
|
+
// Every subcommand here runs `@ultimat3/db`'s own engine, which is the engine `ROLE=migrate` runs
|
|
6
|
+
// (`serve.ts`): one `x_migrations` ledger, one checksum rule, one advisory lock, from a laptop to
|
|
7
|
+
// a release phase. Until 1.2.0 these shelled out to `bunx drizzle-kit` — a second engine with a
|
|
8
|
+
// second journal, declared in no `package.json` and fetched unpinned at run time.
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
// `node:fs/promises` for `rm` — `Bun.file().delete()` takes one file, and `x db reset` removes a
|
|
11
|
+
// directory tree. `node:path` for `join` — Bun exposes no path joiner.
|
|
6
12
|
import { rm } from 'node:fs/promises';
|
|
7
13
|
import { join } from 'node:path';
|
|
8
|
-
import {
|
|
14
|
+
import { resolveEnvironment } from '@ultimat3/core';
|
|
15
|
+
import { type DriftReport, driftError, withTransaction } from '@ultimat3/db';
|
|
16
|
+
import { postgresDriver } from '@ultimat3/entity';
|
|
17
|
+
import { BackfillPendingError } from '@ultimat3/jobs';
|
|
18
|
+
import { loadApp } from './app-load';
|
|
9
19
|
import { requireAppRoot } from './app-root';
|
|
20
|
+
import { runBranchCommand } from './cmd-db-branch';
|
|
21
|
+
import { plannedSubcommand } from './cmd-planned';
|
|
10
22
|
import type { CliCommand, CommandContext } from './command';
|
|
23
|
+
import type { BackfillAction, BackfillPlanRow } from './db-backfill';
|
|
24
|
+
import {
|
|
25
|
+
listBackfills,
|
|
26
|
+
pendingReport,
|
|
27
|
+
pendingToJson,
|
|
28
|
+
planToJson,
|
|
29
|
+
readAppliedMigrations,
|
|
30
|
+
renderBackfillTable,
|
|
31
|
+
renderPendingTable,
|
|
32
|
+
renderPlanTable,
|
|
33
|
+
runBackfills,
|
|
34
|
+
} from './db-backfill';
|
|
35
|
+
import { BRANCH_SUBCOMMANDS } from './db-branch';
|
|
36
|
+
import { stepFinding } from './db-finding';
|
|
37
|
+
import { generateAppMigration } from './db-generate';
|
|
38
|
+
import type { SeedPassRow } from './db-seed';
|
|
39
|
+
import {
|
|
40
|
+
discoverSeeds,
|
|
41
|
+
parseSeedTierFlag,
|
|
42
|
+
renderSeedTable,
|
|
43
|
+
runSeeds,
|
|
44
|
+
seedPassToJson,
|
|
45
|
+
seedTotals,
|
|
46
|
+
selectSeeds,
|
|
47
|
+
} from './db-seed';
|
|
11
48
|
import { resolveServices } from './dev-services';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
49
|
+
import {
|
|
50
|
+
BadFlagError,
|
|
51
|
+
CliNotImplementedError,
|
|
52
|
+
MissingSubcommandError,
|
|
53
|
+
UnknownCommandError,
|
|
54
|
+
} from './errors';
|
|
55
|
+
import { withJobDriver } from './jobs-driver';
|
|
56
|
+
import { backfillToJson } from './jobs-json';
|
|
16
57
|
import { msg } from './messages';
|
|
17
58
|
import type { CommandResult, Finding } from './output';
|
|
18
59
|
import { findingFrom } from './output';
|
|
19
|
-
import { flagString } from './parse';
|
|
60
|
+
import { flagBool, flagString } from './parse';
|
|
61
|
+
import { runMigrations } from './serve';
|
|
20
62
|
|
|
21
|
-
export const DB_SUBCOMMANDS = [
|
|
63
|
+
export const DB_SUBCOMMANDS = [
|
|
64
|
+
'gen',
|
|
65
|
+
'migrate',
|
|
66
|
+
'reset',
|
|
67
|
+
'seed',
|
|
68
|
+
'studio',
|
|
69
|
+
'branch',
|
|
70
|
+
'backfill',
|
|
71
|
+
] as const;
|
|
22
72
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
73
|
+
export const dbCommand: CliCommand = {
|
|
74
|
+
spec: {
|
|
75
|
+
name: 'db',
|
|
76
|
+
summary: 'gen, migrate, reset, seed, studio, branch, backfill',
|
|
77
|
+
usage:
|
|
78
|
+
'x db gen "add publish_at" | migrate | reset | seed [<name>] [--tier reference|dev] [--dry-run] | studio | branch ls | branch create <name> | branch drop <name> | backfill [<name>|--all] [--write] [--force] | backfill --pending | backfill --list [--name n] [--status s] [--limit n]',
|
|
79
|
+
requiresApp: true,
|
|
80
|
+
subcommands: DB_SUBCOMMANDS,
|
|
81
|
+
// Declared from the constant `runBranchCommand` validates against, never a second literal: it
|
|
82
|
+
// is what lets the `errors` step resolve `x db branch ls` — a fix line three shipped errors
|
|
83
|
+
// hand out, which read `ls` as a branch name and cloned a database until 1.2.x.
|
|
84
|
+
subcommandPositionals: { branch: BRANCH_SUBCOMMANDS },
|
|
85
|
+
flags: [
|
|
86
|
+
{
|
|
87
|
+
name: 'name',
|
|
88
|
+
type: 'string',
|
|
89
|
+
summary: 'migration, branch or seed name, or backfill to filter',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'tier',
|
|
93
|
+
type: 'string',
|
|
94
|
+
summary: 'seed: which tier to run — reference or dev; also ULTIMATE_SEED_TIER',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'dry-run',
|
|
98
|
+
type: 'boolean',
|
|
99
|
+
summary: 'seed: report what each seed would write, and write nothing',
|
|
100
|
+
},
|
|
101
|
+
{ name: 'list', type: 'boolean', summary: 'backfill: print the x_backfills ledger' },
|
|
102
|
+
{
|
|
103
|
+
name: 'pending',
|
|
104
|
+
type: 'boolean',
|
|
105
|
+
summary: 'backfill: declared minus completed; non-zero exit when anything is unswept',
|
|
106
|
+
},
|
|
107
|
+
{ name: 'all', type: 'boolean', summary: 'backfill: every pending sweep, isolated per name' },
|
|
108
|
+
{ name: 'write', type: 'boolean', summary: 'backfill: enqueue the pass; dry run without it' },
|
|
109
|
+
{
|
|
110
|
+
name: 'force',
|
|
111
|
+
type: 'boolean',
|
|
112
|
+
summary: 'backfill: sweep a name the ledger records as completed, as a NEW ledger row',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'status',
|
|
116
|
+
type: 'string',
|
|
117
|
+
summary: 'backfill: filter by running, completed or failed',
|
|
118
|
+
},
|
|
119
|
+
{ name: 'limit', type: 'string', summary: 'backfill: max ledger rows to return' },
|
|
120
|
+
// Declared because `X_MIGRATION_IRREVERSIBLE`'s own fix line names it. A `fix:` is copied
|
|
121
|
+
// and run verbatim, so a flag the parser refuses would make the error unfollowable.
|
|
122
|
+
{
|
|
123
|
+
name: 'allow-destructive',
|
|
124
|
+
type: 'boolean',
|
|
125
|
+
summary: 'let x db gen emit a drop whose down cannot restore the rows',
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
130
|
+
const root = requireAppRoot('db', ctx.cwd).dir;
|
|
131
|
+
// No default, and no `?? 'migrate'` here either: `gen` writes a migration file and `reset`
|
|
132
|
+
// drops the database, so "whatever the caller left out" is not a safe guess for any of the six.
|
|
133
|
+
// The parser refuses a bare `x db`; this covers a `ParsedArgs` built by hand.
|
|
134
|
+
const sub = ctx.args.subcommand;
|
|
135
|
+
if (sub === undefined)
|
|
136
|
+
throw new MissingSubcommandError({ command: 'db', known: DB_SUBCOMMANDS });
|
|
137
|
+
const argument = ctx.args.positionals[0] ?? flagString(ctx.args, 'name');
|
|
29
138
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
139
|
+
if (sub === 'gen') return runGen(ctx, root, argument ?? 'change');
|
|
140
|
+
if (sub === 'migrate') return runMigrate(ctx, root, msg('cli.db.migrate.applied'));
|
|
141
|
+
if (sub === 'reset') return runReset(ctx, root);
|
|
142
|
+
if (sub === 'seed') return runSeed(ctx, root);
|
|
143
|
+
if (sub === 'studio') throw plannedSubcommand('db', 'studio');
|
|
144
|
+
if (sub === 'backfill') return runBackfill(ctx, root);
|
|
145
|
+
if (sub === 'branch') return runBranchCommand(ctx, root);
|
|
34
146
|
|
|
35
|
-
|
|
36
|
-
|
|
147
|
+
// Never a fall-through. This used to end `return runBranch(ctx, root, argument ?? 'preview')`,
|
|
148
|
+
// so ANY subcommand the parser had not already refused was reinterpreted as a branch NAME and
|
|
149
|
+
// cloned a database out of it. A word this command does not know is a refusal, not a guess.
|
|
150
|
+
throw new UnknownCommandError({
|
|
151
|
+
path: `db ${sub}`,
|
|
152
|
+
known: DB_SUBCOMMANDS,
|
|
153
|
+
// Help, and not the nearest name: `studio` is planned, and `branch`/`backfill` both need a
|
|
154
|
+
// word this refusal does not have — a suggestion that refuses in turn is not a fix.
|
|
155
|
+
suggestion: 'help db',
|
|
156
|
+
});
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Source in, files out — no database is opened, so this answers the same in CI and on a laptop
|
|
162
|
+
* with nothing running. A diff that finds nothing writes no MIGRATION and still exits 0: "no
|
|
163
|
+
* change" is an answer, and an empty migration would take a ledger row and a checksum forever. It
|
|
164
|
+
* may still write the `.hash` sidecar `x verify`'s `drift` step reads, which is what makes
|
|
165
|
+
* `X_DB_DRIFT`'s `fix:` — this command — a real instruction rather than a no-op.
|
|
166
|
+
*
|
|
167
|
+
* So there are THREE answers, not two, and `--json` carries `outcome` on every one: collapsing
|
|
168
|
+
* `hash-recorded` into either neighbour tells the machine reading this output that a migration
|
|
169
|
+
* exists when none does, or that nothing was written when the sidecar was.
|
|
170
|
+
*/
|
|
171
|
+
async function runGen(ctx: CommandContext, root: string, name: string): Promise<CommandResult> {
|
|
172
|
+
let generated: Awaited<ReturnType<typeof generateAppMigration>>;
|
|
173
|
+
try {
|
|
174
|
+
generated = await generateAppMigration(root, {
|
|
175
|
+
name,
|
|
176
|
+
allowDestructive: flagBool(ctx.args, 'allow-destructive'),
|
|
177
|
+
});
|
|
178
|
+
} catch (error) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
command: 'db',
|
|
182
|
+
summary: msg('cli.db.gen.failed'),
|
|
183
|
+
findings: [stepFinding(error, 'X_DB_GEN_FAILED')],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const migration = generated.migration;
|
|
187
|
+
if (migration === undefined) {
|
|
188
|
+
return {
|
|
189
|
+
ok: generated.findings.length === 0,
|
|
190
|
+
command: 'db',
|
|
191
|
+
// The sidecar path, never a bare id: `hash-recorded` writes exactly one, and it is the file
|
|
192
|
+
// the `drift` step reads back.
|
|
193
|
+
summary:
|
|
194
|
+
generated.outcome === 'hash-recorded'
|
|
195
|
+
? msg('cli.db.gen.recorded', { file: generated.files[0] ?? '' })
|
|
196
|
+
: msg('cli.db.gen.unchanged'),
|
|
197
|
+
findings: generated.findings,
|
|
198
|
+
// `files` is what this command WROTE, so the empty array here was a false claim.
|
|
199
|
+
lines: generated.files.map((file) => ` ${file}`),
|
|
200
|
+
data: {
|
|
201
|
+
outcome: generated.outcome,
|
|
202
|
+
migration: null,
|
|
203
|
+
files: [...generated.files],
|
|
204
|
+
schemaHash: generated.schemaHash ?? null,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
command: 'db',
|
|
211
|
+
summary: msg('cli.db.gen.written', { id: migration.id }),
|
|
212
|
+
lines: generated.files.map((file) => ` ${file}`),
|
|
213
|
+
data: {
|
|
214
|
+
outcome: generated.outcome,
|
|
215
|
+
migration: migration.id,
|
|
216
|
+
name: migration.name,
|
|
217
|
+
files: [...generated.files],
|
|
218
|
+
schemaHash: generated.schemaHash ?? null,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
37
221
|
}
|
|
38
222
|
|
|
39
|
-
|
|
40
|
-
|
|
223
|
+
/**
|
|
224
|
+
* The post-migrate report, rendered. Through `driftError` rather than a second literal: the
|
|
225
|
+
* three-line `X_DB_DRIFT` output is pinned by the framework contract, and this command must not be
|
|
226
|
+
* where a copy of it drifts from the one `x verify` prints.
|
|
227
|
+
*/
|
|
228
|
+
export const driftFindings = (report: DriftReport): readonly Finding[] =>
|
|
229
|
+
report.differences.map((difference) => findingFrom(driftError(difference)));
|
|
41
230
|
|
|
42
|
-
|
|
231
|
+
/**
|
|
232
|
+
* `runMigrations` is `serve.ts`'s, unchanged and unwrapped: the developer applying a migration and
|
|
233
|
+
* the release-phase container applying it run the same function, over the same file list, through
|
|
234
|
+
* the same ledger, and verify the same post-condition — the live schema against that ledger. A
|
|
235
|
+
* database that migrated cleanly and still disagrees is the failure this command exists to
|
|
236
|
+
* surface, and only a check that opened the connection can see it.
|
|
237
|
+
*
|
|
238
|
+
* The *source* half — an entity edited with no migration generated — is `x verify`'s `drift` step
|
|
239
|
+
* (`checkSourceDrift`) and is deliberately not repeated here: two reporters of one condition is the
|
|
240
|
+
* duplication this package's own rule forbids, and that one needs no database at all.
|
|
241
|
+
*/
|
|
242
|
+
async function runMigrate(
|
|
43
243
|
ctx: CommandContext,
|
|
44
244
|
root: string,
|
|
45
|
-
|
|
245
|
+
summary: string,
|
|
46
246
|
): Promise<CommandResult> {
|
|
247
|
+
try {
|
|
248
|
+
const migrated = await runMigrations({ root, env: ctx.env });
|
|
249
|
+
const report = migrated.report;
|
|
250
|
+
return {
|
|
251
|
+
ok: migrated.drift.ok,
|
|
252
|
+
command: 'db',
|
|
253
|
+
summary,
|
|
254
|
+
findings: driftFindings(migrated.drift),
|
|
255
|
+
data: {
|
|
256
|
+
applied: report.applied.map((entry) => entry.id),
|
|
257
|
+
skipped: report.skipped.length,
|
|
258
|
+
appVersion: report.appVersion,
|
|
259
|
+
durationMs: report.durationMs,
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return {
|
|
264
|
+
ok: false,
|
|
265
|
+
command: 'db',
|
|
266
|
+
summary: msg('cli.db.migrate.failed'),
|
|
267
|
+
findings: [stepFinding(error, 'X_DB_MIGRATE_FAILED')],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Embedded only: `rm -rf` against a database this process does not own is not a reset, it is an
|
|
274
|
+
* outage. The data directory goes before the migrator starts, so the run that follows is a fresh
|
|
275
|
+
* database with an empty ledger rather than a re-apply over a live one.
|
|
276
|
+
*/
|
|
277
|
+
async function runReset(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
47
278
|
const services = resolveServices(root, ctx.env);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
const info = await branchPglite(branch, { from: services.db.url });
|
|
55
|
-
return {
|
|
56
|
-
ok: true,
|
|
57
|
-
command: 'db',
|
|
58
|
-
summary: msg('cli.db.branch.ready', { name: branch }),
|
|
59
|
-
data: { branch, database: info.dataDir, preview: url, mode: 'embedded' },
|
|
60
|
-
};
|
|
61
|
-
} catch (error) {
|
|
62
|
-
return {
|
|
63
|
-
ok: false,
|
|
64
|
-
command: 'db',
|
|
65
|
-
summary: msg('cli.usage'),
|
|
66
|
-
findings: [findingFrom(error)],
|
|
67
|
-
};
|
|
68
|
-
}
|
|
279
|
+
if (services.db.mode === 'external') {
|
|
280
|
+
throw new CliNotImplementedError({
|
|
281
|
+
feature: 'x db reset against an external Postgres',
|
|
282
|
+
fix: 'drop and recreate the database yourself, then run: x db migrate',
|
|
283
|
+
});
|
|
69
284
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
285
|
+
await rm(join(services.stateDir, 'pgdata'), { recursive: true, force: true });
|
|
286
|
+
return runMigrate(ctx, root, msg('cli.db.reset.done'));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* `x db seed [<name>]` — the fixture graph, applied and replayable.
|
|
291
|
+
*
|
|
292
|
+
* The environment is resolved BEFORE anything is imported or connected: a run this environment does
|
|
293
|
+
* not take must refuse without having opened a connection to the database it was refusing to write
|
|
294
|
+
* to. `selectSeeds` asks the same question a second time, on the seeds themselves, because seeding
|
|
295
|
+
* is the one irreversible thing this command does (`db-seed.ts`).
|
|
296
|
+
*
|
|
297
|
+
* `withJobDriver` is the boot, though nothing here claims a job: it is the CLI's one answer to
|
|
298
|
+
* "which database is this command talking to", and it also puts a real queue behind any
|
|
299
|
+
* `handle.enqueue()` a seeded write triggers. A second boot path would be a second answer.
|
|
300
|
+
*/
|
|
301
|
+
async function runSeed(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
302
|
+
const environment = resolveEnvironment({ env: ctx.env });
|
|
303
|
+
const requested = parseSeedTierFlag(
|
|
304
|
+
flagString(ctx.args, 'tier') ?? ctx.env['ULTIMATE_SEED_TIER'],
|
|
305
|
+
);
|
|
306
|
+
const name = ctx.args.positionals[0] ?? flagString(ctx.args, 'name');
|
|
307
|
+
const dryRun = flagBool(ctx.args, 'dry-run');
|
|
308
|
+
const discovery = await discoverSeeds(root);
|
|
309
|
+
const chosen = selectSeeds({
|
|
310
|
+
discovered: discovery.seeds,
|
|
311
|
+
...(name === undefined ? {} : { name }),
|
|
312
|
+
environment,
|
|
313
|
+
requested,
|
|
74
314
|
});
|
|
75
|
-
if (
|
|
315
|
+
if (chosen.length === 0) {
|
|
76
316
|
return {
|
|
77
|
-
ok:
|
|
317
|
+
ok: true,
|
|
78
318
|
command: 'db',
|
|
79
|
-
summary: msg('cli.
|
|
80
|
-
findings:
|
|
81
|
-
|
|
82
|
-
psql,
|
|
83
|
-
'X_DB_BRANCH_FAILED',
|
|
84
|
-
`close open connections to "${source}" (a TEMPLATE clone needs none), then retry`,
|
|
85
|
-
),
|
|
86
|
-
],
|
|
319
|
+
summary: msg('cli.db.seed.none'),
|
|
320
|
+
findings: discovery.findings,
|
|
321
|
+
data: seedPassToJson([]),
|
|
87
322
|
};
|
|
88
323
|
}
|
|
324
|
+
return withJobDriver(root, ctx, async () => {
|
|
325
|
+
const rows = await runSeeds({
|
|
326
|
+
seeds: chosen,
|
|
327
|
+
driver: postgresDriver(),
|
|
328
|
+
dryRun,
|
|
329
|
+
env: ctx.env,
|
|
330
|
+
// One transaction per seed, so a seed that throws takes only its own rows with it.
|
|
331
|
+
transaction: (work) => withTransaction(() => work()),
|
|
332
|
+
});
|
|
333
|
+
return seedPassResult(rows, dryRun, discovery.findings);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function seedPassResult(
|
|
338
|
+
rows: readonly SeedPassRow[],
|
|
339
|
+
dryRun: boolean,
|
|
340
|
+
findings: readonly Finding[],
|
|
341
|
+
): CommandResult {
|
|
342
|
+
const totals = seedTotals(rows);
|
|
343
|
+
const failures = rows.flatMap((row) => (row.finding === null ? [] : [row.finding]));
|
|
89
344
|
return {
|
|
90
|
-
ok:
|
|
345
|
+
ok: failures.length === 0 && findings.length === 0,
|
|
91
346
|
command: 'db',
|
|
92
|
-
summary:
|
|
93
|
-
|
|
347
|
+
summary:
|
|
348
|
+
totals.failed > 0
|
|
349
|
+
? msg('cli.db.seed.failed', { failed: totals.failed, count: rows.length })
|
|
350
|
+
: dryRun
|
|
351
|
+
? msg('cli.db.seed.dryRun', { count: rows.length })
|
|
352
|
+
: msg('cli.db.seed.done', {
|
|
353
|
+
count: rows.length,
|
|
354
|
+
inserted: totals.inserted,
|
|
355
|
+
updated: totals.updated,
|
|
356
|
+
skipped: totals.skipped,
|
|
357
|
+
}),
|
|
358
|
+
findings: [...failures, ...findings],
|
|
359
|
+
lines: renderSeedTable(rows).map((line) => ` ${line}`),
|
|
360
|
+
data: seedPassToJson(rows),
|
|
94
361
|
};
|
|
95
362
|
}
|
|
96
363
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
364
|
+
/**
|
|
365
|
+
* Four shapes, one subcommand: `--list` reads the `x_backfills` ledger, `--pending` diffs it
|
|
366
|
+
* against what the app DECLARED, and `<name>` / `--all` gate a pass and put it on the queue. A
|
|
367
|
+
* bare `x db backfill` is still refused rather than defaulted — the four answer four different
|
|
368
|
+
* questions, and picking one for the operator is the ambiguity axiom 1 exists to refuse.
|
|
369
|
+
*
|
|
370
|
+
* An empty ledger is `ok: true`. "Nothing has swept this database yet" is an answer to the
|
|
371
|
+
* question asked, and a command that failed over it would be unrunnable on a fresh app.
|
|
372
|
+
*/
|
|
373
|
+
async function runBackfill(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
374
|
+
if (flagBool(ctx.args, 'list')) return runBackfillList(ctx, root);
|
|
375
|
+
const all = flagBool(ctx.args, 'all');
|
|
376
|
+
const name = ctx.args.positionals[0] ?? flagString(ctx.args, 'name');
|
|
377
|
+
if (flagBool(ctx.args, 'pending')) return runBackfillPending(ctx, root);
|
|
378
|
+
if (all) return runBackfillPass(ctx, root, 'all');
|
|
379
|
+
if (name !== undefined) return runBackfillPass(ctx, root, [name]);
|
|
380
|
+
throw new BadFlagError({
|
|
381
|
+
flag: 'list',
|
|
382
|
+
command: 'db',
|
|
383
|
+
reason:
|
|
384
|
+
'x db backfill needs a shape: --list (the ledger), --pending (declared minus completed), <name> or --all (run one, or every pending one)',
|
|
385
|
+
fix: 'x db backfill --pending --json',
|
|
386
|
+
});
|
|
387
|
+
}
|
|
110
388
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
data: { stateDir: services.stateDir },
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (sub === 'studio') {
|
|
165
|
-
const result = await ctx.runner(['bunx', 'drizzle-kit', 'studio'], { cwd: root });
|
|
166
|
-
return {
|
|
167
|
-
ok: result.ok,
|
|
168
|
-
command: 'db',
|
|
169
|
-
summary: result.ok ? 'studio exited' : 'studio failed to start',
|
|
170
|
-
findings: result.ok ? [] : [failure(result, 'X_DB_STUDIO_FAILED', 'x doctor --json')],
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return runBranch(ctx, root, argument ?? 'preview');
|
|
175
|
-
},
|
|
176
|
-
};
|
|
389
|
+
async function runBackfillList(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
390
|
+
return withJobDriver(root, ctx, async (driver) => {
|
|
391
|
+
const rows = await listBackfills(driver, {
|
|
392
|
+
name: flagString(ctx.args, 'name'),
|
|
393
|
+
status: flagString(ctx.args, 'status'),
|
|
394
|
+
limit: flagString(ctx.args, 'limit'),
|
|
395
|
+
});
|
|
396
|
+
return {
|
|
397
|
+
ok: true,
|
|
398
|
+
command: 'db',
|
|
399
|
+
summary:
|
|
400
|
+
rows.length === 0
|
|
401
|
+
? msg('cli.db.backfill.empty')
|
|
402
|
+
: msg('cli.db.backfill.listed', { count: rows.length }),
|
|
403
|
+
lines: rows.length === 0 ? [] : renderBackfillTable(rows).map((line) => ` ${line}`),
|
|
404
|
+
data: rows.map(backfillToJson),
|
|
405
|
+
};
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The alarm the framework did not have. Non-zero when anything is unswept, so a cron or a deploy
|
|
411
|
+
* check can read the exit code — a `--json` nobody has to parse to know something is wrong.
|
|
412
|
+
* `loadApp` first: importing the app's modules IS the declaration, and a diff run without it
|
|
413
|
+
* would report a clean database against an empty declaration list.
|
|
414
|
+
*/
|
|
415
|
+
async function runBackfillPending(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
416
|
+
await loadApp(root);
|
|
417
|
+
const environment = resolveEnvironment({ env: ctx.env });
|
|
418
|
+
return withJobDriver(root, ctx, async (driver) => {
|
|
419
|
+
const report = await pendingReport(driver, environment);
|
|
420
|
+
return {
|
|
421
|
+
ok: report.pending.length === 0,
|
|
422
|
+
command: 'db',
|
|
423
|
+
summary:
|
|
424
|
+
report.pending.length === 0
|
|
425
|
+
? msg('cli.db.backfill.swept', { declared: report.rows.length })
|
|
426
|
+
: msg('cli.db.backfill.pending', {
|
|
427
|
+
count: report.pending.length,
|
|
428
|
+
declared: report.rows.length,
|
|
429
|
+
}),
|
|
430
|
+
findings: report.pending.map((row) =>
|
|
431
|
+
findingFrom(new BackfillPendingError({ backfill: row.name, environment })),
|
|
432
|
+
),
|
|
433
|
+
lines: report.rows.length === 0 ? [] : renderPendingTable(report).map((line) => ` ${line}`),
|
|
434
|
+
data: pendingToJson(report),
|
|
435
|
+
};
|
|
436
|
+
});
|
|
437
|
+
}
|
|
177
438
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
439
|
+
/**
|
|
440
|
+
* DRY RUN by default: `--write` is never implied, because the alternative is a command whose
|
|
441
|
+
* inspection form writes to a production table. What `--write` does is ENQUEUE — the queue is a
|
|
442
|
+
* job's execution surface, so the sweep runs on the workers already serving the new release
|
|
443
|
+
* rather than inside this process.
|
|
444
|
+
*/
|
|
445
|
+
async function runBackfillPass(
|
|
446
|
+
ctx: CommandContext,
|
|
447
|
+
root: string,
|
|
448
|
+
names: readonly string[] | 'all',
|
|
449
|
+
): Promise<CommandResult> {
|
|
450
|
+
await loadApp(root);
|
|
451
|
+
const environment = resolveEnvironment({ env: ctx.env });
|
|
452
|
+
const write = flagBool(ctx.args, 'write');
|
|
453
|
+
return withJobDriver(root, ctx, async (driver) => {
|
|
454
|
+
const rows = await runBackfills({
|
|
455
|
+
driver,
|
|
456
|
+
names,
|
|
457
|
+
write,
|
|
458
|
+
force: flagBool(ctx.args, 'force'),
|
|
459
|
+
environment,
|
|
460
|
+
appliedMigrations: await readAppliedMigrations(),
|
|
461
|
+
});
|
|
462
|
+
return backfillPassResult(rows, write);
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* A blocked or deduped name is a finding and a non-zero exit, and every OTHER name still ran —
|
|
468
|
+
* that isolation is what stops one wedged cleanup blocking every later one forever.
|
|
469
|
+
*/
|
|
470
|
+
function backfillPassResult(rows: readonly BackfillPlanRow[], write: boolean): CommandResult {
|
|
471
|
+
const findings = rows.flatMap((row) => (row.finding === null ? [] : [row.finding]));
|
|
472
|
+
// Counted per action, never derived from the total: a deduped pass is neither enqueued nor
|
|
473
|
+
// blocked, and `rows.length - enqueued` reported it as blocked while `--json` reported it as
|
|
474
|
+
// deduped. `planToJson` is the same list, so the two renders now add up to the same run.
|
|
475
|
+
const tally = (action: BackfillAction): number =>
|
|
476
|
+
rows.filter((row) => row.action === action).length;
|
|
477
|
+
return {
|
|
478
|
+
ok: findings.length === 0,
|
|
479
|
+
command: 'db',
|
|
480
|
+
summary: write
|
|
481
|
+
? msg('cli.db.backfill.planned', {
|
|
482
|
+
count: rows.length,
|
|
483
|
+
enqueued: tally('enqueued'),
|
|
484
|
+
deduped: tally('deduped'),
|
|
485
|
+
blocked: tally('blocked'),
|
|
486
|
+
})
|
|
487
|
+
: msg('cli.db.backfill.dryRun', { count: rows.length }),
|
|
488
|
+
findings,
|
|
489
|
+
lines: rows.length === 0 ? [] : renderPlanTable(rows).map((line) => ` ${line}`),
|
|
490
|
+
data: planToJson(rows),
|
|
491
|
+
};
|
|
187
492
|
}
|