@ultimat3/cli 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +724 -0
- package/README.md +41 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +87 -17
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +13 -7
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +186 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +205 -140
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +87 -14
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +73 -0
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +202 -18
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// One rule for turning a thrown value into a `Finding` on the `x db` path, shared by every
|
|
2
|
+
// subcommand: a framework error reaches the caller verbatim, and anything else is named by the
|
|
3
|
+
// step that failed. Its own module because `cmd-db.ts` and `cmd-db-branch.ts` both need it and
|
|
4
|
+
// neither may import the other.
|
|
5
|
+
|
|
6
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import type { Finding } from './output';
|
|
8
|
+
import { findingFrom, isUltimateErrorShape } from './output';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The engine names its own failures — `X_MIGRATION_CONFLICT` carries the ledger row that disagrees,
|
|
12
|
+
* `X_MIGRATION_IRREVERSIBLE` carries the exact `--allow-destructive` line to rerun, and
|
|
13
|
+
* `X_BRANCH_EXISTS` carries the `x db branch drop` that clears it — so those reach the caller
|
|
14
|
+
* verbatim. `X_DB_GEN_FAILED` / `X_DB_MIGRATE_FAILED` / `X_DB_BRANCH_FAILED` are what is left: the
|
|
15
|
+
* step failed for a reason no framework error claimed, and the raw message is all there is.
|
|
16
|
+
*/
|
|
17
|
+
export const stepFinding = (error: unknown, code: string): Finding =>
|
|
18
|
+
isUltimateErrorShape(error)
|
|
19
|
+
? findingFrom(error)
|
|
20
|
+
: {
|
|
21
|
+
code,
|
|
22
|
+
// The engine may throw a non-Error, and an Error whose `message` is a getter: core's
|
|
23
|
+
// `renderThrowable` reads both without trusting either, so the refusal cannot be lost to
|
|
24
|
+
// a TypeError raised while reporting it.
|
|
25
|
+
cause: renderThrowable(error),
|
|
26
|
+
fix: 'x doctor --json',
|
|
27
|
+
docs: `https://ultimate.dev/errors/${code}`,
|
|
28
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// `x db gen`, on the framework's own engine. The app's registered entities diffed against what the
|
|
2
|
+
// migration files already declare, written as one reversible `.sql` plus the two sidecars the gate
|
|
3
|
+
// reads back — the snapshot the next generation diffs against, and the schema hash `drift` checks.
|
|
4
|
+
|
|
5
|
+
// `node:path` — Bun ships no path joiner of its own, and these paths are built, not opened.
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import type { GeneratedMigration } from '@ultimat3/db';
|
|
8
|
+
import {
|
|
9
|
+
DESTRUCTIVE_MARKER,
|
|
10
|
+
declaredSchema,
|
|
11
|
+
generateMigration,
|
|
12
|
+
migrationSnapshotMissing,
|
|
13
|
+
snapshotJson,
|
|
14
|
+
} from '@ultimat3/db';
|
|
15
|
+
import { describeEntities } from '@ultimat3/entity';
|
|
16
|
+
import { loadApp } from './app-load';
|
|
17
|
+
import { writeSchemaHash } from './drift';
|
|
18
|
+
import { hashFileName, MIGRATIONS_DIR, readMigrations, snapshotFileName } from './migrations';
|
|
19
|
+
import type { Finding } from './output';
|
|
20
|
+
|
|
21
|
+
export interface GenerateMigrationOptions {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
/** `x db gen "…" --allow-destructive`. A DROP whose `down` cannot restore the rows. */
|
|
24
|
+
readonly allowDestructive?: boolean | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GeneratedFiles {
|
|
28
|
+
/** Absent when the entities and the migrations already agree — nothing was written. */
|
|
29
|
+
readonly migration?: GeneratedMigration | undefined;
|
|
30
|
+
/** App-root-relative paths written, in write order. Empty when there was nothing to write. */
|
|
31
|
+
readonly files: readonly string[];
|
|
32
|
+
readonly schemaHash?: string | undefined;
|
|
33
|
+
/** Modules that would not load. Non-empty means nothing was generated. */
|
|
34
|
+
readonly findings: readonly Finding[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The header every generated migration carries. It names the id rather than interpolating the
|
|
39
|
+
* caller's own message: a `name` is free text from argv, and a newline in it would end the comment
|
|
40
|
+
* and turn prose into SQL.
|
|
41
|
+
*
|
|
42
|
+
* A destructive `up` carries the marker `x verify` demands, written here rather than hand-added
|
|
43
|
+
* later: the marker is part of the SQL the checksum covers, so a migration that earns it after it
|
|
44
|
+
* has been applied is an edited migration and `X_MIGRATION_CONFLICT` — correctly — says so.
|
|
45
|
+
*/
|
|
46
|
+
export function migrationSql(migration: GeneratedMigration): string {
|
|
47
|
+
return [
|
|
48
|
+
`-- ${migration.id}`,
|
|
49
|
+
"-- GENERATED by `x db gen` from the app's entities — do not edit.",
|
|
50
|
+
'-- Editing an applied migration changes its checksum: X_MIGRATION_CONFLICT on the next apply.',
|
|
51
|
+
...(migration.destructive ? [DESTRUCTIVE_MARKER] : []),
|
|
52
|
+
'',
|
|
53
|
+
migration.up,
|
|
54
|
+
'',
|
|
55
|
+
'-- down',
|
|
56
|
+
migration.down,
|
|
57
|
+
'',
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Generation reads source and writes files; it never opens a database. The previous migration's
|
|
63
|
+
* snapshot is what it diffs against (`declaredSchema`), so `x db gen` answers the same in CI, on a
|
|
64
|
+
* laptop with no Postgres running, and against a database three migrations behind.
|
|
65
|
+
*
|
|
66
|
+
* An app that would not load generates nothing. `describeEntities()` is the registry a failed
|
|
67
|
+
* import leaves short, and a diff against a short registry is a migration that drops the tables
|
|
68
|
+
* whose module happened to throw — the one destructive edit no `--allow-destructive` was asked for.
|
|
69
|
+
*/
|
|
70
|
+
export async function generateAppMigration(
|
|
71
|
+
root: string,
|
|
72
|
+
options: GenerateMigrationOptions,
|
|
73
|
+
): Promise<GeneratedFiles> {
|
|
74
|
+
const app = await loadApp(root);
|
|
75
|
+
if (app.findings.length > 0) return { files: [], findings: app.findings };
|
|
76
|
+
|
|
77
|
+
const migrations = await readMigrations(root);
|
|
78
|
+
const current = declaredSchema(migrations);
|
|
79
|
+
// Refused, never defaulted to the empty schema: with nothing to diff against, every table the
|
|
80
|
+
// database already holds looks new and the generated `up` is `create table` for all of them.
|
|
81
|
+
if (current === undefined) {
|
|
82
|
+
const newest = migrations[migrations.length - 1];
|
|
83
|
+
const id = newest?.id ?? '';
|
|
84
|
+
throw migrationSnapshotMissing(id, join(MIGRATIONS_DIR, snapshotFileName(id)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const migration = generateMigration({
|
|
88
|
+
entities: describeEntities(),
|
|
89
|
+
current,
|
|
90
|
+
name: options.name,
|
|
91
|
+
...(options.allowDestructive === true ? { allowDestructive: true } : {}),
|
|
92
|
+
});
|
|
93
|
+
// An empty diff writes nothing. A migration with no statement still takes a ledger row, a
|
|
94
|
+
// checksum and a place in the apply order — a permanent record that nothing changed.
|
|
95
|
+
if (migration.up.trim().length === 0) return { files: [], findings: [] };
|
|
96
|
+
|
|
97
|
+
const dir = join(root, MIGRATIONS_DIR);
|
|
98
|
+
const sql = `${migration.id}.sql`;
|
|
99
|
+
const snapshot = snapshotFileName(migration.id);
|
|
100
|
+
await Bun.write(join(dir, sql), migrationSql(migration));
|
|
101
|
+
// `snapshotJson`, never `JSON.stringify`: the sidecar is the one migration artefact a scaffolded
|
|
102
|
+
// app's `biome check .` reads, and its bytes carry their own trailing newline.
|
|
103
|
+
await Bun.write(join(dir, snapshot), snapshotJson(migration.snapshot));
|
|
104
|
+
const schemaHash = await writeSchemaHash(root, migration.id);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
migration,
|
|
108
|
+
schemaHash,
|
|
109
|
+
files: [sql, snapshot, hashFileName(migration.id)].map((file) => `${MIGRATIONS_DIR}/${file}`),
|
|
110
|
+
findings: [],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// `x db gen`'s own precondition, asked as a diagnostic rather than met as a throw: a newest
|
|
2
|
+
// migration with no `.snapshot.json` leaves the next generation nothing to diff against, so it
|
|
3
|
+
// refuses before writing anything. `@ultimat3/db` owns both the condition (`declaredSchema`) and
|
|
4
|
+
// the wording, so this check and that refusal cannot disagree about one directory.
|
|
5
|
+
|
|
6
|
+
import { declaredSchema, migrationSnapshotMissing } from '@ultimat3/db';
|
|
7
|
+
import { MIGRATIONS_DIR, readMigrations, snapshotFileName } from './migrations';
|
|
8
|
+
import { type Finding, findingFrom } from './output';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Empty result = the next `x db gen` has something to start from. An app with no migrations at all
|
|
12
|
+
* reports nothing: `declaredSchema([])` is the empty schema, which is a real answer and the state a
|
|
13
|
+
* freshly scaffolded app is in before its first generation.
|
|
14
|
+
*
|
|
15
|
+
* One finding, never one per file. Only the NEWEST snapshot is what a diff starts from, so an older
|
|
16
|
+
* migration missing one is a fact about history and not something the author can act on.
|
|
17
|
+
*/
|
|
18
|
+
export async function checkMigrationSnapshots(root: string): Promise<readonly Finding[]> {
|
|
19
|
+
const migrations = await readMigrations(root);
|
|
20
|
+
if (declaredSchema(migrations) !== undefined) return [];
|
|
21
|
+
const id = migrations[migrations.length - 1]?.id ?? '';
|
|
22
|
+
const file = `${MIGRATIONS_DIR}/${snapshotFileName(id)}`;
|
|
23
|
+
return [{ ...findingFrom(migrationSnapshotMissing(id, file)), at: file }];
|
|
24
|
+
}
|
package/src/dev-assets.ts
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
// Projecting the framework's one image pipeline onto the routes `x dev` serves. Three packages
|
|
2
2
|
// declare what an image is — `@ultimat3/seo` a variant URL, `@ultimat3/storage` a variant key,
|
|
3
3
|
// `@ultimat3/pwa` the icons a web manifest promises — and `@ultimat3/core`'s pipeline owns every
|
|
4
|
-
// pixel, so this file picks the two base paths they hang off and decides nothing else.
|
|
4
|
+
// pixel, so this file picks the two base paths they hang off and decides nothing else. The one
|
|
5
|
+
// thing it does NOT decide is who may read a stored object: `/media` borrows that whole answer
|
|
6
|
+
// from `dev-storage.ts`, because the same bytes are reachable through both.
|
|
5
7
|
|
|
6
8
|
// `join` is `node:`-only by necessity: Bun exposes no path-join primitive, and `ICON_SOURCE` is
|
|
7
9
|
// app-root-relative, so resolving it against the root is string work no `Bun.file` overload does.
|
|
8
10
|
import { join } from 'node:path';
|
|
9
11
|
import { probeImage } from '@ultimat3/core';
|
|
10
|
-
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
12
|
+
import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
|
|
11
13
|
import { applyCacheHeaders } from '@ultimat3/http';
|
|
12
14
|
import type { IconPlan } from '@ultimat3/pwa';
|
|
13
15
|
import { BuiltinImagePipeline, PwaIconMissingError, planIcons } from '@ultimat3/pwa';
|
|
14
|
-
import type { ImageQuery } from '@ultimat3/seo';
|
|
16
|
+
import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
|
|
15
17
|
import { builtinImageDriver, parseImageQuery } from '@ultimat3/seo';
|
|
16
18
|
import type { ImageFormat, ImageTransform, Storage } from '@ultimat3/storage';
|
|
17
|
-
import { IMAGE_FORMATS, variantKey } from '@ultimat3/storage';
|
|
19
|
+
import { IMAGE_FORMATS, isTenantScoped, variantKey } from '@ultimat3/storage';
|
|
20
|
+
import {
|
|
21
|
+
AUTHORIZED_OBJECT_CACHE,
|
|
22
|
+
assertReadableKey,
|
|
23
|
+
authorizeStorageRead,
|
|
24
|
+
STORAGE_READ_PERMISSION,
|
|
25
|
+
} from './dev-storage';
|
|
18
26
|
|
|
19
27
|
/**
|
|
20
28
|
* The one source image every generated icon derives from. `x new` scaffolds it, `x doctor` checks
|
|
@@ -26,22 +34,41 @@ export const ICON_SOURCE = 'apps/web/site/icon.png';
|
|
|
26
34
|
/** Where `planIcons` writes, and therefore the paths the generated web manifest names. */
|
|
27
35
|
export const ICON_BASE_PATH = '/icons';
|
|
28
36
|
|
|
29
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Storage-backed images. `responsiveImage({ src: '/media/<key>' })` mints its variants under it.
|
|
39
|
+
* Guarded exactly as `/_storage` is — an object reachable through two URLs must not be reachable
|
|
40
|
+
* on two different terms — so a `src` under this path needs a signed-in reader holding
|
|
41
|
+
* `storage:read`. A genuinely public image belongs in `apps/web/site/`, which is served as a
|
|
42
|
+
* static asset and never touches a disk holding another tenant's uploads.
|
|
43
|
+
*/
|
|
30
44
|
export const MEDIA_BASE_PATH = '/media';
|
|
31
45
|
|
|
32
46
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
47
|
+
* A generated icon and a content-addressed variant answer forever with the same bytes, so the
|
|
48
|
+
* immutable hint is a fact about the key. It is NOT a fact about this route — see `mediaCache`.
|
|
35
49
|
*/
|
|
36
|
-
const
|
|
50
|
+
const IMMUTABLE_IMAGE: CacheHint = { mode: 'immutable' };
|
|
51
|
+
|
|
52
|
+
const imageResponse = (bytes: Uint8Array, contentType: string, cache: CacheHint): Response =>
|
|
37
53
|
applyCacheHeaders(
|
|
38
54
|
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
39
55
|
// `SharedArrayBuffer`, which `Response` does not accept, and copying is what makes that true
|
|
40
56
|
// by construction rather than by a cast that would only silence it.
|
|
41
57
|
new Response(new Uint8Array(bytes), { headers: { 'content-type': contentType } }),
|
|
42
|
-
|
|
58
|
+
cache,
|
|
43
59
|
);
|
|
44
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Immutable is a claim about the KEY, and a tenant-scoped key names one org's private object: a
|
|
63
|
+
* CDN or shared proxy that stores it under a public URL for a year hands it to every other tenant,
|
|
64
|
+
* which is the cross-tenant read one hop removed. `/media` declared `public, max-age=31536000,
|
|
65
|
+
* immutable` for exactly those keys until this branch. Applied in the handler rather than declared
|
|
66
|
+
* in `meta.cache` because the posture is decided by the key, which no route declaration can see —
|
|
67
|
+
* the pipeline's `cache-headers` stage only fills a `cache-control` a handler did not set.
|
|
68
|
+
*/
|
|
69
|
+
const mediaCache = (key: string): CacheHint =>
|
|
70
|
+
isTenantScoped(key) ? AUTHORIZED_OBJECT_CACHE : IMMUTABLE_IMAGE;
|
|
71
|
+
|
|
45
72
|
const isImageFormat = (value: string): value is ImageFormat =>
|
|
46
73
|
(IMAGE_FORMATS as readonly string[]).includes(value);
|
|
47
74
|
|
|
@@ -66,6 +93,7 @@ async function transformedVariant(
|
|
|
66
93
|
storage: Storage,
|
|
67
94
|
key: string,
|
|
68
95
|
query: ImageQuery,
|
|
96
|
+
images: ImageTransformDriver | undefined,
|
|
69
97
|
): Promise<Response> {
|
|
70
98
|
const disk = storage.disk();
|
|
71
99
|
// A format storage cannot name has no variant key, so it cannot be cached. The driver refuses
|
|
@@ -74,13 +102,23 @@ async function transformedVariant(
|
|
|
74
102
|
query.format !== undefined && isImageFormat(query.format) ? query.format : undefined;
|
|
75
103
|
const cacheable = query.format === undefined || format !== undefined;
|
|
76
104
|
const cached = cacheable ? variantKey(key, storageTransform(query, format)) : undefined;
|
|
105
|
+
// The SOURCE key decides the posture, not the variant's: `variantKey` keeps the source's prefix,
|
|
106
|
+
// so a variant of `org/<id>/…` is one org's object too, and reading the hint off the derived key
|
|
107
|
+
// would be a second answer to a question the source already settled.
|
|
108
|
+
const cache = mediaCache(key);
|
|
77
109
|
if (cached !== undefined && (await disk.exists(cached))) {
|
|
78
110
|
const hit = await disk.get(cached);
|
|
79
|
-
return imageResponse(hit.bytes, hit.object.contentType);
|
|
111
|
+
return imageResponse(hit.bytes, hit.object.contentType, cache);
|
|
80
112
|
}
|
|
81
113
|
|
|
82
114
|
const source = await disk.get(key);
|
|
83
|
-
|
|
115
|
+
// The seam is WHICH driver transforms, not who resolves the bytes: `TransformRequest.width` is
|
|
116
|
+
// required, and a request with no `?w=` gets its width from the source's own header — so the
|
|
117
|
+
// read happens either way and a supplied driver is handed the same resolved request the builtin
|
|
118
|
+
// one gets. Constructed inline before this, with no seam at all, so a deployment that routes
|
|
119
|
+
// transforms through a CDN had to fork the route to do it.
|
|
120
|
+
const driver = images ?? builtinImageDriver({ read: async () => source.bytes });
|
|
121
|
+
const variant = await driver.transform({
|
|
84
122
|
src: key,
|
|
85
123
|
// A header read, not a decode: `?f=webp` alone still needs a width, and the source's own is
|
|
86
124
|
// the only one that does not resize an image the caller never asked to resize.
|
|
@@ -91,16 +129,29 @@ async function transformedVariant(
|
|
|
91
129
|
if (cached !== undefined) {
|
|
92
130
|
await disk.put(cached, variant.bytes, { contentType: variant.contentType });
|
|
93
131
|
}
|
|
94
|
-
return imageResponse(variant.bytes, variant.contentType);
|
|
132
|
+
return imageResponse(variant.bytes, variant.contentType, cache);
|
|
95
133
|
}
|
|
96
134
|
|
|
97
|
-
|
|
98
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Authorized before a key is parsed and before a disk is touched — the same two calls, in the same
|
|
137
|
+
* order, that `/_storage` makes. This route made NEITHER: it was `auth: 'public'` with no policy
|
|
138
|
+
* and handed the raw client-supplied key to `disk().get`, so every object on the app's only disk
|
|
139
|
+
* was one unauthenticated URL away, and `?w=` made it an unauthenticated `put` besides.
|
|
140
|
+
*/
|
|
141
|
+
async function mediaResponse(
|
|
142
|
+
request: UltimateRequest,
|
|
143
|
+
ctx: RequestContext,
|
|
144
|
+
storage: Storage,
|
|
145
|
+
images: ImageTransformDriver | undefined,
|
|
146
|
+
): Promise<Response> {
|
|
147
|
+
const requested = request.params['key'] ?? '';
|
|
148
|
+
authorizeStorageRead({ disk: storage.defaultDisk, key: requested }, ctx);
|
|
149
|
+
const key = assertReadableKey(requested, ctx.actor);
|
|
99
150
|
const query = parseImageQuery(request.url.searchParams);
|
|
100
|
-
if (query !== null) return transformedVariant(storage, key, query);
|
|
151
|
+
if (query !== null) return transformedVariant(storage, key, query, images);
|
|
101
152
|
// No transform asked for: the object itself, still under the storage key's own safety checks.
|
|
102
153
|
const read = await storage.disk().get(key);
|
|
103
|
-
return imageResponse(read.bytes, read.object.contentType);
|
|
154
|
+
return imageResponse(read.bytes, read.object.contentType, mediaCache(key));
|
|
104
155
|
}
|
|
105
156
|
|
|
106
157
|
/**
|
|
@@ -145,6 +196,8 @@ export interface AssetRoutesOptions {
|
|
|
145
196
|
/** App root. The source icon is resolved against it; storage keys never are. */
|
|
146
197
|
readonly root: string;
|
|
147
198
|
readonly storage: Storage;
|
|
199
|
+
/** Replaces `builtinImageDriver` for `/media/*`. Omitted, core's PNG/JPEG pipeline. */
|
|
200
|
+
readonly images?: ImageTransformDriver;
|
|
148
201
|
}
|
|
149
202
|
|
|
150
203
|
/**
|
|
@@ -163,14 +216,27 @@ export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
|
|
|
163
216
|
path: entry.outputPath,
|
|
164
217
|
meta: { name: `assets.icon.${entry.spec.filename}`, auth: 'public', tags: ['assets'] },
|
|
165
218
|
handler: async (request: UltimateRequest): Promise<Response> =>
|
|
166
|
-
imageResponse(await render(plan, request.pathname), 'image/png'),
|
|
219
|
+
imageResponse(await render(plan, request.pathname), 'image/png', IMMUTABLE_IMAGE),
|
|
167
220
|
}));
|
|
221
|
+
// The icons above are genuinely public — they are rendered from a file committed in the app, and
|
|
222
|
+
// an install prompt fetches them before anyone has signed in. `/media` is the opposite: it serves
|
|
223
|
+
// whatever is on the app's only disk, which is every tenant's uploads, so it takes `/_storage`'s
|
|
224
|
+
// declaration verbatim. `enforcedBy: 'handler'` for the reason that route gives — the `authz`
|
|
225
|
+
// stage resolves a policy from `@ultimat3/render`'s page table, which this route is not in.
|
|
226
|
+
// `cache` declares the conservative posture; `mediaCache` narrows or widens it per key.
|
|
168
227
|
routes.push({
|
|
169
228
|
method: 'GET',
|
|
170
229
|
path: `${MEDIA_BASE_PATH}/*key`,
|
|
171
|
-
meta: {
|
|
172
|
-
|
|
173
|
-
|
|
230
|
+
meta: {
|
|
231
|
+
name: 'assets.media',
|
|
232
|
+
auth: 'required',
|
|
233
|
+
policy: STORAGE_READ_PERMISSION,
|
|
234
|
+
enforcedBy: 'handler',
|
|
235
|
+
cache: AUTHORIZED_OBJECT_CACHE,
|
|
236
|
+
tags: ['assets'],
|
|
237
|
+
},
|
|
238
|
+
handler: async (request: UltimateRequest, ctx: RequestContext): Promise<Response> =>
|
|
239
|
+
mediaResponse(request, ctx, options.storage, options.images),
|
|
174
240
|
});
|
|
175
241
|
|
|
176
242
|
return routes;
|
package/src/dev-cache.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Which cache tiers this process reads through, and the hop that tells the other replicas what it
|
|
2
|
+
// just dropped. `createMemoTier`, `createLruTier` and `createRedisTier` were built, exported and
|
|
3
|
+
// tested with ZERO callers — `dev-runtime.ts` registered the CDN tier and nothing else — so every
|
|
4
|
+
// cached read was recomputed on every replica on every request, and `registerInvalidationBroadcast`
|
|
5
|
+
// had no one to register it.
|
|
6
|
+
|
|
7
|
+
import type { CacheTier, PurgeDriver } from '@ultimat3/cache';
|
|
8
|
+
import {
|
|
9
|
+
createCdnTier,
|
|
10
|
+
createLruTier,
|
|
11
|
+
createMemoTier,
|
|
12
|
+
createRedisTier,
|
|
13
|
+
isNoopPurgeDriver,
|
|
14
|
+
receiveInvalidationBroadcast,
|
|
15
|
+
registerInvalidationBroadcast,
|
|
16
|
+
registerTier,
|
|
17
|
+
resetTiers,
|
|
18
|
+
} from '@ultimat3/cache';
|
|
19
|
+
import { logger } from '@ultimat3/core';
|
|
20
|
+
import type { Transport, TransportSubscription } from '@ultimat3/realtime';
|
|
21
|
+
import type { Env } from './dev-services';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The subject every replica of every app publishes tag invalidations on. One subject and not one
|
|
25
|
+
* per app: a transport is already namespaced by the bus an operator pointed the deployment at,
|
|
26
|
+
* and a second namespace here would be a knob whose only correct value is the default.
|
|
27
|
+
*/
|
|
28
|
+
export const CACHE_INVALIDATE_SUBJECT = 'x.cache.invalidate';
|
|
29
|
+
|
|
30
|
+
export interface CacheTiersOptions {
|
|
31
|
+
readonly env: Env;
|
|
32
|
+
/** Already resolved by the boot — the CDN tier is registered only for a real edge. */
|
|
33
|
+
readonly purge: PurgeDriver;
|
|
34
|
+
readonly transport: Transport;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The shared tier, or nothing. `REDIS_URL` is the same "an unset variable means the embedded
|
|
39
|
+
* default" law the db, events, storage, mail and CDN bindings already follow — and it is the
|
|
40
|
+
* variable Bun's own `Bun.redis` reads, so a tier selected here and a client built there cannot
|
|
41
|
+
* point at two servers.
|
|
42
|
+
*/
|
|
43
|
+
function sharedTier(env: Env): CacheTier | undefined {
|
|
44
|
+
const url = env['REDIS_URL']?.trim();
|
|
45
|
+
return url === undefined || url === '' ? undefined : createRedisTier();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Register the tiers, wire both halves of cross-instance invalidation, and return the release.
|
|
50
|
+
*
|
|
51
|
+
* The outbound half publishes the wire tags this process just dropped; the inbound half applies
|
|
52
|
+
* another instance's. A message this process published is delivered back to it on every real bus
|
|
53
|
+
* and is applied again — deliberately, with no node-id filter: dropping an already-dropped key is
|
|
54
|
+
* idempotent and free, while a dedup table is state that can be wrong. Re-emit is impossible by
|
|
55
|
+
* construction, not by a flag: `receiveInvalidationBroadcast` is the only entry point that
|
|
56
|
+
* suppresses it, and `emit` is not a public parameter.
|
|
57
|
+
*/
|
|
58
|
+
export function startCacheTiers(options: CacheTiersOptions): () => Promise<void> {
|
|
59
|
+
// Request-scoped memo first, then the process-local LRU: both are free of external state, so
|
|
60
|
+
// they are the "embedded default" that needs no variable to switch on. Registration order does
|
|
61
|
+
// not decide read order — `sortTiers` does — but it is written in read order anyway.
|
|
62
|
+
registerTier(createMemoTier());
|
|
63
|
+
registerTier(createLruTier());
|
|
64
|
+
const shared = sharedTier(options.env);
|
|
65
|
+
if (shared !== undefined) registerTier(shared);
|
|
66
|
+
// Nothing installs a read tier here, and that is the point. `@ultimat3/query` used to own a
|
|
67
|
+
// private `ReadCache` this boot had to hand-wire over one of the objects above, because
|
|
68
|
+
// `invalidateTags` fans out to registered tiers and to nothing else — so a read cache holding
|
|
69
|
+
// entries of its own was a `cache:` query an action's `invalidates` could never bust. The seam
|
|
70
|
+
// is gone: a `cache:` read fills the ladder registered here, so there is one registry and one
|
|
71
|
+
// fan-out and no wiring to get wrong.
|
|
72
|
+
// Registered only when a credential named a real edge. A noop tier would put a `cdn` line in
|
|
73
|
+
// every invalidation report claiming keys an edge that does not exist had accepted — and the
|
|
74
|
+
// `/_x` cache panel renders those reports, so the lie would be the thing an agent reads.
|
|
75
|
+
if (!isNoopPurgeDriver(options.purge)) {
|
|
76
|
+
registerTier(createCdnTier({ purge: options.purge }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
registerInvalidationBroadcast(async (wireTags) => {
|
|
80
|
+
await options.transport.publish(CACHE_INVALIDATE_SUBJECT, JSON.stringify(wireTags));
|
|
81
|
+
});
|
|
82
|
+
let subscription: TransportSubscription | undefined;
|
|
83
|
+
// Not awaited: the boot must not block on a subscribe, and a bus that refuses one is a process
|
|
84
|
+
// that misses peer invalidations, never a process that fails to start.
|
|
85
|
+
void options.transport
|
|
86
|
+
.subscribe(CACHE_INVALIDATE_SUBJECT, (payload: string) => {
|
|
87
|
+
void applyBroadcast(payload);
|
|
88
|
+
})
|
|
89
|
+
.then((handle) => {
|
|
90
|
+
subscription = handle;
|
|
91
|
+
})
|
|
92
|
+
.catch((error: unknown) => {
|
|
93
|
+
logger.warn('cache.broadcast.subscribe-failed', { error: messageOf(error) });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// `resetTiers()` drops the registry AND the broadcast in one call: this boot is the only thing
|
|
97
|
+
// that registers either, and a tier left behind would purge for a process that has stopped.
|
|
98
|
+
return async () => {
|
|
99
|
+
subscription?.unsubscribe();
|
|
100
|
+
subscription = undefined;
|
|
101
|
+
resetTiers();
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const messageOf = (error: unknown): string =>
|
|
106
|
+
error instanceof Error ? error.message : 'unknown error';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A peer's wire tags, applied here. Never throws: a malformed frame or an undeclared tag must not
|
|
110
|
+
* kill the subscriber loop, because that would silently end cross-instance invalidation for the
|
|
111
|
+
* whole process — the exact failure this hop exists to prevent, arriving quietly.
|
|
112
|
+
*/
|
|
113
|
+
async function applyBroadcast(payload: string): Promise<void> {
|
|
114
|
+
try {
|
|
115
|
+
const parsed: unknown = JSON.parse(payload);
|
|
116
|
+
if (!Array.isArray(parsed)) return;
|
|
117
|
+
const wire = parsed.filter((value): value is string => typeof value === 'string');
|
|
118
|
+
if (wire.length > 0) await receiveInvalidationBroadcast(wire);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
logger.warn('cache.broadcast.apply-failed', { error: messageOf(error) });
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/dev-dashboard.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
PolicyFact,
|
|
13
13
|
RequestTrace,
|
|
14
14
|
SqlResult,
|
|
15
|
+
StatementLoopFact,
|
|
15
16
|
} from '@ultimat3/admin/dev';
|
|
16
17
|
import { DEV_BASE_PATH, DEV_PANELS, defaultDevSources, devDashboard } from '@ultimat3/admin/dev';
|
|
17
18
|
import { recentInvalidations } from '@ultimat3/cache';
|
|
@@ -23,11 +24,13 @@ import { isMemoryDriver } from '@ultimat3/mail';
|
|
|
23
24
|
import type { Manifest } from '@ultimat3/manifest';
|
|
24
25
|
import { checkAppBoundaries } from './app-boundaries';
|
|
25
26
|
import { appManifest, readAppManifest } from './app-manifest';
|
|
27
|
+
import type { StatementLedger } from './dev-n-plus-one';
|
|
26
28
|
import { devPolicyMatrix } from './dev-policy';
|
|
27
29
|
import type { RunningServices } from './dev-runtime';
|
|
28
30
|
import type { DevServices } from './dev-services';
|
|
29
31
|
import type { TraceRecorder } from './dev-traces';
|
|
30
32
|
import type { Finding } from './output';
|
|
33
|
+
import { loopFacts } from './statement-loop';
|
|
31
34
|
|
|
32
35
|
export interface DevStatus {
|
|
33
36
|
readonly url: string;
|
|
@@ -46,6 +49,8 @@ export interface DevDashboardInput {
|
|
|
46
49
|
readonly env?: string | undefined;
|
|
47
50
|
/** The spans this process recorded. Absent when `x dev` did not install the exporter. */
|
|
48
51
|
readonly traces?: TraceRecorder | undefined;
|
|
52
|
+
/** The statement shapes this process counted. Absent when `x dev` did not install the observer. */
|
|
53
|
+
readonly statements?: StatementLedger | undefined;
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/**
|
|
@@ -127,6 +132,7 @@ const invalidationFacts = (): readonly InvalidationFact[] =>
|
|
|
127
132
|
*/
|
|
128
133
|
export function devSources(input: DevDashboardInput): DevSources {
|
|
129
134
|
const traces = input.traces;
|
|
135
|
+
const statements = input.statements;
|
|
130
136
|
// Only the memory driver retains what it accepted. Once a credential selects a real transport
|
|
131
137
|
// the messages are at the provider, so the hook is omitted rather than answered with `[]` —
|
|
132
138
|
// an empty outbox claims nobody was mailed, which is a different and unearned answer.
|
|
@@ -148,6 +154,15 @@ export function devSources(input: DevDashboardInput): DevSources {
|
|
|
148
154
|
...(traces === undefined
|
|
149
155
|
? {}
|
|
150
156
|
: { traces: (): Promise<readonly RequestTrace[]> => Promise.resolve(traces.traces()) }),
|
|
157
|
+
// Same rule, same reason: the timeline panel answers `null` for a host that counted nothing,
|
|
158
|
+
// and an empty list here would instead claim every request on screen was clean. The verdicts
|
|
159
|
+
// are read at request time, so a loop that just happened is on the panel without a remount.
|
|
160
|
+
...(statements === undefined
|
|
161
|
+
? {}
|
|
162
|
+
: {
|
|
163
|
+
statementLoops: (): Promise<readonly StatementLoopFact[]> =>
|
|
164
|
+
Promise.resolve(statements.repeats().map(loopFacts)),
|
|
165
|
+
}),
|
|
151
166
|
},
|
|
152
167
|
});
|
|
153
168
|
}
|
|
@@ -163,8 +178,8 @@ interface ServicesPanelData extends DevStatus {
|
|
|
163
178
|
*/
|
|
164
179
|
const servicesPanel = (input: DevDashboardInput): DevPanel<ServicesPanelData> => ({
|
|
165
180
|
key: 'services',
|
|
166
|
-
titleKey: 'dev.panel.services',
|
|
167
|
-
|
|
181
|
+
titleKey: 'dev.panel.services.title',
|
|
182
|
+
questionKey: 'dev.panel.services.question',
|
|
168
183
|
data(): Promise<ServicesPanelData> {
|
|
169
184
|
const status = input.status();
|
|
170
185
|
return Promise.resolve({ ...status, stateDir: status.services.stateDir });
|
|
@@ -177,8 +192,8 @@ interface BoundariesPanelData {
|
|
|
177
192
|
|
|
178
193
|
const boundariesPanel = (input: DevDashboardInput): DevPanel<BoundariesPanelData> => ({
|
|
179
194
|
key: 'boundaries',
|
|
180
|
-
titleKey: 'dev.panel.boundaries',
|
|
181
|
-
|
|
195
|
+
titleKey: 'dev.panel.boundaries.title',
|
|
196
|
+
questionKey: 'dev.panel.boundaries.question',
|
|
182
197
|
async data(): Promise<BoundariesPanelData> {
|
|
183
198
|
return { findings: await checkAppBoundaries(input.root) };
|
|
184
199
|
},
|
package/src/dev-hooks.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { actorOf } from '@ultimat3/action';
|
|
6
6
|
import type { AuthzDecision, ServerHooks } from '@ultimat3/http';
|
|
7
|
-
import { asCtx } from '@ultimat3/http';
|
|
7
|
+
import { asCtx, configuredAuthenticator } from '@ultimat3/http';
|
|
8
8
|
import type { KnownPermission, Policy } from '@ultimat3/policy';
|
|
9
9
|
import { can, evaluate } from '@ultimat3/policy';
|
|
10
10
|
import { routeFor } from '@ultimat3/render';
|
|
@@ -22,8 +22,33 @@ function policyFor(path: string): Policy<unknown, unknown> | undefined {
|
|
|
22
22
|
return permission !== undefined && isPermission(permission) ? can(permission) : undefined;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Both seams, never one. This returned `authorize` alone, so `hooks.authenticate` — the only
|
|
27
|
+
* place an actor can come from — had no caller anywhere in the framework: every request under
|
|
28
|
+
* `x dev` AND under `apps/web/server.ts` (both boot through `startRoles`) was anonymous, and
|
|
29
|
+
* `auth: 'required'` was unsatisfiable. The app declares the resolver with
|
|
30
|
+
* `configureAuthenticator()` at import time; this reads it back at server start, which is after
|
|
31
|
+
* `loadApp` has imported the app's modules.
|
|
32
|
+
*
|
|
33
|
+
* Read here rather than captured at module load so a test — and a watch-mode restart — sees the
|
|
34
|
+
* function the app configured, not the one that was absent when this module first evaluated.
|
|
35
|
+
*/
|
|
36
|
+
export interface DevHookOptions {
|
|
37
|
+
/**
|
|
38
|
+
* Dev-only findings this process accumulated for the request being answered — `x dev`'s N+1
|
|
39
|
+
* ledger, and nothing else today. Passed rather than read from a module-global for the reason
|
|
40
|
+
* `authorize` is passed a route: a hook that reached for the ledger itself would make every host
|
|
41
|
+
* that starts a web role — `serve.ts` included — carry a diagnostic only one of them installs.
|
|
42
|
+
*/
|
|
43
|
+
readonly devNotices?: ServerHooks['devNotices'];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function devHooks(options: DevHookOptions = {}): ServerHooks {
|
|
47
|
+
const authenticate = configuredAuthenticator();
|
|
48
|
+
const devNotices = options.devNotices;
|
|
26
49
|
return {
|
|
50
|
+
...(authenticate === undefined ? {} : { authenticate }),
|
|
51
|
+
...(devNotices === undefined ? {} : { devNotices }),
|
|
27
52
|
authorize: (route, _request, ctx): AuthzDecision => {
|
|
28
53
|
// An action route never arrives here: it carries `enforcedBy: 'handler'`, so the pipeline
|
|
29
54
|
// never asks. `invoke` is its one evaluation, and the only one holding the row a row-level
|