@ultimat3/cli 1.1.0 → 2.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 +724 -0
- package/README.md +41 -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 +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +87 -17
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- 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 +13 -7
- 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 +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- 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 +186 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +205 -140
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -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 +87 -14
- 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 +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +73 -0
- 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 +202 -18
- 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 +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -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,364 @@
|
|
|
1
|
-
// `x db gen|migrate|reset|studio|branch` — everything that touches the database
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// `x db gen|migrate|reset|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 } from '@ultimat3/db';
|
|
16
|
+
import { BackfillPendingError } from '@ultimat3/jobs';
|
|
17
|
+
import { loadApp } from './app-load';
|
|
9
18
|
import { requireAppRoot } from './app-root';
|
|
19
|
+
import { runBranchCommand } from './cmd-db-branch';
|
|
20
|
+
import { plannedSubcommand } from './cmd-planned';
|
|
10
21
|
import type { CliCommand, CommandContext } from './command';
|
|
22
|
+
import type { BackfillAction, BackfillPlanRow } from './db-backfill';
|
|
23
|
+
import {
|
|
24
|
+
listBackfills,
|
|
25
|
+
pendingReport,
|
|
26
|
+
pendingToJson,
|
|
27
|
+
planToJson,
|
|
28
|
+
readAppliedMigrations,
|
|
29
|
+
renderBackfillTable,
|
|
30
|
+
renderPendingTable,
|
|
31
|
+
renderPlanTable,
|
|
32
|
+
runBackfills,
|
|
33
|
+
} from './db-backfill';
|
|
34
|
+
import { BRANCH_SUBCOMMANDS } from './db-branch';
|
|
35
|
+
import { stepFinding } from './db-finding';
|
|
36
|
+
import { generateAppMigration } from './db-generate';
|
|
11
37
|
import { resolveServices } from './dev-services';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
38
|
+
import {
|
|
39
|
+
BadFlagError,
|
|
40
|
+
CliNotImplementedError,
|
|
41
|
+
MissingSubcommandError,
|
|
42
|
+
UnknownCommandError,
|
|
43
|
+
} from './errors';
|
|
44
|
+
import { withJobDriver } from './jobs-driver';
|
|
45
|
+
import { backfillToJson } from './jobs-json';
|
|
16
46
|
import { msg } from './messages';
|
|
17
47
|
import type { CommandResult, Finding } from './output';
|
|
18
48
|
import { findingFrom } from './output';
|
|
19
|
-
import { flagString } from './parse';
|
|
49
|
+
import { flagBool, flagString } from './parse';
|
|
50
|
+
import { runMigrations } from './serve';
|
|
20
51
|
|
|
21
|
-
export const DB_SUBCOMMANDS = ['gen', 'migrate', 'reset', 'studio', 'branch'] as const;
|
|
52
|
+
export const DB_SUBCOMMANDS = ['gen', 'migrate', 'reset', 'studio', 'branch', 'backfill'] as const;
|
|
22
53
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
54
|
+
export const dbCommand: CliCommand = {
|
|
55
|
+
spec: {
|
|
56
|
+
name: 'db',
|
|
57
|
+
summary: 'gen, migrate, reset, studio, branch, backfill',
|
|
58
|
+
usage:
|
|
59
|
+
'x db gen "add publish_at" | migrate | reset | studio | branch ls | branch create <name> | branch drop <name> | backfill [<name>|--all] [--write] [--force] | backfill --pending | backfill --list [--name n] [--status s] [--limit n]',
|
|
60
|
+
requiresApp: true,
|
|
61
|
+
subcommands: DB_SUBCOMMANDS,
|
|
62
|
+
// Declared from the constant `runBranchCommand` validates against, never a second literal: it
|
|
63
|
+
// is what lets the `errors` step resolve `x db branch ls` — a fix line three shipped errors
|
|
64
|
+
// hand out, which read `ls` as a branch name and cloned a database until 1.2.x.
|
|
65
|
+
subcommandPositionals: { branch: BRANCH_SUBCOMMANDS },
|
|
66
|
+
flags: [
|
|
67
|
+
{ name: 'name', type: 'string', summary: 'migration or branch name, or backfill to filter' },
|
|
68
|
+
{ name: 'list', type: 'boolean', summary: 'backfill: print the x_backfills ledger' },
|
|
69
|
+
{
|
|
70
|
+
name: 'pending',
|
|
71
|
+
type: 'boolean',
|
|
72
|
+
summary: 'backfill: declared minus completed; non-zero exit when anything is unswept',
|
|
73
|
+
},
|
|
74
|
+
{ name: 'all', type: 'boolean', summary: 'backfill: every pending sweep, isolated per name' },
|
|
75
|
+
{ name: 'write', type: 'boolean', summary: 'backfill: enqueue the pass; dry run without it' },
|
|
76
|
+
{
|
|
77
|
+
name: 'force',
|
|
78
|
+
type: 'boolean',
|
|
79
|
+
summary: 'backfill: sweep a name the ledger records as completed, as a NEW ledger row',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'status',
|
|
83
|
+
type: 'string',
|
|
84
|
+
summary: 'backfill: filter by running, completed or failed',
|
|
85
|
+
},
|
|
86
|
+
{ name: 'limit', type: 'string', summary: 'backfill: max ledger rows to return' },
|
|
87
|
+
// Declared because `X_MIGRATION_IRREVERSIBLE`'s own fix line names it. A `fix:` is copied
|
|
88
|
+
// and run verbatim, so a flag the parser refuses would make the error unfollowable.
|
|
89
|
+
{
|
|
90
|
+
name: 'allow-destructive',
|
|
91
|
+
type: 'boolean',
|
|
92
|
+
summary: 'let x db gen emit a drop whose down cannot restore the rows',
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
97
|
+
const root = requireAppRoot('db', ctx.cwd).dir;
|
|
98
|
+
// No default, and no `?? 'migrate'` here either: `gen` writes a migration file and `reset`
|
|
99
|
+
// drops the database, so "whatever the caller left out" is not a safe guess for any of the six.
|
|
100
|
+
// The parser refuses a bare `x db`; this covers a `ParsedArgs` built by hand.
|
|
101
|
+
const sub = ctx.args.subcommand;
|
|
102
|
+
if (sub === undefined)
|
|
103
|
+
throw new MissingSubcommandError({ command: 'db', known: DB_SUBCOMMANDS });
|
|
104
|
+
const argument = ctx.args.positionals[0] ?? flagString(ctx.args, 'name');
|
|
34
105
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
106
|
+
if (sub === 'gen') return runGen(ctx, root, argument ?? 'change');
|
|
107
|
+
if (sub === 'migrate') return runMigrate(ctx, root, msg('cli.db.migrate.applied'));
|
|
108
|
+
if (sub === 'reset') return runReset(ctx, root);
|
|
109
|
+
if (sub === 'studio') throw plannedSubcommand('db', 'studio');
|
|
110
|
+
if (sub === 'backfill') return runBackfill(ctx, root);
|
|
111
|
+
if (sub === 'branch') return runBranchCommand(ctx, root);
|
|
38
112
|
|
|
39
|
-
|
|
40
|
-
|
|
113
|
+
// Never a fall-through. This used to end `return runBranch(ctx, root, argument ?? 'preview')`,
|
|
114
|
+
// so ANY subcommand the parser had not already refused was reinterpreted as a branch NAME and
|
|
115
|
+
// cloned a database out of it. A word this command does not know is a refusal, not a guess.
|
|
116
|
+
throw new UnknownCommandError({
|
|
117
|
+
path: `db ${sub}`,
|
|
118
|
+
known: DB_SUBCOMMANDS,
|
|
119
|
+
// Help, and not the nearest name: `studio` is planned, and `branch`/`backfill` both need a
|
|
120
|
+
// word this refusal does not have — a suggestion that refuses in turn is not a fix.
|
|
121
|
+
suggestion: 'help db',
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
};
|
|
41
125
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
}
|
|
69
|
-
}
|
|
70
|
-
const source = services.db.url.split('/').at(-1) ?? 'postgres';
|
|
71
|
-
const database = branchDatabaseName(source, branch);
|
|
72
|
-
const psql = await ctx.runner(['psql', services.db.url, '-c', branchSql(source, database)], {
|
|
73
|
-
cwd: root,
|
|
74
|
-
});
|
|
75
|
-
if (!psql.ok) {
|
|
126
|
+
/**
|
|
127
|
+
* Source in, files out — no database is opened, so this answers the same in CI and on a laptop
|
|
128
|
+
* with nothing running. A diff that finds nothing writes nothing and still exits 0: "no change" is
|
|
129
|
+
* an answer, and an empty migration would take a ledger row and a checksum forever.
|
|
130
|
+
*/
|
|
131
|
+
async function runGen(ctx: CommandContext, root: string, name: string): Promise<CommandResult> {
|
|
132
|
+
let generated: Awaited<ReturnType<typeof generateAppMigration>>;
|
|
133
|
+
try {
|
|
134
|
+
generated = await generateAppMigration(root, {
|
|
135
|
+
name,
|
|
136
|
+
allowDestructive: flagBool(ctx.args, 'allow-destructive'),
|
|
137
|
+
});
|
|
138
|
+
} catch (error) {
|
|
76
139
|
return {
|
|
77
140
|
ok: false,
|
|
78
141
|
command: 'db',
|
|
79
|
-
summary: msg('cli.
|
|
80
|
-
findings: [
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
142
|
+
summary: msg('cli.db.gen.failed'),
|
|
143
|
+
findings: [stepFinding(error, 'X_DB_GEN_FAILED')],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const migration = generated.migration;
|
|
147
|
+
if (migration === undefined) {
|
|
148
|
+
return {
|
|
149
|
+
ok: generated.findings.length === 0,
|
|
150
|
+
command: 'db',
|
|
151
|
+
summary: msg('cli.db.gen.unchanged'),
|
|
152
|
+
findings: generated.findings,
|
|
153
|
+
data: { migration: null, files: [] },
|
|
87
154
|
};
|
|
88
155
|
}
|
|
89
156
|
return {
|
|
90
157
|
ok: true,
|
|
91
158
|
command: 'db',
|
|
92
|
-
summary: msg('cli.db.
|
|
93
|
-
|
|
159
|
+
summary: msg('cli.db.gen.written', { id: migration.id }),
|
|
160
|
+
lines: generated.files.map((file) => ` ${file}`),
|
|
161
|
+
data: {
|
|
162
|
+
migration: migration.id,
|
|
163
|
+
name: migration.name,
|
|
164
|
+
files: [...generated.files],
|
|
165
|
+
schemaHash: generated.schemaHash ?? null,
|
|
166
|
+
},
|
|
94
167
|
};
|
|
95
168
|
}
|
|
96
169
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
170
|
+
/**
|
|
171
|
+
* The post-migrate report, rendered. Through `driftError` rather than a second literal: the
|
|
172
|
+
* three-line `X_DB_DRIFT` output is pinned by the framework contract, and this command must not be
|
|
173
|
+
* where a copy of it drifts from the one `x verify` prints.
|
|
174
|
+
*/
|
|
175
|
+
export const driftFindings = (report: DriftReport): readonly Finding[] =>
|
|
176
|
+
report.differences.map((difference) => findingFrom(driftError(difference)));
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `runMigrations` is `serve.ts`'s, unchanged and unwrapped: the developer applying a migration and
|
|
180
|
+
* the release-phase container applying it run the same function, over the same file list, through
|
|
181
|
+
* the same ledger, and verify the same post-condition — the live schema against that ledger. A
|
|
182
|
+
* database that migrated cleanly and still disagrees is the failure this command exists to
|
|
183
|
+
* surface, and only a check that opened the connection can see it.
|
|
184
|
+
*
|
|
185
|
+
* The *source* half — an entity edited with no migration generated — is `x verify`'s `drift` step
|
|
186
|
+
* (`checkSourceDrift`) and is deliberately not repeated here: two reporters of one condition is the
|
|
187
|
+
* duplication this package's own rule forbids, and that one needs no database at all.
|
|
188
|
+
*/
|
|
189
|
+
async function runMigrate(
|
|
190
|
+
ctx: CommandContext,
|
|
191
|
+
root: string,
|
|
192
|
+
summary: string,
|
|
193
|
+
): Promise<CommandResult> {
|
|
194
|
+
try {
|
|
195
|
+
const migrated = await runMigrations({ root, env: ctx.env });
|
|
196
|
+
const report = migrated.report;
|
|
197
|
+
return {
|
|
198
|
+
ok: migrated.drift.ok,
|
|
199
|
+
command: 'db',
|
|
200
|
+
summary,
|
|
201
|
+
findings: driftFindings(migrated.drift),
|
|
202
|
+
data: {
|
|
203
|
+
applied: report.applied.map((entry) => entry.id),
|
|
204
|
+
skipped: report.skipped.length,
|
|
205
|
+
appVersion: report.appVersion,
|
|
206
|
+
durationMs: report.durationMs,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
} catch (error) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
command: 'db',
|
|
213
|
+
summary: msg('cli.db.migrate.failed'),
|
|
214
|
+
findings: [stepFinding(error, 'X_DB_MIGRATE_FAILED')],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
110
218
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
command: 'db',
|
|
128
|
-
summary: `migration ${name} generated`,
|
|
129
|
-
data: { migration: name, schemaHash: hash },
|
|
130
|
-
};
|
|
131
|
-
}
|
|
219
|
+
/**
|
|
220
|
+
* Embedded only: `rm -rf` against a database this process does not own is not a reset, it is an
|
|
221
|
+
* outage. The data directory goes before the migrator starts, so the run that follows is a fresh
|
|
222
|
+
* database with an empty ledger rather than a re-apply over a live one.
|
|
223
|
+
*/
|
|
224
|
+
async function runReset(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
225
|
+
const services = resolveServices(root, ctx.env);
|
|
226
|
+
if (services.db.mode === 'external') {
|
|
227
|
+
throw new CliNotImplementedError({
|
|
228
|
+
feature: 'x db reset against an external Postgres',
|
|
229
|
+
fix: 'drop and recreate the database yourself, then run: x db migrate',
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
await rm(join(services.stateDir, 'pgdata'), { recursive: true, force: true });
|
|
233
|
+
return runMigrate(ctx, root, msg('cli.db.reset.done'));
|
|
234
|
+
}
|
|
132
235
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
236
|
+
/**
|
|
237
|
+
* Four shapes, one subcommand: `--list` reads the `x_backfills` ledger, `--pending` diffs it
|
|
238
|
+
* against what the app DECLARED, and `<name>` / `--all` gate a pass and put it on the queue. A
|
|
239
|
+
* bare `x db backfill` is still refused rather than defaulted — the four answer four different
|
|
240
|
+
* questions, and picking one for the operator is the ambiguity axiom 1 exists to refuse.
|
|
241
|
+
*
|
|
242
|
+
* An empty ledger is `ok: true`. "Nothing has swept this database yet" is an answer to the
|
|
243
|
+
* question asked, and a command that failed over it would be unrunnable on a fresh app.
|
|
244
|
+
*/
|
|
245
|
+
async function runBackfill(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
246
|
+
if (flagBool(ctx.args, 'list')) return runBackfillList(ctx, root);
|
|
247
|
+
const all = flagBool(ctx.args, 'all');
|
|
248
|
+
const name = ctx.args.positionals[0] ?? flagString(ctx.args, 'name');
|
|
249
|
+
if (flagBool(ctx.args, 'pending')) return runBackfillPending(ctx, root);
|
|
250
|
+
if (all) return runBackfillPass(ctx, root, 'all');
|
|
251
|
+
if (name !== undefined) return runBackfillPass(ctx, root, [name]);
|
|
252
|
+
throw new BadFlagError({
|
|
253
|
+
flag: 'list',
|
|
254
|
+
command: 'db',
|
|
255
|
+
reason:
|
|
256
|
+
'x db backfill needs a shape: --list (the ledger), --pending (declared minus completed), <name> or --all (run one, or every pending one)',
|
|
257
|
+
fix: 'x db backfill --pending --json',
|
|
258
|
+
});
|
|
259
|
+
}
|
|
144
260
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
261
|
+
async function runBackfillList(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
262
|
+
return withJobDriver(root, ctx, async (driver) => {
|
|
263
|
+
const rows = await listBackfills(driver, {
|
|
264
|
+
name: flagString(ctx.args, 'name'),
|
|
265
|
+
status: flagString(ctx.args, 'status'),
|
|
266
|
+
limit: flagString(ctx.args, 'limit'),
|
|
267
|
+
});
|
|
268
|
+
return {
|
|
269
|
+
ok: true,
|
|
270
|
+
command: 'db',
|
|
271
|
+
summary:
|
|
272
|
+
rows.length === 0
|
|
273
|
+
? msg('cli.db.backfill.empty')
|
|
274
|
+
: msg('cli.db.backfill.listed', { count: rows.length }),
|
|
275
|
+
lines: rows.length === 0 ? [] : renderBackfillTable(rows).map((line) => ` ${line}`),
|
|
276
|
+
data: rows.map(backfillToJson),
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
}
|
|
163
280
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
281
|
+
/**
|
|
282
|
+
* The alarm the framework did not have. Non-zero when anything is unswept, so a cron or a deploy
|
|
283
|
+
* check can read the exit code — a `--json` nobody has to parse to know something is wrong.
|
|
284
|
+
* `loadApp` first: importing the app's modules IS the declaration, and a diff run without it
|
|
285
|
+
* would report a clean database against an empty declaration list.
|
|
286
|
+
*/
|
|
287
|
+
async function runBackfillPending(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
288
|
+
await loadApp(root);
|
|
289
|
+
const environment = resolveEnvironment({ env: ctx.env });
|
|
290
|
+
return withJobDriver(root, ctx, async (driver) => {
|
|
291
|
+
const report = await pendingReport(driver, environment);
|
|
292
|
+
return {
|
|
293
|
+
ok: report.pending.length === 0,
|
|
294
|
+
command: 'db',
|
|
295
|
+
summary:
|
|
296
|
+
report.pending.length === 0
|
|
297
|
+
? msg('cli.db.backfill.swept', { declared: report.rows.length })
|
|
298
|
+
: msg('cli.db.backfill.pending', {
|
|
299
|
+
count: report.pending.length,
|
|
300
|
+
declared: report.rows.length,
|
|
301
|
+
}),
|
|
302
|
+
findings: report.pending.map((row) =>
|
|
303
|
+
findingFrom(new BackfillPendingError({ backfill: row.name, environment })),
|
|
304
|
+
),
|
|
305
|
+
lines: report.rows.length === 0 ? [] : renderPendingTable(report).map((line) => ` ${line}`),
|
|
306
|
+
data: pendingToJson(report),
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
}
|
|
173
310
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
311
|
+
/**
|
|
312
|
+
* DRY RUN by default: `--write` is never implied, because the alternative is a command whose
|
|
313
|
+
* inspection form writes to a production table. What `--write` does is ENQUEUE — the queue is a
|
|
314
|
+
* job's execution surface, so the sweep runs on the workers already serving the new release
|
|
315
|
+
* rather than inside this process.
|
|
316
|
+
*/
|
|
317
|
+
async function runBackfillPass(
|
|
318
|
+
ctx: CommandContext,
|
|
319
|
+
root: string,
|
|
320
|
+
names: readonly string[] | 'all',
|
|
321
|
+
): Promise<CommandResult> {
|
|
322
|
+
await loadApp(root);
|
|
323
|
+
const environment = resolveEnvironment({ env: ctx.env });
|
|
324
|
+
const write = flagBool(ctx.args, 'write');
|
|
325
|
+
return withJobDriver(root, ctx, async (driver) => {
|
|
326
|
+
const rows = await runBackfills({
|
|
327
|
+
driver,
|
|
328
|
+
names,
|
|
329
|
+
write,
|
|
330
|
+
force: flagBool(ctx.args, 'force'),
|
|
331
|
+
environment,
|
|
332
|
+
appliedMigrations: await readAppliedMigrations(),
|
|
333
|
+
});
|
|
334
|
+
return backfillPassResult(rows, write);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
177
337
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
338
|
+
/**
|
|
339
|
+
* A blocked or deduped name is a finding and a non-zero exit, and every OTHER name still ran —
|
|
340
|
+
* that isolation is what stops one wedged cleanup blocking every later one forever.
|
|
341
|
+
*/
|
|
342
|
+
function backfillPassResult(rows: readonly BackfillPlanRow[], write: boolean): CommandResult {
|
|
343
|
+
const findings = rows.flatMap((row) => (row.finding === null ? [] : [row.finding]));
|
|
344
|
+
// Counted per action, never derived from the total: a deduped pass is neither enqueued nor
|
|
345
|
+
// blocked, and `rows.length - enqueued` reported it as blocked while `--json` reported it as
|
|
346
|
+
// deduped. `planToJson` is the same list, so the two renders now add up to the same run.
|
|
347
|
+
const tally = (action: BackfillAction): number =>
|
|
348
|
+
rows.filter((row) => row.action === action).length;
|
|
349
|
+
return {
|
|
350
|
+
ok: findings.length === 0,
|
|
351
|
+
command: 'db',
|
|
352
|
+
summary: write
|
|
353
|
+
? msg('cli.db.backfill.planned', {
|
|
354
|
+
count: rows.length,
|
|
355
|
+
enqueued: tally('enqueued'),
|
|
356
|
+
deduped: tally('deduped'),
|
|
357
|
+
blocked: tally('blocked'),
|
|
358
|
+
})
|
|
359
|
+
: msg('cli.db.backfill.dryRun', { count: rows.length }),
|
|
360
|
+
findings,
|
|
361
|
+
lines: rows.length === 0 ? [] : renderPlanTable(rows).map((line) => ` ${line}`),
|
|
362
|
+
data: planToJson(rows),
|
|
363
|
+
};
|
|
187
364
|
}
|
package/src/cmd-deploy.ts
CHANGED
|
@@ -6,12 +6,35 @@ import { existsSync } from 'node:fs';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { requireAppRoot } from './app-root';
|
|
8
8
|
import type { CliCommand, CommandContext } from './command';
|
|
9
|
-
import { CliNotImplementedError } from './errors';
|
|
9
|
+
import { BadFlagError, CliNotImplementedError } from './errors';
|
|
10
10
|
import { msg } from './messages';
|
|
11
11
|
import type { CommandResult, JsonValue } from './output';
|
|
12
12
|
import { flagBool, flagString } from './parse';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Ordered, and the order is the design. `migrate` GATES — it runs to completion before anything
|
|
16
|
+
* serves, and a schema difference after it fails the deploy. `backfill` is last and TRIGGERS: a
|
|
17
|
+
* data sweep put inside a release gate holds the deploy open while a slow UPDATE runs against a
|
|
18
|
+
* database still serving the PREVIOUS release, so it runs after the new pods are up and the
|
|
19
|
+
* workers already draining the queue are what perform it. That is also why it is not wired into
|
|
20
|
+
* `runMigrations()` and never will be.
|
|
21
|
+
*
|
|
22
|
+
* `backfill` is a one-shot like `migrate`, so it takes the same `run --rm` shape; the compose
|
|
23
|
+
* service behind it runs `x db backfill --all --write --json` rather than a `ROLE`, because
|
|
24
|
+
* `@ultimat3/core`'s `ROLES` is a closed list of process shapes and a sweep trigger is a command.
|
|
25
|
+
*
|
|
26
|
+
* ORDER HERE IS NECESSARY AND NOT SUFFICIENT. `docker compose up -d` returns when a container has
|
|
27
|
+
* STARTED, not when the application inside it is serving, so this list alone puts the trigger after
|
|
28
|
+
* the serving roles were asked to start and not after they are ready. The barrier that makes
|
|
29
|
+
* "after" true is declarative and belongs to the compose file, not to this plan: the `backfill`
|
|
30
|
+
* service needs `depends_on: { web: { condition: service_healthy } }`, which `docker compose run`
|
|
31
|
+
* honours. Both compose definitions — `docker/docker-compose.prod.yml` and the one
|
|
32
|
+
* `templates/scaffold-container.ts` scaffolds — still owe that service and that condition.
|
|
33
|
+
*/
|
|
34
|
+
export const DEPLOY_ROLES = ['migrate', 'web', 'sync', 'worker', 'scheduler', 'backfill'] as const;
|
|
35
|
+
|
|
36
|
+
/** The roles that run to completion and exit, as against the ones that stay up serving. */
|
|
37
|
+
const ONE_SHOT_ROLES: readonly string[] = ['migrate', 'backfill'];
|
|
15
38
|
|
|
16
39
|
export interface DeployPlan {
|
|
17
40
|
readonly image: string;
|
|
@@ -19,8 +42,39 @@ export interface DeployPlan {
|
|
|
19
42
|
readonly steps: readonly { readonly role: string; readonly command: readonly string[] }[];
|
|
20
43
|
}
|
|
21
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The chart declares `image` as a MAP — `repository`, `tag`, `pullPolicy` — and `_helpers.tpl`
|
|
47
|
+
* renders `printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag)`.
|
|
48
|
+
* `--set image=<ref>` replaces that map with a string, so every workload template fails on
|
|
49
|
+
* `.repository` and the deploy that was asked to ship one image ships nothing. The reference is
|
|
50
|
+
* split into the two keys the chart actually reads; a reference with no tag sets only the
|
|
51
|
+
* repository, which leaves the chart's own `default .Chart.AppVersion` in force.
|
|
52
|
+
*
|
|
53
|
+
* The last `:` after the last `/`, because a registry may carry a port: `localhost:5000/app` is a
|
|
54
|
+
* repository with no tag and `localhost:5000/app:1.2.3` is the same repository with one.
|
|
55
|
+
*/
|
|
56
|
+
export function helmImageOverrides(image: string): readonly string[] {
|
|
57
|
+
const colon = image.lastIndexOf(':');
|
|
58
|
+
const tag = colon > image.lastIndexOf('/') ? image.slice(colon + 1) : '';
|
|
59
|
+
const repository = tag === '' ? image : image.slice(0, colon);
|
|
60
|
+
return tag === ''
|
|
61
|
+
? ['--set', `image.repository=${repository}`]
|
|
62
|
+
: ['--set', `image.repository=${repository}`, '--set', `image.tag=${tag}`];
|
|
63
|
+
}
|
|
64
|
+
|
|
22
65
|
export function planDeploy(image: string, method: 'compose' | 'helm', root: string): DeployPlan {
|
|
23
66
|
if (method === 'helm') {
|
|
67
|
+
// `repo@sha256:…` is a reference this chart cannot express: it renders `repository:tag` and
|
|
68
|
+
// has no digest branch, so passing one through would deploy `repo@sha256:…:<appVersion>` —
|
|
69
|
+
// a tag no registry has. Refused here rather than by a `helm upgrade` failing halfway.
|
|
70
|
+
if (image.lastIndexOf('@') > image.lastIndexOf('/')) {
|
|
71
|
+
throw new BadFlagError({
|
|
72
|
+
flag: 'image',
|
|
73
|
+
command: 'deploy',
|
|
74
|
+
reason: `"${image}" pins a digest, and docker/helm renders repository:tag with no digest branch`,
|
|
75
|
+
fix: `x deploy --method helm --image ${image.slice(0, image.lastIndexOf('@'))}:<tag> --json`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
24
78
|
return {
|
|
25
79
|
image,
|
|
26
80
|
steps: [
|
|
@@ -32,8 +86,7 @@ export function planDeploy(image: string, method: 'compose' | 'helm', root: stri
|
|
|
32
86
|
'--install',
|
|
33
87
|
'app',
|
|
34
88
|
join(root, 'docker', 'helm'),
|
|
35
|
-
|
|
36
|
-
`image=${image}`,
|
|
89
|
+
...helmImageOverrides(image),
|
|
37
90
|
],
|
|
38
91
|
},
|
|
39
92
|
],
|
|
@@ -48,8 +101,8 @@ export function planDeploy(image: string, method: 'compose' | 'helm', root: stri
|
|
|
48
101
|
'compose',
|
|
49
102
|
'-f',
|
|
50
103
|
join(root, 'docker', 'docker-compose.prod.yml'),
|
|
51
|
-
role
|
|
52
|
-
role
|
|
104
|
+
ONE_SHOT_ROLES.includes(role) ? 'run' : 'up',
|
|
105
|
+
ONE_SHOT_ROLES.includes(role) ? '--rm' : '-d',
|
|
53
106
|
role,
|
|
54
107
|
],
|
|
55
108
|
})),
|