@ultimat3/cli 22.15.0 → 24.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 +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -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 +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- 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/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -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/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- 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 +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -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/naming.ts +28 -0
- 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 +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- 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 +144 -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-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- 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 +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- 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 +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- 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/verify-errors.ts +0 -49
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// `x db migrate`'s second look at the database it just migrated: does it hold a trigger, function,
|
|
2
|
+
// view, type or sequence that replaying the migrations does not create? `runMigrations` compares
|
|
3
|
+
// tables against the snapshot and closes its connection; a snapshot records only what entities
|
|
4
|
+
// declare, so everything else is asked here, against the catalog the schema dump was rendered from.
|
|
5
|
+
|
|
6
|
+
import type { DriftDifference } from '@ultimat3/db';
|
|
7
|
+
import type { CatalogDescription } from '@ultimat3/db/schema-dump';
|
|
8
|
+
import { introspectCatalog, unexpectedObjects } from '@ultimat3/db/schema-dump';
|
|
9
|
+
import { resolveServices } from './runtime-bindings';
|
|
10
|
+
import { startQueue } from './runtime-queue';
|
|
11
|
+
|
|
12
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The same boot `runMigrations` used, reopened: that function is `serve.ts`'s and is deliberately
|
|
16
|
+
* not wrapped, so the connection it held is gone by the time its report is in hand. Reopening an
|
|
17
|
+
* embedded database is a fraction of a second; `x db migrate` is not a hot path.
|
|
18
|
+
*/
|
|
19
|
+
export async function liveObjectDrift(
|
|
20
|
+
root: string,
|
|
21
|
+
env: Env,
|
|
22
|
+
expected: CatalogDescription,
|
|
23
|
+
): Promise<readonly DriftDifference[]> {
|
|
24
|
+
const queue = await startQueue(resolveServices(root, env), undefined, env);
|
|
25
|
+
try {
|
|
26
|
+
return unexpectedObjects(await introspectCatalog({ client: queue.db }), expected);
|
|
27
|
+
} finally {
|
|
28
|
+
await queue.stop();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Which database the schema dump is replayed on, and that database, opened and thrown away. ONE
|
|
2
|
+
// rule: the embedded database, unless the migrations create an extension it cannot link — then a
|
|
3
|
+
// real Postgres, in a database this module creates and drops. Decided from the migrations and the
|
|
4
|
+
// installed PGlite alone, never from which URL happens to be set, so a laptop and CI choose alike.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path joiner.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import type { DbClient, LinkedExtensions, Migration } from '@ultimat3/db';
|
|
9
|
+
import {
|
|
10
|
+
createPgliteClient,
|
|
11
|
+
createPostgresClient,
|
|
12
|
+
DbError,
|
|
13
|
+
linkPgliteExtensions,
|
|
14
|
+
raw,
|
|
15
|
+
} from '@ultimat3/db';
|
|
16
|
+
import { migrationExtensions } from './migration-extensions';
|
|
17
|
+
|
|
18
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where a real Postgres is looked for, in order — the admin URL `@ultimat3/testing` already
|
|
22
|
+
* clones its template databases from (`template-db.ts`), so a CI that runs the `live` suite has
|
|
23
|
+
* it set and nothing new to configure.
|
|
24
|
+
*/
|
|
25
|
+
export const REPLAY_URL_ENVS = ['TEST_DATABASE_URL', 'DATABASE_URL'] as const;
|
|
26
|
+
|
|
27
|
+
export type ReplayEngine =
|
|
28
|
+
| { readonly kind: 'embedded'; readonly extensions: readonly string[] }
|
|
29
|
+
| {
|
|
30
|
+
readonly kind: 'postgres';
|
|
31
|
+
readonly adminUrl: string;
|
|
32
|
+
/** The extensions that forced it, for the finding a missing URL becomes. */
|
|
33
|
+
readonly requires: readonly string[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The migrations need a server and none is named. `X_SCHEMA_DUMP_DRIFT`, because the dump cannot
|
|
38
|
+
* be produced or held; the cause names the extension, the fix the variable and the command.
|
|
39
|
+
*/
|
|
40
|
+
export function replayServerMissing(requires: readonly string[]): DbError {
|
|
41
|
+
const named = requires.map((name) => `"${name}"`).join(', ');
|
|
42
|
+
return new DbError({
|
|
43
|
+
code: 'X_SCHEMA_DUMP_DRIFT',
|
|
44
|
+
cause: `the migrations create extension ${named}, which the embedded database cannot link, so the schema dump is replayed on a real Postgres — and neither ${REPLAY_URL_ENVS.join(' nor ')} names one`,
|
|
45
|
+
fix: `${REPLAY_URL_ENVS[0]}=postgres://<user>:<password>@localhost:5432/postgres x db gen # a server with that extension installed, and a role that may create a database`,
|
|
46
|
+
meta: { kind: 'engine-unavailable', extensions: [...requires] },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ExtensionLinker = (names: readonly string[]) => Promise<LinkedExtensions>;
|
|
51
|
+
|
|
52
|
+
/** The rule. Throws `replayServerMissing` when it selects a server nobody named. */
|
|
53
|
+
export async function chooseReplayEngine(
|
|
54
|
+
migrations: readonly Migration[],
|
|
55
|
+
env: Env,
|
|
56
|
+
link: ExtensionLinker = linkPgliteExtensions,
|
|
57
|
+
): Promise<ReplayEngine> {
|
|
58
|
+
const extensions = migrationExtensions(migrations);
|
|
59
|
+
const { missing } = await link(extensions);
|
|
60
|
+
if (missing.length === 0) return { kind: 'embedded', extensions };
|
|
61
|
+
const adminUrl = REPLAY_URL_ENVS.map((name) => env[name]).find(
|
|
62
|
+
(value) => value !== undefined && value.length > 0,
|
|
63
|
+
);
|
|
64
|
+
if (adminUrl === undefined) throw replayServerMissing(missing);
|
|
65
|
+
return { kind: 'postgres', adminUrl, requires: missing };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Unique per call: two `x db gen` runs against one server must not share a scratch database. */
|
|
69
|
+
const scratchName = (): string => `x_schema_dump_${process.pid}_${crypto.randomUUID().slice(0, 8)}`;
|
|
70
|
+
|
|
71
|
+
async function withPostgresScratch<T>(
|
|
72
|
+
adminUrl: string,
|
|
73
|
+
work: (client: DbClient) => Promise<T>,
|
|
74
|
+
): Promise<T> {
|
|
75
|
+
const database = scratchName();
|
|
76
|
+
const admin = createPostgresClient({ url: adminUrl });
|
|
77
|
+
try {
|
|
78
|
+
// `template0`: the server's `template1` may carry objects an operator put there, and they
|
|
79
|
+
// would be dumped as the app's.
|
|
80
|
+
await admin.execute(raw(`create database ${database} template template0`));
|
|
81
|
+
const target = new URL(adminUrl);
|
|
82
|
+
target.pathname = `/${database}`;
|
|
83
|
+
const client = createPostgresClient({ url: target.toString() });
|
|
84
|
+
try {
|
|
85
|
+
return await work(client);
|
|
86
|
+
} finally {
|
|
87
|
+
await client.close();
|
|
88
|
+
await admin.execute(raw(`drop database if exists ${database} with (force)`));
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
await admin.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run `work` against an empty scratch database on `engine`, and release it. The embedded one
|
|
97
|
+
* boots from the snapshot cache under `stateDir`, so only the first boot per PGlite version runs
|
|
98
|
+
* `initdb`.
|
|
99
|
+
*/
|
|
100
|
+
export async function withScratchDatabase<T>(
|
|
101
|
+
engine: ReplayEngine,
|
|
102
|
+
stateDir: string | undefined,
|
|
103
|
+
work: (client: DbClient) => Promise<T>,
|
|
104
|
+
): Promise<T> {
|
|
105
|
+
if (engine.kind === 'postgres') return withPostgresScratch(engine.adminUrl, work);
|
|
106
|
+
const client = createPgliteClient({
|
|
107
|
+
extensions: engine.extensions,
|
|
108
|
+
...(stateDir === undefined ? {} : { snapshotDir: join(stateDir, 'cache') }),
|
|
109
|
+
});
|
|
110
|
+
try {
|
|
111
|
+
return await work(client);
|
|
112
|
+
} finally {
|
|
113
|
+
await client.close();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// The app's schema dump — `packages/db/schema/` — produced, read and written. ONE producer: a
|
|
2
|
+
// scratch database (`db-replay-engine.ts` says which) with the framework's tables applied and
|
|
3
|
+
// every migration replayed, never the developer's own database. `x db gen`, `x db migrate` and the `drift` step all call it,
|
|
4
|
+
// so the bytes the gate compares against are the bytes the commands wrote, on one engine.
|
|
5
|
+
|
|
6
|
+
// why: Bun has no directory probe — `Bun.file().exists()` answers about a file.
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
// why: Bun ships no path joiner.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import type { Migration } from '@ultimat3/db';
|
|
11
|
+
import { migrate, raw } from '@ultimat3/db';
|
|
12
|
+
import type {
|
|
13
|
+
CatalogDescription,
|
|
14
|
+
SchemaDumpDifference,
|
|
15
|
+
SchemaDumpFile,
|
|
16
|
+
} from '@ultimat3/db/schema-dump';
|
|
17
|
+
import {
|
|
18
|
+
introspectCatalog,
|
|
19
|
+
loadSchemaDump,
|
|
20
|
+
reloadDifferences,
|
|
21
|
+
renderSchemaDump,
|
|
22
|
+
schemaDumpDifferenceOf,
|
|
23
|
+
} from '@ultimat3/db/schema-dump';
|
|
24
|
+
import { chooseReplayEngine, withScratchDatabase } from './db-replay-engine';
|
|
25
|
+
import { applyFrameworkSchema } from './framework-schema';
|
|
26
|
+
import { resolveServices } from './runtime-bindings';
|
|
27
|
+
|
|
28
|
+
/** Where the dump lives. App-root-relative, POSIX — beside `MIGRATIONS_DIR`, its one source. */
|
|
29
|
+
export const SCHEMA_DUMP_DIR = 'packages/db/schema';
|
|
30
|
+
|
|
31
|
+
export interface ReplayedSchema {
|
|
32
|
+
readonly catalog: CatalogDescription;
|
|
33
|
+
readonly files: readonly SchemaDumpFile[];
|
|
34
|
+
/** Empty when a database loaded from `files` renders `files` again. Always empty without `reload`. */
|
|
35
|
+
readonly reload: readonly SchemaDumpDifference[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ReplayOptions {
|
|
39
|
+
/** Also prove load equals replay: reset the schema, load the dump, render it again. */
|
|
40
|
+
readonly reload?: boolean | undefined;
|
|
41
|
+
/** The app's state directory (`.x/`), where the embedded scratch boot keeps its snapshot. */
|
|
42
|
+
readonly stateDir?: string | undefined;
|
|
43
|
+
/** Read for the server URL when the migrations cannot replay on the embedded database. */
|
|
44
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Framework tables first, then every migration — the order every real boot runs them in
|
|
49
|
+
* (`startQueue`, then `migrate`), so a migration's foreign key into `x_users` finds its target.
|
|
50
|
+
* The client is handed to each call and never installed as the ambient `db()`: this runs inside
|
|
51
|
+
* `x verify`, beside steps that own that accessor.
|
|
52
|
+
*
|
|
53
|
+
* The reload reuses the SAME database, emptied — a second scratch database is a second boot for a
|
|
54
|
+
* question `drop schema … cascade` answers as well. It is asked only of a dump that claims to be
|
|
55
|
+
* whole: one that lists objects in `unrendered.sql` has already said a database loaded from it is
|
|
56
|
+
* not the migrated one.
|
|
57
|
+
*/
|
|
58
|
+
export async function replaySchema(
|
|
59
|
+
migrations: readonly Migration[],
|
|
60
|
+
options: ReplayOptions = {},
|
|
61
|
+
): Promise<ReplayedSchema> {
|
|
62
|
+
const engine = await chooseReplayEngine(migrations, options.env ?? Bun.env);
|
|
63
|
+
return withScratchDatabase(engine, options.stateDir, async (client) => {
|
|
64
|
+
await applyFrameworkSchema((statement) => client.execute(raw(statement)));
|
|
65
|
+
await migrate({ migrations, client });
|
|
66
|
+
const catalog = await introspectCatalog({ client });
|
|
67
|
+
const files = renderSchemaDump(catalog);
|
|
68
|
+
if (options.reload !== true || catalog.unrendered.length > 0) {
|
|
69
|
+
return { catalog, files, reload: [] };
|
|
70
|
+
}
|
|
71
|
+
await client.execute(raw('drop schema public cascade'));
|
|
72
|
+
await client.execute(raw('create schema public'));
|
|
73
|
+
try {
|
|
74
|
+
await loadSchemaDump({ client, files });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// A refusal to load is a verdict about the dump, not a failed replay: reported as the
|
|
77
|
+
// difference it is, so the caller's finding names the file rather than the engine.
|
|
78
|
+
const refused = schemaDumpDifferenceOf(error);
|
|
79
|
+
if (refused === undefined) throw error;
|
|
80
|
+
return { catalog, files, reload: [refused] };
|
|
81
|
+
}
|
|
82
|
+
const reloaded = renderSchemaDump(await introspectCatalog({ client }));
|
|
83
|
+
return { catalog, files, reload: reloadDifferences(files, reloaded) };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The replay for an app on disk: its own state directory, its command's environment. */
|
|
88
|
+
export const replayFor = (
|
|
89
|
+
root: string,
|
|
90
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
91
|
+
reload: boolean,
|
|
92
|
+
): ((migrations: readonly Migration[]) => Promise<ReplayedSchema>) => {
|
|
93
|
+
return (migrations) =>
|
|
94
|
+
replaySchema(migrations, { reload, env, stateDir: resolveServices(root, env).stateDir });
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const hasSchemaDump = (root: string): boolean => existsSync(join(root, SCHEMA_DUMP_DIR));
|
|
98
|
+
|
|
99
|
+
/** Every `.sql` under the dump directory, sorted by path — the shape `renderSchemaDump` answers in. */
|
|
100
|
+
export async function readSchemaDump(root: string): Promise<readonly SchemaDumpFile[]> {
|
|
101
|
+
const dir = join(root, SCHEMA_DUMP_DIR);
|
|
102
|
+
if (!existsSync(dir)) return [];
|
|
103
|
+
const paths: string[] = [];
|
|
104
|
+
for await (const path of new Bun.Glob('**/*.sql').scan({ cwd: dir })) {
|
|
105
|
+
paths.push(path.replaceAll('\\', '/'));
|
|
106
|
+
}
|
|
107
|
+
paths.sort();
|
|
108
|
+
const files: SchemaDumpFile[] = [];
|
|
109
|
+
for (const path of paths) {
|
|
110
|
+
files.push({ path, content: await Bun.file(join(dir, path)).text() });
|
|
111
|
+
}
|
|
112
|
+
return files;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface SchemaDumpWrite {
|
|
116
|
+
/** App-root-relative paths whose bytes this call changed or created. */
|
|
117
|
+
readonly written: readonly string[];
|
|
118
|
+
/** Files the dump no longer produces, deleted. */
|
|
119
|
+
readonly removed: readonly string[];
|
|
120
|
+
/** Every file the dump holds after the write. */
|
|
121
|
+
readonly total: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Make the directory equal to `files`: write what differs, delete what nothing renders. A file
|
|
126
|
+
* whose bytes already match is left untouched, so an unchanged table keeps its mtime and a
|
|
127
|
+
* one-table migration is a one-file write.
|
|
128
|
+
*/
|
|
129
|
+
export async function writeSchemaDump(
|
|
130
|
+
root: string,
|
|
131
|
+
files: readonly SchemaDumpFile[],
|
|
132
|
+
): Promise<SchemaDumpWrite> {
|
|
133
|
+
const dir = join(root, SCHEMA_DUMP_DIR);
|
|
134
|
+
const held = new Map((await readSchemaDump(root)).map((file) => [file.path, file.content]));
|
|
135
|
+
const written: string[] = [];
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
if (held.get(file.path) === file.content) continue;
|
|
138
|
+
// `createPath` is Bun's default, spelled: a kind directory may not exist yet.
|
|
139
|
+
await Bun.write(join(dir, file.path), file.content, { createPath: true });
|
|
140
|
+
written.push(`${SCHEMA_DUMP_DIR}/${file.path}`);
|
|
141
|
+
}
|
|
142
|
+
const rendered = new Set(files.map((file) => file.path));
|
|
143
|
+
const removed: string[] = [];
|
|
144
|
+
for (const path of held.keys()) {
|
|
145
|
+
if (rendered.has(path)) continue;
|
|
146
|
+
await Bun.file(join(dir, path)).delete();
|
|
147
|
+
removed.push(`${SCHEMA_DUMP_DIR}/${path}`);
|
|
148
|
+
}
|
|
149
|
+
return { written, removed, total: files.length };
|
|
150
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The schema dump, regenerated on behalf of a command: `x db gen` and `x db migrate` both end by
|
|
2
|
+
// making `packages/db/schema/` equal to what the migrations produce. Split from
|
|
3
|
+
// `db-schema-dump.ts`, which replays and writes; this decides what a command REPORTS when the dump
|
|
4
|
+
// was written, was already right, was not owed, or could not be produced — and holds the two
|
|
5
|
+
// findings that are about the dump as a whole rather than about one file in it.
|
|
6
|
+
|
|
7
|
+
import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
|
|
8
|
+
import type { Migration } from '@ultimat3/db';
|
|
9
|
+
import { PGLITE_MISSING } from '@ultimat3/db';
|
|
10
|
+
import type { CatalogDescription } from '@ultimat3/db/schema-dump';
|
|
11
|
+
import {
|
|
12
|
+
hasSchemaDump,
|
|
13
|
+
type ReplayedSchema,
|
|
14
|
+
replayFor,
|
|
15
|
+
SCHEMA_DUMP_DIR,
|
|
16
|
+
type SchemaDumpWrite,
|
|
17
|
+
writeSchemaDump,
|
|
18
|
+
} from './db-schema-dump';
|
|
19
|
+
import { MIGRATIONS_DIR, readMigrations } from './migrations';
|
|
20
|
+
import type { Finding, JsonValue } from './output';
|
|
21
|
+
import { findingFrom, isUltimateErrorShape } from './output';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The migrations would not replay, so there is no dump to write and nothing to compare one with.
|
|
25
|
+
* A framework error keeps its own cause; the fix is replaced, because the engine's own (`set
|
|
26
|
+
* DATABASE_URL …`) answers a question nobody asked — this database is a scratch one booted here.
|
|
27
|
+
*
|
|
28
|
+
* Two causes are not about the migrations at all, and each keeps the fix that repairs it: a
|
|
29
|
+
* refusal this pipeline raised itself (`X_SCHEMA_DUMP_DRIFT` — no server for an extension the
|
|
30
|
+
* embedded database cannot link) arrives whole, and a missing optional peer gets the install,
|
|
31
|
+
* because `x db migrate` would succeed and teach nothing.
|
|
32
|
+
*/
|
|
33
|
+
export function replayFailure(error: unknown): Finding {
|
|
34
|
+
if (isUltimateErrorShape(error) && error.code === 'X_SCHEMA_DUMP_DRIFT') {
|
|
35
|
+
return { ...findingFrom(error), docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR };
|
|
36
|
+
}
|
|
37
|
+
const detail = isUltimateErrorShape(error) ? findingFrom(error).cause : renderThrowable(error);
|
|
38
|
+
const missing = detail.startsWith(PGLITE_MISSING);
|
|
39
|
+
return {
|
|
40
|
+
code: 'X_SCHEMA_DUMP_DRIFT',
|
|
41
|
+
cause: missing
|
|
42
|
+
? `the schema dump is replayed on the embedded database and ${detail}`
|
|
43
|
+
: `the migrations do not replay on the scratch database, so the schema dump cannot be produced: ${detail}`,
|
|
44
|
+
fix: missing
|
|
45
|
+
? // A literal, as `@ultimat3/db`'s own `PGLITE_FIX` is: a `fix:` splices nothing into its
|
|
46
|
+
// command position, and `db-schema-refresh.test.ts` holds it equal to `PGLITE_PACKAGE`.
|
|
47
|
+
'bun add -d @electric-sql/pglite # then: x db gen'
|
|
48
|
+
: 'x db migrate --json # applies the same migrations to the dev database; a statement refused there too is the one to repair — then: x db gen',
|
|
49
|
+
docs: ERROR_DOCS_URL,
|
|
50
|
+
at: missing ? SCHEMA_DUMP_DIR : MIGRATIONS_DIR,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The same finding, for a run of `x db gen` that DID write its migration before the dump failed.
|
|
56
|
+
* The exit code is 1 and the migration is on disk, and a reader who sees only the first fact
|
|
57
|
+
* generates a second migration for a schema that is already recorded. So the cause leads with
|
|
58
|
+
* the file that exists, and says what the next `x db gen` will and will not do.
|
|
59
|
+
*/
|
|
60
|
+
export const afterWrittenMigration = (finding: Finding, id: string): Finding => ({
|
|
61
|
+
...finding,
|
|
62
|
+
cause: `migration ${id} WAS written to ${MIGRATIONS_DIR} and is not to be generated again; only the schema dump failed, and once that is repaired a bare x db gen writes the dump and no second migration — ${finding.cause}`,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** An app with a migration and no dump directory: one finding, and no database booted to say so. */
|
|
66
|
+
export const dumpNeverGenerated = (): Finding => ({
|
|
67
|
+
code: 'X_SCHEMA_DUMP_DRIFT',
|
|
68
|
+
cause: `${MIGRATIONS_DIR} holds migrations and ${SCHEMA_DUMP_DIR} does not exist: the schema dump was never generated`,
|
|
69
|
+
fix: 'x db gen',
|
|
70
|
+
docs: ERROR_DOCS_URL,
|
|
71
|
+
at: SCHEMA_DUMP_DIR,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export interface SchemaDumpRefresh extends SchemaDumpWrite {
|
|
75
|
+
/**
|
|
76
|
+
* `skipped` — no migration and no dump, so nothing is owed and no database is booted.
|
|
77
|
+
* `failed` — the migrations did not replay; `finding` says why, and the dump on disk is left
|
|
78
|
+
* exactly as it was.
|
|
79
|
+
*/
|
|
80
|
+
readonly status: 'written' | 'unchanged' | 'skipped' | 'failed';
|
|
81
|
+
readonly finding?: Finding | undefined;
|
|
82
|
+
/** The replayed catalog, for a caller that compares a live database against it. */
|
|
83
|
+
readonly catalog?: CatalogDescription | undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const NOTHING: SchemaDumpWrite = { written: [], removed: [], total: 0 };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Regenerate the dump after `x db gen` or `x db migrate`. Never throws: a replay that fails is
|
|
90
|
+
* reported as the finding it is, after the migration the command was asked for is already written.
|
|
91
|
+
*/
|
|
92
|
+
export async function refreshSchemaDump(
|
|
93
|
+
root: string,
|
|
94
|
+
env: Readonly<Record<string, string | undefined>> = Bun.env,
|
|
95
|
+
// Injected so the reporting rules are testable without booting a database per case.
|
|
96
|
+
replay: (migrations: readonly Migration[]) => Promise<ReplayedSchema> = replayFor(
|
|
97
|
+
root,
|
|
98
|
+
env,
|
|
99
|
+
false,
|
|
100
|
+
),
|
|
101
|
+
): Promise<SchemaDumpRefresh> {
|
|
102
|
+
const migrations = await readMigrations(root);
|
|
103
|
+
if (migrations.length === 0 && !hasSchemaDump(root)) return { ...NOTHING, status: 'skipped' };
|
|
104
|
+
let replayed: ReplayedSchema;
|
|
105
|
+
try {
|
|
106
|
+
replayed = await replay(migrations);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return { ...NOTHING, status: 'failed', finding: replayFailure(error) };
|
|
109
|
+
}
|
|
110
|
+
const write = await writeSchemaDump(root, replayed.files);
|
|
111
|
+
const changed = write.written.length + write.removed.length > 0;
|
|
112
|
+
return { ...write, status: changed ? 'written' : 'unchanged', catalog: replayed.catalog };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The `--json` shape both commands report under `data.schemaDump`. */
|
|
116
|
+
export const schemaDumpJson = (refresh: SchemaDumpRefresh): JsonValue => ({
|
|
117
|
+
directory: SCHEMA_DUMP_DIR,
|
|
118
|
+
status: refresh.status,
|
|
119
|
+
files: refresh.total,
|
|
120
|
+
written: [...refresh.written],
|
|
121
|
+
removed: [...refresh.removed],
|
|
122
|
+
});
|
package/src/db-subscribes.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// Single responsibility: which tables `x db gen` must grant `REPLICA IDENTITY FULL
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Single responsibility: which tables `x db gen` must grant `REPLICA IDENTITY FULL` — the live
|
|
2
|
+
// queries' DECLARED `subscribes:` plus the `records` tables of every channel with params — and the
|
|
3
|
+
// refusal for a declared name no entity's table matches. Only this tier holds all the registries.
|
|
4
4
|
|
|
5
5
|
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
import { paramsChannelTables } from '@ultimat3/realtime/server';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The two fields this reads, and nothing else. Structurally satisfied by `QueryDescriptor` (whose
|
|
@@ -63,12 +64,19 @@ export class QuerySubscribesUnknownError extends UltimateError {
|
|
|
63
64
|
* Every name is checked against `tables` BEFORE any of them is returned: a run that emitted the
|
|
64
65
|
* good half and refused afterwards would leave an author with a migration that is right for one
|
|
65
66
|
* table and silently absent for the other.
|
|
67
|
+
*
|
|
68
|
+
* `channelTables` defaults to what the declared channels need (`paramsChannelTables()`): a channel
|
|
69
|
+
* with params routes a DELETE to the topic the OLD row names, and under the default identity that
|
|
70
|
+
* image is the key alone — no topic, no `remove`, and members keep the deleted record. They are
|
|
71
|
+
* derived from entity projections rather than typed by an author, so one outside `tables` is
|
|
72
|
+
* dropped, not refused: there is no declaration to send anyone back to.
|
|
66
73
|
*/
|
|
67
74
|
export function replicaIdentityTables(
|
|
68
75
|
queries: readonly SubscribingQuery[],
|
|
69
76
|
tables: ReadonlySet<string>,
|
|
77
|
+
channelTables: readonly string[] = paramsChannelTables(),
|
|
70
78
|
): readonly string[] {
|
|
71
|
-
const wanted = new Set<string>();
|
|
79
|
+
const wanted = new Set<string>(channelTables.filter((table) => tables.has(table)));
|
|
72
80
|
for (const query of queries) {
|
|
73
81
|
for (const table of query.subscribes ?? []) {
|
|
74
82
|
if (!tables.has(table)) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The doors in front of `/_x` that a browser can knock on. `x dev` binds loopback, but a page the
|
|
2
|
+
// developer has open can still reach it: by DNS rebinding (a hostile name that resolves to
|
|
3
|
+
// 127.0.0.1, read back same-origin) and by a cross-site request to the SQL panel.
|
|
4
|
+
|
|
5
|
+
import { proveSameOrigin, renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
6
|
+
import type { UltimateRequest } from '@ultimat3/http';
|
|
7
|
+
import { csrfBlocked, HttpError, json as jsonResponse, selfOrigin } from '@ultimat3/http';
|
|
8
|
+
import { t } from '@ultimat3/schema';
|
|
9
|
+
import { isLoopbackHostname } from './loopback-host';
|
|
10
|
+
|
|
11
|
+
/** A `/_x` request named a Host that is not this machine. Never echoes the Host it was sent. */
|
|
12
|
+
export class DevHostRefusedError extends UltimateError {
|
|
13
|
+
constructor(input: { devUrl: string }) {
|
|
14
|
+
super({
|
|
15
|
+
code: 'X_DEV_HOST_REFUSED',
|
|
16
|
+
cause:
|
|
17
|
+
'a /_x request named a Host that is not localhost, a *.localhost name or a loopback address — the shape of a DNS-rebinding page reading the dev dashboard',
|
|
18
|
+
fix: `curl -sS -H 'accept: application/json' ${renderFixShellArg(`${input.devUrl}/_x`, 'http://localhost:3000/_x')}`,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The Host as sent, else the one the URL was built from — Bun builds it from the header. */
|
|
24
|
+
const hostnameOf = (request: UltimateRequest): string | undefined => {
|
|
25
|
+
const header = request.header('host') ?? request.url.host;
|
|
26
|
+
return URL.parse(`http://${header}`)?.hostname;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `421 Misdirected Request` for a Host that is not this machine, on every `/_x` route. The status
|
|
31
|
+
* says "not this server", which is the truth: a rebound name was never meant to reach it.
|
|
32
|
+
*/
|
|
33
|
+
export function hostRefusal(request: UltimateRequest, devUrl: string): Response | undefined {
|
|
34
|
+
const hostname = hostnameOf(request);
|
|
35
|
+
if (hostname !== undefined && isLoopbackHostname(hostname)) return undefined;
|
|
36
|
+
const error = new DevHostRefusedError({ devUrl });
|
|
37
|
+
return jsonResponse(
|
|
38
|
+
{ ok: false, error: { code: error.code, cause: error.cause, fix: error.fix } },
|
|
39
|
+
{ status: 421 },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A statement runs on a POST that came from this origin — core's `proveSameOrigin`, the rule the
|
|
45
|
+
* app's own writes and the sync upgrade answer to, asked HERE rather than left to the pipeline's
|
|
46
|
+
* CSRF stage: an app with `csrf: { mode: 'off' }` must not switch the dev database's door off with
|
|
47
|
+
* it. Neither header present is `curl`, as `checkCsrf` reads it: no page can drive that client.
|
|
48
|
+
*/
|
|
49
|
+
export function assertSameOrigin(request: UltimateRequest): void {
|
|
50
|
+
const origin = request.header('origin');
|
|
51
|
+
const secFetchSite = request.header('sec-fetch-site');
|
|
52
|
+
if (origin === null && secFetchSite === null) return;
|
|
53
|
+
const verdict = proveSameOrigin({
|
|
54
|
+
selfOrigins: [selfOrigin(request.url, request.ctx.https)],
|
|
55
|
+
origin,
|
|
56
|
+
secFetchSite,
|
|
57
|
+
listed: () => false,
|
|
58
|
+
listName: 'no list — /_x admits its own origin only',
|
|
59
|
+
});
|
|
60
|
+
if (!verdict.ok) throw csrfBlocked(request.pathname, verdict.reason, request.ctx.ip);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The SQL panel's one input, from a form or a JSON body — `request.body` parses either. */
|
|
64
|
+
export const SQL_BODY = t.object({ sql: t.string });
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A statement on the query string is refused rather than run: a GET is what `<img src>` and a
|
|
68
|
+
* prefetch send cross-site with nobody's consent. The fix is the POST that does run it.
|
|
69
|
+
*/
|
|
70
|
+
export const sqlOnGet = (devUrl: string): HttpError =>
|
|
71
|
+
new HttpError({
|
|
72
|
+
code: 'X_METHOD_NOT_ALLOWED',
|
|
73
|
+
cause:
|
|
74
|
+
'/_x/db runs a statement on a same-origin POST only — a GET carrying ?sql= is what a cross-site page can send',
|
|
75
|
+
fix: `curl -sS -H 'accept: application/json' --data-urlencode 'sql=select 1' ${renderFixShellArg(`${devUrl}/_x/db`, 'http://localhost:3000/_x/db')}`,
|
|
76
|
+
});
|
package/src/dev-dashboard.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Mounting `@ultimat3/admin`'s `/_x` dashboard in the `x dev` process. The CLI contributes only
|
|
2
|
-
// what no registry holds —
|
|
3
|
-
//
|
|
2
|
+
// what no registry holds — the live dev database, the caught outbox, the committed manifest, and
|
|
3
|
+
// two panels of process facts — and projects the dashboard onto HTTP routes behind a Host check.
|
|
4
4
|
// A panel implemented here instead of in `admin` would be the second copy this seam exists to ban.
|
|
5
5
|
|
|
6
6
|
import type {
|
|
@@ -14,7 +14,13 @@ import type {
|
|
|
14
14
|
SqlResult,
|
|
15
15
|
StatementLoopFact,
|
|
16
16
|
} from '@ultimat3/admin/dev';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
DEV_BASE_PATH,
|
|
19
|
+
DEV_PANELS,
|
|
20
|
+
defaultDevSources,
|
|
21
|
+
devDashboard,
|
|
22
|
+
readOnlySql,
|
|
23
|
+
} from '@ultimat3/admin/dev';
|
|
18
24
|
import { recentInvalidations } from '@ultimat3/cache';
|
|
19
25
|
import type { Role } from '@ultimat3/core';
|
|
20
26
|
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
@@ -24,6 +30,7 @@ import { isMemoryDriver } from '@ultimat3/mail';
|
|
|
24
30
|
import type { Manifest } from '@ultimat3/manifest';
|
|
25
31
|
import { appManifest, readAppManifest } from './app-manifest';
|
|
26
32
|
import { appBoundaryFindings } from './boundary-findings';
|
|
33
|
+
import { assertSameOrigin, hostRefusal, SQL_BODY, sqlOnGet } from './dev-dashboard-guard';
|
|
27
34
|
import type { StatementLedger } from './dev-n-plus-one';
|
|
28
35
|
import { devPolicyMatrix } from './dev-policy';
|
|
29
36
|
import type { TraceRecorder } from './dev-traces';
|
|
@@ -53,24 +60,6 @@ export interface DevDashboardInput {
|
|
|
53
60
|
readonly statements?: StatementLedger | undefined;
|
|
54
61
|
}
|
|
55
62
|
|
|
56
|
-
/**
|
|
57
|
-
* Read-only is already enforced by `assertReadOnly` inside `dbPanel`, before `runSql` is ever
|
|
58
|
-
* reached. A second gate here would be a second authz: two places to update when `x db psql
|
|
59
|
-
* --write` changes what is allowed, and one of them would eventually disagree.
|
|
60
|
-
*/
|
|
61
|
-
async function runSql(input: DevDashboardInput, sql: string): Promise<SqlResult> {
|
|
62
|
-
const started = performance.now();
|
|
63
|
-
const rows = await input.runtime.db.query<Readonly<Record<string, unknown>>>({
|
|
64
|
-
text: sql,
|
|
65
|
-
values: [],
|
|
66
|
-
});
|
|
67
|
-
const elapsedMs = Math.round(performance.now() - started);
|
|
68
|
-
// Columns come from the first row because the driver returns objects, not a described result
|
|
69
|
-
// set; no rows means no columns to name, which the panel renders as an empty grid.
|
|
70
|
-
const columns = Object.keys(rows[0] ?? {});
|
|
71
|
-
return { columns, rows: rows.map((row) => columns.map((column) => row[column])), elapsedMs };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
63
|
/** `MailMessage.locale` is non-optional in `@ultimat3/mail`, so the panel never has to guess. */
|
|
75
64
|
function mailFacts(outbox: MemoryMailDriver): readonly MailFact[] {
|
|
76
65
|
return outbox.outbox().map((entry) => ({
|
|
@@ -137,9 +126,13 @@ export function devSources(input: DevDashboardInput): DevSources {
|
|
|
137
126
|
// the messages are at the provider, so the hook is omitted rather than answered with `[]` —
|
|
138
127
|
// an empty outbox claims nobody was mailed, which is a different and unearned answer.
|
|
139
128
|
const outbox = isMemoryDriver(input.runtime.mail) ? input.runtime.mail : undefined;
|
|
129
|
+
// `dbPanel`'s `assertReadOnly` is the parse guard; `readOnlySql` is the server saying no — its own
|
|
130
|
+
// `BEGIN READ ONLY` that always rolls back, a statement timeout and a row ceiling. The plain
|
|
131
|
+
// `db.query` it replaces ran whatever the guard let through with the app's own credentials.
|
|
132
|
+
const runSql = readOnlySql(input.runtime.db);
|
|
140
133
|
return defaultDevSources({
|
|
141
134
|
hooks: {
|
|
142
|
-
runSql: (sql: string): Promise<SqlResult> => runSql(
|
|
135
|
+
runSql: (sql: string): Promise<SqlResult> => runSql(sql),
|
|
143
136
|
...(outbox === undefined
|
|
144
137
|
? {}
|
|
145
138
|
: { mail: (): Promise<readonly MailFact[]> => Promise.resolve(mailFacts(outbox)) }),
|
|
@@ -222,8 +215,13 @@ const notClaimed = (path: string): Response =>
|
|
|
222
215
|
{ status: 404 },
|
|
223
216
|
);
|
|
224
217
|
|
|
225
|
-
const devRoute = (
|
|
226
|
-
|
|
218
|
+
const devRoute = (
|
|
219
|
+
path: string,
|
|
220
|
+
name: string,
|
|
221
|
+
handler: Route['handler'],
|
|
222
|
+
method: 'GET' | 'POST' = 'GET',
|
|
223
|
+
): Route => ({
|
|
224
|
+
method,
|
|
227
225
|
path,
|
|
228
226
|
// Public: /_x exists to be read without credentials by whatever drives the dev loop, and
|
|
229
227
|
// `devDashboard` refuses to construct at all outside development.
|
|
@@ -232,9 +230,10 @@ const devRoute = (path: string, name: string, handler: Route['handler']): Route
|
|
|
232
230
|
});
|
|
233
231
|
|
|
234
232
|
/**
|
|
235
|
-
* One route for the base path plus one per panel, because the router matches exact paths
|
|
236
|
-
*
|
|
237
|
-
*
|
|
233
|
+
* One route for the base path plus one per panel, because the router matches exact paths, and a
|
|
234
|
+
* `POST` for the SQL panel — the only `/_x` read that runs something. The dashboard is built once
|
|
235
|
+
* — its sources close over this process, and rebuilding per request would re-run `assertDevOnly`
|
|
236
|
+
* on every hit for no new answer. Every route answers this machine's Host only.
|
|
238
237
|
*/
|
|
239
238
|
export function devDashboardRoutes(input: DevDashboardInput): readonly Route[] {
|
|
240
239
|
const panels = devPanels(input);
|
|
@@ -245,13 +244,36 @@ export function devDashboardRoutes(input: DevDashboardInput): readonly Route[] {
|
|
|
245
244
|
...(input.env === undefined ? {} : { env: input.env }),
|
|
246
245
|
});
|
|
247
246
|
|
|
248
|
-
const
|
|
249
|
-
|
|
247
|
+
const sqlPath = `${DEV_BASE_PATH}/db`;
|
|
248
|
+
const handler = async (request: UltimateRequest): Promise<Response> => {
|
|
249
|
+
const devUrl = input.status().url;
|
|
250
|
+
const refused = hostRefusal(request, devUrl);
|
|
251
|
+
if (refused !== undefined) return refused;
|
|
252
|
+
if (request.pathname === sqlPath && request.url.searchParams.has('sql')) throw sqlOnGet(devUrl);
|
|
253
|
+
return (await dashboard.handle(request.raw)) ?? notClaimed(request.pathname);
|
|
254
|
+
};
|
|
255
|
+
// The statement moves from the body onto the panel's own parameter, server side: `dbPanel`
|
|
256
|
+
// reads `sql` off the query, and this is the one caller allowed to put it there.
|
|
257
|
+
const runStatement = async (request: UltimateRequest): Promise<Response> => {
|
|
258
|
+
const refused = hostRefusal(request, input.status().url);
|
|
259
|
+
if (refused !== undefined) return refused;
|
|
260
|
+
assertSameOrigin(request);
|
|
261
|
+
const { sql } = await request.body(SQL_BODY);
|
|
262
|
+
const target = new URL(request.url);
|
|
263
|
+
target.searchParams.set('sql', sql);
|
|
264
|
+
// A GET carries no body, so the body's own headers stay behind; `accept` and the rest ride.
|
|
265
|
+
const headers = new Headers(request.headers);
|
|
266
|
+
headers.delete('content-type');
|
|
267
|
+
headers.delete('content-length');
|
|
268
|
+
const forwarded = new Request(target, { headers });
|
|
269
|
+
return (await dashboard.handle(forwarded)) ?? notClaimed(request.pathname);
|
|
270
|
+
};
|
|
250
271
|
|
|
251
272
|
return [
|
|
252
273
|
devRoute(DEV_BASE_PATH, 'dev._x', handler),
|
|
253
274
|
...panels.map((panel) =>
|
|
254
275
|
devRoute(`${DEV_BASE_PATH}/${panel.key}`, `dev._x.${panel.key}`, handler),
|
|
255
276
|
),
|
|
277
|
+
devRoute(sqlPath, 'dev._x.db.run', runStatement, 'POST'),
|
|
256
278
|
];
|
|
257
279
|
}
|