@ultimat3/cli 22.14.0 → 23.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 +25 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +35 -3
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-dev.ts +2 -2
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +30 -6
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/document-options.ts +64 -0
- package/src/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/page-speculation.ts +124 -0
- package/src/permission-grants.ts +41 -9
- package/src/prerender.ts +9 -5
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +36 -60
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -147
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/static-document.ts +108 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +142 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
package/src/island-store.ts
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
// The island chunks a container serves, built ONCE
|
|
2
|
-
//
|
|
3
|
-
// JSX transform, a minifier — for every island,
|
|
4
|
-
// URL is a hash of its sources was rebuilt from sources that could not have changed.
|
|
1
|
+
// The island chunks a container serves, built ONCE inside the image build by
|
|
2
|
+
// `x build --target prebuilt` and read back at boot. Without it every web pod's boot ran
|
|
3
|
+
// `Bun.build` — Babel, the JSX transform, a minifier — for every island, on every start, and a
|
|
4
|
+
// chunk whose URL is a hash of its sources was rebuilt from sources that could not have changed.
|
|
5
5
|
//
|
|
6
6
|
// Load-and-VERIFY, never load-and-trust: a store written by another framework or Bun version, one
|
|
7
7
|
// that names a different set of islands than the app has, or a chunk whose bytes do not hash to
|
|
8
|
-
// what the index recorded is refused, and the boot builds instead —
|
|
8
|
+
// what the index recorded is refused, and the boot builds instead — and says so.
|
|
9
9
|
|
|
10
10
|
import { join, relative, sep } from 'node:path'; // why: Bun ships no path-join primitive.
|
|
11
|
-
import { frameworkVersion
|
|
11
|
+
import { frameworkVersion } from '@ultimat3/core';
|
|
12
12
|
import { contentHash, loadStylesheet } from '@ultimat3/render/server';
|
|
13
13
|
import type { IslandBundle, IslandChunk, SharedChunk } from './island-bundle';
|
|
14
14
|
import { buildIslands, discoverIslands, islandBundle } from './island-bundle';
|
|
15
15
|
import { islandStylesheets } from './island-styles';
|
|
16
|
+
import { PREBUILT_ISLANDS_DIR } from './serve-prebuilt-paths';
|
|
16
17
|
|
|
17
|
-
/** App-root-relative
|
|
18
|
-
export const ISLAND_STORE_DIR =
|
|
18
|
+
/** App-root-relative, inside the image's prebuilt store (`serve-prebuilt-paths.ts` says why there). */
|
|
19
|
+
export const ISLAND_STORE_DIR = PREBUILT_ISLANDS_DIR;
|
|
19
20
|
const INDEX = 'index.json';
|
|
20
21
|
|
|
21
22
|
interface StoredChunk {
|
|
@@ -200,16 +201,23 @@ export async function readIslandStore(root: string): Promise<StoreRead> {
|
|
|
200
201
|
return { bundle: islandBundle(chunks, shared) };
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
/** The container's islands, and — when this boot had to build them — why and how many. */
|
|
205
|
+
export interface LoadedIslands {
|
|
206
|
+
readonly bundle: IslandBundle;
|
|
207
|
+
readonly built?: { readonly chunks: number; readonly reason: string };
|
|
208
|
+
}
|
|
209
|
+
|
|
203
210
|
/**
|
|
204
211
|
* The container's islands: the verified store, or a build when there is none to trust. A build is
|
|
205
|
-
* correct and only slower, so a stale store is a
|
|
212
|
+
* correct and only slower, so a stale store is never a refusal — the caller is handed the reason,
|
|
213
|
+
* and `serve-boot.ts` logs it once with everything else the boot built (`X_IMAGE_NOT_PREBUILT`).
|
|
206
214
|
*/
|
|
207
|
-
export async function loadOrBuildIslands(root: string): Promise<
|
|
215
|
+
export async function loadOrBuildIslands(root: string): Promise<LoadedIslands> {
|
|
208
216
|
const read = await readIslandStore(root);
|
|
209
|
-
if (read.bundle !== undefined) return read.bundle;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
217
|
+
if (read.bundle !== undefined) return { bundle: read.bundle };
|
|
218
|
+
const bundle = await buildIslands(root);
|
|
219
|
+
return {
|
|
220
|
+
bundle,
|
|
221
|
+
built: { chunks: bundle.chunks.length + bundle.shared.length, reason: read.stale },
|
|
222
|
+
};
|
|
215
223
|
}
|
package/src/jobs-drain.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// that safe — lease, copy steps, enqueue, then ack — has to be readable in one screen.
|
|
4
4
|
|
|
5
5
|
import { uuid } from '@ultimat3/core';
|
|
6
|
-
import type { JobDriver, JobRecord, JobState } from '@ultimat3/jobs';
|
|
7
|
-
import { inspectJobList } from '@ultimat3/jobs';
|
|
6
|
+
import type { ClaimedJob, JobDriver, JobRecord, JobState } from '@ultimat3/jobs';
|
|
7
|
+
import { claimOf, inspectJobList } from '@ultimat3/jobs';
|
|
8
8
|
import type { Finding } from './output';
|
|
9
9
|
import { findingFrom } from './output';
|
|
10
10
|
|
|
@@ -57,7 +57,7 @@ export interface DrainOutcome {
|
|
|
57
57
|
function leaseCandidates(
|
|
58
58
|
source: JobDriver,
|
|
59
59
|
candidates: readonly JobRecord[],
|
|
60
|
-
): Promise<readonly
|
|
60
|
+
): Promise<readonly ClaimedJob[]> {
|
|
61
61
|
if (candidates.length === 0) return Promise.resolve([]);
|
|
62
62
|
return source.claim({
|
|
63
63
|
queues: [...new Set(candidates.map((record) => record.queue))],
|
|
@@ -82,9 +82,14 @@ async function copySteps(source: JobDriver, target: JobDriver, runId: string): P
|
|
|
82
82
|
* `countsAsAttempt: false` was the only bit the drivers had and `step.sleep` had claimed it;
|
|
83
83
|
* `NackOptions.park` now carries the suspension, so the two callers no longer share one meaning.
|
|
84
84
|
*/
|
|
85
|
-
async function releaseLease(source: JobDriver,
|
|
85
|
+
async function releaseLease(source: JobDriver, leased: ClaimedJob): Promise<void> {
|
|
86
86
|
try {
|
|
87
|
-
|
|
87
|
+
// Fenced on the drain's own claim, like every settle: a lease that lapsed is not handed back.
|
|
88
|
+
await source.nack(leased.id, {
|
|
89
|
+
...claimOf(leased),
|
|
90
|
+
delayMs: 0,
|
|
91
|
+
countsAsAttempt: false,
|
|
92
|
+
});
|
|
88
93
|
} catch {
|
|
89
94
|
// The lease expires on its own. Masking the transfer's real error with this one helps nobody.
|
|
90
95
|
}
|
|
@@ -141,14 +146,16 @@ export async function drainJobs(
|
|
|
141
146
|
// Only now is the ack the drain's to make: the lease proves no source worker holds this
|
|
142
147
|
// job, and the target already has both the row and its steps. A crash between the two
|
|
143
148
|
// leaves the job live on both drivers, where `idempotencyKey` dedupes it.
|
|
144
|
-
|
|
149
|
+
// `counted: false`: the row is finished HERE because it left, and a job that was moved is
|
|
150
|
+
// not a job that completed — it must add nothing to the source's `done` history.
|
|
151
|
+
await source.ack(record.id, { ...claimOf(record), counted: false });
|
|
145
152
|
// Report the row as the drain FOUND it: `claim()` returns it mid-lease (`running`, one
|
|
146
153
|
// attempt higher), a state nothing on either driver is in once this returns.
|
|
147
154
|
moved.push(found.get(record.id) ?? record);
|
|
148
155
|
} catch (error) {
|
|
149
156
|
// A failed enqueue left nothing on the target, so the lease goes back. A failed ack did
|
|
150
157
|
// not: the job is already live there, and releasing it would race the target's worker.
|
|
151
|
-
if (!enqueued) await releaseLease(source, record
|
|
158
|
+
if (!enqueued) await releaseLease(source, record);
|
|
152
159
|
failures.push({ id: record.id, name: record.name, finding: findingFrom(error) });
|
|
153
160
|
}
|
|
154
161
|
}
|
package/src/jobs-json.ts
CHANGED
|
@@ -7,9 +7,11 @@ import type {
|
|
|
7
7
|
DeadLetterEntry,
|
|
8
8
|
JobRecord,
|
|
9
9
|
JobTrace,
|
|
10
|
+
PausedName,
|
|
10
11
|
QueueDepthReport,
|
|
11
12
|
QueueStats,
|
|
12
13
|
StepTrace,
|
|
14
|
+
WorkerRecord,
|
|
13
15
|
} from '@ultimat3/jobs';
|
|
14
16
|
import type { DrainFailure, DrainSkip } from './jobs-drain';
|
|
15
17
|
import type { JsonValue } from './output';
|
|
@@ -59,15 +61,61 @@ export function jobTraceToJson(trace: JobTrace): JsonValue {
|
|
|
59
61
|
runId: trace.runId,
|
|
60
62
|
runAt: trace.runAt,
|
|
61
63
|
lastError: trace.lastError,
|
|
64
|
+
// The failure's stack, bounded by the queue. `null` when the thrown value carried none.
|
|
65
|
+
stack: trace.stack,
|
|
66
|
+
// The payload with every key the app declared secret already replaced (`redactInput`), so it
|
|
67
|
+
// is the queue's promise that this is safe to print — never this file's.
|
|
68
|
+
input: toJson(trace.input),
|
|
69
|
+
progress:
|
|
70
|
+
trace.progress === null
|
|
71
|
+
? null
|
|
72
|
+
: {
|
|
73
|
+
done: trace.progress.done,
|
|
74
|
+
total: trace.progress.total,
|
|
75
|
+
note: trace.progress.note ?? null,
|
|
76
|
+
at: trace.progress.at,
|
|
77
|
+
},
|
|
62
78
|
tenantId: trace.tenantId,
|
|
63
79
|
steps: trace.steps.map(stepTraceToJson),
|
|
64
80
|
retryDelaysMs: trace.retryDelaysMs.map((ms) => ms),
|
|
81
|
+
// `concurrency.key(input)` for a keyed job, `null` for every other: which lease this run waits on.
|
|
82
|
+
concurrencyKey: trace.concurrencyKey,
|
|
65
83
|
// `null` for every job that is not a `backfill()` pass. Dropped, `x jobs show <id> --json`
|
|
66
84
|
// would answer "how far has it got" with silence for the one job kind that can say.
|
|
67
85
|
backfill: trace.backfill === null ? null : backfillToJson(trace.backfill),
|
|
68
86
|
};
|
|
69
87
|
}
|
|
70
88
|
|
|
89
|
+
/**
|
|
90
|
+
* An app payload as plain JSON. A round trip through the serialiser is the proof: whatever a
|
|
91
|
+
* payload held that JSON cannot carry — a bigint, a function, a cycle — degrades to `null` rather
|
|
92
|
+
* than failing the command that was asked to show it.
|
|
93
|
+
*/
|
|
94
|
+
function toJson(value: unknown): JsonValue {
|
|
95
|
+
try {
|
|
96
|
+
const text: string | undefined = JSON.stringify(value);
|
|
97
|
+
return text === undefined ? null : (JSON.parse(text) as JsonValue);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function pausedToJson(paused: PausedName): JsonValue {
|
|
104
|
+
return { name: paused.name, pausedAt: paused.pausedAt };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function workerToJson(worker: WorkerRecord): JsonValue {
|
|
108
|
+
return {
|
|
109
|
+
id: worker.id,
|
|
110
|
+
host: worker.host,
|
|
111
|
+
startedAt: worker.startedAt,
|
|
112
|
+
heartbeatAt: worker.heartbeatAt,
|
|
113
|
+
queues: [...worker.queues],
|
|
114
|
+
concurrency: worker.concurrency,
|
|
115
|
+
inFlight: [...worker.inFlight],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
71
119
|
/**
|
|
72
120
|
* `input` is deliberately excluded: it is `unknown` at the driver boundary (an app-defined
|
|
73
121
|
* payload, not something this package can prove is JSON-safe), and `JobTrace` already sets the
|
|
@@ -100,6 +148,7 @@ function queueStatsToJson(stats: QueueStats): JsonValue {
|
|
|
100
148
|
delayed: stats.delayed,
|
|
101
149
|
running: stats.running,
|
|
102
150
|
suspended: stats.suspended,
|
|
151
|
+
failed: stats.failed,
|
|
103
152
|
dead: stats.dead,
|
|
104
153
|
oldestReadyMs: stats.oldestReadyMs,
|
|
105
154
|
};
|
|
@@ -114,6 +163,7 @@ export function depthToJson(depth: QueueDepthReport): JsonValue {
|
|
|
114
163
|
delayed: depth.totals.delayed,
|
|
115
164
|
running: depth.totals.running,
|
|
116
165
|
suspended: depth.totals.suspended,
|
|
166
|
+
failed: depth.totals.failed,
|
|
117
167
|
dead: depth.totals.dead,
|
|
118
168
|
},
|
|
119
169
|
oldestReadyMs: depth.oldestReadyMs,
|
package/src/jobs-report.ts
CHANGED
|
@@ -72,6 +72,8 @@ export interface JobsListFilter {
|
|
|
72
72
|
readonly state?: string | undefined;
|
|
73
73
|
readonly name?: string | undefined;
|
|
74
74
|
readonly limit?: string | undefined;
|
|
75
|
+
/** `jobCursor(lastRow)` of the page before — the keyset the next page seeks from. */
|
|
76
|
+
readonly after?: string | undefined;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
export interface JobsListResult {
|
|
@@ -103,6 +105,7 @@ export async function listJobs(
|
|
|
103
105
|
...(filter.name === undefined ? {} : { name: filter.name }),
|
|
104
106
|
...(state === undefined ? {} : { state }),
|
|
105
107
|
...(limit === undefined ? {} : { limit }),
|
|
108
|
+
...(filter.after === undefined ? {} : { after: filter.after }),
|
|
106
109
|
};
|
|
107
110
|
const [depth, rows, deadLetters, backfills] = await Promise.all([
|
|
108
111
|
inspectQueues(driver),
|
package/src/json-merge.ts
CHANGED
|
@@ -38,3 +38,49 @@ export function mergeJsonDeep(
|
|
|
38
38
|
}
|
|
39
39
|
return { merged, gained };
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
/** Code units, never `localeCompare`: the bytes must not move with the machine's ICU. */
|
|
43
|
+
const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
44
|
+
|
|
45
|
+
const isSorted = (keys: readonly string[]): boolean =>
|
|
46
|
+
keys.every((key, index) => index === 0 || byCodeUnit(keys[index - 1] ?? '', key) <= 0);
|
|
47
|
+
|
|
48
|
+
/** Every key of every level, sorted — the order a generator's own fresh output is written in. */
|
|
49
|
+
export function sortJsonDeep(value: unknown): unknown {
|
|
50
|
+
if (Array.isArray(value)) return value.map(sortJsonDeep);
|
|
51
|
+
if (!isObject(value)) return value;
|
|
52
|
+
return Object.fromEntries(
|
|
53
|
+
Object.keys(value)
|
|
54
|
+
.sort(byCodeUnit)
|
|
55
|
+
.map((key) => [key, sortJsonDeep(value[key])]),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `mergeJsonDeep`, for a file that is ALREADY ON DISK: the same union, written back in the order
|
|
61
|
+
* the file is in. A catalog a team keeps grouped by screen was re-sorted wholesale by a merge
|
|
62
|
+
* that sorted every level — 424 changed lines for 22 added keys — which buries the keys a run
|
|
63
|
+
* added and turns every generator run into a merge conflict.
|
|
64
|
+
*
|
|
65
|
+
* Per level: a held key stays where it is. A new key takes its SORTED place when that level is
|
|
66
|
+
* sorted already — which is every level a generator wrote, so two runs in either order still write
|
|
67
|
+
* the same bytes — and goes to the END, in sorted order among the new ones, when it is not. A
|
|
68
|
+
* wholly new subtree is the generator's own and arrives sorted.
|
|
69
|
+
*/
|
|
70
|
+
export function mergeJsonInPlace(held: JsonObject, incoming: JsonObject): JsonObject {
|
|
71
|
+
const heldKeys = Object.keys(held);
|
|
72
|
+
const added = Object.keys(incoming)
|
|
73
|
+
.filter((key) => !Object.hasOwn(held, key))
|
|
74
|
+
.sort(byCodeUnit);
|
|
75
|
+
const order = isSorted(heldKeys)
|
|
76
|
+
? [...heldKeys, ...added].sort(byCodeUnit)
|
|
77
|
+
: [...heldKeys, ...added];
|
|
78
|
+
return Object.fromEntries(
|
|
79
|
+
order.map((key) => {
|
|
80
|
+
if (!Object.hasOwn(held, key)) return [key, sortJsonDeep(incoming[key])];
|
|
81
|
+
const current = held[key];
|
|
82
|
+
const next = incoming[key];
|
|
83
|
+
return [key, isObject(current) && isObject(next) ? mergeJsonInPlace(current, next) : current];
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
}
|
package/src/mcp-errors.ts
CHANGED
|
@@ -43,6 +43,14 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
43
43
|
X_VERIFY_MERGE_INCOMPLETE:
|
|
44
44
|
'x verify merge parts/*.json --json # after every CI job uploaded its part',
|
|
45
45
|
X_VERIFY_MERGE_INPUT: 'x verify --only unit --json > part.json # one document per part',
|
|
46
|
+
X_VERIFY_STEP_TIMEOUT:
|
|
47
|
+
'x verify --only unit --json # reproduce the step alone; one that needs longer states it under "stepTimeoutMs" in x.verify.json',
|
|
48
|
+
X_COVERAGE_BELOW_FLOOR:
|
|
49
|
+
'x verify --only unit --json # names the ten files losing the most lines; cover the worst with a test beside it',
|
|
50
|
+
X_COVERAGE_FLOOR_UNSTATED:
|
|
51
|
+
'x verify --only unit --json # the finding carries the measured numbers and the "coverage" line to add to x.verify.json',
|
|
52
|
+
X_COVERAGE_FLOOR_STALE:
|
|
53
|
+
'x verify --only unit --json # the finding carries the measured numbers; raise "coverage" in x.verify.json to them',
|
|
46
54
|
X_TEST_SHARD_FAILED: 'x test --workers 1 --json',
|
|
47
55
|
X_SCAFFOLD_PATH_ESCAPE: 'x g route posts --json # a path with no ".." segment',
|
|
48
56
|
X_GENERATE_JSON_INVALID:
|
|
@@ -233,18 +241,30 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
233
241
|
X_GUARD_FAILED: 'x verify --json # the cause carries the throw the guard raised, verbatim',
|
|
234
242
|
X_GUARD_FINDING_INVALID:
|
|
235
243
|
'x verify --json # then give the finding an X_ code, a cause and a fix naming a command',
|
|
244
|
+
// The finding's own `fix:` is this command plus the edit — the line, and what replaces the call.
|
|
245
|
+
X_BROWSER_TRANSPORT_BYPASS:
|
|
246
|
+
'x verify --only boundaries --json # each finding names the line and the transport to use instead',
|
|
247
|
+
X_BROWSER_SERVER_BARREL:
|
|
248
|
+
'x verify --only boundaries --json # each finding names the import and the browser entry to import instead',
|
|
236
249
|
// `EDITOR=` inline rather than `export`: the variable is only needed for the one invocation, and
|
|
237
250
|
// an agent copying this line gets a working command instead of a shell it has to keep.
|
|
238
251
|
X_SECRETS_EDITOR_MISSING: 'EDITOR=nano x secrets edit',
|
|
239
252
|
X_SECRETS_EDIT_FAILED: 'x secrets show --json # then re-open the buffer: x secrets edit',
|
|
253
|
+
X_SEAL_RESEAL_PENDING:
|
|
254
|
+
'x doctor --json # names the sealed columns and the retired key; re-seal with a backfill(), then: x secrets rotate --drop <keyId>',
|
|
240
255
|
// Render's code, thrown here by the bundler half: the cause names the specifier and the file it
|
|
241
256
|
// resolved to, and `x g island` is what puts that file where the page already says it is.
|
|
242
257
|
X_ISLAND_INVALID: 'x routes --json # the cause names the src; then: x g island <name>',
|
|
243
258
|
// The four slice-11 codes: generated code that passed the gate and failed at runtime.
|
|
259
|
+
// The role map's file is the app's: the finding's `at` names it, so this line never guesses one.
|
|
244
260
|
X_PERMISSION_UNGRANTED:
|
|
245
|
-
'x verify --only policy --json # then add the permission to a role in
|
|
261
|
+
'x verify --only policy --json # then add the permission to a role in the file the finding names (at)',
|
|
246
262
|
X_JOB_UNREGISTERED:
|
|
247
263
|
'x manifest --json # after listing the module under jobs: [...] in apps/web/api/index.ts',
|
|
264
|
+
// The boot's twin of the line above: the worker or scheduler loaded only what the API index
|
|
265
|
+
// imports, and the manifest names a job or task that load did not register.
|
|
266
|
+
X_ROLE_LOAD_INCOMPLETE:
|
|
267
|
+
'x manifest --json # after importing the module the cause names from apps/web/api/index.ts',
|
|
248
268
|
X_FEATURE_UNKNOWN:
|
|
249
269
|
'x entities --json # then x g resource <feature> for the one the cause names',
|
|
250
270
|
X_BUDGET_PARAMS_UNDECLARED:
|
|
@@ -256,6 +276,14 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
256
276
|
X_DEV_RESTART_REQUIRED:
|
|
257
277
|
'x dev --json # a fresh process; the supervised x dev restarts itself on such a save',
|
|
258
278
|
X_DEV_ROOT_GONE: 'x dev --json # from the app root, once it exists again',
|
|
279
|
+
X_DB_HANDLE_UNREGISTERED:
|
|
280
|
+
'x g entity widget --dry-run --json # lists the files; the finding names the two lines to add to packages/db/src/client.ts',
|
|
281
|
+
X_ADMIN_RESOURCE_UNWIRED:
|
|
282
|
+
'x g resource widget --admin --dry-run --json # lists the files; the finding names the two lines to add to apps/admin/app/admin/admin.ts',
|
|
283
|
+
X_IMAGE_NOT_PREBUILT:
|
|
284
|
+
'edit docker/Dockerfile: add RUN bun node_modules/@ultimat3/cli/src/bin.ts build --target prebuilt after COPY . . in the runtime stage, then rebuild the image',
|
|
285
|
+
X_ADMIN_UNSCANNED:
|
|
286
|
+
'git mv apps/admin/src/index.ts apps/admin/app/admin/admin.ts # then repoint its relative imports and x verify --only manifest --json',
|
|
259
287
|
};
|
|
260
288
|
|
|
261
289
|
const isCliCode = (code: string): code is CliErrorCode =>
|
package/src/messages.ts
CHANGED
|
@@ -50,12 +50,21 @@ const CATALOG = {
|
|
|
50
50
|
'cli.db.branch.unknown': '-',
|
|
51
51
|
'cli.db.gen.failed': 'migration not generated',
|
|
52
52
|
'cli.db.gen.unchanged': 'entities and migrations agree — nothing to generate',
|
|
53
|
+
'cli.db.gen.blocked':
|
|
54
|
+
'nothing was compared — the app did not load ({count} finding(s)); fix them, then run x db gen again',
|
|
53
55
|
// A THIRD outcome, and it is neither of the other two: nothing to generate, but the sidecar the
|
|
54
56
|
// `drift` step reads did move — an edit under `packages/db/src` that implies no DDL. Rendering it
|
|
55
57
|
// as `written` would name a migration nobody can apply; as `unchanged`, it would hide a file this
|
|
56
58
|
// command wrote. `GeneratedFiles.outcome` is what `--json` carries the same distinction on.
|
|
57
59
|
'cli.db.gen.recorded': 'no migration needed — schema hash re-recorded in {file}',
|
|
60
|
+
// The fourth: no DDL and no sidecar moved, but `packages/db/schema/` did — a hand edit repaired,
|
|
61
|
+
// or a framework table that changed under an upgrade. `unchanged` would deny a write the lines
|
|
62
|
+
// below it list, and this command is `X_SCHEMA_DUMP_DRIFT`'s whole `fix:`.
|
|
63
|
+
'cli.db.gen.dumped': 'no migration needed — schema dump rewritten ({count} file(s))',
|
|
58
64
|
'cli.db.gen.written': 'migration {id} generated',
|
|
65
|
+
// Exit 1 and a migration on disk at once: the summary carries the half the exit code hides.
|
|
66
|
+
'cli.db.gen.writtenDumpFailed':
|
|
67
|
+
'migration {id} generated and written — the schema dump was not; do not generate it again',
|
|
59
68
|
'cli.db.migrate.applied': 'migrations applied',
|
|
60
69
|
'cli.db.migrate.failed': 'migration failed',
|
|
61
70
|
'cli.db.reset.done': 'database reset and migrated',
|
|
@@ -88,6 +97,11 @@ const CATALOG = {
|
|
|
88
97
|
'cli.deploy.plan': 'containers only: {images} image, roles {roles}',
|
|
89
98
|
'cli.doctor.clean': 'no findings — environment is shippable',
|
|
90
99
|
'cli.doctor.findings': '{count} finding(s)',
|
|
100
|
+
'cli.doctor.guards':
|
|
101
|
+
'{count} shipped guard(s) this app does not hold — each line adds one, then x verify reports what it finds:',
|
|
102
|
+
// `x doctor`'s other listing: what the coverage floor in x.verify.json does not see, and why.
|
|
103
|
+
'cli.doctor.coverageExclude':
|
|
104
|
+
'{count} path(s) x.verify.json excludes from the coverage floor — each with the reason it gives:',
|
|
91
105
|
'cli.docs.code': '{code} is an error code — x errors explain answers it',
|
|
92
106
|
'cli.docs.exports': 'exports: {list}',
|
|
93
107
|
'cli.docs.installed': 'installed: {list}',
|
|
@@ -110,6 +124,8 @@ const CATALOG = {
|
|
|
110
124
|
'cli.fix.plan':
|
|
111
125
|
'{count} boundary violation(s) involve {file} — {edits} edit(s) to make, nothing written',
|
|
112
126
|
'cli.generate.wrote': 'wrote {count} file(s) for {kind} {name}',
|
|
127
|
+
// The run that declared a table: `{next}` is commands, joined with `&&`, that run as printed.
|
|
128
|
+
'cli.generate.wroteNext': 'wrote {count} file(s) for {kind} {name} — next: {next}',
|
|
113
129
|
// A distinct key, not the same sentence with a flag beside it: `--dry-run` reported "wrote 4
|
|
114
130
|
// file(s)" while `data.dryRun` said nothing had landed, so an agent branching on `summary`
|
|
115
131
|
// believed the files were on disk.
|
|
@@ -131,7 +147,13 @@ const CATALOG = {
|
|
|
131
147
|
'cli.jobs.drainedPartial':
|
|
132
148
|
'drained {count} job(s) from {from} to {to} — {skipped} left on {from}',
|
|
133
149
|
'cli.jobs.listed': '{count} job(s)',
|
|
150
|
+
'cli.jobs.nextPage': 'more — next page: x jobs ls --after {cursor}',
|
|
134
151
|
'cli.jobs.noError': 'no error recorded',
|
|
152
|
+
'cli.jobs.paused': 'queue {queue} paused — no worker claims from it; enqueues still land',
|
|
153
|
+
'cli.jobs.pausedQueues': 'paused queue(s): {queues}',
|
|
154
|
+
'cli.jobs.promoted': 'job {id} promoted — due now',
|
|
155
|
+
'cli.jobs.removed': 'job {id} removed — it was {state}',
|
|
156
|
+
'cli.jobs.resumed': 'queue {queue} resumed',
|
|
135
157
|
'cli.jobs.retried': 'job {id} re-queued — {state}',
|
|
136
158
|
'cli.jobs.shown': 'job {id} — {state}, attempt {attempt} of {attempts}',
|
|
137
159
|
'cli.jobs.skipped': '{count} job(s) left on {from} — re-run the drain once each is claimable:',
|
|
@@ -296,6 +318,12 @@ const CATALOG = {
|
|
|
296
318
|
// no skipped test to read a reason off, so the files the step selected hold no test.
|
|
297
319
|
'cli.verify.ranNothing': 'found no test to run in the file(s) it selected',
|
|
298
320
|
'cli.verify.merged': 'merged {parts} part(s) — {summary}',
|
|
321
|
+
// The `unit` step's own measurement, in `output`: what the suite covered of the app's whole
|
|
322
|
+
// source tree, beside the floor it was held to. A shard says it measured a slice instead.
|
|
323
|
+
'cli.verify.coverage':
|
|
324
|
+
'coverage: {lines}% of lines, {funcs}% of functions over {files} source file(s) — floor {floor}',
|
|
325
|
+
'cli.verify.coverageDeferred':
|
|
326
|
+
'coverage: this shard measured its own slice — x verify merge folds every shard and judges the floor',
|
|
299
327
|
'cli.verify.serial': 'serial',
|
|
300
328
|
'cli.verify.workers': '{workers} workers',
|
|
301
329
|
'cli.env.checked': '{count} declared variable(s), all present and valid',
|
|
@@ -314,6 +342,11 @@ const CATALOG = {
|
|
|
314
342
|
'cli.secrets.set': 'sealed {name} into {path} — {count} secret(s)',
|
|
315
343
|
'cli.secrets.rotated':
|
|
316
344
|
'rotated {path} from master key {from} to {to} — {count} secret(s) resealed',
|
|
345
|
+
'cli.secrets.retired':
|
|
346
|
+
' master key {kid} is retired, not gone: values it sealed still open — once they are re-sealed, x secrets rotate --drop {kid}',
|
|
347
|
+
'cli.secrets.dropped':
|
|
348
|
+
'dropped retired master key {kid} from {path} — {count} retired key(s) still declared',
|
|
349
|
+
'cli.secrets.ring': 'retired master key(s) that still open sealed values: {kids}',
|
|
317
350
|
} as const;
|
|
318
351
|
|
|
319
352
|
export type MessageKey = keyof typeof CATALOG;
|
package/src/metrics-endpoint.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Single responsibility: the scrape listener every role opens. `@ultimat3/core` declares the
|
|
2
2
|
// series and renders the body; this is the one place a process answers `METRICS_PATH` with it, so
|
|
3
|
-
// `docker/helm`'s HPAs read a number instead of `<unknown>`.
|
|
3
|
+
// `docker/helm`'s HPAs read a number instead of `<unknown>`. Opening it starts the process's own
|
|
4
|
+
// series (`process_*`): a role that can be scraped reports what it costs.
|
|
4
5
|
|
|
5
6
|
import {
|
|
6
7
|
finiteCount,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
METRICS_PATH,
|
|
10
11
|
markListening,
|
|
11
12
|
metricsText,
|
|
13
|
+
startProcessMetrics,
|
|
12
14
|
stringField,
|
|
13
15
|
UltimateError,
|
|
14
16
|
} from '@ultimat3/core';
|
|
@@ -64,6 +66,55 @@ export interface MetricsEndpointOptions {
|
|
|
64
66
|
readonly port?: number;
|
|
65
67
|
/** A container must bind every interface; a laptop must not. Same decision as the web role. */
|
|
66
68
|
readonly hostname?: string;
|
|
69
|
+
/**
|
|
70
|
+
* The `process_info{role}` label: `ROLE` in a container, the roles `x dev` runs joined by `+`.
|
|
71
|
+
* A host that opens the listener itself and names none is labelled `unknown`.
|
|
72
|
+
*/
|
|
73
|
+
readonly role?: string;
|
|
74
|
+
/**
|
|
75
|
+
* What a taken port means. `refuse` (the default, and every container's): `X_PORT_IN_USE` —
|
|
76
|
+
* Prometheus is configured against the declared port, so listening anywhere else is a scrape
|
|
77
|
+
* that silently finds nothing. `free`: bind whichever port the kernel hands out and say which —
|
|
78
|
+
* `x dev` with no `METRICS_PORT`, where two apps on one laptop both default to 9090.
|
|
79
|
+
*/
|
|
80
|
+
readonly whenTaken?: 'refuse' | 'free';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Which answer a boot gives. A laptop that named no port takes a free one when 9090 is another
|
|
85
|
+
* app's; a declared `METRICS_PORT` — and every container — is the port a scraper was told, so it
|
|
86
|
+
* refuses rather than listen where nothing will look.
|
|
87
|
+
*/
|
|
88
|
+
export const whenMetricsPortTaken = (
|
|
89
|
+
dev: boolean,
|
|
90
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
91
|
+
): 'refuse' | 'free' => {
|
|
92
|
+
const declared = env['METRICS_PORT'];
|
|
93
|
+
return dev && (declared === undefined || declared.trim().length === 0) ? 'free' : 'refuse';
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The bind and its one retry. `bind` is `Bun.serve` behind a seam: whether a kernel refuses a
|
|
98
|
+
* second bind is the OS's answer, and the rule here is testable without racing a socket for it.
|
|
99
|
+
* Port 0 is never retried — the kernel chose it, so a refusal there is not a collision.
|
|
100
|
+
*/
|
|
101
|
+
export function bindScrapePort<T>(
|
|
102
|
+
port: number,
|
|
103
|
+
whenTaken: 'refuse' | 'free',
|
|
104
|
+
bind: (port: number) => T,
|
|
105
|
+
): T {
|
|
106
|
+
try {
|
|
107
|
+
return bind(port);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (!isAddressInUse(error)) throw error;
|
|
110
|
+
if (whenTaken === 'refuse' || port === 0) throw new MetricsPortInUseError({ port });
|
|
111
|
+
logger.warn('ultimate metrics port taken', {
|
|
112
|
+
port,
|
|
113
|
+
cause: `the metrics port ${String(port)} is already bound — another x dev, or a Prometheus — so this process listens on a free one`,
|
|
114
|
+
fix: `METRICS_PORT=${String(neighbouringPort(port))} x dev --json`,
|
|
115
|
+
});
|
|
116
|
+
return bind(0);
|
|
117
|
+
}
|
|
67
118
|
}
|
|
68
119
|
|
|
69
120
|
export interface MetricsEndpoint {
|
|
@@ -86,29 +137,24 @@ export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): Metr
|
|
|
86
137
|
const port = finiteCount('startMetricsEndpoint', 'port', options.port ?? DEFAULT_METRICS_PORT);
|
|
87
138
|
// `startRoles` opens this FIRST, before any role, so `Bun.serve`'s own bare `Error` was what a
|
|
88
139
|
// second `x dev` on one machine reported: no code, no fix, at the boot path this package owns.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (!isAddressInUse(error)) throw error;
|
|
108
|
-
throw new MetricsPortInUseError({ port });
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
const server = listen();
|
|
140
|
+
const server = bindScrapePort(port, options.whenTaken ?? 'refuse', (candidate) =>
|
|
141
|
+
Bun.serve({
|
|
142
|
+
port: candidate,
|
|
143
|
+
hostname: options.hostname ?? 'localhost',
|
|
144
|
+
fetch(request: Request): Response {
|
|
145
|
+
if (new URL(request.url).pathname !== METRICS_PATH) {
|
|
146
|
+
return new Response('not found', { status: 404 });
|
|
147
|
+
}
|
|
148
|
+
// `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot
|
|
149
|
+
// steal each other's samples — but a cache would hand the second one a stale window.
|
|
150
|
+
return new Response(metricsText(), {
|
|
151
|
+
headers: { 'content-type': METRICS_CONTENT_TYPE, 'cache-control': 'no-store' },
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
// After the bind: a port that was taken starts no sampler nobody would stop.
|
|
157
|
+
const stopProcessMetrics = startProcessMetrics({ role: options.role ?? 'unknown' });
|
|
112
158
|
// Same rule as every other socket the framework opens: announce it, so a request back to it is
|
|
113
159
|
// recognisably this process calling itself rather than egress the test seal must refuse.
|
|
114
160
|
const stopListening = markListening(server.url.origin);
|
|
@@ -118,6 +164,7 @@ export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): Metr
|
|
|
118
164
|
stop(): void {
|
|
119
165
|
server.stop(true);
|
|
120
166
|
stopListening();
|
|
167
|
+
stopProcessMetrics();
|
|
121
168
|
},
|
|
122
169
|
};
|
|
123
170
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Which Postgres extensions an app's migrations create. One reader for the two embedded boots
|
|
2
|
+
// that must agree — `x dev`'s database (`runtime-queue.ts`) and the schema dump's scratch replay
|
|
3
|
+
// (`db-schema-dump.ts`): PGlite links an extension only when it is named at boot, and an app that
|
|
4
|
+
// replays in the gate and fails in `x dev` was the result of only one of them asking.
|
|
5
|
+
|
|
6
|
+
import type { Migration } from '@ultimat3/db';
|
|
7
|
+
import { readMigrations } from './migrations';
|
|
8
|
+
|
|
9
|
+
const CREATE_EXTENSION = /\bcreate\s+extension\s+(?:if\s+not\s+exists\s+)?"?([a-z][a-z0-9_-]*)"?/gi;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Every extension the migrations create, sorted. Matched on the raw text, comments included, on
|
|
13
|
+
* purpose: over-reading costs one bundle that is never created, and a name nothing ships is
|
|
14
|
+
* refused later by `create extension` itself. Blanking quoted identifiers first would lose
|
|
15
|
+
* `"uuid-ossp"`, the one spelling that needs its quotes.
|
|
16
|
+
*/
|
|
17
|
+
export function migrationExtensions(migrations: readonly Migration[]): readonly string[] {
|
|
18
|
+
const names = new Set<string>();
|
|
19
|
+
for (const migration of migrations) {
|
|
20
|
+
for (const match of migration.up.matchAll(CREATE_EXTENSION)) {
|
|
21
|
+
if (match[1] !== undefined) names.add(match[1].toLowerCase());
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return [...names].sort();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The same list for an app on disk — what an embedded boot hands `createPgliteClient`. */
|
|
28
|
+
export const appExtensions = async (root: string): Promise<readonly string[]> =>
|
|
29
|
+
migrationExtensions(await readMigrations(root));
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The files one module's source imports, resolved the way Bun will resolve them. One function for
|
|
2
|
+
// the two graphs built over it: the reload graph (`app-reload-graph.ts`), which follows every file
|
|
3
|
+
// a module NAMES, and the document graph (`document-graph.ts`), which follows only what
|
|
4
|
+
// EVALUATING the module evaluates. Each caller keeps its own rule for which targets it walks.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path API; an import specifier resolves against the importing file's directory.
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `referenced`: every file the module names, an `await import()` target included — a save to one
|
|
11
|
+
* must reload its importer. `evaluated`: what importing the module evaluates, so the lazy targets
|
|
12
|
+
* are left out. Each keeps the transpiler pass its graph was built on (`scanImports`, the fast
|
|
13
|
+
* one, and `scan`); measured on Bun 1.4.0 the two agree on every static shape — both drop
|
|
14
|
+
* `import type` and an inline `type` specifier, both keep a value import used only as a type.
|
|
15
|
+
*/
|
|
16
|
+
export type ImportEdges = 'referenced' | 'evaluated';
|
|
17
|
+
|
|
18
|
+
const transpilers = {
|
|
19
|
+
ts: new Bun.Transpiler({ loader: 'ts' }),
|
|
20
|
+
tsx: new Bun.Transpiler({ loader: 'tsx' }),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Absolute paths, in source order. A file that will not parse imports nothing yet, and a
|
|
25
|
+
* specifier that will not resolve contributes no edge: the module's own import reports both.
|
|
26
|
+
*/
|
|
27
|
+
export function resolvedImports(path: string, source: string, edges: ImportEdges): string[] {
|
|
28
|
+
const transpiler = path.endsWith('x') ? transpilers.tsx : transpilers.ts;
|
|
29
|
+
let specifiers: readonly { readonly path: string; readonly kind: string }[];
|
|
30
|
+
try {
|
|
31
|
+
specifiers =
|
|
32
|
+
edges === 'referenced'
|
|
33
|
+
? transpiler.scanImports(source)
|
|
34
|
+
: transpiler.scan(source).imports.filter((one) => one.kind !== 'dynamic-import');
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const found: string[] = [];
|
|
39
|
+
for (const { path: specifier } of specifiers) {
|
|
40
|
+
try {
|
|
41
|
+
found.push(Bun.resolveSync(specifier, dirname(path)));
|
|
42
|
+
} catch {
|
|
43
|
+
// Unresolvable: no edge.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return found;
|
|
47
|
+
}
|