@ultimat3/cli 21.0.0 → 22.1.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 +84 -1650
- package/README.md +40 -2
- package/package.json +30 -30
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- package/src/app-load.ts +37 -5
- package/src/app-openapi.ts +27 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-root.ts +1 -1
- package/src/async-pages.ts +32 -0
- package/src/bin.ts +7 -1
- package/src/boundary-findings.ts +36 -0
- package/src/browser-launcher-fake-html.ts +80 -0
- package/src/browser-launcher-fake.ts +165 -0
- package/src/browser-launcher-port.ts +159 -0
- package/src/browser-launcher.ts +26 -133
- package/src/budgets.ts +11 -1
- package/src/cdp-shot-a11y.ts +77 -0
- package/src/cdp-shot-clock.ts +14 -0
- package/src/cdp-shot-driver.ts +150 -0
- package/src/cdp-shot-element.ts +147 -0
- package/src/cdp-shot-errors.ts +62 -0
- package/src/cdp-shot-keys.ts +152 -0
- package/src/cdp-shot-page.ts +230 -0
- package/src/cdp-shot-watch.ts +241 -0
- package/src/cmd-affected-spec.ts +24 -0
- package/src/cmd-affected.ts +3 -19
- package/src/cmd-build-spec.ts +16 -0
- package/src/cmd-build.ts +46 -20
- package/src/cmd-ci-spec.ts +24 -0
- package/src/cmd-ci.ts +17 -21
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db-spec.ts +99 -0
- package/src/cmd-db.ts +15 -94
- package/src/cmd-deploy-helm.ts +136 -0
- package/src/cmd-deploy-spec.ts +40 -0
- package/src/cmd-deploy.ts +111 -43
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +55 -36
- package/src/cmd-docs-spec.ts +16 -0
- package/src/cmd-docs.ts +2 -11
- package/src/cmd-doctor-spec.ts +21 -0
- package/src/cmd-doctor.ts +4 -18
- package/src/cmd-env-spec.ts +18 -0
- package/src/cmd-env.ts +4 -13
- package/src/cmd-errors-spec.ts +23 -0
- package/src/cmd-errors.ts +3 -17
- package/src/cmd-fix-spec.ts +18 -0
- package/src/cmd-fix.ts +4 -13
- package/src/cmd-generate-spec.ts +31 -0
- package/src/cmd-generate.ts +46 -42
- package/src/cmd-i18n-spec.ts +16 -0
- package/src/cmd-i18n.ts +7 -14
- package/src/cmd-jobs-spec.ts +56 -0
- package/src/cmd-jobs.ts +3 -50
- package/src/cmd-manifest-spec.ts +15 -0
- package/src/cmd-manifest.ts +19 -20
- package/src/cmd-mcp-spec.ts +21 -0
- package/src/cmd-mcp.ts +2 -15
- package/src/cmd-new-spec.ts +33 -0
- package/src/cmd-new.ts +38 -33
- package/src/cmd-policy-spec.ts +13 -0
- package/src/cmd-policy.ts +2 -9
- package/src/cmd-pr-spec.ts +39 -0
- package/src/cmd-pr.ts +5 -65
- package/src/cmd-registries-spec.ts +32 -0
- package/src/cmd-registries.ts +4 -25
- package/src/cmd-routes-spec.ts +12 -0
- package/src/cmd-routes.ts +2 -7
- package/src/cmd-secrets-spec.ts +19 -0
- package/src/cmd-secrets.ts +22 -34
- package/src/cmd-shot-island.ts +12 -13
- package/src/cmd-shot-spec.ts +53 -0
- package/src/cmd-shot.ts +11 -60
- package/src/cmd-tasks-spec.ts +21 -0
- package/src/cmd-tasks.ts +2 -16
- package/src/cmd-test-spec.ts +54 -0
- package/src/cmd-test.ts +4 -49
- package/src/cmd-verify-spec.ts +28 -0
- package/src/cmd-verify.ts +3 -23
- package/src/db-seed.ts +4 -2
- package/src/dev-dashboard.ts +4 -4
- package/src/dev-lock.ts +18 -2
- package/src/dev-port.ts +21 -0
- package/src/dev-route-table.ts +3 -3
- package/src/dispatch.ts +12 -5
- package/src/document-styles.ts +1 -1
- package/src/drift.ts +4 -18
- package/src/error-catalog.ts +16 -9
- package/src/error-codes.ts +28 -30
- package/src/error-pages.ts +19 -7
- package/src/errors.ts +14 -0
- package/src/favicon.ts +2 -2
- package/src/fix-imports.ts +1 -1
- package/src/fix-scan.ts +2 -9
- package/src/flag-reads.ts +1 -2
- package/src/foreign-text.ts +36 -0
- package/src/framework-schema.ts +5 -6
- package/src/generate-feature.ts +42 -0
- package/src/generate-files.ts +7 -3
- package/src/generate-grants.ts +83 -0
- package/src/generate-kinds.ts +56 -4
- package/src/i18n-index.ts +59 -10
- package/src/icon-assets.ts +1 -1
- package/src/image-prepare.ts +14 -0
- package/src/import-scan.ts +63 -0
- package/src/index.ts +67 -348
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +6 -1
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +15 -8
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +1 -1
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +28 -27
- package/src/mcp-host.ts +27 -12
- package/src/mcp-ui-diff.ts +27 -0
- package/src/mcp-ui-inspect.ts +4 -4
- package/src/mcp-ui-interact.ts +21 -15
- package/src/mcp-ui.ts +23 -17
- package/src/measure-database.ts +73 -0
- package/src/measure-paths.ts +74 -0
- package/src/measure-scope.ts +74 -0
- package/src/messages.ts +1 -3
- package/src/metrics-endpoint.ts +1 -1
- package/src/otlp-export.ts +1 -1
- package/src/output.ts +6 -0
- package/src/permission-grants.ts +86 -0
- package/src/prerender-out.ts +25 -0
- package/src/prerender.ts +121 -102
- package/src/pwa-artifacts.ts +3 -3
- package/src/reexport-manifest.ts +2 -1
- package/src/registry.ts +80 -56
- package/src/role-realtime.ts +36 -0
- package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
- package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
- package/src/role-start-types.ts +112 -0
- package/src/{dev-roles.ts → role-start.ts} +42 -115
- package/src/{dev-sync.ts → role-sync.ts} +22 -8
- package/src/root-env.ts +67 -0
- package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
- package/src/{dev-services.ts → runtime-bindings.ts} +39 -16
- package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
- package/src/runtime-jobs.ts +87 -0
- package/src/{dev-live-feed.ts → runtime-live-feed.ts} +18 -5
- package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
- package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
- package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
- package/src/runtime-realtime.ts +55 -0
- package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
- package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
- package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
- package/src/scaffold-fixture.ts +28 -6
- package/src/scaffold-typecheck.ts +6 -3
- package/src/schema-drift.ts +7 -1
- package/src/script-csp.ts +5 -2
- package/src/secrets-rotation.ts +59 -0
- package/src/serve-boot.ts +192 -0
- package/src/serve-drain.ts +24 -0
- package/src/serve-entry.ts +6 -0
- package/src/serve-env.ts +116 -0
- package/src/serve-types.ts +55 -0
- package/src/serve.ts +44 -347
- package/src/shot-server.ts +2 -2
- package/src/shot-settle.ts +10 -1
- package/src/shot-theme.ts +3 -3
- package/src/shot-verdict.ts +16 -7
- package/src/signal-shred.ts +27 -0
- package/src/solid-loader.ts +26 -2
- package/src/static-report.ts +8 -1
- package/src/sync-url.ts +21 -1
- package/src/templates/action.ts +30 -16
- package/src/templates/entity.ts +12 -7
- package/src/templates/index.ts +1 -1
- package/src/templates/job.ts +10 -7
- package/src/templates/policy.ts +20 -2
- package/src/templates/resource-create.ts +127 -0
- package/src/templates/resource-form-island.ts +49 -12
- package/src/templates/resource.ts +10 -4
- package/src/templates/scaffold-app.ts +4 -1
- package/src/templates/scaffold-auth.ts +3 -1
- package/src/templates/scaffold-container-compose.ts +184 -0
- package/src/templates/scaffold-container.ts +24 -159
- package/src/templates/scaffold-dashboard-example.ts +2 -2
- package/src/templates/scaffold-db-package.ts +16 -3
- package/src/templates/scaffold-demo-org.ts +41 -0
- package/src/templates/scaffold-entries.ts +1 -1
- package/src/templates/scaffold-env.ts +6 -0
- package/src/templates/scaffold-helm-templates.ts +66 -7
- package/src/templates/scaffold-helm.ts +27 -5
- package/src/templates/scaffold-i18n.ts +15 -10
- package/src/templates/scaffold-repo.ts +13 -10
- package/src/templates/scaffold-roles.ts +38 -10
- package/src/templates/slice-foundation.ts +1 -1
- package/src/templates/wrap.ts +4 -1
- package/src/test-passes.ts +2 -1
- package/src/test-workers.ts +26 -0
- package/src/ts-scan.ts +3 -6
- package/src/tsconfig-references.ts +1 -2
- package/src/verify-checks.ts +41 -39
- package/src/verify-e2e.ts +10 -7
- package/src/verify-run.ts +11 -1
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +1 -26
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -94
- package/src/cdp-connection.ts +0 -247
- package/src/cdp-e2e-page.ts +0 -180
- package/src/cdp-e2e-session.ts +0 -199
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -193
- package/src/cdp-offline-script.ts +0 -73
- package/src/cdp-pipe.ts +0 -77
- package/src/e2e-app.ts +0 -103
- package/src/e2e-browser-handle.ts +0 -55
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -117
- package/src/e2e-errors.ts +0 -117
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -153
- package/src/e2e-preload.ts +0 -64
- package/src/e2e-probe.ts +0 -23
- package/src/e2e-selection.ts +0 -182
- package/src/e2e-spawn.ts +0 -169
- package/src/measurement-actor.ts +0 -26
- /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
- /package/src/{dev-render.ts → runtime-render.ts} +0 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// `jobs.queues`, `jobs.concurrency` and `jobs.visibilityTimeoutMs`, read out of the app's own
|
|
2
|
+
// `app.config.ts` for the `worker` role — the sibling of `runtime-realtime.ts`, and structural
|
|
3
|
+
// for its reason. Until 22.0.0 `createWorker` was called with none of them: every worker served
|
|
4
|
+
// `default` at its own concurrency whatever the config said, so a `mail` queue ran nothing.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path-joining API — `Object.keys(Bun)` has `file`, `write`, `Glob`,
|
|
7
|
+
// `pathToFileURL` and `fileURLToPath`, and nothing that joins a path.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { registeredJobs, type WorkerOptions } from '@ultimat3/jobs';
|
|
10
|
+
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
11
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
12
|
+
|
|
13
|
+
/** What the worker takes from the config. An absent number keeps the worker's own default. */
|
|
14
|
+
export interface WorkerConfig {
|
|
15
|
+
readonly queues: readonly string[];
|
|
16
|
+
readonly concurrency: number | undefined;
|
|
17
|
+
readonly visibilityTimeoutMs: number | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NO_WORKER_CONFIG: WorkerConfig = Object.freeze({
|
|
21
|
+
queues: [],
|
|
22
|
+
concurrency: undefined,
|
|
23
|
+
visibilityTimeoutMs: undefined,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
27
|
+
typeof value === 'object' && value !== null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A number is handed on AS WRITTEN, never screened here: `createWorker`'s `resolveWorkerTimings`
|
|
31
|
+
* is the one refusal for a non-finite knob, and core's `defineConfig` screens both counts first.
|
|
32
|
+
*/
|
|
33
|
+
const numberOf = (value: unknown): number | undefined =>
|
|
34
|
+
typeof value === 'number' ? value : undefined;
|
|
35
|
+
|
|
36
|
+
export async function loadWorkerConfig(root: string): Promise<WorkerConfig> {
|
|
37
|
+
const path = join(root, APP_CONFIG_FILE);
|
|
38
|
+
if (!(await Bun.file(path).exists())) return NO_WORKER_CONFIG;
|
|
39
|
+
const module = (await import(path)) as Record<string, unknown>;
|
|
40
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
41
|
+
const jobs = isRecord(config) ? config['jobs'] : undefined;
|
|
42
|
+
if (!isRecord(jobs)) return NO_WORKER_CONFIG;
|
|
43
|
+
const queues = Array.isArray(jobs['queues'])
|
|
44
|
+
? jobs['queues'].filter((queue): queue is string => typeof queue === 'string' && queue !== '')
|
|
45
|
+
: [];
|
|
46
|
+
return {
|
|
47
|
+
queues,
|
|
48
|
+
concurrency: numberOf(jobs['concurrency']),
|
|
49
|
+
visibilityTimeoutMs: numberOf(jobs['visibilityTimeoutMs']),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The queues a worker serves: the configured ones, then every queue a registered job is enqueued
|
|
55
|
+
* on. The union and not the list, decided 2026-09-23: `x new` and the deployed demo configure
|
|
56
|
+
* `['<app>-default']` while a `job()` naming no queue lands on `default`, so obeying the list alone
|
|
57
|
+
* would leave every such job queued for ever — the silent direction. `undefined` when both are
|
|
58
|
+
* empty, which is `createWorker`'s own `['default']`.
|
|
59
|
+
*/
|
|
60
|
+
export function workerQueuesFor(
|
|
61
|
+
configured: readonly string[],
|
|
62
|
+
registered: readonly string[],
|
|
63
|
+
): readonly string[] | undefined {
|
|
64
|
+
const served = [...new Set([...configured, ...registered])];
|
|
65
|
+
return served.length === 0 ? undefined : served;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type WorkerConfigOptions = Pick<WorkerOptions, 'queues' | 'concurrency' | 'visibilityTimeoutMs'>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The `createWorker` options the config supplies, read at START so every job the app registered
|
|
72
|
+
* on import is counted. A key the config leaves unset is omitted, never passed as `undefined`, so
|
|
73
|
+
* the worker's own default applies (`exactOptionalPropertyTypes`).
|
|
74
|
+
*/
|
|
75
|
+
export function workerOptionsFor(config: WorkerConfig | undefined): WorkerConfigOptions {
|
|
76
|
+
const queues = workerQueuesFor(
|
|
77
|
+
config?.queues ?? [],
|
|
78
|
+
registeredJobs().map((handle) => handle.queue),
|
|
79
|
+
);
|
|
80
|
+
return {
|
|
81
|
+
...(queues === undefined ? {} : { queues }),
|
|
82
|
+
...(config?.concurrency === undefined ? {} : { concurrency: config.concurrency }),
|
|
83
|
+
...(config?.visibilityTimeoutMs === undefined
|
|
84
|
+
? {}
|
|
85
|
+
: { visibilityTimeoutMs: config.visibilityTimeoutMs }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -4,17 +4,22 @@
|
|
|
4
4
|
// subscription under `x dev` took its snapshot and then heard nothing, so every `--live` query in
|
|
5
5
|
// every scaffolded app was dead in development — which is where an author first tries one.
|
|
6
6
|
//
|
|
7
|
-
// The bridge is `@ultimat3/
|
|
7
|
+
// The bridge is `@ultimat3/realtime/server`'s `startLiveReplicator`, the same in-process row observer the
|
|
8
8
|
// framework's own live tests run on: a repository write in THIS process becomes a `ChangeEvent`
|
|
9
9
|
// shaped exactly as the WAL decoder shapes one, fanned into the node's registry. Its honest bound
|
|
10
10
|
// is stated there and repeated here — a write another process makes is invisible — and `x dev` is
|
|
11
11
|
// the one boot where that bound holds by construction: every role runs in this one process.
|
|
12
12
|
|
|
13
13
|
import { logger, renderThrowable } from '@ultimat3/core';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
import
|
|
17
|
-
|
|
14
|
+
// `@ultimat3/realtime/server`, never `@ultimat3/testing`: this module is on the production boot
|
|
15
|
+
// path (`serve.ts` → `role-start.ts` → here), and the test harness was 38 modules of every image.
|
|
16
|
+
import {
|
|
17
|
+
type LiveReplicator,
|
|
18
|
+
RealtimeTopologyError,
|
|
19
|
+
startLiveReplicator,
|
|
20
|
+
} from '@ultimat3/realtime/server';
|
|
21
|
+
import type { RunningSync } from './role-sync';
|
|
22
|
+
import type { ServiceMode } from './runtime-bindings';
|
|
18
23
|
|
|
19
24
|
/** What feeds the sync node, said out loud on the boot line and in `--json`. */
|
|
20
25
|
export type LiveFeed = 'in-process' | 'replication' | 'none';
|
|
@@ -31,6 +36,10 @@ export interface LiveFeedInput {
|
|
|
31
36
|
readonly sync: RunningSync | null;
|
|
32
37
|
/** The database's binding: `embedded` is PGlite, which has no log to decode. */
|
|
33
38
|
readonly dbMode: ServiceMode;
|
|
39
|
+
/** The bus this process fans out on (`Transport.name`): `in-process` reaches no other process. */
|
|
40
|
+
readonly transport: string;
|
|
41
|
+
/** Whether the `replicator` role runs in THIS process. */
|
|
42
|
+
readonly replicatorHere: boolean;
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
/** The label the `x dev` boot line carries beside `db=`, `events=` and `storage=`. */
|
|
@@ -45,6 +54,10 @@ export const liveFeedLabel = (feed: LiveFeed): string => `live=${feed}`;
|
|
|
45
54
|
export async function startLiveFeed(input: LiveFeedInput): Promise<RunningLiveFeed> {
|
|
46
55
|
if (input.sync === null) return { feed: 'none', bridge: null, stop: () => undefined };
|
|
47
56
|
if (input.dbMode !== 'embedded') {
|
|
57
|
+
// A decoder this node can hear: in this process, or on a bus other processes share. Neither
|
|
58
|
+
// is a sync node that reported `replication` while nothing could ever reach it.
|
|
59
|
+
if (input.transport === 'in-process' && !input.replicatorHere)
|
|
60
|
+
throw new RealtimeTopologyError();
|
|
48
61
|
return { feed: 'replication', bridge: null, stop: () => undefined };
|
|
49
62
|
}
|
|
50
63
|
const bridge = await startLiveReplicator({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// `notify.inboxReadRetentionMs` and `notify.inboxUnreadRetentionMs`, read out of the app's own
|
|
2
|
-
// `app.config.ts`. The sibling of `app-auth.ts`'s `loadSignInPath` and `
|
|
2
|
+
// `app.config.ts`. The sibling of `app-auth.ts`'s `loadSignInPath` and `runtime-cache.ts`'s
|
|
3
3
|
// `loadCacheTiers`, and structural for the same reason: `defineConfig` returns a plain object, so
|
|
4
4
|
// a config that resolved through an older core simply has no `notify` section.
|
|
5
5
|
//
|
|
@@ -23,8 +23,8 @@ import type { PostgresRateLimitStore } from '@ultimat3/http';
|
|
|
23
23
|
import type { JobHandle, PurgeInput, PurgeTarget } from '@ultimat3/jobs';
|
|
24
24
|
import { DEFAULT_PURGE_CRON, getJob, getTask, purge, task } from '@ultimat3/jobs';
|
|
25
25
|
import { purgeNotifyDeliveries, purgeNotifyInbox } from '@ultimat3/notify';
|
|
26
|
-
import type { InboxRetention } from './
|
|
27
|
-
import { NO_INBOX_RETENTION } from './
|
|
26
|
+
import type { InboxRetention } from './runtime-notify-retention';
|
|
27
|
+
import { NO_INBOX_RETENTION } from './runtime-notify-retention';
|
|
28
28
|
|
|
29
29
|
/** The durable queue key. Pinned, like every framework-owned job name — rows carry it. */
|
|
30
30
|
export const PURGE_JOB_NAME = 'x.purge';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The database and the job queue, started together and released together. Split from
|
|
2
|
-
// `
|
|
2
|
+
// `runtime-services.ts` because `x jobs` needs exactly this pair and nothing else — and because a
|
|
3
3
|
// process that installs ambient accessors (`db()`, `jobDriver()`, the jobs facade, the event bus)
|
|
4
4
|
// must have one place that takes them all back, or the next command in the same process inherits
|
|
5
5
|
// a driver over a closed socket.
|
|
@@ -32,10 +32,10 @@ import {
|
|
|
32
32
|
setJobDriver,
|
|
33
33
|
setJobsFacade,
|
|
34
34
|
} from '@ultimat3/jobs';
|
|
35
|
-
import { attachReplica, type ReplicaEnv, replicaUrlFor } from './dev-replica';
|
|
36
|
-
import type { DevServices } from './dev-services';
|
|
37
35
|
import { applyFrameworkSchema } from './framework-schema';
|
|
36
|
+
import type { DevServices } from './runtime-bindings';
|
|
38
37
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
38
|
+
import { attachReplica, type ReplicaEnv, replicaUrlFor } from './runtime-replica';
|
|
39
39
|
|
|
40
40
|
/** Both embedded and external clients boot lazily and close explicitly. */
|
|
41
41
|
export type DevDbClient = PgliteClient | PostgresClient;
|
|
@@ -51,7 +51,7 @@ export interface RunningQueue {
|
|
|
51
51
|
readonly jobs: JobDriver;
|
|
52
52
|
/**
|
|
53
53
|
* The `x_outbox` store this boot installed behind `handle.enqueue()`. Returned because the
|
|
54
|
-
* relay that drains it is a ROLE's decision, not the queue's — `
|
|
54
|
+
* relay that drains it is a ROLE's decision, not the queue's — `role-start.ts` starts one.
|
|
55
55
|
*/
|
|
56
56
|
readonly outbox: OutboxStore;
|
|
57
57
|
/** The `x_job_events` bus a `step.waitForEvent` resumes from. Durable, not per-process. */
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// `realtime.enabled`, `realtime.transport` and `realtime.urlEnv`, read out of the app's own
|
|
2
|
+
// `app.config.ts` for the boot. The sibling of `runtime-notify-retention.ts` and `runtime-cache.ts`, and
|
|
3
|
+
// structural for their reason: `startServices` holds no `AppConfig`, and `defineConfig` returns a
|
|
4
|
+
// plain object. Until 22.0.0 no boot read any of the three and `NATS_URL` alone decided the bus.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path-joining API — `Object.keys(Bun)` has `file`, `write`, `Glob`,
|
|
7
|
+
// `pathToFileURL` and `fileURLToPath`, and nothing that joins a path.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { ConfigInvalidError, type RealtimeConfig, type RealtimeTransport } from '@ultimat3/core';
|
|
10
|
+
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
11
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
12
|
+
|
|
13
|
+
/** Core's `defaults()` for the section, restated as the answer for a config that names none. */
|
|
14
|
+
export const REALTIME_DEFAULTS: RealtimeConfig = Object.freeze({
|
|
15
|
+
enabled: true,
|
|
16
|
+
transport: 'memory',
|
|
17
|
+
urlEnv: undefined,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const TRANSPORTS: readonly RealtimeTransport[] = ['memory', 'nats'];
|
|
21
|
+
|
|
22
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
23
|
+
typeof value === 'object' && value !== null;
|
|
24
|
+
|
|
25
|
+
/** A transport this boot can build, or a refusal — never a guess at a bus. */
|
|
26
|
+
function transportOf(section: Record<string, unknown>): RealtimeTransport {
|
|
27
|
+
const value = section['transport'];
|
|
28
|
+
if (value === undefined) return REALTIME_DEFAULTS.transport;
|
|
29
|
+
const known = TRANSPORTS.find((transport) => transport === value);
|
|
30
|
+
if (known !== undefined) return known;
|
|
31
|
+
throw new ConfigInvalidError({
|
|
32
|
+
cause: `realtime.transport in ${APP_CONFIG_FILE} is not one of ${TRANSPORTS.join(', ')}`,
|
|
33
|
+
fix: `set realtime: { transport: 'memory' } in ${APP_CONFIG_FILE}, or { transport: 'nats', urlEnv: 'NATS_URL' } for more than one node`,
|
|
34
|
+
meta: { key: 'realtime.transport' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The section as the boot obeys it. A key the file does not name keeps core's default, the same
|
|
40
|
+
* per-key merge `defineConfig` performs, so the boot and the app's own config object agree.
|
|
41
|
+
*/
|
|
42
|
+
export async function loadRealtimeConfig(root: string): Promise<RealtimeConfig> {
|
|
43
|
+
const path = join(root, APP_CONFIG_FILE);
|
|
44
|
+
if (!(await Bun.file(path).exists())) return REALTIME_DEFAULTS;
|
|
45
|
+
const module = (await import(path)) as Record<string, unknown>;
|
|
46
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
47
|
+
const section = isRecord(config) ? config['realtime'] : undefined;
|
|
48
|
+
if (!isRecord(section)) return REALTIME_DEFAULTS;
|
|
49
|
+
const { enabled, urlEnv } = section;
|
|
50
|
+
return {
|
|
51
|
+
enabled: typeof enabled === 'boolean' ? enabled : REALTIME_DEFAULTS.enabled,
|
|
52
|
+
transport: transportOf(section),
|
|
53
|
+
urlEnv: typeof urlEnv === 'string' && urlEnv.trim() !== '' ? urlEnv.trim() : undefined,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// 1. `defaultClient()` is the one place db composes `replicatedClient(primary, replica)` from
|
|
8
8
|
// `DATABASE_REPLICA_URL`, and it runs only from `baseClient()` — "the client an app installed
|
|
9
|
-
// none for". Every process the framework boots installs one: `
|
|
9
|
+
// none for". Every process the framework boots installs one: `runtime-queue.ts` calls
|
|
10
10
|
// `setDbClient(createPgliteClient(…) | createPostgresClient({ url }))`, so `defaultClient()` was
|
|
11
11
|
// unreachable from `x dev`, from `apps/web/server.ts` and from every container role.
|
|
12
12
|
// 2. Routing needs an OPEN scope as well as a configured replica, and nothing opened one.
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
withReplicaReads,
|
|
25
25
|
} from '@ultimat3/db';
|
|
26
26
|
import type { Middleware } from '@ultimat3/http';
|
|
27
|
-
import type { ServiceBinding } from './
|
|
27
|
+
import type { ServiceBinding } from './runtime-bindings';
|
|
28
28
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
29
29
|
|
|
30
30
|
export type ReplicaEnv = Readonly<Record<string, string | undefined>>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Starting the services `
|
|
1
|
+
// Starting the services `runtime-bindings.ts` resolved. Resolution answers "which database"; this
|
|
2
2
|
// answers "it is running, and every ambient accessor in the framework now points at it" — so
|
|
3
3
|
// `db()`, `jobDriver()`, `mailDriver()` and the realtime transport are the objects a production
|
|
4
4
|
// boot installs, only backed by embedded drivers.
|
|
@@ -10,6 +10,7 @@ import type { PurgeDriver } from '@ultimat3/cache';
|
|
|
10
10
|
import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
|
|
11
11
|
import {
|
|
12
12
|
isLocal,
|
|
13
|
+
type RealtimeConfig,
|
|
13
14
|
registerReadinessCheck,
|
|
14
15
|
renderThrowable,
|
|
15
16
|
resolveEnvironment,
|
|
@@ -30,15 +31,17 @@ import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
|
|
|
30
31
|
import { selectTransport } from '@ultimat3/realtime/server';
|
|
31
32
|
import type { Storage } from '@ultimat3/storage';
|
|
32
33
|
import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
|
|
33
|
-
import { loadCacheTiers, startCacheTiers } from './dev-cache';
|
|
34
|
-
import { loadInboxRetention } from './dev-notify-retention';
|
|
35
|
-
import { installRetentionSweep } from './dev-purge';
|
|
36
|
-
import type { DevDbClient } from './dev-queue';
|
|
37
|
-
import { pgExecutorFor, startQueue } from './dev-queue';
|
|
38
|
-
import type { DevServices, Env } from './dev-services';
|
|
39
34
|
import { LocalDiskUnsafeError, StorageUnwritableError } from './errors';
|
|
40
35
|
import { msg } from './messages';
|
|
36
|
+
import type { DevServices, Env } from './runtime-bindings';
|
|
37
|
+
import { loadCacheTiers, startCacheTiers } from './runtime-cache';
|
|
38
|
+
import { loadWorkerConfig, type WorkerConfig } from './runtime-jobs';
|
|
39
|
+
import { loadInboxRetention } from './runtime-notify-retention';
|
|
41
40
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
41
|
+
import { installRetentionSweep } from './runtime-purge';
|
|
42
|
+
import type { DevDbClient } from './runtime-queue';
|
|
43
|
+
import { pgExecutorFor, startQueue } from './runtime-queue';
|
|
44
|
+
import { loadRealtimeConfig } from './runtime-realtime';
|
|
42
45
|
|
|
43
46
|
export interface RunningServices {
|
|
44
47
|
readonly services: DevServices;
|
|
@@ -48,6 +51,17 @@ export interface RunningServices {
|
|
|
48
51
|
readonly outbox: OutboxStore;
|
|
49
52
|
readonly events: EventBus;
|
|
50
53
|
readonly transport: Transport;
|
|
54
|
+
/**
|
|
55
|
+
* `app.config.ts`'s `realtime` section as this boot obeyed it. `transport` and `urlEnv` chose
|
|
56
|
+
* `transport` above; `enabled` is what `startRoles` reads before it starts `sync` or a replicator.
|
|
57
|
+
*/
|
|
58
|
+
readonly realtime: RealtimeConfig;
|
|
59
|
+
/**
|
|
60
|
+
* `app.config.ts`'s `jobs.queues` / `concurrency` / `visibilityTimeoutMs`, for the `worker` role.
|
|
61
|
+
* Optional because a `RunningServices` can be hand-built, and a test runtime with none keeps the
|
|
62
|
+
* worker's own defaults — the answer it always got. `startServices` always sets it.
|
|
63
|
+
*/
|
|
64
|
+
readonly workerConfig?: WorkerConfig;
|
|
51
65
|
readonly storage: Storage;
|
|
52
66
|
readonly mail: MailDriver;
|
|
53
67
|
/**
|
|
@@ -193,8 +207,13 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
|
|
|
193
207
|
// `usesDevStorageSecret({ env })` left bare, refuse a boot whose own env carries a real
|
|
194
208
|
// `STORAGE_SIGNING_SECRET` because the PROCESS does not. Which environment, whether a secret
|
|
195
209
|
// exists, and the name the message prints are one question about one table.
|
|
196
|
-
|
|
197
|
-
|
|
210
|
+
// `fallback: 'production'` on both, the reading `localDriver` itself takes: a boot that names no
|
|
211
|
+
// environment is not a development one, and this message beats the driver's to it.
|
|
212
|
+
if (!isLocal({ env, fallback: 'production' }) && usesDevStorageSecret({ env })) {
|
|
213
|
+
throw new LocalDiskUnsafeError({
|
|
214
|
+
environment: resolveEnvironment({ env, fallback: 'production' }),
|
|
215
|
+
root,
|
|
216
|
+
});
|
|
198
217
|
}
|
|
199
218
|
try {
|
|
200
219
|
mkdirSync(root, { recursive: true });
|
|
@@ -293,12 +312,15 @@ export async function startServices(
|
|
|
293
312
|
// Same reason, same place: building a purge driver reads env and dials nothing, so a half-set
|
|
294
313
|
// `FASTLY_API_TOKEN` without its service id fails here rather than on the first stale page.
|
|
295
314
|
const cdn = selectPurgeDriver(env);
|
|
296
|
-
// Third of the same kind. `
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
// `@ultimat3/realtime`'s decision, and it is the same call a
|
|
300
|
-
// process cannot resolve the bus differently from the
|
|
301
|
-
|
|
315
|
+
// Third of the same kind. `realtime.transport` selects the bus and `realtime.urlEnv` names the
|
|
316
|
+
// variable holding its url; a config and an environment that disagree refuse here, before any
|
|
317
|
+
// service starts, rather than quietly keeping the in-process bus. Which transport, which KV
|
|
318
|
+
// bucket and which presence TTL is `@ultimat3/realtime`'s decision, and it is the same call a
|
|
319
|
+
// `ROLE=sync` container makes, so this process cannot resolve the bus differently from the
|
|
320
|
+
// container it stands in for. The root is `dirname(stateDir)`, as `loadCacheTiers` reads it.
|
|
321
|
+
const realtime = await loadRealtimeConfig(dirname(services.stateDir));
|
|
322
|
+
const workerConfig = await loadWorkerConfig(dirname(services.stateDir));
|
|
323
|
+
const bus: TransportSelection = selectTransport(env, realtime);
|
|
302
324
|
// `env`, not the ambient one: this function is HANDED the boot's environment and every other
|
|
303
325
|
// reader here already uses it, so a queue that asked `process.env` would decide the standby from
|
|
304
326
|
// a different answer than the middleware that routes to it.
|
|
@@ -335,7 +357,7 @@ export async function startServices(
|
|
|
335
357
|
// ships a `purgeExpired()` that nothing called, so every row written was a row kept —
|
|
336
358
|
// `x_rate_limit` takes one upsert per request the web role serves, assets included.
|
|
337
359
|
//
|
|
338
|
-
// `inboxRetention` is READ HERE and not defaulted in `
|
|
360
|
+
// `inboxRetention` is READ HERE and not defaulted in `runtime-purge.ts`: the two windows are the
|
|
339
361
|
// app's, `startServices` holds no `AppConfig`, and a loader that silently answered "never
|
|
340
362
|
// sweep" from inside the sweep would be indistinguishable from an app that chose it.
|
|
341
363
|
started.push(
|
|
@@ -347,7 +369,7 @@ export async function startServices(
|
|
|
347
369
|
);
|
|
348
370
|
// One readiness check per resource this boot OWNS, released with it. Nothing in the tree
|
|
349
371
|
// registered one, so `/readyz` was `markReady()` alone — "this process bound a socket" — and
|
|
350
|
-
// `packages/http/src/server.ts` calls that BEFORE `Bun.serve`, while `
|
|
372
|
+
// `packages/http/src/server.ts` calls that BEFORE `Bun.serve`, while `role-start.ts` calls it
|
|
351
373
|
// before `sync`, `worker` and `scheduler` start. The chart's `readinessProbe` and the container
|
|
352
374
|
// healthcheck both route on it, so a replica whose pool was gone kept taking traffic.
|
|
353
375
|
started.push(probedReadinessCheck('database', () => db.ping()));
|
|
@@ -399,6 +421,8 @@ export async function startServices(
|
|
|
399
421
|
outbox,
|
|
400
422
|
events,
|
|
401
423
|
transport,
|
|
424
|
+
realtime,
|
|
425
|
+
workerConfig,
|
|
402
426
|
storage,
|
|
403
427
|
mail,
|
|
404
428
|
mailDetail: selection.detail,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Single responsibility: the one HTTP surface that SERVES a stored object. `@ultimat3/storage`
|
|
2
2
|
// owns keys, bytes and the tenant boundary and owns no `Response`; `@ultimat3/policy` owns the
|
|
3
|
-
// one authz decision; this file is where those two meet a `Route` — the same shape `
|
|
3
|
+
// one authz decision; this file is where those two meet a `Route` — the same shape `runtime-assets.ts`
|
|
4
4
|
// uses for `/icons` and `/media`, so `x dev` and `apps/web/server.ts` mount one read path, not two.
|
|
5
5
|
//
|
|
6
6
|
// The base path is `@ultimat3/storage`'s `DEFAULT_SIGNED_URL_BASE`, imported and never restated:
|
|
@@ -37,7 +37,7 @@ export const STORAGE_READ_PERMISSION = 'storage:read';
|
|
|
37
37
|
* augments `PermissionRegistry` — and this package compiles against no app, so the bare literal is
|
|
38
38
|
* a type error inside a generated project (`scaffold-typecheck` is what proves it). The check that
|
|
39
39
|
* decides is the runtime one anyway: `can()` calls `assertPermission`, which throws
|
|
40
|
-
* `X_PERMISSION_UNKNOWN` naming the `definePermissions` edit. `
|
|
40
|
+
* `X_PERMISSION_UNKNOWN` naming the `definePermissions` edit. `runtime-hooks.ts` narrows a route's
|
|
41
41
|
* structurally-typed permission for the same reason.
|
|
42
42
|
*/
|
|
43
43
|
const READ_PERMISSION = STORAGE_READ_PERMISSION as unknown as KnownPermission;
|
|
@@ -94,7 +94,7 @@ export function authorizeStorageRead(input: StorageReadInput, ctx: RequestContex
|
|
|
94
94
|
* (never 403 — `error-map.ts` maps `X_STORAGE_ORG_MISMATCH` there so a refusal cannot confirm that
|
|
95
95
|
* a key exists).
|
|
96
96
|
*
|
|
97
|
-
* Split out of `readStorageObject` because `/media/*key` (`
|
|
97
|
+
* Split out of `readStorageObject` because `/media/*key` (`runtime-assets.ts`) has to make the same
|
|
98
98
|
* decision and made none at all: it passed a client-supplied key straight to `disk().get`, so every
|
|
99
99
|
* object on the app's only disk was one unauthenticated URL away. A second copy of this test is how
|
|
100
100
|
* one of the two surfaces would drift back — `storage-surfaces.test.ts` is what holds them level.
|
|
@@ -198,7 +198,7 @@ export function storageResponse(request: UltimateRequest, read: StorageRead): Re
|
|
|
198
198
|
headers.set('content-range', `bytes */${size}`);
|
|
199
199
|
return new Response(null, { status: 416, headers });
|
|
200
200
|
}
|
|
201
|
-
// Copied at each call, not through a helper, for `
|
|
201
|
+
// Copied at each call, not through a helper, for `runtime-assets.ts`'s reason: a
|
|
202
202
|
// `Uint8Array<ArrayBufferLike>` may be backed by a `SharedArrayBuffer`, which `Response` does
|
|
203
203
|
// not accept — and a helper's declared return type widens the copy back to the type it refuses.
|
|
204
204
|
if (range === undefined) {
|
package/src/scaffold-fixture.ts
CHANGED
|
@@ -46,6 +46,16 @@ export async function list(limit = 50): Promise<readonly ShortLink[]> {
|
|
|
46
46
|
}
|
|
47
47
|
`;
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The `invoice` resource's own `entity.ts`, as `x g` would read it off disk after the resource
|
|
51
|
+
* below ran — the generators writing INTO that slice are handed it, because a slice with no entity
|
|
52
|
+
* now gets the neutral body and no invented table.
|
|
53
|
+
*/
|
|
54
|
+
export const INVOICE_ENTITY = String(
|
|
55
|
+
generate({ kind: 'entity', name: 'invoice' }).find((file) => file.path.endsWith('/entity.ts'))
|
|
56
|
+
?.contents,
|
|
57
|
+
);
|
|
58
|
+
|
|
49
59
|
/**
|
|
50
60
|
* One realistic invocation of every generator, on top of `x new --example`. Names differ from
|
|
51
61
|
* their feature on purpose: `x g query invoice --feature invoice` would collide with the entity
|
|
@@ -59,19 +69,31 @@ export const FIXTURE_GENERATORS: readonly GenerateOptions[] = [
|
|
|
59
69
|
{ kind: 'resource', name: 'invoice', admin: true },
|
|
60
70
|
{ kind: 'entity', name: 'credit-note', feature: 'credit-note' },
|
|
61
71
|
{ kind: 'policy', name: 'credit-note', feature: 'credit-note' },
|
|
62
|
-
{ kind: 'action', name: 'send-invoice', feature: 'invoice' },
|
|
63
|
-
{ kind: 'mutator', name: 'rename-invoice', feature: 'invoice' },
|
|
72
|
+
{ kind: 'action', name: 'send-invoice', feature: 'invoice', sliceEntity: INVOICE_ENTITY },
|
|
73
|
+
{ kind: 'mutator', name: 'rename-invoice', feature: 'invoice', sliceEntity: INVOICE_ENTITY },
|
|
64
74
|
// The other shape both templates have: a slice whose `errors.ts` is the author's and declares
|
|
65
75
|
// no `InvoiceNotFoundError`. The resource's own `errors.ts` still lands in the sandbox (it does
|
|
66
76
|
// declare one), which is the point — this compiles the file `x g action` writes when it must
|
|
67
77
|
// not import that class, beside the one it writes when it may.
|
|
68
|
-
{
|
|
69
|
-
|
|
78
|
+
{
|
|
79
|
+
kind: 'action',
|
|
80
|
+
name: 'ping-invoice',
|
|
81
|
+
feature: 'invoice',
|
|
82
|
+
sliceErrors: HANDWRITTEN_ERRORS,
|
|
83
|
+
sliceEntity: INVOICE_ENTITY,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
kind: 'mutator',
|
|
87
|
+
name: 'touch-invoice',
|
|
88
|
+
feature: 'invoice',
|
|
89
|
+
sliceErrors: HANDWRITTEN_ERRORS,
|
|
90
|
+
sliceEntity: INVOICE_ENTITY,
|
|
91
|
+
},
|
|
70
92
|
{ kind: 'query', name: 'invoice-search', feature: 'invoice' },
|
|
71
93
|
{ kind: 'query', name: 'invoice-feed', feature: 'invoice', live: true },
|
|
72
|
-
{ kind: 'job', name: 'sweep-invoices', feature: 'invoice' },
|
|
94
|
+
{ kind: 'job', name: 'sweep-invoices', feature: 'invoice', sliceEntity: INVOICE_ENTITY },
|
|
73
95
|
{ kind: 'backfill', name: 'reindex-invoices', feature: 'invoice' },
|
|
74
|
-
{ kind: 'task', name: 'nightly-sweep', feature: 'invoice' },
|
|
96
|
+
{ kind: 'task', name: 'nightly-sweep', feature: 'invoice', sliceEntity: INVOICE_ENTITY },
|
|
75
97
|
// The other shape both templates have: a feature whose entity names no tenant column, so the
|
|
76
98
|
// job/task must not assume one — compiled here beside the tenant-scoped pair above, exactly as
|
|
77
99
|
// `ping-invoice`/`touch-invoice` compile the action's other shape beside `send-invoice`.
|
|
@@ -146,11 +146,14 @@ const overlay = (root: string, app: string): string =>
|
|
|
146
146
|
compilerOptions: {
|
|
147
147
|
noEmit: true,
|
|
148
148
|
paths: {
|
|
149
|
-
// The
|
|
150
|
-
//
|
|
151
|
-
//
|
|
149
|
+
// The subpath exports a generated app reaches for, spelled the way the packages' own
|
|
150
|
+
// `exports` maps spell them — the wildcard below would read `ui/icons/zap` as a package
|
|
151
|
+
// name, and `cli/serve` as `packages/cli/serve`. Longest prefix wins, so these are
|
|
152
|
+
// consulted first. `@ultimat3/cli/serve` is what `x new`'s `apps/web/server.ts` imports.
|
|
152
153
|
'@ultimat3/ui/icons/*': [`${root}/packages/ui/src/icons/glyphs/*`],
|
|
153
154
|
'@ultimat3/render/server': [`${root}/packages/render/src/server`],
|
|
155
|
+
'@ultimat3/cli/serve': [`${root}/packages/cli/src/serve-entry`],
|
|
156
|
+
'@ultimat3/testing/test-types': [`${root}/packages/testing/src/test-types`],
|
|
154
157
|
'@ultimat3/*': [`${root}/packages/*/src`],
|
|
155
158
|
[`@${app}/web/*`]: ['./apps/web/*'],
|
|
156
159
|
[`@${app}/admin/*`]: ['./apps/admin/*'],
|
package/src/schema-drift.ts
CHANGED
|
@@ -89,8 +89,14 @@ function repairFix(
|
|
|
89
89
|
* never get what the app declares; `undeclared` means the database holds what nothing declares.
|
|
90
90
|
* One "drift" verdict over both teaches a reader neither.
|
|
91
91
|
*/
|
|
92
|
+
/** A table difference names the table once: `table "customers" on table "customers"` did not. */
|
|
93
|
+
export const schemaDifferenceCause = (difference: SchemaDifference): string =>
|
|
94
|
+
difference.part === 'table'
|
|
95
|
+
? `table "${difference.table}" ${difference.detail}`
|
|
96
|
+
: `${difference.part} "${difference.name}" on table "${difference.table}" ${difference.detail}`;
|
|
97
|
+
|
|
92
98
|
function findingFor(difference: SchemaDifference, fix: string): Finding {
|
|
93
|
-
const cause =
|
|
99
|
+
const cause = schemaDifferenceCause(difference);
|
|
94
100
|
return difference.direction === 'unmigrated'
|
|
95
101
|
? { code: 'X_DB_SCHEMA_UNMIGRATED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR }
|
|
96
102
|
: { code: 'X_DB_SCHEMA_UNDECLARED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR };
|
package/src/script-csp.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// invisible in `x dev`, where the policy is report-only.
|
|
6
6
|
|
|
7
7
|
import { cspHashSource } from '@ultimat3/http';
|
|
8
|
-
import { HYDRATE_RUNTIME_BODIES } from '@ultimat3/render';
|
|
8
|
+
import { HYDRATE_RUNTIME_BODIES, STREAM_REVEAL_BODIES } from '@ultimat3/render';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Hashes, never a nonce: a `render: 'static'` page is a file on disk, so no per-response value can
|
|
@@ -16,5 +16,8 @@ import { HYDRATE_RUNTIME_BODIES } from '@ultimat3/render';
|
|
|
16
16
|
* which `theme-boot.ts` derives from the same string it inlines.
|
|
17
17
|
*/
|
|
18
18
|
export function inlineScriptSources(extra: readonly string[] = []): readonly string[] {
|
|
19
|
-
|
|
19
|
+
// The stream reveal too: a `render: 'stream'` page swaps each Suspense boundary in with a
|
|
20
|
+
// constant inline `$X()` call, and unhashed it is blocked under the enforced policy.
|
|
21
|
+
const bodies = [...HYDRATE_RUNTIME_BODIES, ...STREAM_REVEAL_BODIES];
|
|
22
|
+
return [...new Set([...bodies.map(cspHashSource), ...extra])].sort();
|
|
20
23
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// A master-key rotation in an order a crash cannot turn into data loss. The new key is STAGED at
|
|
2
|
+
// `<key>.next` (0600) before anything is sealed with it, the committed file is sealed second, and
|
|
3
|
+
// the rename that makes the new key live is last. Interrupted anywhere, some key on disk opens the
|
|
4
|
+
// committed file — and `recoverRotation`, which every `x secrets` command runs first, finishes or
|
|
5
|
+
// abandons the move by asking the file which key it answers to.
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; // why: the staged key is written and renamed synchronously at 0600, and Bun has no mode-setting write or rename.
|
|
8
|
+
import type { MasterKeyRef, SecretValues } from '@ultimat3/core';
|
|
9
|
+
import {
|
|
10
|
+
generateMasterKey,
|
|
11
|
+
isUltimateError,
|
|
12
|
+
masterKeyPath,
|
|
13
|
+
readSecretsFile,
|
|
14
|
+
SECRETS_KEY_MODE,
|
|
15
|
+
stagedMasterKeyPath,
|
|
16
|
+
writeSecretsFile,
|
|
17
|
+
} from '@ultimat3/core';
|
|
18
|
+
|
|
19
|
+
const fileKey = (root: string, hex: string): MasterKeyRef => ({
|
|
20
|
+
hex,
|
|
21
|
+
source: 'file',
|
|
22
|
+
at: masterKeyPath(root),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Stage, seal, then make live. Returns the key now in force. */
|
|
26
|
+
export async function rotateMasterKey(root: string, values: SecretValues): Promise<MasterKeyRef> {
|
|
27
|
+
const staged = stagedMasterKeyPath(root);
|
|
28
|
+
const hex = generateMasterKey();
|
|
29
|
+
rmSync(staged, { force: true });
|
|
30
|
+
// `wx`: the mode applies only when a write CREATES the file, and a leftover could be 0644.
|
|
31
|
+
writeFileSync(staged, `${hex}\n`, { encoding: 'utf-8', mode: SECRETS_KEY_MODE, flag: 'wx' });
|
|
32
|
+
const next = fileKey(root, hex);
|
|
33
|
+
await writeSecretsFile(root, values, next);
|
|
34
|
+
renameSync(staged, masterKeyPath(root));
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A staged key left by an interrupted rotation: whichever of the two keys opens the committed file
|
|
40
|
+
* is the live one. The staged key wins only by opening it, and is then moved into place; otherwise
|
|
41
|
+
* it never sealed anything and is dropped. With no staged key this is `key`, unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export async function recoverRotation(root: string, key: MasterKeyRef): Promise<MasterKeyRef> {
|
|
44
|
+
const staged = stagedMasterKeyPath(root);
|
|
45
|
+
if (!existsSync(staged)) return key;
|
|
46
|
+
try {
|
|
47
|
+
await readSecretsFile(root, key);
|
|
48
|
+
rmSync(staged, { force: true });
|
|
49
|
+
return key;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (!isUltimateError(error) || error.code !== 'X_SECRETS_KEY_MISMATCH') throw error;
|
|
52
|
+
const candidate = fileKey(root, readFileSync(staged, 'utf-8').trim());
|
|
53
|
+
// Throws the staged key's own mismatch when neither opens it — the committed file's problem,
|
|
54
|
+
// which `X_SECRETS_KEY_MISMATCH`'s fix (restore it from git) answers.
|
|
55
|
+
await readSecretsFile(root, candidate);
|
|
56
|
+
renameSync(staged, masterKeyPath(root));
|
|
57
|
+
return candidate;
|
|
58
|
+
}
|
|
59
|
+
}
|