@ultimat3/cli 22.15.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 +23 -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 +25 -1
- 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-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 +9 -0
- 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/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/permission-grants.ts +41 -9
- 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 +6 -0
- 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 -157
- 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/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/budgets.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { join } from 'node:path';
|
|
|
11
11
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
12
12
|
import type { Manifest, RouteFact } from '@ultimat3/manifest';
|
|
13
13
|
import {
|
|
14
|
+
describeRoutes,
|
|
14
15
|
formatBytes,
|
|
15
16
|
parseByteBudget,
|
|
16
17
|
SPECULATION_RULES_TYPE,
|
|
@@ -74,6 +75,25 @@ const chainOf = (stats: RouteStats): string =>
|
|
|
74
75
|
|
|
75
76
|
const jsBudgetOf = (route: RouteFact): number | null => parseByteBudget(route.budget?.js);
|
|
76
77
|
|
|
78
|
+
/** Which file declares a route, by URL — the loaded app's route table, read when the step runs. */
|
|
79
|
+
export type RouteFileOf = (url: string) => string | undefined;
|
|
80
|
+
|
|
81
|
+
const declaredFileOf: RouteFileOf = (url) =>
|
|
82
|
+
describeRoutes().find((route) => route.path === url && route.mount === undefined)?.file;
|
|
83
|
+
|
|
84
|
+
const KB = 1024;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The paste, not the advice: the file, the literal to set (the measured bytes rounded UP to a
|
|
88
|
+
* whole kb, so the raise is the smallest that clears) and the comment `bun run budget-raises`
|
|
89
|
+
* reads above it. The other way out stays beside it — a raise is axiom 9's to allow, not to force.
|
|
90
|
+
*/
|
|
91
|
+
function exceededJsFix(url: string, file: string | undefined, bytes: number): string {
|
|
92
|
+
const where = file ?? `the file declaring ${url}`;
|
|
93
|
+
const kb = Math.ceil(bytes / KB);
|
|
94
|
+
return `edit ${where} — set budget: { js: '${String(kb)}kb' } with // measured: ${String(bytes)} B (x build --target static) — why: <the function it buys> directly above it; or x routes --json for the chain and move the heavy import behind hydrate: 'interaction'`;
|
|
95
|
+
}
|
|
96
|
+
|
|
77
97
|
/** Which budgets the route declared, for a cause line that names what went unmeasured. */
|
|
78
98
|
function declaredBudgets(js: number | null, lcp: number | undefined): string {
|
|
79
99
|
const labels: string[] = [];
|
|
@@ -169,11 +189,15 @@ function ownCodeFinding(url: string, unmeasured: readonly UnmeasuredRoute[]): Fi
|
|
|
169
189
|
* each failure's code when it had one. Optional because the report is written beside the stats
|
|
170
190
|
* and can be absent for the same reason; with it, a route whose measurement failed on a code in
|
|
171
191
|
* `REPORTED_BY_OWN_CODE` is reported under that code, with the build's own cause and fix.
|
|
192
|
+
*
|
|
193
|
+
* `fileOf` names the file an `X_BUDGET_EXCEEDED` fix edits: the step runs after the app loaded,
|
|
194
|
+
* so render's route table already holds it. A test hands its own.
|
|
172
195
|
*/
|
|
173
196
|
export function checkBudgets(
|
|
174
197
|
manifest: Manifest,
|
|
175
198
|
stats: BuildStats | undefined,
|
|
176
199
|
unmeasured: readonly UnmeasuredRoute[] = [],
|
|
200
|
+
fileOf: RouteFileOf = declaredFileOf,
|
|
177
201
|
): readonly Finding[] {
|
|
178
202
|
const byPath = new Map((stats?.routes ?? []).map((route) => [route.path, route]));
|
|
179
203
|
// Routes no build can weigh by construction (`UnmeasuredRoute.weighable`): printed by the step,
|
|
@@ -205,7 +229,7 @@ export function checkBudgets(
|
|
|
205
229
|
findings.push({
|
|
206
230
|
code: 'X_BUDGET_EXCEEDED',
|
|
207
231
|
cause: `${route.url} ships ${formatBytes(measured.jsBytes)} of JS (minified, uncompressed) over a ${formatBytes(js)} budget via ${chainOf(measured)}`,
|
|
208
|
-
fix:
|
|
232
|
+
fix: exceededJsFix(route.url, fileOf(route.url), measured.jsBytes),
|
|
209
233
|
docs: ERROR_DOCS_URL,
|
|
210
234
|
at: route.url,
|
|
211
235
|
});
|
package/src/cmd-build-spec.ts
CHANGED
|
@@ -5,12 +5,19 @@ import type { CommandSpec } from './parse';
|
|
|
5
5
|
|
|
6
6
|
export const buildSpec: CommandSpec = {
|
|
7
7
|
name: 'build',
|
|
8
|
-
summary:
|
|
8
|
+
summary:
|
|
9
|
+
'build a container image, a single binary, a prerendered static site, or the store an image boots from',
|
|
9
10
|
usage:
|
|
10
|
-
'x build --target docker|binary|static [--tag name] [--out path] [--no-preflight] [--json]',
|
|
11
|
+
'x build --target docker|binary|static|prebuilt [--tag name] [--out path] [--no-preflight] [--json]',
|
|
11
12
|
requiresApp: true,
|
|
12
13
|
flags: [
|
|
13
|
-
{
|
|
14
|
+
{
|
|
15
|
+
name: 'target',
|
|
16
|
+
type: 'string',
|
|
17
|
+
summary:
|
|
18
|
+
'docker | binary | static | prebuilt — prebuilt is the line a Dockerfile runs inside the image build',
|
|
19
|
+
default: 'docker',
|
|
20
|
+
},
|
|
14
21
|
{ name: 'tag', type: 'string', summary: 'image tag (docker target)' },
|
|
15
22
|
{ name: 'out', type: 'string', summary: 'output path (binary and static targets)' },
|
|
16
23
|
{
|
package/src/cmd-build.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// `x build --target docker|binary|static` —
|
|
2
|
-
//
|
|
1
|
+
// `x build --target docker|binary|static|prebuilt` — no platform primitives. Deploy anywhere means
|
|
2
|
+
// "anywhere that runs a container or a binary"; nothing here knows the name of a cloud. `prebuilt`
|
|
3
|
+
// is the docker target's other half: the line the Dockerfile runs INSIDE the image build.
|
|
3
4
|
|
|
4
5
|
import { existsSync } from 'node:fs';
|
|
5
6
|
import { join, resolve } from 'node:path';
|
|
@@ -9,13 +10,13 @@ import { buildSpec } from './cmd-build-spec';
|
|
|
9
10
|
import { runVerify } from './cmd-verify';
|
|
10
11
|
import type { CliCommand, CommandContext } from './command';
|
|
11
12
|
import { externalArgs } from './compile-externals';
|
|
12
|
-
import { BuildEntryMissingError, UnknownCommandError } from './errors';
|
|
13
|
+
import { BadFlagError, BuildEntryMissingError, UnknownCommandError } from './errors';
|
|
13
14
|
import type { ExecResult } from './exec';
|
|
14
15
|
import { execOutput } from './exec';
|
|
15
|
-
import { prepareImage } from './image-prepare';
|
|
16
16
|
import { msg } from './messages';
|
|
17
17
|
import type { CommandResult } from './output';
|
|
18
18
|
import { flagString } from './parse';
|
|
19
|
+
import { PREBUILT_DIR } from './serve-prebuilt-paths';
|
|
19
20
|
import type { StaticReport } from './static-report';
|
|
20
21
|
import {
|
|
21
22
|
readStaticReport,
|
|
@@ -24,10 +25,13 @@ import {
|
|
|
24
25
|
staticReportData,
|
|
25
26
|
} from './static-report';
|
|
26
27
|
|
|
27
|
-
export const BUILD_TARGETS = ['docker', 'binary', 'static'] as const;
|
|
28
|
+
export const BUILD_TARGETS = ['docker', 'binary', 'static', 'prebuilt'] as const;
|
|
28
29
|
|
|
29
30
|
export type BuildTarget = (typeof BUILD_TARGETS)[number];
|
|
30
31
|
|
|
32
|
+
/** The targets whose builder is a subprocess. `prebuilt` is this process, inside `docker build`. */
|
|
33
|
+
export type SpawnedTarget = Exclude<BuildTarget, 'prebuilt'>;
|
|
34
|
+
|
|
31
35
|
export function readTarget(raw: string | undefined): BuildTarget {
|
|
32
36
|
const targets: readonly string[] = BUILD_TARGETS;
|
|
33
37
|
if (raw === undefined) return 'docker';
|
|
@@ -48,6 +52,8 @@ export const BUILD_ENTRY: Readonly<Record<BuildTarget, string>> = {
|
|
|
48
52
|
docker: 'docker/Dockerfile',
|
|
49
53
|
binary: 'apps/web/server.ts',
|
|
50
54
|
static: 'apps/web/prerender.ts',
|
|
55
|
+
// What boots from the store: an image with no server entry has nothing to prebuild for.
|
|
56
|
+
prebuilt: 'apps/web/server.ts',
|
|
51
57
|
};
|
|
52
58
|
|
|
53
59
|
/** Absolute path of the target's entry, or the error that names the file and what writes it. */
|
|
@@ -109,7 +115,7 @@ export function staticArgs(root: string, out: string): readonly string[] {
|
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
export function argsFor(
|
|
112
|
-
target:
|
|
118
|
+
target: SpawnedTarget,
|
|
113
119
|
paths: {
|
|
114
120
|
readonly root: string;
|
|
115
121
|
readonly tag: string;
|
|
@@ -184,6 +190,45 @@ export function buildResult(input: {
|
|
|
184
190
|
};
|
|
185
191
|
}
|
|
186
192
|
|
|
193
|
+
/**
|
|
194
|
+
* `x build --target prebuilt`: the island chunks and compiled stylesheets, written by the process
|
|
195
|
+
* `docker build` runs after the source is copied in. No gate and no subprocess — the image holds
|
|
196
|
+
* no devDependencies to typecheck with, and `x verify` ran before `docker build` was called.
|
|
197
|
+
*
|
|
198
|
+
* A module that would not import fails the image build rather than warning into a build log
|
|
199
|
+
* nobody reads: its stylesheets are missing from the store, and every pod would compile them.
|
|
200
|
+
*/
|
|
201
|
+
async function buildPrebuilt(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
202
|
+
// The boot reads ONE place; a flag that seemed to move it would write a store no pod opens.
|
|
203
|
+
for (const flag of ['tag', 'out']) {
|
|
204
|
+
if (flagString(ctx.args, flag) === undefined) continue;
|
|
205
|
+
throw new BadFlagError({
|
|
206
|
+
flag,
|
|
207
|
+
command: 'build',
|
|
208
|
+
reason: `the prebuilt target writes ${PREBUILT_DIR}, the one place a container boots from, and takes no --${flag}`,
|
|
209
|
+
fix: 'x build --target prebuilt',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const started = Bun.nanoseconds();
|
|
213
|
+
const { prebuildImage } = await import('./image-prepare');
|
|
214
|
+
const built = await prebuildImage(root);
|
|
215
|
+
const ok = built.findings.length === 0;
|
|
216
|
+
return {
|
|
217
|
+
ok,
|
|
218
|
+
command: 'build',
|
|
219
|
+
summary: msg(ok ? 'cli.build.done' : 'cli.build.failed', { target: 'prebuilt' }),
|
|
220
|
+
findings: built.findings,
|
|
221
|
+
data: {
|
|
222
|
+
target: 'prebuilt',
|
|
223
|
+
artifact: built.dir,
|
|
224
|
+
durationMs: Math.round((Bun.nanoseconds() - started) / 1e6),
|
|
225
|
+
islands: built.islands,
|
|
226
|
+
stylesheets: built.stylesheets,
|
|
227
|
+
},
|
|
228
|
+
lines: [],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
187
232
|
/** The static steps `x build` runs before it builds, and `--no-preflight` leaves to the gate. */
|
|
188
233
|
export const PREFLIGHT_STEPS: readonly string[] = [
|
|
189
234
|
'typecheck',
|
|
@@ -203,6 +248,7 @@ export const buildCommand: CliCommand = {
|
|
|
203
248
|
// typecheck, and eight seconds of `tsc` ahead of "that file does not exist" is eight seconds
|
|
204
249
|
// an agent spends on the wrong question.
|
|
205
250
|
requireEntry(root, target);
|
|
251
|
+
if (target === 'prebuilt') return buildPrebuilt(ctx, root);
|
|
206
252
|
|
|
207
253
|
// Run static verify steps before building — unless the caller is a gate that runs the same
|
|
208
254
|
// six steps itself right after (`bin/check`: `x build --no-preflight && x verify`), where the
|
|
@@ -227,9 +273,10 @@ export const buildCommand: CliCommand = {
|
|
|
227
273
|
? join(root, '.x', target === 'static' ? 'static' : 'app')
|
|
228
274
|
: resolve(ctx.cwd, outFlag);
|
|
229
275
|
const tag = flagString(ctx.args, 'tag') ?? 'ultimate-app:dev';
|
|
230
|
-
// The docker target stamps the manifest's build id into the image,
|
|
231
|
-
//
|
|
232
|
-
const buildId =
|
|
276
|
+
// The docker target stamps the manifest's build id into the image, so no role derives one at
|
|
277
|
+
// boot. The island chunks and stylesheets are the image build's own (`--target prebuilt`).
|
|
278
|
+
const buildId =
|
|
279
|
+
target === 'docker' ? await (await import('./image-prepare')).imageBuildId(root) : undefined;
|
|
233
280
|
const command = argsFor(target, {
|
|
234
281
|
root,
|
|
235
282
|
tag,
|
package/src/cmd-db.ts
CHANGED
|
@@ -22,6 +22,13 @@ import { plannedSubcommand } from './cmd-planned';
|
|
|
22
22
|
import type { CliCommand, CommandContext } from './command';
|
|
23
23
|
import { stepFinding } from './db-finding';
|
|
24
24
|
import { generateAppMigration, unrenderedJson, unrenderedLines } from './db-generate';
|
|
25
|
+
import { liveObjectDrift } from './db-object-drift';
|
|
26
|
+
import {
|
|
27
|
+
afterWrittenMigration,
|
|
28
|
+
refreshSchemaDump,
|
|
29
|
+
type SchemaDumpRefresh,
|
|
30
|
+
schemaDumpJson,
|
|
31
|
+
} from './db-schema-refresh';
|
|
25
32
|
import type { SeedPassRow } from './db-seed';
|
|
26
33
|
import {
|
|
27
34
|
discoverSeeds,
|
|
@@ -109,34 +116,52 @@ async function runGen(ctx: CommandContext, root: string, name: string): Promise<
|
|
|
109
116
|
// `x i18n add fr` failure, repeated. The count is the verdict; the gate's own `drift` step is
|
|
110
117
|
// where a red belongs, and it reads the same list to decide that `x db gen` is not the fix.
|
|
111
118
|
const lost = unrenderedLines(generated.unrendered);
|
|
119
|
+
// After the migration, from the migrations alone: the dump is what replaying them produces, so
|
|
120
|
+
// it is rewritten on all three outcomes — `unchanged` included, which is what makes this
|
|
121
|
+
// command the `fix:` on `X_SCHEMA_DUMP_DRIFT` when a framework table or a hand edit moved it.
|
|
122
|
+
const dump = await refreshSchemaDump(root, ctx.env);
|
|
123
|
+
const dumpFindings = dump.finding === undefined ? [] : [dump.finding];
|
|
112
124
|
const migration = generated.migration;
|
|
113
125
|
if (migration === undefined) {
|
|
114
126
|
return {
|
|
115
|
-
ok: generated.findings.length === 0,
|
|
127
|
+
ok: generated.findings.length === 0 && dumpFindings.length === 0,
|
|
116
128
|
command: 'db',
|
|
117
129
|
// The sidecar path, never a bare id: `hash-recorded` writes exactly one, and it is the file
|
|
118
130
|
// the `drift` step reads back.
|
|
131
|
+
// `blocked` FIRST: the app did not load, so nothing was diffed, and every sentence below
|
|
132
|
+
// it is a claim about a comparison that never ran. It read "entities and migrations agree"
|
|
133
|
+
// over an exit 1.
|
|
119
134
|
summary:
|
|
120
|
-
generated.outcome === '
|
|
121
|
-
? msg('cli.db.gen.
|
|
122
|
-
:
|
|
123
|
-
|
|
135
|
+
generated.outcome === 'blocked'
|
|
136
|
+
? msg('cli.db.gen.blocked', { count: generated.findings.length })
|
|
137
|
+
: generated.outcome === 'hash-recorded'
|
|
138
|
+
? msg('cli.db.gen.recorded', { file: generated.files[0] ?? '' })
|
|
139
|
+
: dump.status === 'written'
|
|
140
|
+
? msg('cli.db.gen.dumped', { count: dump.written.length + dump.removed.length })
|
|
141
|
+
: msg('cli.db.gen.unchanged'),
|
|
142
|
+
findings: [...generated.findings, ...dumpFindings],
|
|
124
143
|
// `files` is what this command WROTE, so the empty array here was a false claim.
|
|
125
|
-
lines: [...generated.files.map((file) => ` ${file}`), ...lost],
|
|
144
|
+
lines: [...generated.files.map((file) => ` ${file}`), ...dumpLines(dump), ...lost],
|
|
126
145
|
data: {
|
|
127
146
|
outcome: generated.outcome,
|
|
128
147
|
migration: null,
|
|
129
148
|
files: [...generated.files],
|
|
130
149
|
schemaHash: generated.schemaHash ?? null,
|
|
131
150
|
unrendered: unrenderedJson(generated.unrendered),
|
|
151
|
+
schemaDump: schemaDumpJson(dump),
|
|
132
152
|
},
|
|
133
153
|
};
|
|
134
154
|
}
|
|
135
155
|
return {
|
|
136
|
-
ok:
|
|
156
|
+
ok: dumpFindings.length === 0,
|
|
137
157
|
command: 'db',
|
|
138
|
-
summary:
|
|
139
|
-
|
|
158
|
+
summary:
|
|
159
|
+
dumpFindings.length === 0
|
|
160
|
+
? msg('cli.db.gen.written', { id: migration.id })
|
|
161
|
+
: msg('cli.db.gen.writtenDumpFailed', { id: migration.id }),
|
|
162
|
+
// Exit 1 with a migration on disk: the finding has to say so, or the next move is a duplicate.
|
|
163
|
+
findings: dumpFindings.map((finding) => afterWrittenMigration(finding, migration.id)),
|
|
164
|
+
lines: [...generated.files.map((file) => ` ${file}`), ...dumpLines(dump), ...lost],
|
|
140
165
|
data: {
|
|
141
166
|
outcome: generated.outcome,
|
|
142
167
|
migration: migration.id,
|
|
@@ -144,10 +169,20 @@ async function runGen(ctx: CommandContext, root: string, name: string): Promise<
|
|
|
144
169
|
files: [...generated.files],
|
|
145
170
|
schemaHash: generated.schemaHash ?? null,
|
|
146
171
|
unrendered: unrenderedJson(generated.unrendered),
|
|
172
|
+
schemaDump: schemaDumpJson(dump),
|
|
147
173
|
},
|
|
148
174
|
};
|
|
149
175
|
}
|
|
150
176
|
|
|
177
|
+
/**
|
|
178
|
+
* What the dump refresh did, one path per line — the same list `data.schemaDump` carries, so the
|
|
179
|
+
* human render and `--json` report one fact. A refresh that changed nothing prints nothing.
|
|
180
|
+
*/
|
|
181
|
+
const dumpLines = (dump: SchemaDumpRefresh): readonly string[] => [
|
|
182
|
+
...dump.written.map((file) => ` ${file}`),
|
|
183
|
+
...dump.removed.map((file) => ` - ${file}`),
|
|
184
|
+
];
|
|
185
|
+
|
|
151
186
|
/**
|
|
152
187
|
* The post-migrate report, rendered. Through `driftError` rather than a second literal: the
|
|
153
188
|
* three-line `X_DB_DRIFT` output is pinned by the framework contract, and this command must not be
|
|
@@ -175,16 +210,30 @@ async function runMigrate(
|
|
|
175
210
|
try {
|
|
176
211
|
const migrated = await runMigrations({ root, env: ctx.env });
|
|
177
212
|
const report = migrated.report;
|
|
213
|
+
// A hand-written or pulled migration reaches the dump here, with no `x db gen` in between.
|
|
214
|
+
const dump = await refreshSchemaDump(root, ctx.env);
|
|
215
|
+
// The half a snapshot cannot hold: a trigger, function, view, type or sequence this database
|
|
216
|
+
// carries and no migration creates. Asked only when the replay produced a catalog to ask
|
|
217
|
+
// against — without one there is nothing that says what the migrations create.
|
|
218
|
+
const objects =
|
|
219
|
+
dump.catalog === undefined ? [] : await liveObjectDrift(root, ctx.env, dump.catalog);
|
|
220
|
+
const findings = [
|
|
221
|
+
...driftFindings(migrated.drift),
|
|
222
|
+
...objects.map((difference) => findingFrom(driftError(difference))),
|
|
223
|
+
...(dump.finding === undefined ? [] : [dump.finding]),
|
|
224
|
+
];
|
|
178
225
|
return {
|
|
179
|
-
ok:
|
|
226
|
+
ok: findings.length === 0,
|
|
180
227
|
command: 'db',
|
|
181
228
|
summary,
|
|
182
|
-
findings
|
|
229
|
+
findings,
|
|
230
|
+
lines: dumpLines(dump),
|
|
183
231
|
data: {
|
|
184
232
|
applied: report.applied.map((entry) => entry.id),
|
|
185
233
|
skipped: report.skipped.length,
|
|
186
234
|
appVersion: report.appVersion,
|
|
187
235
|
durationMs: report.durationMs,
|
|
236
|
+
schemaDump: schemaDumpJson(dump),
|
|
188
237
|
},
|
|
189
238
|
};
|
|
190
239
|
} catch (error) {
|
package/src/cmd-doctor.ts
CHANGED
|
@@ -22,8 +22,12 @@ import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
|
|
|
22
22
|
import { DEFAULT_DOCTOR_PORT, doctorSpec } from './cmd-doctor-spec';
|
|
23
23
|
import type { CliCommand, CommandContext } from './command';
|
|
24
24
|
import { checkMigrationSnapshots } from './db-snapshot';
|
|
25
|
+
import { coverageExcludesProbe, withCoverageExcludes } from './doctor-coverage';
|
|
26
|
+
import { shippedGuardsProbe, withGuardListing } from './doctor-guards';
|
|
25
27
|
import type { OfflineFallbackFact } from './doctor-offline';
|
|
26
28
|
import { offlineFallbackFinding, offlineFallbackProbe } from './doctor-offline';
|
|
29
|
+
import type { SealedKeysFact } from './doctor-sealed';
|
|
30
|
+
import { sealedKeyFindings, sealedKeysProbe } from './doctor-sealed';
|
|
27
31
|
import { intFlagOr, PORT_RANGE, portPairAfter } from './flag-number';
|
|
28
32
|
import { ICON_SOURCE } from './icon-assets';
|
|
29
33
|
import { msg } from './messages';
|
|
@@ -91,6 +95,12 @@ export interface DoctorProbe {
|
|
|
91
95
|
* reaching the disk — the same split `drift()` makes one question over.
|
|
92
96
|
*/
|
|
93
97
|
offlineFallback(): Promise<OfflineFallbackFact>;
|
|
98
|
+
/**
|
|
99
|
+
* The app's `.sealed()` columns and the master keys this environment declares. A FACT, for the
|
|
100
|
+
* reason `offlineFallback` is one: loading the app and opening the secrets file is IO, and what
|
|
101
|
+
* a retired key beside a sealed column means is `sealedKeyFindings`' pure rule.
|
|
102
|
+
*/
|
|
103
|
+
sealedKeys(): Promise<SealedKeysFact>;
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
/** What `x doctor` reads about the embedded database, without opening it. */
|
|
@@ -281,6 +291,8 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
281
291
|
// author reaches it from: `x db gen` throwing `X_MIGRATION_SNAPSHOT_MISSING` used to be a
|
|
282
292
|
// condition this diagnostic could not see at all, so the fix line ran clean over a broken app.
|
|
283
293
|
findings.push(...(await probe.snapshots()));
|
|
294
|
+
// Sealed columns against the key ring: no key at all, or a rotation whose re-seal is pending.
|
|
295
|
+
findings.push(...sealedKeyFindings(await probe.sealedKeys()));
|
|
284
296
|
return findings;
|
|
285
297
|
}
|
|
286
298
|
|
|
@@ -394,6 +406,8 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
|
|
|
394
406
|
// `runDoctor` returns on `X_NOT_IN_APP` before this can be asked.
|
|
395
407
|
offlineFallback: async () =>
|
|
396
408
|
root === undefined ? { fallback: null, routes: undefined } : offlineFallbackProbe(root),
|
|
409
|
+
sealedKeys: async () =>
|
|
410
|
+
root === undefined ? { columns: undefined, keys: undefined } : sealedKeysProbe(root),
|
|
397
411
|
};
|
|
398
412
|
}
|
|
399
413
|
|
|
@@ -401,16 +415,26 @@ export const doctorCommand: CliCommand = {
|
|
|
401
415
|
spec: doctorSpec,
|
|
402
416
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
403
417
|
const port = doctorPort(ctx.args);
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
418
|
+
const probe = probeFor(ctx.cwd, ctx.bunVersion, port);
|
|
419
|
+
const findings = await runDoctor(probe);
|
|
420
|
+
const listed = withGuardListing(
|
|
421
|
+
{
|
|
422
|
+
ok: findings.length === 0,
|
|
423
|
+
command: 'doctor',
|
|
424
|
+
summary:
|
|
425
|
+
findings.length === 0
|
|
426
|
+
? msg('cli.doctor.clean')
|
|
427
|
+
: msg('cli.doctor.findings', { count: findings.length }),
|
|
428
|
+
findings,
|
|
429
|
+
data: { count: findings.length, codes: findings.map((entry) => entry.code) },
|
|
430
|
+
},
|
|
431
|
+
// A listing beside the verdict, never part of it (`doctor-guards.ts` says why).
|
|
432
|
+
probe.root === undefined ? [] : await shippedGuardsProbe(probe.root),
|
|
433
|
+
);
|
|
434
|
+
// The second listing: what the coverage floor does not see (`doctor-coverage.ts`).
|
|
435
|
+
return withCoverageExcludes(
|
|
436
|
+
listed,
|
|
437
|
+
probe.root === undefined ? [] : await coverageExcludesProbe(probe.root),
|
|
438
|
+
);
|
|
415
439
|
},
|
|
416
440
|
};
|
package/src/cmd-generate.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
7
|
+
import { registerAdminResources } from './admin-registration';
|
|
7
8
|
import { registerGeneratedPrimitives } from './api-registration';
|
|
8
9
|
import { writeAppArtifacts } from './app-artifacts';
|
|
9
10
|
import { appManifest } from './app-manifest';
|
|
@@ -13,18 +14,21 @@ import type { CliCommand, CommandContext } from './command';
|
|
|
13
14
|
import { invocationOf } from './command';
|
|
14
15
|
import { assertFeatureExists } from './generate-feature';
|
|
15
16
|
import { generate, sliceDir } from './generate-files';
|
|
17
|
+
import { ungrantedByGenerator } from './generate-grant-findings';
|
|
16
18
|
import { grantGeneratedPermissions } from './generate-grants';
|
|
17
19
|
import type { Generator } from './generate-kinds';
|
|
18
20
|
import { readFeature, readKind, readName, readPermission, readSurface } from './generate-kinds';
|
|
19
21
|
import { containedPath, writeFiles } from './generate-write';
|
|
22
|
+
import { declareGeneratedImports } from './generated-imports';
|
|
23
|
+
import { registerGeneratedEntities, resolveDbModule } from './handle-registration';
|
|
20
24
|
import { resolveCatalogModule } from './i18n-audit';
|
|
21
|
-
import { syncI18nIndex } from './i18n-index';
|
|
25
|
+
import { catalogLocales, syncI18nIndex } from './i18n-index';
|
|
22
26
|
import { reproducedFlags } from './invocation-flags';
|
|
23
27
|
import { msg } from './messages';
|
|
24
28
|
import type { CommandResult, Finding } from './output';
|
|
25
29
|
import { flagBool, flagList, flagString } from './parse';
|
|
26
30
|
import { quoteArg } from './shell-quote';
|
|
27
|
-
import { kebab, resolveLocales } from './templates';
|
|
31
|
+
import { kebab, names, resolveLocales } from './templates';
|
|
28
32
|
|
|
29
33
|
// One import path for the generator, unchanged by the split: `index.ts`, `x new` and the scaffold
|
|
30
34
|
// fixture reach the kinds, the pure file list and the writer through this module, and a second path
|
|
@@ -46,7 +50,7 @@ export const generateCommand: CliCommand = {
|
|
|
46
50
|
// Both flags are resolved before a single file is planned: a bad surface or a locale that is
|
|
47
51
|
// really a path fails here, with nothing written and nothing to undo.
|
|
48
52
|
const surface = readSurface(flagString(ctx.args, 'surface'), kind, name);
|
|
49
|
-
const locales = resolveLocales(flagList(ctx.args, 'locales'));
|
|
53
|
+
const locales = resolveLocales(await namedOrAppLocales(root, flagList(ctx.args, 'locales')));
|
|
50
54
|
const at = flagString(ctx.args, 'at');
|
|
51
55
|
// Read with the two above, and refused here for their reason: the value is spliced into the
|
|
52
56
|
// emitted source three times, and a value that is not a `<resource>:<verb>` is a page the app
|
|
@@ -55,6 +59,10 @@ export const generateCommand: CliCommand = {
|
|
|
55
59
|
// Read before a file is planned, like the flags above: which module a generated component
|
|
56
60
|
// imports `useT()` from is a fact about THIS app, and `generate` is a pure function.
|
|
57
61
|
const catalogModule = await resolveCatalogModule(root);
|
|
62
|
+
// And for the same reason: which package a generated `repo.ts` imports the typed handle from.
|
|
63
|
+
const dbModule = await resolveDbModule(root);
|
|
64
|
+
// And whether the app has the frame a resource's page sits in.
|
|
65
|
+
const shell = existsSync(containedPath(root, SHELL_MODULE));
|
|
58
66
|
// Read for the same reason: which errors the slice declares is written on THIS app's disk.
|
|
59
67
|
const slice = sliceDir(surface, kebab(featureFlag ?? name));
|
|
60
68
|
// A named slice that is not there is refused, never invented (X_FEATURE_UNKNOWN).
|
|
@@ -78,6 +86,8 @@ export const generateCommand: CliCommand = {
|
|
|
78
86
|
admin: flagBool(ctx.args, 'admin'),
|
|
79
87
|
locales,
|
|
80
88
|
...(catalogModule === undefined ? {} : { catalogModule }),
|
|
89
|
+
...(dbModule === undefined ? {} : { dbModule }),
|
|
90
|
+
shell,
|
|
81
91
|
});
|
|
82
92
|
if (flagBool(ctx.args, 'dry-run')) {
|
|
83
93
|
return {
|
|
@@ -97,13 +107,29 @@ export const generateCommand: CliCommand = {
|
|
|
97
107
|
...reproducedFlags(generateCommand.spec, ctx.args),
|
|
98
108
|
].join(' ');
|
|
99
109
|
const report = await writeFiles(root, files, flagBool(ctx.args, 'force'), invocation);
|
|
100
|
-
// The
|
|
101
|
-
// as findings: a declared permission granted to a role,
|
|
102
|
-
//
|
|
110
|
+
// The three edits a generated primitive needs outside its own slice, performed rather than
|
|
111
|
+
// left as findings: a declared permission granted to a role, a job listed in `defineApi`, and
|
|
112
|
+
// an entity added to the typed handle its `repo.ts` reads through. Before the manifest load
|
|
113
|
+
// below, so the projection sees all three — a repo reading a table the handle lacks would not
|
|
114
|
+
// load at all.
|
|
115
|
+
const handle = await registerGeneratedEntities(root, report.written, dbModule);
|
|
116
|
+
// And a fourth, for `--admin`: the override it wrote, listed where `defineAdmin()` reads it.
|
|
117
|
+
const adminWiring = await registerAdminResources(root, report.written);
|
|
118
|
+
// And a fifth, last because the others add imports too: every sibling workspace a written file
|
|
119
|
+
// imports, declared in the manifest it landed under — or `package-shape` refuses the output.
|
|
103
120
|
const edited = [
|
|
104
|
-
...
|
|
105
|
-
|
|
121
|
+
...new Set([
|
|
122
|
+
...(await grantGeneratedPermissions(root, report.written)),
|
|
123
|
+
...(await registerGeneratedPrimitives(root, report.written)),
|
|
124
|
+
...handle.edited,
|
|
125
|
+
...adminWiring.edited,
|
|
126
|
+
...(await declareGeneratedImports(root, report.written)),
|
|
127
|
+
]),
|
|
106
128
|
];
|
|
129
|
+
// A grant that edit could not place is said so, with the role each permission belongs to: an
|
|
130
|
+
// app whose role map is not the scaffold's got no grant and no word, and a 403 on every
|
|
131
|
+
// endpoint the run had just written.
|
|
132
|
+
const ungranted = await ungrantedByGenerator(root, report.written);
|
|
107
133
|
// A locale's catalog existing on disk and the app being able to select it are two different
|
|
108
134
|
// facts — see `syncI18nIndex`. Runs before the manifest load below so a route or resource
|
|
109
135
|
// this same invocation just wrote never gets projected against a stale catalog registration.
|
|
@@ -133,17 +159,36 @@ export const generateCommand: CliCommand = {
|
|
|
133
159
|
buildId = manifest.buildId;
|
|
134
160
|
} else loadFailures.push(...findings);
|
|
135
161
|
}
|
|
136
|
-
const findings = [
|
|
162
|
+
const findings = [
|
|
163
|
+
...report.conflicts,
|
|
164
|
+
...handle.findings,
|
|
165
|
+
...ungranted,
|
|
166
|
+
...adminWiring.findings,
|
|
167
|
+
...indexSync.findings,
|
|
168
|
+
...loadFailures,
|
|
169
|
+
];
|
|
137
170
|
// One list behind all three renderings. The manifest was printed as a `+` line while the count
|
|
138
171
|
// beside it came from `report.written` alone, so `x g island` said "wrote 2 file(s)" over three
|
|
139
172
|
// lines — and `--json` carried the shorter list, which is the drift `--json` exists to prevent.
|
|
140
173
|
const written = [...report.written, ...edited, ...artifacts];
|
|
174
|
+
// Named only over a clean run: with a finding, the finding's own `fix:` is the next command.
|
|
175
|
+
const table = names(isEntityKind(kind) ? name : (featureFlag ?? name)).table;
|
|
176
|
+
const next = findings.length === 0 ? nextSteps(report.written, edited, table) : [];
|
|
141
177
|
return {
|
|
142
178
|
ok: findings.length === 0,
|
|
143
179
|
command: 'g',
|
|
144
|
-
summary:
|
|
180
|
+
summary:
|
|
181
|
+
next.length === 0
|
|
182
|
+
? msg('cli.generate.wrote', { count: written.length, kind, name })
|
|
183
|
+
: msg('cli.generate.wroteNext', {
|
|
184
|
+
count: written.length,
|
|
185
|
+
kind,
|
|
186
|
+
name,
|
|
187
|
+
next: next.join(' && '),
|
|
188
|
+
}),
|
|
145
189
|
data: {
|
|
146
190
|
files: written,
|
|
191
|
+
...(next.length === 0 ? {} : { next }),
|
|
147
192
|
...(buildId === undefined ? {} : { manifest: { buildId } }),
|
|
148
193
|
},
|
|
149
194
|
lines: written.map((path) => msg('cli.file.added', { path })),
|
|
@@ -152,6 +197,59 @@ export const generateCommand: CliCommand = {
|
|
|
152
197
|
},
|
|
153
198
|
};
|
|
154
199
|
|
|
200
|
+
/**
|
|
201
|
+
* `--locales` when the caller named any, and otherwise every locale the app already has a catalog
|
|
202
|
+
* for: a key written to `en` alone in an app that also ships `es` renders ⟦key⟧ for every Spanish
|
|
203
|
+
* reader. A stem that is not a locale tag is some other JSON file, and not a catalog to write to.
|
|
204
|
+
*/
|
|
205
|
+
async function namedOrAppLocales(
|
|
206
|
+
root: string,
|
|
207
|
+
named: readonly string[],
|
|
208
|
+
): Promise<readonly string[]> {
|
|
209
|
+
if (named.length > 0) return named;
|
|
210
|
+
return (await catalogLocales(root)).filter((stem) => {
|
|
211
|
+
try {
|
|
212
|
+
return Intl.getCanonicalLocales(stem).length === 1;
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const isEntityKind = (kind: Generator): boolean => kind === 'entity' || kind === 'resource';
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* What a run that declared a table owes before the app can read it, in the order it has to
|
|
223
|
+
* happen — each one a command that runs as printed. `bunx x`, never a bare `x`: `bun install`
|
|
224
|
+
* links the binary into `./node_modules/.bin` and nowhere on PATH.
|
|
225
|
+
*
|
|
226
|
+
* `bun install` leads when a manifest gained a workspace edge: the handle imports the entity from
|
|
227
|
+
* the web workspace, and an edge only a manifest declares is not linked until the install runs.
|
|
228
|
+
* `x db gen` then writes the migration AND `packages/db/schema/`, which the `drift` step holds.
|
|
229
|
+
*/
|
|
230
|
+
export function nextSteps(
|
|
231
|
+
written: readonly string[],
|
|
232
|
+
edited: readonly string[],
|
|
233
|
+
table: string,
|
|
234
|
+
): readonly string[] {
|
|
235
|
+
if (!written.some((path) => path.endsWith('/entity.ts'))) return [];
|
|
236
|
+
return [
|
|
237
|
+
...(edited.some((path) => path.endsWith('package.json')) ? ['bun install'] : []),
|
|
238
|
+
`bunx x db gen "create ${table}"`,
|
|
239
|
+
'bunx x db migrate',
|
|
240
|
+
// A page that mounts an island has a byte budget only a build can weigh — measured on `x new`'s
|
|
241
|
+
// app (`RESOURCE_PAGE_BUDGET`), and an app that charges more per document is over it on this
|
|
242
|
+
// run rather than on the next `bin/check`.
|
|
243
|
+
...(written.some((path) => path.endsWith('/page.tsx')) ? [MEASURE_PAGE] : []),
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The two commands that weigh a page against its budget: the build writes the bytes it reads. */
|
|
248
|
+
export const MEASURE_PAGE = 'bunx x build --target static && bunx x verify --only budgets';
|
|
249
|
+
|
|
250
|
+
/** The frame `x new` writes: a resource's page renders inside it when the app has one. */
|
|
251
|
+
export const SHELL_MODULE = 'apps/web/shared/shell.tsx';
|
|
252
|
+
|
|
155
253
|
/**
|
|
156
254
|
* The slice's `errors.ts`, for the two generators whose template throws from it. Only those two:
|
|
157
255
|
* a route or an island names no slice, and the "feature" the fallback derives for them is a path
|
|
@@ -167,20 +265,23 @@ async function readSliceErrors(
|
|
|
167
265
|
return existsSync(file) ? await Bun.file(file).text() : undefined;
|
|
168
266
|
}
|
|
169
267
|
|
|
268
|
+
/** The generators whose output depends on what the slice's `entity.ts` / `repo.ts` declare. */
|
|
269
|
+
const READS_SLICE: ReadonlySet<Generator> = new Set(['job', 'task', 'action', 'mutator', 'query']);
|
|
270
|
+
|
|
170
271
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
272
|
+
* The slice's `entity.ts`/`repo.ts` as they stand on disk, absent when the generator's kind reads
|
|
273
|
+
* neither or the file does not exist yet. `readSliceErrors`'s reason — whichever generator reads
|
|
274
|
+
* it decides on THIS app's disk, not on a default the template assumes. `query` reads them for its
|
|
275
|
+
* test alone: whether it may store a row is a fact about the entity an author may have reshaped.
|
|
276
|
+
* Exported so a test can ask which kinds read the disk without running a whole generation.
|
|
174
277
|
*/
|
|
175
|
-
async function readSliceFile(
|
|
278
|
+
export async function readSliceFile(
|
|
176
279
|
root: string,
|
|
177
280
|
kind: Generator,
|
|
178
281
|
slice: string,
|
|
179
282
|
name: 'entity.ts' | 'repo.ts',
|
|
180
283
|
): Promise<string | undefined> {
|
|
181
|
-
if (kind
|
|
182
|
-
return undefined;
|
|
183
|
-
}
|
|
284
|
+
if (!READS_SLICE.has(kind)) return undefined;
|
|
184
285
|
const file = containedPath(root, `${slice}/${name}`);
|
|
185
286
|
return existsSync(file) ? await Bun.file(file).text() : undefined;
|
|
186
287
|
}
|