@ultimat3/cli 24.0.0 → 25.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 +15 -8
- package/README.md +30 -5
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +3 -1
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +3 -3
- package/src/app-env.ts +21 -14
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +2 -2
- package/src/app-mcp.ts +25 -27
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +2 -8
- package/src/app-permissions-site.ts +31 -7
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +115 -121
- package/src/cdp-shot-driver.ts +17 -0
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth.ts +2 -2
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +89 -9
- package/src/cmd-db-backfill.ts +1 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +5 -2
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -357
- package/src/cmd-doctor.ts +16 -66
- package/src/cmd-env.ts +29 -14
- package/src/cmd-generate.ts +12 -2
- package/src/cmd-i18n.ts +11 -8
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +18 -137
- package/src/cmd-mcp.ts +25 -7
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +9 -0
- package/src/cmd-routes.ts +3 -3
- package/src/cmd-shot-matrix.ts +21 -4
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +35 -39
- package/src/cmd-test.ts +1 -1
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +19 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +1 -1
- package/src/dev-dashboard.ts +5 -1
- package/src/dev-lock.ts +96 -24
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +14 -7
- package/src/doctor-auth.ts +4 -4
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +2 -2
- package/src/drift.ts +29 -6
- package/src/error-codes.ts +6 -0
- package/src/error-page-csp.ts +14 -15
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-kinds.ts +3 -17
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +50 -1
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +21 -8
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +54 -64
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +32 -16
- package/src/local-cli-handoff.ts +90 -0
- package/src/mcp-errors.ts +8 -1
- package/src/mcp-host.ts +6 -5
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +27 -16
- package/src/metrics-endpoint.ts +3 -2
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +18 -0
- package/src/page-speculation.ts +14 -11
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +63 -98
- package/src/pwa-artifacts.ts +8 -1
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/retry-memo.ts +1 -1
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +110 -31
- package/src/role-start.ts +64 -85
- package/src/role-sync.ts +65 -6
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +15 -2
- package/src/runtime-bindings.ts +23 -3
- package/src/runtime-cache.ts +8 -8
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +2 -2
- package/src/runtime-jobs.ts +78 -18
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +2 -2
- package/src/runtime-overrides.ts +3 -3
- package/src/runtime-purge.ts +1 -1
- package/src/runtime-queue.ts +12 -12
- package/src/runtime-realtime.ts +5 -3
- package/src/runtime-render.ts +77 -82
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +33 -13
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +3 -3
- package/src/schema-diff.ts +1 -1
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +2 -2
- package/src/serve-boot.ts +22 -11
- package/src/serve-drain.ts +3 -1
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +19 -8
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -15
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-seo.ts +6 -4
- package/src/source-files.ts +13 -3
- package/src/static-report.ts +5 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +1 -1
- package/src/templates/action.ts +4 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +23 -8
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +2 -1
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container.ts +12 -6
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-templates.ts +74 -18
- package/src/templates/scaffold-helm.ts +31 -4
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +18 -15
- package/src/templates/scaffold-shell.ts +17 -2
- package/src/test-only-shipped.ts +193 -0
- package/src/test-select.ts +2 -1
- package/src/test-shards.ts +1 -4
- package/src/theme-boot.ts +2 -2
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/verify-checks.ts +12 -2
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +87 -10
- package/src/verify-merge.ts +48 -16
- package/src/verify-part-step.ts +10 -2
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +3 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +0 -1
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/jobs-drain.ts +0 -230
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
package/src/jobs-drain.ts
DELETED
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
// `x jobs drain`: moving in-flight work from one queue driver onto another. Its own file because
|
|
2
|
-
// it is the only command here that WRITES to two drivers at once, and the ordering rule that makes
|
|
3
|
-
// that safe — lease, copy steps, enqueue, then ack — has to be readable in one screen.
|
|
4
|
-
|
|
5
|
-
import { logger, uuid } from '@ultimat3/core';
|
|
6
|
-
import type { ClaimedJob, JobDriver, JobRecord, JobState } from '@ultimat3/jobs';
|
|
7
|
-
import { claimOf, inspectJobList, jobCursor, MAX_JOB_PAGE } from '@ultimat3/jobs';
|
|
8
|
-
import type { Finding } from './output';
|
|
9
|
-
import { findingFrom } from './output';
|
|
10
|
-
|
|
11
|
-
/** `running` is deliberately excluded: a job a worker is mid-execution on is not "pending". */
|
|
12
|
-
const PENDING_STATES: readonly JobState[] = ['ready', 'delayed', 'suspended'];
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The lease has to outlive the transfer of the whole BATCH it covers: a batch is claimed up front,
|
|
16
|
-
* and a lease expiring mid-batch would hand a half-transferred job back to a source worker. A
|
|
17
|
-
* batch is at most `MAX_JOB_PAGE` rows, so this is a ceiling over a couple of hundred sequential
|
|
18
|
-
* enqueues rather than a guess about however many the queue holds.
|
|
19
|
-
*/
|
|
20
|
-
const DRAIN_LEASE_MS = 300_000;
|
|
21
|
-
|
|
22
|
-
const NO_LEASE = 'no lease could be taken — not yet due, or a worker is already running it';
|
|
23
|
-
|
|
24
|
-
export interface DrainFailure {
|
|
25
|
-
readonly id: string;
|
|
26
|
-
readonly name: string;
|
|
27
|
-
readonly finding: Finding;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** A candidate the drain deliberately left alone, because it could not prove it owned the job. */
|
|
31
|
-
export interface DrainSkip {
|
|
32
|
-
readonly id: string;
|
|
33
|
-
readonly name: string;
|
|
34
|
-
readonly queue: string;
|
|
35
|
-
readonly state: JobState;
|
|
36
|
-
readonly reason: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface DrainOutcome {
|
|
40
|
-
readonly from: string;
|
|
41
|
-
readonly to: string;
|
|
42
|
-
readonly dryRun: boolean;
|
|
43
|
-
readonly candidates: readonly JobRecord[];
|
|
44
|
-
readonly moved: readonly JobRecord[];
|
|
45
|
-
readonly skipped: readonly DrainSkip[];
|
|
46
|
-
readonly failures: readonly DrainFailure[];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* The lease IS the proof of ownership. `ack()` takes only a job id, so a drain that ack'd rows
|
|
51
|
-
* off a snapshot could acknowledge a job a source worker claimed and is executing right now —
|
|
52
|
-
* the target would then run a duplicate of a job still in flight. One batch claim over the
|
|
53
|
-
* candidates' own queues; whatever comes back is the drain's to move, and everything else is
|
|
54
|
-
* left alone. `queues` is always explicit because the pg driver reads an empty list as
|
|
55
|
-
* `['default']` rather than as "every queue".
|
|
56
|
-
*/
|
|
57
|
-
function leaseBatch(
|
|
58
|
-
source: JobDriver,
|
|
59
|
-
candidates: readonly JobRecord[],
|
|
60
|
-
limit: number,
|
|
61
|
-
): Promise<readonly ClaimedJob[]> {
|
|
62
|
-
if (candidates.length === 0 || limit <= 0) return Promise.resolve([]);
|
|
63
|
-
return source.claim({
|
|
64
|
-
queues: [...new Set(candidates.map((record) => record.queue))],
|
|
65
|
-
limit,
|
|
66
|
-
visibilityTimeoutMs: DRAIN_LEASE_MS,
|
|
67
|
-
workerId: `x-jobs-drain:${uuid()}`,
|
|
68
|
-
// A claim BURIES a row whose lease lapsed on its final attempt, whoever makes it — this one
|
|
69
|
-
// too. The drain is an operator's tool with no worker context, so it runs no `onSettled`
|
|
70
|
-
// (at most once across a crash, as documented) and names no `dropExhausted`: said out loud
|
|
71
|
-
// here, because an unlistened burial is a row that went `dead` with nothing anywhere saying so.
|
|
72
|
-
onExhausted: (dead) => {
|
|
73
|
-
for (const row of dead) {
|
|
74
|
-
logger.error('jobs.claim.exhausted', {
|
|
75
|
-
workerId: 'x-jobs-drain',
|
|
76
|
-
job: row.name,
|
|
77
|
-
jobId: row.id,
|
|
78
|
-
attempt: row.attempt,
|
|
79
|
-
maxAttempts: row.maxAttempts,
|
|
80
|
-
error: row.lastError ?? '',
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
},
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Every row in `state`, page by page on the keyset cursor until a short page: one page was the
|
|
89
|
-
* drivers' default 100, so a drain of 350 ready jobs moved 100 and left 250 on the source.
|
|
90
|
-
*/
|
|
91
|
-
async function everyRow(source: JobDriver, state: JobState): Promise<readonly JobRecord[]> {
|
|
92
|
-
const rows: JobRecord[] = [];
|
|
93
|
-
let after: string | undefined;
|
|
94
|
-
for (;;) {
|
|
95
|
-
const page = await inspectJobList(source, {
|
|
96
|
-
state,
|
|
97
|
-
limit: MAX_JOB_PAGE,
|
|
98
|
-
...(after === undefined ? {} : { after }),
|
|
99
|
-
});
|
|
100
|
-
rows.push(...page);
|
|
101
|
-
const last = page.at(-1);
|
|
102
|
-
if (page.length < MAX_JOB_PAGE || last === undefined) return rows;
|
|
103
|
-
after = jobCursor(last);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Steps carry their own `runId`, so a copy lands under the same key the target job resumes on. */
|
|
108
|
-
async function copySteps(source: JobDriver, target: JobDriver, runId: string): Promise<void> {
|
|
109
|
-
for (const record of await source.steps.list(runId)) await target.steps.put(record);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Hand a leased job back exactly as the drain found it. `countsAsAttempt: false` is the point:
|
|
114
|
-
* a transfer that failed is not a failed attempt, and burning one per `x jobs drain` retry would
|
|
115
|
-
* dead-letter a job nobody ever ran.
|
|
116
|
-
*
|
|
117
|
-
* It returns to `ready`, which is what "as the drain found it" means — the drain leased a ready
|
|
118
|
-
* job and could not move it. It used to land in `suspended`, not by intent but because
|
|
119
|
-
* `countsAsAttempt: false` was the only bit the drivers had and `step.sleep` had claimed it;
|
|
120
|
-
* `NackOptions.park` now carries the suspension, so the two callers no longer share one meaning.
|
|
121
|
-
*/
|
|
122
|
-
async function releaseLease(source: JobDriver, leased: ClaimedJob): Promise<void> {
|
|
123
|
-
try {
|
|
124
|
-
// Fenced on the drain's own claim, like every settle: a lease that lapsed is not handed back.
|
|
125
|
-
await source.nack(leased.id, {
|
|
126
|
-
...claimOf(leased),
|
|
127
|
-
delayMs: 0,
|
|
128
|
-
countsAsAttempt: false,
|
|
129
|
-
});
|
|
130
|
-
} catch {
|
|
131
|
-
// The lease expires on its own. Masking the transfer's real error with this one helps nobody.
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const toSkip = (record: JobRecord): DrainSkip => ({
|
|
136
|
-
id: record.id,
|
|
137
|
-
name: record.name,
|
|
138
|
-
queue: record.queue,
|
|
139
|
-
state: record.state,
|
|
140
|
-
reason: NO_LEASE,
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Move every pending job the drain can take a lease on from `source` onto `target`. `--dry-run`
|
|
145
|
-
* reports the candidate list, takes no lease and enqueues nothing. Per-record try/catch, not a
|
|
146
|
-
* batch operation: one record that cannot enqueue on the target (a redis/nats stub, a transient
|
|
147
|
-
* error) must not stop the rest from moving, and the caller reports each failure on its own.
|
|
148
|
-
*/
|
|
149
|
-
export async function drainJobs(
|
|
150
|
-
source: JobDriver,
|
|
151
|
-
target: JobDriver,
|
|
152
|
-
dryRun: boolean,
|
|
153
|
-
): Promise<DrainOutcome> {
|
|
154
|
-
const lists = await Promise.all(PENDING_STATES.map((state) => everyRow(source, state)));
|
|
155
|
-
const candidates = lists.flat();
|
|
156
|
-
const base = { from: source.name, to: target.name, candidates };
|
|
157
|
-
if (dryRun) return { ...base, dryRun: true, moved: [], skipped: [], failures: [] };
|
|
158
|
-
|
|
159
|
-
const found = new Map(candidates.map((record) => [record.id, record]));
|
|
160
|
-
const held = new Set<string>();
|
|
161
|
-
const moved: JobRecord[] = [];
|
|
162
|
-
const failures: DrainFailure[] = [];
|
|
163
|
-
// Batch by batch, each under its own lease, until the claims stop or the snapshot's count is
|
|
164
|
-
// spent — a claim may lease a row enqueued after the snapshot, and that row spends the budget
|
|
165
|
-
// too, so a queue that keeps filling cannot keep the drain running.
|
|
166
|
-
let budget = candidates.length;
|
|
167
|
-
while (budget > 0) {
|
|
168
|
-
const leased = await leaseBatch(source, candidates, Math.min(budget, MAX_JOB_PAGE));
|
|
169
|
-
if (leased.length === 0) break;
|
|
170
|
-
// A row the target already refused is tried once per drain: handed straight back, spending
|
|
171
|
-
// no budget and adding no second failure for the same id.
|
|
172
|
-
const refused = new Set(failures.map((failure) => failure.id));
|
|
173
|
-
const fresh = leased.filter((record) => !refused.has(record.id));
|
|
174
|
-
for (const record of leased) if (refused.has(record.id)) await releaseLease(source, record);
|
|
175
|
-
if (fresh.length === 0) break;
|
|
176
|
-
budget -= fresh.length;
|
|
177
|
-
for (const record of fresh) held.add(record.id);
|
|
178
|
-
const before = moved.length;
|
|
179
|
-
await transfer(source, target, fresh, found, moved, failures);
|
|
180
|
-
// A batch that moved nothing handed its leases back, and the next claim would take the same
|
|
181
|
-
// rows to the same refusing target: stop, and let the failures already counted say why.
|
|
182
|
-
if (moved.length === before) break;
|
|
183
|
-
}
|
|
184
|
-
const skipped = candidates.filter((record) => !held.has(record.id)).map(toSkip);
|
|
185
|
-
return { ...base, dryRun: false, moved, skipped, failures };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** One leased batch onto the target: steps, then the job, then the ack — per record. */
|
|
189
|
-
async function transfer(
|
|
190
|
-
source: JobDriver,
|
|
191
|
-
target: JobDriver,
|
|
192
|
-
leased: readonly ClaimedJob[],
|
|
193
|
-
found: ReadonlyMap<string, JobRecord>,
|
|
194
|
-
moved: JobRecord[],
|
|
195
|
-
failures: DrainFailure[],
|
|
196
|
-
): Promise<void> {
|
|
197
|
-
for (const record of leased) {
|
|
198
|
-
let enqueued = false;
|
|
199
|
-
try {
|
|
200
|
-
// Steps BEFORE the job: a worker on the target must never be able to claim a run whose
|
|
201
|
-
// checkpoint has not landed, or it repeats completed steps or loses them outright.
|
|
202
|
-
await copySteps(source, target, record.runId);
|
|
203
|
-
await target.enqueue({
|
|
204
|
-
name: record.name,
|
|
205
|
-
queue: record.queue,
|
|
206
|
-
input: record.input,
|
|
207
|
-
idempotencyKey: record.idempotencyKey,
|
|
208
|
-
runId: record.runId,
|
|
209
|
-
maxAttempts: record.maxAttempts,
|
|
210
|
-
runAt: record.runAt,
|
|
211
|
-
...(record.tenantId === undefined ? {} : { tenantId: record.tenantId }),
|
|
212
|
-
});
|
|
213
|
-
enqueued = true;
|
|
214
|
-
// Only now is the ack the drain's to make: the lease proves no source worker holds this
|
|
215
|
-
// job, and the target already has both the row and its steps. A crash between the two
|
|
216
|
-
// leaves the job live on both drivers, where `idempotencyKey` dedupes it.
|
|
217
|
-
// `counted: false`: the row is finished HERE because it left, and a job that was moved is
|
|
218
|
-
// not a job that completed — it must add nothing to the source's `done` history.
|
|
219
|
-
await source.ack(record.id, { ...claimOf(record), counted: false });
|
|
220
|
-
// Report the row as the drain FOUND it: `claim()` returns it mid-lease (`running`, one
|
|
221
|
-
// attempt higher), a state nothing on either driver is in once this returns.
|
|
222
|
-
moved.push(found.get(record.id) ?? record);
|
|
223
|
-
} catch (error) {
|
|
224
|
-
// A failed enqueue left nothing on the target, so the lease goes back. A failed ack did
|
|
225
|
-
// not: the job is already live there, and releasing it would race the target's worker.
|
|
226
|
-
if (!enqueued) await releaseLease(source, record);
|
|
227
|
-
failures.push({ id: record.id, name: record.name, finding: findingFrom(error) });
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
// Typechecks what `x new` and `x g` write, the way the user's own `tsc` will: real files on disk,
|
|
2
|
-
// the real workspace packages, the real compiler. A template that merely parses has not been
|
|
3
|
-
// checked — it has moved its failure from this gate to the first command the user runs.
|
|
4
|
-
|
|
5
|
-
// `node:` and not Bun: Bun has no API for a temporary directory (`mkdtempSync` + `tmpdir`), for a
|
|
6
|
-
// symlink (`symlinkSync`, how the sandbox borrows the workspace's node_modules), or for a
|
|
7
|
-
// recursive delete (`rmSync`). `node:path` comes with them — `Bun.write` takes the joined path,
|
|
8
|
-
// but only `resolve`/`sep` can prove that path stayed inside the sandbox.
|
|
9
|
-
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
|
|
10
|
-
import { tmpdir } from 'node:os';
|
|
11
|
-
import { join, resolve, sep } from 'node:path';
|
|
12
|
-
import { ScaffoldPathEscapeError } from './errors';
|
|
13
|
-
import type { Runner } from './exec';
|
|
14
|
-
import { exec } from './exec';
|
|
15
|
-
import { FIXTURE_APP, scaffoldFixture } from './scaffold-fixture';
|
|
16
|
-
import type { GeneratedFile } from './templates';
|
|
17
|
-
|
|
18
|
-
/** Derived from this file, never from cwd, so the harness works from any working directory. */
|
|
19
|
-
export const workspaceRoot = (): string => resolve(import.meta.dir, '..', '..', '..');
|
|
20
|
-
|
|
21
|
-
export interface TypeDiagnostic {
|
|
22
|
-
/** Sandbox-relative path, or '' for a diagnostic the compiler raised about the project itself. */
|
|
23
|
-
readonly file: string;
|
|
24
|
-
readonly line: number;
|
|
25
|
-
readonly code: string;
|
|
26
|
-
readonly message: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const AT_FILE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
|
|
30
|
-
const PROJECT_WIDE = /^error (TS\d+): (.*)$/;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Config errors carry no file, so both shapes are collected: a silent harness is a green lie.
|
|
34
|
-
* Split on `\r?\n`, because a trailing `\r` lands inside the message capture and no `KNOWN_GAPS`
|
|
35
|
-
* entry would match it — a CRLF `tsc` would turn every pinned gap into an unexplained failure.
|
|
36
|
-
*/
|
|
37
|
-
export function parseDiagnostics(output: string): readonly TypeDiagnostic[] {
|
|
38
|
-
const found: TypeDiagnostic[] = [];
|
|
39
|
-
for (const line of output.split(/\r?\n/)) {
|
|
40
|
-
const at = AT_FILE.exec(line);
|
|
41
|
-
if (at !== null) {
|
|
42
|
-
found.push({
|
|
43
|
-
file: at[1] ?? '',
|
|
44
|
-
line: Number.parseInt(at[2] ?? '0', 10),
|
|
45
|
-
code: at[4] ?? '',
|
|
46
|
-
message: at[5] ?? '',
|
|
47
|
-
});
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
const wide = PROJECT_WIDE.exec(line.trim());
|
|
51
|
-
if (wide !== null)
|
|
52
|
-
found.push({ file: '', line: 0, code: wide[1] ?? '', message: wide[2] ?? '' });
|
|
53
|
-
}
|
|
54
|
-
return found;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface KnownGap {
|
|
58
|
-
/** `ScaffoldVariant.name`. A pin bought by one invocation may not excuse another. */
|
|
59
|
-
readonly variant: string;
|
|
60
|
-
readonly code: string;
|
|
61
|
-
/** Exact sandbox-relative path. A pattern would absolve the same bug in a file nobody pinned. */
|
|
62
|
-
readonly file: string;
|
|
63
|
-
/** Exact `tsc` message. Matched literally so a near-miss regression is not absorbed. */
|
|
64
|
-
readonly message: string;
|
|
65
|
-
/** Who fixes it, and why the template cannot. */
|
|
66
|
-
readonly owner: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Diagnostics a template cannot fix, pinned one occurrence at a time. Pinned, never ignored:
|
|
71
|
-
* `unexpectedIn` fails on anything not listed here — including a second copy of a listed
|
|
72
|
-
* diagnostic, because each entry is consumed by exactly one match — and `staleGapsIn` fails when
|
|
73
|
-
* a listed entry stops reproducing, so an entry cannot outlive the bug it describes.
|
|
74
|
-
*
|
|
75
|
-
* Empty, and that is the point: `invariants:` is one callback over the whole list, so `c` is typed
|
|
76
|
-
* from the entity's own columns and the six `TS18048 'c.title' is possibly 'undefined'` pins this
|
|
77
|
-
* list used to carry are gone. A new entry here is a template shipping a diagnostic, which needs
|
|
78
|
-
* an owner who can remove it — not a permanent exemption.
|
|
79
|
-
*/
|
|
80
|
-
export const KNOWN_GAPS: readonly KnownGap[] = [];
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* The pins one invocation may spend. Another variant's pins are not its to spend. `gaps` is a
|
|
84
|
-
* parameter for the same reason it is on `unexpectedIn` and `staleGapsIn`: with `KNOWN_GAPS`
|
|
85
|
-
* legitimately empty, the bookkeeping is only testable against a list a test supplies.
|
|
86
|
-
*/
|
|
87
|
-
export const gapsFor = (
|
|
88
|
-
variant: string,
|
|
89
|
-
gaps: readonly KnownGap[] = KNOWN_GAPS,
|
|
90
|
-
): readonly KnownGap[] => gaps.filter((gap) => gap.variant === variant);
|
|
91
|
-
|
|
92
|
-
const matches = (diagnostic: TypeDiagnostic, gap: KnownGap): boolean =>
|
|
93
|
-
diagnostic.code === gap.code &&
|
|
94
|
-
diagnostic.file === gap.file &&
|
|
95
|
-
diagnostic.message === gap.message;
|
|
96
|
-
|
|
97
|
-
export interface GapPartition {
|
|
98
|
-
/** Diagnostics no unconsumed `KNOWN_GAPS` entry accounts for. */
|
|
99
|
-
readonly unexpected: readonly TypeDiagnostic[];
|
|
100
|
-
/** Entries that found no match — the bug is fixed and the pin has to go. */
|
|
101
|
-
readonly stale: readonly KnownGap[];
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* One pass, first-fit, each pin spent once. Counting matters: two occurrences of a diagnostic
|
|
106
|
-
* pinned once means a new regression is hiding behind an old bug, so the surplus is unexpected.
|
|
107
|
-
*/
|
|
108
|
-
export function partitionDiagnostics(
|
|
109
|
-
diagnostics: readonly TypeDiagnostic[],
|
|
110
|
-
gaps: readonly KnownGap[] = KNOWN_GAPS,
|
|
111
|
-
): GapPartition {
|
|
112
|
-
const budget = gaps.map((gap) => ({ gap, spent: false }));
|
|
113
|
-
const unexpected: TypeDiagnostic[] = [];
|
|
114
|
-
for (const entry of diagnostics) {
|
|
115
|
-
const slot = budget.find((pin) => !pin.spent && matches(entry, pin.gap));
|
|
116
|
-
if (slot === undefined) unexpected.push(entry);
|
|
117
|
-
else slot.spent = true;
|
|
118
|
-
}
|
|
119
|
-
return { unexpected, stale: budget.filter((pin) => !pin.spent).map((pin) => pin.gap) };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Everything the gate refuses: a diagnostic no unconsumed `KNOWN_GAPS` entry accounts for. */
|
|
123
|
-
export const unexpectedIn = (
|
|
124
|
-
diagnostics: readonly TypeDiagnostic[],
|
|
125
|
-
gaps: readonly KnownGap[] = KNOWN_GAPS,
|
|
126
|
-
): readonly TypeDiagnostic[] => partitionDiagnostics(diagnostics, gaps).unexpected;
|
|
127
|
-
|
|
128
|
-
/** Entries that no longer reproduce — the bug is fixed and the pin has to go. */
|
|
129
|
-
export const staleGapsIn = (
|
|
130
|
-
diagnostics: readonly TypeDiagnostic[],
|
|
131
|
-
gaps: readonly KnownGap[] = KNOWN_GAPS,
|
|
132
|
-
): readonly KnownGap[] => partitionDiagnostics(diagnostics, gaps).stale;
|
|
133
|
-
|
|
134
|
-
/** Written beside the app's own tsconfig so the gate inherits every flag the app ships with. */
|
|
135
|
-
const OVERLAY = 'tsconfig.scaffold-check.json';
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* The one thing the sandbox may change about the generated project: where its imports resolve.
|
|
139
|
-
* `@ultimat3/*` points at workspace source instead of a published tarball, and the app's own
|
|
140
|
-
* workspace packages resolve without a `bun install` that a sealed test could never run.
|
|
141
|
-
*/
|
|
142
|
-
const overlay = (root: string, app: string): string =>
|
|
143
|
-
`${JSON.stringify(
|
|
144
|
-
{
|
|
145
|
-
extends: './tsconfig.json',
|
|
146
|
-
compilerOptions: {
|
|
147
|
-
noEmit: true,
|
|
148
|
-
paths: {
|
|
149
|
-
// The subpath exports a generated app reaches for, spelled the way the packages' own
|
|
150
|
-
// `exports` maps spell them — the wildcard below would read `ui/icons/zap` as a package
|
|
151
|
-
// name, and `cli/serve` as `packages/cli/serve`. Longest prefix wins, so these are
|
|
152
|
-
// consulted first. `@ultimat3/cli/serve` is what `x new`'s `apps/web/server.ts` imports.
|
|
153
|
-
'@ultimat3/ui/icons/*': [`${root}/packages/ui/src/icons/glyphs/*`],
|
|
154
|
-
'@ultimat3/query/client': [`${root}/packages/query/src/client`],
|
|
155
|
-
'@ultimat3/render/server': [`${root}/packages/render/src/server`],
|
|
156
|
-
'@ultimat3/cli/serve': [`${root}/packages/cli/src/serve-entry`],
|
|
157
|
-
'@ultimat3/testing/test-types': [`${root}/packages/testing/src/test-types`],
|
|
158
|
-
'@ultimat3/*': [`${root}/packages/*/src`],
|
|
159
|
-
[`@${app}/web/*`]: ['./apps/web/*'],
|
|
160
|
-
[`@${app}/admin/*`]: ['./apps/admin/*'],
|
|
161
|
-
[`@${app}/*`]: ['./packages/*/src'],
|
|
162
|
-
},
|
|
163
|
-
},
|
|
164
|
-
},
|
|
165
|
-
null,
|
|
166
|
-
2,
|
|
167
|
-
)}\n`;
|
|
168
|
-
|
|
169
|
-
export interface TypecheckOptions {
|
|
170
|
-
readonly files?: readonly GeneratedFile[];
|
|
171
|
-
readonly app?: string;
|
|
172
|
-
readonly runner?: Runner;
|
|
173
|
-
/** Leave the sandbox on disk. For debugging a red gate by hand, never for the gate itself. */
|
|
174
|
-
readonly keep?: boolean;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export interface TypecheckReport {
|
|
178
|
-
readonly dir: string;
|
|
179
|
-
readonly fileCount: number;
|
|
180
|
-
readonly diagnostics: readonly TypeDiagnostic[];
|
|
181
|
-
readonly output: string;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* `GeneratedFile.path` is documented as relative-POSIX, not enforced as it. A `..` segment would
|
|
186
|
-
* put template output on the developer's real disk, so the sandbox proves containment before it
|
|
187
|
-
* writes rather than after.
|
|
188
|
-
*/
|
|
189
|
-
export function sandboxPath(dir: string, path: string): string {
|
|
190
|
-
const target = resolve(dir, path);
|
|
191
|
-
if (target !== dir && !target.startsWith(`${dir}${sep}`))
|
|
192
|
-
throw new ScaffoldPathEscapeError({ path, dir });
|
|
193
|
-
return target;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export async function typecheckScaffold(options: TypecheckOptions = {}): Promise<TypecheckReport> {
|
|
197
|
-
const root = workspaceRoot();
|
|
198
|
-
const app = options.app ?? FIXTURE_APP;
|
|
199
|
-
const files = options.files ?? scaffoldFixture();
|
|
200
|
-
const dir = mkdtempSync(join(tmpdir(), 'x-scaffold-'));
|
|
201
|
-
try {
|
|
202
|
-
for (const file of files) await Bun.write(sandboxPath(dir, file.path), file.contents);
|
|
203
|
-
// The sandbox borrows the workspace's installed dependencies. The gate is about the
|
|
204
|
-
// templates; whether a registry install succeeds is a different question, and a sealed
|
|
205
|
-
// test cannot ask it.
|
|
206
|
-
symlinkSync(join(root, 'node_modules'), join(dir, 'node_modules'), 'dir');
|
|
207
|
-
await Bun.write(join(dir, OVERLAY), overlay(root, app));
|
|
208
|
-
const result = await (options.runner ?? exec)(
|
|
209
|
-
[join(root, 'node_modules', '.bin', 'tsc'), '--noEmit', '--pretty', 'false', '-p', OVERLAY],
|
|
210
|
-
{ cwd: dir },
|
|
211
|
-
);
|
|
212
|
-
const output = [result.stdout, result.stderr].filter((part) => part.length > 0).join('\n');
|
|
213
|
-
return { dir, fileCount: files.length, diagnostics: parseDiagnostics(output), output };
|
|
214
|
-
} finally {
|
|
215
|
-
if (options.keep !== true) rmSync(dir, { recursive: true, force: true });
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** One diagnostic per line, in the shape `tsc` prints — a failed gate is a runnable bug report. */
|
|
220
|
-
export const formatDiagnostics = (diagnostics: readonly TypeDiagnostic[]): string =>
|
|
221
|
-
diagnostics
|
|
222
|
-
.map((entry) =>
|
|
223
|
-
entry.file === ''
|
|
224
|
-
? `error ${entry.code}: ${entry.message}`
|
|
225
|
-
: `${entry.file}:${entry.line} ${entry.code}: ${entry.message}`,
|
|
226
|
-
)
|
|
227
|
-
.join('\n');
|
package/src/thrown-by.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// One `thrownBy` for every test in this package. What a caller acts on is the thrown *shape* —
|
|
2
|
-
// the code, the cause and the fix — never the class, so a test that asserts on the class would
|
|
3
|
-
// pass while the fix line rots. Shared rather than copied: a second copy asserts less, silently.
|
|
4
|
-
|
|
5
|
-
import { expect } from 'bun:test';
|
|
6
|
-
|
|
7
|
-
/** The three fields every `UltimateError` carries, as a test reads them off the thrown value. */
|
|
8
|
-
export interface ThrownShape {
|
|
9
|
-
readonly code?: string;
|
|
10
|
-
readonly cause?: string;
|
|
11
|
-
readonly fix?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function thrownBy(call: () => unknown): ThrownShape {
|
|
15
|
-
try {
|
|
16
|
-
call();
|
|
17
|
-
} catch (error) {
|
|
18
|
-
return error as ThrownShape;
|
|
19
|
-
}
|
|
20
|
-
// Not `throw new Error(...)`: a bare throw carries no code and no fix, and this is the one
|
|
21
|
-
// failure a test author most needs named. `expect.unreachable` fails through the runner, which
|
|
22
|
-
// prints the assertion the caller wrote instead of a stack from inside this helper.
|
|
23
|
-
return expect.unreachable('expected a throw');
|
|
24
|
-
}
|