@ultimat3/cli 2.0.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 +40 -3
- package/README.md +1 -0
- package/package.json +24 -24
- package/src/budgets.ts +23 -3
- package/src/cmd-db-branch.ts +6 -2
- package/src/cmd-db.ts +138 -10
- package/src/cmd-dev.ts +9 -2
- package/src/cmd-doctor.ts +16 -7
- package/src/cmd-new.ts +1 -1
- package/src/cmd-test.ts +14 -3
- package/src/cmd-verify.ts +25 -7
- package/src/db-branch.ts +18 -0
- package/src/db-generate.ts +38 -6
- package/src/db-seed.ts +294 -0
- package/src/dev-assets.ts +22 -3
- package/src/dev-roles.ts +5 -3
- package/src/dev-storage.ts +6 -4
- package/src/dev-traces.ts +26 -4
- package/src/drift.ts +41 -1
- package/src/error-catalog.ts +1 -0
- package/src/error-codes.ts +6 -0
- package/src/exec.ts +42 -8
- package/src/flag-number.ts +11 -0
- package/src/index.ts +11 -4
- package/src/mcp-errors.ts +8 -0
- package/src/messages.ts +12 -0
- package/src/metrics-endpoint.ts +60 -13
- package/src/serve.ts +15 -3
- package/src/shell-quote.ts +15 -0
- package/src/test-shards.ts +1 -10
- package/src/test-workers.ts +4 -1
- package/src/ts-scan.ts +13 -2
- package/src/tsconfig-references.ts +27 -2
package/CLAUDE.md
CHANGED
|
@@ -7,12 +7,13 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
|
|
|
7
7
|
| Entry | `src/bin.ts` (`#!/usr/bin/env bun`) — argv, stdout, exit code only |
|
|
8
8
|
| stdout | `write-line.ts`'s `writeLine` — synchronous fd 1, never `process.stdout.write`, which truncates at the 64KB pipe buffer when `process.exit` follows. Exported, because `create-ultimate`'s entry point needs the same one |
|
|
9
9
|
| Numeric flags | `flag-number.ts` — one reader for `--port` / `--workers` / `--shard`. A bare `Number.parseInt` accepts `4abc` and answers `NaN`, which turned three checks into ones that cannot fail |
|
|
10
|
+
| Shell quoting | `shell-quote.ts`'s `quoteArg` — every value the CLI pastes into a `fix:` or a reproduce line, `exec.ts`'s missing-program refusal and `test-shards.ts`'s reproduce command both. A name holding a space or a `;` interpolated bare is an instruction that runs something else |
|
|
10
11
|
| Missing positionals | `MissingPositionalError`, never `BadFlagError` (names a flag that does not exist) and never `UnknownCommandError` (says a known command form is not one). Its `example` is a REAL invocation — `x g route <name>` in a shell is a redirect |
|
|
11
12
|
| Bare subcommands | `CommandSpec.defaultSubcommand`, **declared**. The parser answered `subcommands[0]` until 1.2.0, so `x db` ran `gen` — the migration GENERATOR — because it sorted first, and `x mcp` started a server. A command with no defensible default declares none and `MissingSubcommandError` refuses the bare form; `parse.test.ts` pins the set at exactly `db` and `mcp`. Its fix is `x help <command>`, never `x <command> --help`: the subcommand is resolved *after* the flag loop, so the latter throws the same error again — a fix line that reproduced its own failure |
|
|
12
13
|
| I/O | only `dispatch.ts` renders or exits; commands return `CommandResult` |
|
|
13
14
|
| Staying up | a command still listening when `run` resolves returns `hold` (`hold.ts`), or `bin.ts` exits out from under it |
|
|
14
15
|
| `--json` | every command, no exceptions — same data as the human render |
|
|
15
|
-
| Errors | codes + titles in `src/error-codes.ts`, classes in `src/errors.ts`, subclass `UltimateError`, never a bare `Error` |
|
|
16
|
+
| Errors | codes + titles in `src/error-codes.ts`, classes in `src/errors.ts`, subclass `UltimateError`, never a bare `Error`. A class may sit beside its one thrower when `errors.ts` has no room under the 500-line ceiling — `db-seed.ts` and `metrics-endpoint.ts` do |
|
|
16
17
|
| Subprocesses | only through `exec.ts`, so a test can inject a fake `Runner` |
|
|
17
18
|
| Templates | `templates/*.ts` return strings; no fixture files on disk |
|
|
18
19
|
| Strings | rendered output through `messages.ts`, missing key renders `⟦key⟧` — see below for what is *not* rendered output |
|
|
@@ -285,6 +286,7 @@ regex and `+` is a quantifier — `n1` is what actually selects these tests.
|
|
|
285
286
|
| `drift.ts` | `checkSourceDrift`: the `.hash` sidecar `x verify`'s `drift` step compares, no database needed |
|
|
286
287
|
| `db-destructive.ts` | `checkDestructiveMigrations`: the same step's second half — every committed `up` that drops, truncates or retypes must carry `-- destructive: true` |
|
|
287
288
|
| `db-backfill.ts` | `x db backfill --list`: the flag parsing, the ledger read and the table |
|
|
289
|
+
| `db-seed.ts` | `x db seed`, everything except the argv: `SEED_GLOBS` (where a seed is declared), `discoverSeeds`, `parseSeedTierFlag`, `selectSeeds` (which seeds this invocation runs, and its two refusals — `X_DECLARATION_UNKNOWN` and `X_SEED_ENVIRONMENT`), `runSeeds` (one transaction **per seed**, never one around the run) and the two renderers. The `db-backfill.ts` split repeated: a driver plus plain strings in, plain rows out, so every rule is testable with no `ParsedArgs` and no boot. Which tiers an environment takes is `@ultimat3/entity`'s `seedTiersFor` — two copies of "may this seed run" would be two answers |
|
|
288
290
|
|
|
289
291
|
`jobs-driver.ts` is the ONE place a CLI command gets hold of the app's queue — `withJobDriver`,
|
|
290
292
|
which `x jobs` and `x db backfill` both call. It reuses an ambient `jobDriver()` when a process
|
|
@@ -399,6 +401,29 @@ with nothing running, and against a database three migrations behind. An app who
|
|
|
399
401
|
load generates **nothing**: a short registry is indistinguishable from deleted entities, and the
|
|
400
402
|
diff would be a DROP nobody asked for.
|
|
401
403
|
|
|
404
|
+
**An empty diff re-records the `.hash` sidecar, and that is what makes `X_DB_DRIFT` followable.**
|
|
405
|
+
The hash `checkSourceDrift` compares covers every non-test file under `packages/db/src` — a seed, a
|
|
406
|
+
helper, a decorator — not only the ones that imply DDL, and narrowing that glob would trade a loud
|
|
407
|
+
error for a silent gap in the one check that catches "entities changed and no migration was
|
|
408
|
+
generated". So detection stays broad and the REMEDY carries the weight: `x db gen "describe the
|
|
409
|
+
change"` — the exact `fix:` the error hands out — records the current hash against the newest
|
|
410
|
+
migration when the diff is empty, instead of writing nothing and leaving the gate red forever with
|
|
411
|
+
hand-editing a generated file as the only way out. `GeneratedFiles.outcome` is the four things a run
|
|
412
|
+
can be — `generated`, `hash-recorded`, `unchanged`, `blocked` — and `runGen` projects it onto
|
|
413
|
+
`--json` on **every** branch: reporting `hash-recorded` as `generated` would name a migration nobody
|
|
414
|
+
can apply, and reporting it as `unchanged` would hide a file this command wrote. That second one
|
|
415
|
+
shipped: the no-migration branch hardcoded `data: { migration: null, files: [] }`, so the run that
|
|
416
|
+
wrote the sidecar reported writing nothing to the machine reading the output.
|
|
417
|
+
|
|
418
|
+
Nothing is masked, and the branch proves it rather than promising it: `loadApp` reported no findings
|
|
419
|
+
(the registry is whole, never short), `declaredSchema` returned a real snapshot (`X_MIGRATION_SNAPSHOT_MISSING`
|
|
420
|
+
otherwise), and the emptiness is `generateMigration`'s own verdict — the same call the written path
|
|
421
|
+
takes. A migration with no migration id to record against writes nothing, which is the
|
|
422
|
+
`x new --no-example` case: an entity against zero migrations is `create table` for all of it and
|
|
423
|
+
never an empty diff. `reconcileSchemaHash` also declines to write when an OLDER migration already
|
|
424
|
+
recorded the hash, because `checkSourceDrift` already answers clean there and restamping the newest
|
|
425
|
+
sidecar would claim it produced a schema it did not — one predicate, `isRecorded`, read by both.
|
|
426
|
+
|
|
402
427
|
One migration is one file, split by a lone `-- down` line. `<id>.down.sql` is a pre-1.2.0
|
|
403
428
|
hand-written layout and `readMigrations` skips it — read as a migration it sorts next to its own
|
|
404
429
|
`up` and drops every table the pair exists to reverse.
|
|
@@ -488,8 +513,10 @@ session-scoped and the grant dies when the connection returns to the pool, so ev
|
|
|
488
513
|
itself as leader and a rolling update double-fires every task.
|
|
489
514
|
|
|
490
515
|
The relay runs on `worker` and only `worker` — the role that exists wherever jobs run at all.
|
|
491
|
-
Duplicating it is safe
|
|
492
|
-
|
|
516
|
+
Duplicating it is safe — the claim is a **lease** taken in the statement that locks the row
|
|
517
|
+
(`@ultimat3/jobs`' `outbox-pg.ts`, fenced on `claimed_by`), so two relays never hold one batch —
|
|
518
|
+
but pointless. The idempotency key is not the reason and never was: its conflict target is a
|
|
519
|
+
partial index over live states, so it collapses a repeat only while the first job is still live.
|
|
493
520
|
|
|
494
521
|
`SQL_IDEMPOTENCY_TABLE` is applied beside `SQL_JOBS_TABLE`, and the store is installed by the boot
|
|
495
522
|
rather than by the app, even though `@ultimat3/action` documents
|
|
@@ -581,6 +608,16 @@ route: a tenant-scoped key takes `AUTHORIZED_OBJECT_CACHE` (`private, max-age=0`
|
|
|
581
608
|
`authorization`/`cookie`), and only a key no tenant owns keeps `immutable`. A genuinely public image
|
|
582
609
|
belongs under `apps/web/site/`, which is a static asset and never touches that disk.
|
|
583
610
|
|
|
611
|
+
**A variant is CACHED only at a width the framework can mint.** The cache key is built entirely
|
|
612
|
+
from caller-supplied query values, so `?w=1`, `?w=2`, … each wrote a new object to the app's only
|
|
613
|
+
disk, on a route every signed-in tenant may reach for their own keys. `@ultimat3/seo`'s
|
|
614
|
+
`MAX_IMAGE_WIDTH` (8192) bounds that and does not close it. `isMintableWidth` is the bound:
|
|
615
|
+
`DEFAULT_WIDTHS` **plus the source's own intrinsic width**, which is exactly the set `usableWidths`
|
|
616
|
+
puts in a `srcset` — the constant alone would refuse the widest entry of any image whose intrinsic
|
|
617
|
+
width is not one of the eight. Anything outside it is still served; only the `put` is refused, so
|
|
618
|
+
no caller gains a new 4xx. `?q=` is deliberately still unbounded here — the closed set for quality
|
|
619
|
+
is `@ultimat3/seo`'s to declare, not this file's.
|
|
620
|
+
|
|
584
621
|
`ICON_SOURCE` lives here, not in `cmd-doctor.ts`, because this is the module that reads it: the
|
|
585
622
|
diagnostic checks what `x dev` serves, so one constant cannot pass the check and serve nothing.
|
|
586
623
|
It is a **PNG** — core decodes PNG and JPEG only, and the SVG this used to name could never
|
package/README.md
CHANGED
|
@@ -82,6 +82,7 @@ is held to the same error contract shipped source is (`X_GUARD_INVALID`, `X_GUAR
|
|
|
82
82
|
| `dispatch.ts` | parse → run → render → exit; the only I/O boundary |
|
|
83
83
|
| `parse.ts` | flags, subcommands, `--json`, `--help`, suggestions |
|
|
84
84
|
| `flag-number.ts` | the one integer-flag reader — `--port`, `--workers`, `--shard` |
|
|
85
|
+
| `shell-quote.ts` | the one POSIX quoter for a value pasted into a `fix:` or a reproduce line |
|
|
85
86
|
| `output.ts` | one data shape, two renderers, the 3-line error format |
|
|
86
87
|
| `registry.ts` | the one command list |
|
|
87
88
|
| `generate-kinds.ts` | which generators exist, and how a command line names one |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -35,28 +35,28 @@
|
|
|
35
35
|
"dev": "bun run src/bin.ts dev"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ultimat3/action": "
|
|
39
|
-
"@ultimat3/admin": "
|
|
40
|
-
"@ultimat3/ai": "
|
|
41
|
-
"@ultimat3/cache": "
|
|
42
|
-
"@ultimat3/core": "
|
|
43
|
-
"@ultimat3/db": "
|
|
44
|
-
"@ultimat3/entity": "
|
|
45
|
-
"@ultimat3/http": "
|
|
46
|
-
"@ultimat3/i18n": "
|
|
47
|
-
"@ultimat3/jobs": "
|
|
48
|
-
"@ultimat3/mail": "
|
|
49
|
-
"@ultimat3/manifest": "
|
|
50
|
-
"@ultimat3/mcp": "
|
|
51
|
-
"@ultimat3/policy": "
|
|
52
|
-
"@ultimat3/pwa": "
|
|
53
|
-
"@ultimat3/query": "
|
|
54
|
-
"@ultimat3/realtime": "
|
|
55
|
-
"@ultimat3/render": "
|
|
56
|
-
"@ultimat3/schema": "
|
|
57
|
-
"@ultimat3/seo": "
|
|
58
|
-
"@ultimat3/storage": "
|
|
59
|
-
"@ultimat3/testing": "
|
|
60
|
-
"@ultimat3/time": "
|
|
38
|
+
"@ultimat3/action": "3.0.0",
|
|
39
|
+
"@ultimat3/admin": "3.0.0",
|
|
40
|
+
"@ultimat3/ai": "3.0.0",
|
|
41
|
+
"@ultimat3/cache": "3.0.0",
|
|
42
|
+
"@ultimat3/core": "3.0.0",
|
|
43
|
+
"@ultimat3/db": "3.0.0",
|
|
44
|
+
"@ultimat3/entity": "3.0.0",
|
|
45
|
+
"@ultimat3/http": "3.0.0",
|
|
46
|
+
"@ultimat3/i18n": "3.0.0",
|
|
47
|
+
"@ultimat3/jobs": "3.0.0",
|
|
48
|
+
"@ultimat3/mail": "3.0.0",
|
|
49
|
+
"@ultimat3/manifest": "3.0.0",
|
|
50
|
+
"@ultimat3/mcp": "3.0.0",
|
|
51
|
+
"@ultimat3/policy": "3.0.0",
|
|
52
|
+
"@ultimat3/pwa": "3.0.0",
|
|
53
|
+
"@ultimat3/query": "3.0.0",
|
|
54
|
+
"@ultimat3/realtime": "3.0.0",
|
|
55
|
+
"@ultimat3/render": "3.0.0",
|
|
56
|
+
"@ultimat3/schema": "3.0.0",
|
|
57
|
+
"@ultimat3/seo": "3.0.0",
|
|
58
|
+
"@ultimat3/storage": "3.0.0",
|
|
59
|
+
"@ultimat3/testing": "3.0.0",
|
|
60
|
+
"@ultimat3/time": "3.0.0"
|
|
61
61
|
}
|
|
62
62
|
}
|
package/src/budgets.ts
CHANGED
|
@@ -123,6 +123,23 @@ export async function readBuildStats(root: string): Promise<BuildStats | undefin
|
|
|
123
123
|
|
|
124
124
|
const SCRIPT_TAG = /<script(?<attrs>[^>]*)>(?<body>[\s\S]*?)<\/script>/g;
|
|
125
125
|
const SRC_ATTR = /\ssrc="(?<src>[^"]*)"/;
|
|
126
|
+
const TYPE_ATTR = /\stype="(?<type>[^"]*)"/;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `application/ld+json`, `application/json`, any `…+json`: the body is data, not code — the rule
|
|
130
|
+
* `@ultimat3/render`'s `head.ts` already states, restated because its `carriesJson` reads a
|
|
131
|
+
* `HeadTag` and is not exported, and this side has an attribute string off the emitted document.
|
|
132
|
+
* Without it a page shipping only `meta.ld` structured data and island props measured 8kb of JS
|
|
133
|
+
* and failed a 2kb budget with a `fix:` naming an import chain that does not exist.
|
|
134
|
+
*/
|
|
135
|
+
const carriesJson = (attrs: string): boolean => {
|
|
136
|
+
// Everything from the first `;` is a MIME PARAMETER and not the type: a real document writes
|
|
137
|
+
// `type="application/ld+json; charset=utf-8"`, which does not END with `json`, so the suffix
|
|
138
|
+
// test alone charged an SEO structured-data block as executable JavaScript all over again.
|
|
139
|
+
const [type = ''] = (TYPE_ATTR.exec(attrs)?.groups?.['type'] ?? '').split(';');
|
|
140
|
+
return type.trim().toLowerCase().endsWith('json');
|
|
141
|
+
};
|
|
142
|
+
|
|
126
143
|
/**
|
|
127
144
|
* An island's chunk is reached by `import()` from inside the hydration runtime, so it never appears
|
|
128
145
|
* as a `<script src>` — and a document weighed by script tags alone was charged for the runtime and
|
|
@@ -144,8 +161,9 @@ export interface MeasuredJs {
|
|
|
144
161
|
}
|
|
145
162
|
|
|
146
163
|
/**
|
|
147
|
-
* What a rendered document actually makes the browser execute: the bytes of every inline script
|
|
148
|
-
* the size of every file a `src` points at, and the size of every island
|
|
164
|
+
* What a rendered document actually makes the browser execute: the bytes of every inline script
|
|
165
|
+
* the parser will run, the size of every file a `src` points at, and the size of every island
|
|
166
|
+
* chunk it boots. A JSON-typed script is skipped — it is data the parser never runs. Measured
|
|
149
167
|
* from the emitted HTML rather than from the declared graph, because the graph is what a route
|
|
150
168
|
* *says* it ships and this gate exists to catch the case where those two disagree.
|
|
151
169
|
*/
|
|
@@ -162,7 +180,9 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
162
180
|
};
|
|
163
181
|
|
|
164
182
|
for (const match of html.matchAll(SCRIPT_TAG)) {
|
|
165
|
-
const
|
|
183
|
+
const attrs = match.groups?.['attrs'] ?? '';
|
|
184
|
+
if (carriesJson(attrs)) continue;
|
|
185
|
+
const src = SRC_ATTR.exec(attrs)?.groups?.['src'];
|
|
166
186
|
if (src === undefined) {
|
|
167
187
|
jsBytes += Buffer.byteLength(match.groups?.['body'] ?? '', 'utf8');
|
|
168
188
|
continue;
|
package/src/cmd-db-branch.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
branchDatabaseName,
|
|
12
12
|
createExternalBranch,
|
|
13
13
|
createPgliteBranch,
|
|
14
|
+
databaseNameOf,
|
|
14
15
|
dropExternalBranch,
|
|
15
16
|
dropPgliteBranch,
|
|
16
17
|
isBranchName,
|
|
@@ -27,6 +28,7 @@ import { MissingPositionalError, UnknownCommandError } from './errors';
|
|
|
27
28
|
import { msg } from './messages';
|
|
28
29
|
import type { CommandResult, Finding } from './output';
|
|
29
30
|
import { flagString, nearest } from './parse';
|
|
31
|
+
import { portFromEnv } from './serve';
|
|
30
32
|
import { renderTable } from './table';
|
|
31
33
|
|
|
32
34
|
/**
|
|
@@ -145,7 +147,9 @@ async function runCreate(
|
|
|
145
147
|
services: DevServices,
|
|
146
148
|
name: string,
|
|
147
149
|
): Promise<CommandResult> {
|
|
148
|
-
|
|
150
|
+
// `portFromEnv`, never a bare `Number.parseInt`: the latter reads `PORT=abc` as `NaN` and put
|
|
151
|
+
// `http://feat.localhost:NaN` in `data.preview` — a machine-readable field naming no port.
|
|
152
|
+
const port = portFromEnv(ctx.env);
|
|
149
153
|
let branch: BranchRow;
|
|
150
154
|
try {
|
|
151
155
|
branch =
|
|
@@ -205,7 +209,7 @@ function notABranch(services: DevServices, name: string): CommandResult {
|
|
|
205
209
|
const target =
|
|
206
210
|
services.db.mode === 'embedded'
|
|
207
211
|
? pgliteBranchLocation(services.db.url, name)
|
|
208
|
-
: branchDatabaseName(services.db.url
|
|
212
|
+
: branchDatabaseName(databaseNameOf(services.db.url), name);
|
|
209
213
|
return failure(msg('cli.db.branch.failed'), {
|
|
210
214
|
code: 'X_DB_BRANCH_FAILED',
|
|
211
215
|
cause: `"${name}" is not a branch of this database, so nothing was dropped (it would be ${target})`,
|
package/src/cmd-db.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `x db gen|migrate|reset|studio|branch|backfill` — everything that touches the database. One
|
|
1
|
+
// `x db gen|migrate|reset|seed|studio|branch|backfill` — everything that touches the database. One
|
|
2
2
|
// subcommand per line and no fall-through: a word this file does not know is refused, never
|
|
3
3
|
// re-read as an argument to the last branch. `branch` itself is `cmd-db-branch.ts`.
|
|
4
4
|
//
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
import { rm } from 'node:fs/promises';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { resolveEnvironment } from '@ultimat3/core';
|
|
15
|
-
import { type DriftReport, driftError } from '@ultimat3/db';
|
|
15
|
+
import { type DriftReport, driftError, withTransaction } from '@ultimat3/db';
|
|
16
|
+
import { postgresDriver } from '@ultimat3/entity';
|
|
16
17
|
import { BackfillPendingError } from '@ultimat3/jobs';
|
|
17
18
|
import { loadApp } from './app-load';
|
|
18
19
|
import { requireAppRoot } from './app-root';
|
|
@@ -34,6 +35,16 @@ import {
|
|
|
34
35
|
import { BRANCH_SUBCOMMANDS } from './db-branch';
|
|
35
36
|
import { stepFinding } from './db-finding';
|
|
36
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';
|
|
37
48
|
import { resolveServices } from './dev-services';
|
|
38
49
|
import {
|
|
39
50
|
BadFlagError,
|
|
@@ -49,14 +60,22 @@ import { findingFrom } from './output';
|
|
|
49
60
|
import { flagBool, flagString } from './parse';
|
|
50
61
|
import { runMigrations } from './serve';
|
|
51
62
|
|
|
52
|
-
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;
|
|
53
72
|
|
|
54
73
|
export const dbCommand: CliCommand = {
|
|
55
74
|
spec: {
|
|
56
75
|
name: 'db',
|
|
57
|
-
summary: 'gen, migrate, reset, studio, branch, backfill',
|
|
76
|
+
summary: 'gen, migrate, reset, seed, studio, branch, backfill',
|
|
58
77
|
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]',
|
|
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]',
|
|
60
79
|
requiresApp: true,
|
|
61
80
|
subcommands: DB_SUBCOMMANDS,
|
|
62
81
|
// Declared from the constant `runBranchCommand` validates against, never a second literal: it
|
|
@@ -64,7 +83,21 @@ export const dbCommand: CliCommand = {
|
|
|
64
83
|
// hand out, which read `ls` as a branch name and cloned a database until 1.2.x.
|
|
65
84
|
subcommandPositionals: { branch: BRANCH_SUBCOMMANDS },
|
|
66
85
|
flags: [
|
|
67
|
-
{
|
|
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
|
+
},
|
|
68
101
|
{ name: 'list', type: 'boolean', summary: 'backfill: print the x_backfills ledger' },
|
|
69
102
|
{
|
|
70
103
|
name: 'pending',
|
|
@@ -106,6 +139,7 @@ export const dbCommand: CliCommand = {
|
|
|
106
139
|
if (sub === 'gen') return runGen(ctx, root, argument ?? 'change');
|
|
107
140
|
if (sub === 'migrate') return runMigrate(ctx, root, msg('cli.db.migrate.applied'));
|
|
108
141
|
if (sub === 'reset') return runReset(ctx, root);
|
|
142
|
+
if (sub === 'seed') return runSeed(ctx, root);
|
|
109
143
|
if (sub === 'studio') throw plannedSubcommand('db', 'studio');
|
|
110
144
|
if (sub === 'backfill') return runBackfill(ctx, root);
|
|
111
145
|
if (sub === 'branch') return runBranchCommand(ctx, root);
|
|
@@ -125,8 +159,14 @@ export const dbCommand: CliCommand = {
|
|
|
125
159
|
|
|
126
160
|
/**
|
|
127
161
|
* 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
|
|
129
|
-
* an answer, and an empty migration would take a ledger row and a checksum forever.
|
|
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.
|
|
130
170
|
*/
|
|
131
171
|
async function runGen(ctx: CommandContext, root: string, name: string): Promise<CommandResult> {
|
|
132
172
|
let generated: Awaited<ReturnType<typeof generateAppMigration>>;
|
|
@@ -148,9 +188,21 @@ async function runGen(ctx: CommandContext, root: string, name: string): Promise<
|
|
|
148
188
|
return {
|
|
149
189
|
ok: generated.findings.length === 0,
|
|
150
190
|
command: 'db',
|
|
151
|
-
|
|
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'),
|
|
152
197
|
findings: generated.findings,
|
|
153
|
-
|
|
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
|
+
},
|
|
154
206
|
};
|
|
155
207
|
}
|
|
156
208
|
return {
|
|
@@ -159,6 +211,7 @@ async function runGen(ctx: CommandContext, root: string, name: string): Promise<
|
|
|
159
211
|
summary: msg('cli.db.gen.written', { id: migration.id }),
|
|
160
212
|
lines: generated.files.map((file) => ` ${file}`),
|
|
161
213
|
data: {
|
|
214
|
+
outcome: generated.outcome,
|
|
162
215
|
migration: migration.id,
|
|
163
216
|
name: migration.name,
|
|
164
217
|
files: [...generated.files],
|
|
@@ -233,6 +286,81 @@ async function runReset(ctx: CommandContext, root: string): Promise<CommandResul
|
|
|
233
286
|
return runMigrate(ctx, root, msg('cli.db.reset.done'));
|
|
234
287
|
}
|
|
235
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,
|
|
314
|
+
});
|
|
315
|
+
if (chosen.length === 0) {
|
|
316
|
+
return {
|
|
317
|
+
ok: true,
|
|
318
|
+
command: 'db',
|
|
319
|
+
summary: msg('cli.db.seed.none'),
|
|
320
|
+
findings: discovery.findings,
|
|
321
|
+
data: seedPassToJson([]),
|
|
322
|
+
};
|
|
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]));
|
|
344
|
+
return {
|
|
345
|
+
ok: failures.length === 0 && findings.length === 0,
|
|
346
|
+
command: 'db',
|
|
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),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
236
364
|
/**
|
|
237
365
|
* Four shapes, one subcommand: `--list` reads the `x_backfills` ledger, `--pending` diffs it
|
|
238
366
|
* against what the app DECLARED, and `<name>` / `--all` gate a pass and put it on the queue. A
|
package/src/cmd-dev.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { msg } from './messages';
|
|
|
42
42
|
import type { CommandResult, Finding } from './output';
|
|
43
43
|
import { findingFrom } from './output';
|
|
44
44
|
import { flagString } from './parse';
|
|
45
|
+
import { metricsPortFor } from './serve';
|
|
45
46
|
import { loopFacts, loopFinding, loopNotice } from './statement-loop';
|
|
46
47
|
|
|
47
48
|
const DEFAULT_PORT = 3000;
|
|
@@ -185,6 +186,10 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
185
186
|
const running = await startRoles({
|
|
186
187
|
roles: options.roles ?? DEV_ROLES,
|
|
187
188
|
port: options.port,
|
|
189
|
+
// `serve.ts`'s expression, called rather than restated: `METRICS_PORT` was read in the
|
|
190
|
+
// container and ignored here, so the scrape port an operator moved was the one port `x dev`
|
|
191
|
+
// could not move — and the second `x dev` on a box died binding the hardcoded 9090.
|
|
192
|
+
metricsPort: metricsPortFor(options.env, options.port),
|
|
188
193
|
buildId,
|
|
189
194
|
runtime,
|
|
190
195
|
routes,
|
|
@@ -266,14 +271,16 @@ export const devCommand: CliCommand = {
|
|
|
266
271
|
spec: {
|
|
267
272
|
name: 'dev',
|
|
268
273
|
summary: 'all roles in one process: embedded services, sub-second reload, /_x mounted',
|
|
269
|
-
usage: 'x dev [--port 3000] [--role web,worker] [--json]',
|
|
274
|
+
usage: 'x dev [--port 3000] [--role web,worker] [--once] [--json]',
|
|
270
275
|
requiresApp: true,
|
|
271
276
|
flags: [
|
|
272
277
|
{ name: 'port', type: 'string', summary: 'HTTP port', default: String(DEFAULT_PORT) },
|
|
273
278
|
{
|
|
274
279
|
name: 'role',
|
|
275
280
|
type: 'string',
|
|
276
|
-
|
|
281
|
+
// `replicator` is named because it is selectable and NOT default — it takes a replication
|
|
282
|
+
// slot on a shared database, which is not something every `x dev` should do by starting.
|
|
283
|
+
summary: `roles to run (default: all of ${DEV_ROLES.join(',')}; replicator is opt-in)`,
|
|
277
284
|
},
|
|
278
285
|
{ name: 'once', type: 'boolean', summary: 'boot, report, exit — for smoke tests and CI' },
|
|
279
286
|
],
|
package/src/cmd-doctor.ts
CHANGED
|
@@ -11,9 +11,10 @@ import type { CliCommand, CommandContext } from './command';
|
|
|
11
11
|
import { checkMigrationSnapshots } from './db-snapshot';
|
|
12
12
|
import { ICON_SOURCE } from './dev-assets';
|
|
13
13
|
import { checkSourceDrift } from './drift';
|
|
14
|
-
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
14
|
+
import { intFlagOr, neighbouringPort, PORT_RANGE } from './flag-number';
|
|
15
15
|
import { msg } from './messages';
|
|
16
16
|
import type { CommandResult, Finding } from './output';
|
|
17
|
+
import type { ParsedArgs } from './parse';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
|
|
@@ -126,7 +127,7 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
126
127
|
finding(
|
|
127
128
|
'X_PORT_IN_USE',
|
|
128
129
|
`port ${probe.port} is already listening`,
|
|
129
|
-
`x dev --port ${probe.port
|
|
130
|
+
`x dev --port ${neighbouringPort(probe.port)}`,
|
|
130
131
|
),
|
|
131
132
|
);
|
|
132
133
|
}
|
|
@@ -165,6 +166,18 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
165
166
|
return findings;
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The port to TEST, and the one place `x doctor` reads it. `PORT_RANGE.min` is 0 because `x dev
|
|
171
|
+
* --port 0` means "let the kernel pick"; here 0 means nothing, and `Bun.serve({ port: 0 })` always
|
|
172
|
+
* succeeds — so the port check could not fail, which is worse than not running it.
|
|
173
|
+
*/
|
|
174
|
+
export const doctorPort = (args: ParsedArgs): number =>
|
|
175
|
+
intFlagOr(
|
|
176
|
+
args,
|
|
177
|
+
{ name: 'port', command: 'doctor', ...PORT_RANGE, min: 1, example: 'x doctor --port 3000' },
|
|
178
|
+
DEFAULT_DOCTOR_PORT,
|
|
179
|
+
);
|
|
180
|
+
|
|
168
181
|
const portFree = async (port: number): Promise<boolean> => {
|
|
169
182
|
try {
|
|
170
183
|
const server = Bun.serve({ port, fetch: () => new Response('') });
|
|
@@ -213,11 +226,7 @@ export const doctorCommand: CliCommand = {
|
|
|
213
226
|
],
|
|
214
227
|
},
|
|
215
228
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
216
|
-
const port =
|
|
217
|
-
ctx.args,
|
|
218
|
-
{ name: 'port', command: 'doctor', ...PORT_RANGE, example: 'x doctor --port 3000' },
|
|
219
|
-
DEFAULT_DOCTOR_PORT,
|
|
220
|
-
);
|
|
229
|
+
const port = doctorPort(ctx.args);
|
|
221
230
|
const findings = await runDoctor(probeFor(ctx.cwd, ctx.bunVersion, port));
|
|
222
231
|
return {
|
|
223
232
|
ok: findings.length === 0,
|
package/src/cmd-new.ts
CHANGED
|
@@ -67,7 +67,7 @@ export const newCommand: CliCommand = {
|
|
|
67
67
|
spec: {
|
|
68
68
|
name: 'new',
|
|
69
69
|
summary: 'scaffold a new Ultimate monorepo that already runs',
|
|
70
|
-
usage: 'x new <name> [--dir path] [--no-example] [--dry-run] [--json]',
|
|
70
|
+
usage: 'x new <name> [--dir path] [--no-example] [--dry-run] [--force] [--json]',
|
|
71
71
|
flags: [
|
|
72
72
|
{ name: 'dir', type: 'string', summary: 'parent directory (default: cwd)' },
|
|
73
73
|
{
|
package/src/cmd-test.ts
CHANGED
|
@@ -9,9 +9,10 @@ import { readIntFlag } from './flag-number';
|
|
|
9
9
|
import type { CommandResult } from './output';
|
|
10
10
|
import type { ParsedArgs } from './parse';
|
|
11
11
|
import { flagString } from './parse';
|
|
12
|
+
import { quoteArg } from './shell-quote';
|
|
12
13
|
import { discoverTests, missingSelection, readSample, readType, sampleFiles } from './test-select';
|
|
13
|
-
import {
|
|
14
|
-
import { defaultWorkers } from './test-workers';
|
|
14
|
+
import { runShards } from './test-shards';
|
|
15
|
+
import { defaultWorkers, WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
|
|
15
16
|
import type { TestType } from './verify-tests';
|
|
16
17
|
import { TEST_TYPES } from './verify-tests';
|
|
17
18
|
|
|
@@ -25,6 +26,12 @@ const readIndex = (args: ParsedArgs, name: string, min: number): number | undefi
|
|
|
25
26
|
name,
|
|
26
27
|
command: 'test',
|
|
27
28
|
min,
|
|
29
|
+
// The ceiling the summary already claimed and the reader never enforced: `--workers 5000` was
|
|
30
|
+
// accepted, `planShards` clamps only to the file count, and `runParallel` `Promise.all`s them —
|
|
31
|
+
// one Bun process per test FILE, each with the framework module graph and a cloned database.
|
|
32
|
+
// `--worker` is an index into that split, so the same bound holds it (the exact upper index is
|
|
33
|
+
// `workers - 1`, refused a line below by the check that knows the real width).
|
|
34
|
+
max: WORKER_CEILING,
|
|
28
35
|
example: `x test --${name} ${Math.max(min, 1)}`,
|
|
29
36
|
});
|
|
30
37
|
|
|
@@ -54,7 +61,11 @@ export const testCommand: CliCommand = {
|
|
|
54
61
|
usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--workers N] [--worker I] [--json]`,
|
|
55
62
|
positionalChoices: TEST_TYPES,
|
|
56
63
|
flags: [
|
|
57
|
-
{
|
|
64
|
+
{
|
|
65
|
+
name: 'workers',
|
|
66
|
+
type: 'string',
|
|
67
|
+
summary: `process count (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
|
|
68
|
+
},
|
|
58
69
|
{
|
|
59
70
|
name: 'worker',
|
|
60
71
|
type: 'string',
|