@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
package/src/dev-lock.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync
|
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { stringField, UltimateError } from '@ultimat3/core';
|
|
20
20
|
import { exec, type Runner } from './exec';
|
|
21
|
+
import { portPairAfter } from './flag-number';
|
|
21
22
|
import { quoteArg } from './shell-quote';
|
|
22
23
|
|
|
23
24
|
/** Where the running dev server records itself, inside the state directory it already owns. */
|
|
@@ -220,12 +221,6 @@ export class DevPortInUseError extends UltimateError {
|
|
|
220
221
|
}
|
|
221
222
|
}
|
|
222
223
|
|
|
223
|
-
/**
|
|
224
|
-
* The next port to suggest. Never `port + 1` at the top of the range — 65536 is not a port, and a
|
|
225
|
-
* `fix:` that cannot run is the failure this whole module exists to end.
|
|
226
|
-
*/
|
|
227
|
-
export const suggestPort = (port: number): number => (port >= 65535 ? port - 1 : port + 1);
|
|
228
|
-
|
|
229
224
|
/**
|
|
230
225
|
* Is anything listening? A successful bind-then-close is the only answer that does not lie.
|
|
231
226
|
*
|
|
@@ -379,7 +374,9 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
|
|
|
379
374
|
release();
|
|
380
375
|
throw new DevPortInUseError({
|
|
381
376
|
port: input.port,
|
|
382
|
-
|
|
377
|
+
// `portPairAfter`, never `port + 1`: `x dev` binds a PAIR, so the holder is most often
|
|
378
|
+
// another `x dev` whose sync role already sits on `port + 1`.
|
|
379
|
+
suggestion: portPairAfter(input.port),
|
|
383
380
|
holder: await (input.holder ?? portHolder)(input.port),
|
|
384
381
|
});
|
|
385
382
|
}
|
package/src/dev-route-table.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type { PageSpeculation } from './page-speculation';
|
|
|
21
21
|
import { loadSpeculation, pageSpeculation } from './page-speculation';
|
|
22
22
|
import { pageSync } from './page-sync';
|
|
23
23
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
24
|
+
import { adminMountRoutes } from './runtime-admin';
|
|
24
25
|
import { assetRoutes } from './runtime-assets';
|
|
25
26
|
import { appRoutes } from './runtime-render';
|
|
26
27
|
import { servedStorage, storageRoutes } from './runtime-storage';
|
|
@@ -150,6 +151,14 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
150
151
|
...sync.routes,
|
|
151
152
|
...navigation.routes,
|
|
152
153
|
...pagePostRoutes(),
|
|
154
|
+
// The app's generated admin, when it declared one (`defineAdmin`): every screen under its base
|
|
155
|
+
// path, served through the same document builder as the pages below. Nothing for an app with
|
|
156
|
+
// no admin — the registry is asked, the package is not imported.
|
|
157
|
+
...adminMountRoutes({
|
|
158
|
+
buildId: input.buildId,
|
|
159
|
+
themeHead: theme.head,
|
|
160
|
+
...(origin === undefined ? {} : { origin }),
|
|
161
|
+
}),
|
|
153
162
|
...appRoutes({
|
|
154
163
|
buildId: input.buildId,
|
|
155
164
|
...(input.isr === undefined ? {} : { isr: input.isr }),
|
package/src/dispatch.ts
CHANGED
|
@@ -6,9 +6,9 @@ import { isAbsolute, resolve } from 'node:path';
|
|
|
6
6
|
import { setLogStream } from '@ultimat3/core';
|
|
7
7
|
import { requireAppRoot, requireBunVersion } from './app-root';
|
|
8
8
|
import { createHelpCommand } from './cmd-help';
|
|
9
|
-
import { plannedCommandFor } from './cmd-planned';
|
|
9
|
+
import { plannedCommandError, plannedCommandFor } from './cmd-planned';
|
|
10
10
|
import type { CommandContext } from './command';
|
|
11
|
-
import {
|
|
11
|
+
import { UnknownCommandError } from './errors';
|
|
12
12
|
import type { Runner } from './exec';
|
|
13
13
|
import { exec } from './exec';
|
|
14
14
|
import type { CommandResult } from './output';
|
|
@@ -91,10 +91,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
91
91
|
// X_NOT_IMPLEMENTED with a runnable fix. Substituted here rather than in `parse.ts`, which is
|
|
92
92
|
// pure and knows nothing about what a command means; the precedent is the help swap below.
|
|
93
93
|
const planned = plannedCommandFor(commandFor(options.argv[0] ?? '')?.spec.name);
|
|
94
|
-
const failure =
|
|
95
|
-
planned === undefined
|
|
96
|
-
? error
|
|
97
|
-
: new CliNotImplementedError({ feature: `x ${planned.name}`, fix: planned.fix });
|
|
94
|
+
const failure = planned === undefined ? error : plannedCommandError(planned);
|
|
98
95
|
// `wantsJson`, not `includes('--json')`: a typo'd flag or a typo'd command is exactly the case
|
|
99
96
|
// an agent hits while always passing `-j`, and the short form rendered prose it then parsed.
|
|
100
97
|
const result = errorResult(planned?.name ?? 'x', failure);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// `x doctor`'s APP_URL rule, pure: a set APP_URL must be an origin a browser can match, and a
|
|
2
|
+
// loopback one must name the port `x dev` serves on — OAuth's redirect, absolute links and the sync
|
|
3
|
+
// node's admitted origin all read it, and a laptop origin on another port reaches another server.
|
|
4
|
+
|
|
5
|
+
import { ERROR_DOCS_URL, renderFixShellArg } from '@ultimat3/core';
|
|
6
|
+
import { isLoopbackHostname } from './loopback-host';
|
|
7
|
+
import type { Finding } from './output';
|
|
8
|
+
import { findingFrom } from './output';
|
|
9
|
+
import { syncOriginsFrom } from './sync-url';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Unset is no finding: `x dev` serves without one, and a production build warns on its own
|
|
13
|
+
* (`originWarning`). A public origin is not judged against the dev port — that is the deploy's
|
|
14
|
+
* address, not this machine's. The parse is the sync boot's own (`syncOriginsFrom`), so doctor
|
|
15
|
+
* refuses exactly the values the boot would.
|
|
16
|
+
*/
|
|
17
|
+
export function appUrlFindings(raw: string | undefined, port: number): readonly Finding[] {
|
|
18
|
+
let origins: readonly string[];
|
|
19
|
+
try {
|
|
20
|
+
origins = syncOriginsFrom({ APP_URL: raw });
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return [findingFrom(error)];
|
|
23
|
+
}
|
|
24
|
+
const origin = origins[0];
|
|
25
|
+
if (origin === undefined) return [];
|
|
26
|
+
const url = new URL(origin);
|
|
27
|
+
if (!isLoopbackHostname(url.hostname)) return [];
|
|
28
|
+
const declared = url.port === '' ? (url.protocol === 'https:' ? 443 : 80) : Number(url.port);
|
|
29
|
+
if (declared === port) return [];
|
|
30
|
+
const wanted = `${url.protocol}//${url.hostname}:${String(port)}`;
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
code: 'X_APP_URL_PORT_MISMATCH',
|
|
34
|
+
cause: `APP_URL is ${origin}, but x dev serves this app on port ${String(port)} — OAuth redirects, absolute links and the sync origin would point at another server`,
|
|
35
|
+
fix: `export APP_URL=${renderFixShellArg(wanted, 'http://localhost:3000')}`,
|
|
36
|
+
docs: ERROR_DOCS_URL,
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// `x doctor`'s auth-storage checks: what an upgrade leaves in the database for an operator to
|
|
2
|
+
// finish — second-factor secrets written before they were sealed, and a framework table no
|
|
3
|
+
// release reads any more. Reaching the server is `authStorageProbe`, deciding is
|
|
4
|
+
// `authStorageFindings` — the split `doctor-sealed.ts` makes.
|
|
5
|
+
|
|
6
|
+
import { BuiltinAdapter, countUnsealedMfaSecrets } from '@ultimat3/auth';
|
|
7
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
8
|
+
import { createPostgresClient, raw } from '@ultimat3/db';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Framework tables an earlier release created and none reads now, each with the command that
|
|
13
|
+
* removes it. The boot never drops one: a replica still on the previous release may be using it
|
|
14
|
+
* for the length of a rolling deploy. A whole literal per table — nothing is spliced into a fix.
|
|
15
|
+
*/
|
|
16
|
+
type RetiredTable = 'x_auth_failures';
|
|
17
|
+
|
|
18
|
+
// A Map, not an object: a table name is looked up by key, and an object answers `constructor`.
|
|
19
|
+
const RETIRED_TABLE_FIXES: ReadonlyMap<RetiredTable, string> = new Map([
|
|
20
|
+
[
|
|
21
|
+
'x_auth_failures',
|
|
22
|
+
`psql "$DATABASE_URL" -c 'drop table if exists x_auth_failures' # once every replica runs this release`,
|
|
23
|
+
],
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export const RETIRED_FRAMEWORK_TABLES: readonly RetiredTable[] = Object.freeze([
|
|
27
|
+
...RETIRED_TABLE_FIXES.keys(),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export interface AuthStorageFact {
|
|
31
|
+
/** `x_users` rows holding an `mfa_secret` that is not sealed. */
|
|
32
|
+
readonly unsealedMfaSecrets: number;
|
|
33
|
+
/** Which of `RETIRED_FRAMEWORK_TABLES` are still present. */
|
|
34
|
+
readonly retiredTables: readonly RetiredTable[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What a database that was not asked — embedded, or unreachable — answers: nothing to report. */
|
|
38
|
+
export const NO_AUTH_STORAGE_FACT: AuthStorageFact = Object.freeze({
|
|
39
|
+
unsealedMfaSecrets: 0,
|
|
40
|
+
retiredTables: Object.freeze([]),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** The rule, pure. */
|
|
44
|
+
export function authStorageFindings(fact: AuthStorageFact): readonly Finding[] {
|
|
45
|
+
const findings: Finding[] = [];
|
|
46
|
+
if (fact.unsealedMfaSecrets > 0) {
|
|
47
|
+
findings.push({
|
|
48
|
+
code: 'X_MFA_SECRET_UNSEALED',
|
|
49
|
+
cause: `${fact.unsealedMfaSecrets} user(s) hold a second-factor secret that is not sealed; the framework never reads a plaintext secret, so each of them is refused at the second factor until it is sealed`,
|
|
50
|
+
fix: 'x auth seal-mfa --json',
|
|
51
|
+
docs: ERROR_DOCS_URL,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
for (const table of fact.retiredTables) {
|
|
55
|
+
const fix = RETIRED_TABLE_FIXES.get(table);
|
|
56
|
+
if (fix === undefined) continue;
|
|
57
|
+
findings.push({
|
|
58
|
+
code: 'X_FRAMEWORK_TABLE_ORPHANED',
|
|
59
|
+
cause: `${table} is still in the database and no release reads it any more; the boot does not drop it, because a replica on the previous release may still be writing to it during a rolling deploy`,
|
|
60
|
+
fix,
|
|
61
|
+
docs: ERROR_DOCS_URL,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Asks an EXTERNAL database only — `probeDatabase`'s own rule: opening the embedded one boots
|
|
69
|
+
* PGlite and takes the single-writer lock the next command needs. A server that does not answer
|
|
70
|
+
* is `database()`'s finding to make, so every failure here is "nothing to report".
|
|
71
|
+
*/
|
|
72
|
+
export async function authStorageProbe(url: string | undefined): Promise<AuthStorageFact> {
|
|
73
|
+
if (url === undefined || url.trim() === '') return NO_AUTH_STORAGE_FACT;
|
|
74
|
+
const client = createPostgresClient({ url, applicationName: 'x-doctor' });
|
|
75
|
+
try {
|
|
76
|
+
const present = async (table: string): Promise<boolean> => {
|
|
77
|
+
const rows = await client.query<{ readonly present: boolean }>(
|
|
78
|
+
raw(`select to_regclass('public.${table}') is not null as present`),
|
|
79
|
+
);
|
|
80
|
+
return rows[0]?.present === true;
|
|
81
|
+
};
|
|
82
|
+
const retiredTables: RetiredTable[] = [];
|
|
83
|
+
for (const table of RETIRED_FRAMEWORK_TABLES) {
|
|
84
|
+
if (await present(table)) retiredTables.push(table);
|
|
85
|
+
}
|
|
86
|
+
const unsealedMfaSecrets = (await present('x_users'))
|
|
87
|
+
? await countUnsealedMfaSecrets({ adapter: new BuiltinAdapter(client) })
|
|
88
|
+
: 0;
|
|
89
|
+
return { unsealedMfaSecrets, retiredTables };
|
|
90
|
+
} catch {
|
|
91
|
+
return NO_AUTH_STORAGE_FACT;
|
|
92
|
+
} finally {
|
|
93
|
+
await client.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// `x doctor`'s coverage listing: every file the app's coverage floor does not see, and the reason
|
|
2
|
+
// `x.verify.json` gives for each. A LISTING, never a finding: an `exclude` with its `why` is a
|
|
3
|
+
// decision the app made in a committed file — printing it is what keeps the decision in view, and
|
|
4
|
+
// a red `x doctor` over it would be the framework overruling what it asked to be written down.
|
|
5
|
+
|
|
6
|
+
import type { CoverageExclude } from './coverage-floor';
|
|
7
|
+
import { msg } from './messages';
|
|
8
|
+
import type { CommandResult, JsonValue } from './output';
|
|
9
|
+
import { readVerifyFloor } from './verify-floor';
|
|
10
|
+
|
|
11
|
+
/** The I/O half: the excludes this app's `x.verify.json` states. None when it states no floor. */
|
|
12
|
+
export async function coverageExcludesProbe(root: string): Promise<readonly CoverageExclude[]> {
|
|
13
|
+
return (await readVerifyFloor(root))?.coverage?.exclude ?? [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The listing, on a result `x doctor` already decided: `data.coverage.exclude` for a machine, one
|
|
18
|
+
* line per entry for a person. `ok`, the summary and the findings are returned as they came.
|
|
19
|
+
*/
|
|
20
|
+
export function withCoverageExcludes(
|
|
21
|
+
result: CommandResult,
|
|
22
|
+
exclude: readonly CoverageExclude[],
|
|
23
|
+
): CommandResult {
|
|
24
|
+
const data = typeof result.data === 'object' && result.data !== null ? result.data : {};
|
|
25
|
+
const listed: JsonValue = exclude.map(({ glob, why }) => ({ glob, why }));
|
|
26
|
+
return {
|
|
27
|
+
...result,
|
|
28
|
+
data: { ...(data as Record<string, JsonValue>), coverage: { exclude: listed } },
|
|
29
|
+
...(exclude.length === 0
|
|
30
|
+
? {}
|
|
31
|
+
: {
|
|
32
|
+
lines: [
|
|
33
|
+
...(result.lines ?? []),
|
|
34
|
+
msg('cli.doctor.coverageExclude', { count: exclude.length }),
|
|
35
|
+
...exclude.map(({ glob, why }) => ` ${glob} — ${why}`),
|
|
36
|
+
],
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// `x doctor`'s guard listing: the guards the framework ships that this app's `guards/` does not
|
|
2
|
+
// hold, each with the command that adds it. A LISTING, never a finding: every shipped guard says
|
|
3
|
+
// "delete this file to drop the rule", so an app without one may have decided — and a red
|
|
4
|
+
// `x doctor` over a decision would be the framework overruling the convention it handed over.
|
|
5
|
+
|
|
6
|
+
import { GUARD_DIR, guardPaths } from './guards';
|
|
7
|
+
import { msg } from './messages';
|
|
8
|
+
import type { CommandResult, JsonValue } from './output';
|
|
9
|
+
import { SHIPPED_GUARD_NAMES } from './templates/scaffold-guards';
|
|
10
|
+
|
|
11
|
+
export interface MissingGuard {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
/** The command that writes it, with its test. Then `x verify` says what it finds. */
|
|
14
|
+
readonly add: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The rule, pure: which shipped names have no file among the guards an app holds. */
|
|
18
|
+
export function missingShippedGuards(present: readonly string[]): readonly MissingGuard[] {
|
|
19
|
+
const held = new Set(present.map((path) => path.replace(/\.tsx?$/, '')));
|
|
20
|
+
return SHIPPED_GUARD_NAMES.filter((name) => !held.has(`${GUARD_DIR}/${name}`)).map((name) => ({
|
|
21
|
+
name,
|
|
22
|
+
add: `x g guard ${name}`,
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The I/O half: the same enumeration the `boundaries` step runs, so the two cannot disagree. */
|
|
27
|
+
export async function shippedGuardsProbe(root: string): Promise<readonly MissingGuard[]> {
|
|
28
|
+
return missingShippedGuards(await guardPaths(root));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The listing, on a result `x doctor` already decided: `data.guards.missing` for a machine, one
|
|
33
|
+
* line per command for a person. `ok`, the summary and the findings are returned as they came.
|
|
34
|
+
*/
|
|
35
|
+
export function withGuardListing(
|
|
36
|
+
result: CommandResult,
|
|
37
|
+
missing: readonly MissingGuard[],
|
|
38
|
+
): CommandResult {
|
|
39
|
+
const data = typeof result.data === 'object' && result.data !== null ? result.data : {};
|
|
40
|
+
const listed: JsonValue = missing.map(({ name, add }) => ({ name, add }));
|
|
41
|
+
return {
|
|
42
|
+
...result,
|
|
43
|
+
data: { ...(data as Record<string, JsonValue>), guards: { missing: listed } },
|
|
44
|
+
...(missing.length === 0
|
|
45
|
+
? {}
|
|
46
|
+
: {
|
|
47
|
+
lines: [
|
|
48
|
+
...(result.lines ?? []),
|
|
49
|
+
msg('cli.doctor.guards', { count: missing.length }),
|
|
50
|
+
...missing.map(({ add }) => ` ${add}`),
|
|
51
|
+
],
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// `x doctor`'s sealed-column checks: the columns the app declared `.sealed()`, against the master
|
|
2
|
+
// keys this environment declares. Its own file for `doctor-offline.ts`'s reason — answering needs
|
|
3
|
+
// the app's entities and the committed secrets, and `cmd-doctor.ts`'s job is the probe. The split
|
|
4
|
+
// is the same too: reaching the disk is `sealedKeysProbe`, deciding is `sealedKeyFindings`.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ERROR_DOCS_URL,
|
|
8
|
+
findMasterKey,
|
|
9
|
+
readSecretsFile,
|
|
10
|
+
SECRETS_RETIRED_KEYS_ENV,
|
|
11
|
+
sealKeyIds,
|
|
12
|
+
secretsFileExists,
|
|
13
|
+
} from '@ultimat3/core';
|
|
14
|
+
import { registeredEntities, sealedFields } from '@ultimat3/entity';
|
|
15
|
+
import { loadApp } from './app-load';
|
|
16
|
+
import type { Finding } from './output';
|
|
17
|
+
import { quoteArg } from './shell-quote';
|
|
18
|
+
|
|
19
|
+
export interface SealedColumnFact {
|
|
20
|
+
readonly entity: string;
|
|
21
|
+
/** Property key, as the entity declares it. */
|
|
22
|
+
readonly column: string;
|
|
23
|
+
readonly lookup: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SealedKeysFact {
|
|
27
|
+
/**
|
|
28
|
+
* Every sealed column the app declares, or `undefined` when the app would not load — a short
|
|
29
|
+
* registry reads as "no sealed columns", which is the false green `appEntities` exists to refuse.
|
|
30
|
+
*/
|
|
31
|
+
readonly columns: readonly SealedColumnFact[] | undefined;
|
|
32
|
+
/** The key ids in force, or `undefined` when no usable master key was found. */
|
|
33
|
+
readonly keys: { readonly current: string; readonly retired: readonly string[] } | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const named = (columns: readonly SealedColumnFact[]): string =>
|
|
37
|
+
columns.map((one) => `${one.entity}.${one.column}`).join(', ');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The rule, pure. Two conditions, and an app with no sealed column is judged by neither.
|
|
41
|
+
*
|
|
42
|
+
* No key: every write to a sealed column is `X_SEAL_KEY_MISSING` — core's own code and its own
|
|
43
|
+
* fix, reported here before the first request finds out.
|
|
44
|
+
*
|
|
45
|
+
* A retired key still declared: a rotation whose re-seal has not finished. That is a live-system
|
|
46
|
+
* state and not a fault, but it has a cost that ends only when someone acts — a `lookup` column
|
|
47
|
+
* matches through `in (…)` over every key and its `.unique()` holds per key — so it is a finding
|
|
48
|
+
* until the retired key is dropped.
|
|
49
|
+
*/
|
|
50
|
+
export function sealedKeyFindings(fact: SealedKeysFact): readonly Finding[] {
|
|
51
|
+
const columns = fact.columns;
|
|
52
|
+
if (columns === undefined || columns.length === 0) return [];
|
|
53
|
+
if (fact.keys === undefined) {
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
code: 'X_SEAL_KEY_MISSING',
|
|
57
|
+
cause: `${named(columns)} ${columns.length === 1 ? 'is' : 'are'} sealed and this environment has no usable master key, so every write to ${columns.length === 1 ? 'it' : 'them'} is refused`,
|
|
58
|
+
fix: 'x secrets init # or, where the key already exists: export ULTIMATE_SECRETS_KEY="$(cat .secrets.key)"',
|
|
59
|
+
docs: ERROR_DOCS_URL,
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
const [oldest] = fact.keys.retired;
|
|
64
|
+
if (oldest === undefined) return [];
|
|
65
|
+
const lookups = columns.filter((one) => one.lookup);
|
|
66
|
+
const weakened =
|
|
67
|
+
lookups.length === 0
|
|
68
|
+
? ''
|
|
69
|
+
: ` — ${named(lookups)} ${lookups.length === 1 ? 'is a lookup column' : 'are lookup columns'}, so until then equality reads through in (…) over every key and .unique() holds per key`;
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
code: 'X_SEAL_RESEAL_PENDING',
|
|
73
|
+
cause: `${named(columns)} may still hold values sealed under retired master key(s) ${fact.keys.retired.join(', ')}: a rotation whose re-seal has not finished${weakened}`,
|
|
74
|
+
// The id is 16 hex characters computed here from a key, never text read from a file.
|
|
75
|
+
fix: `x secrets rotate --drop ${quoteArg(oldest)} --json # AFTER a backfill() has rewritten every row — update(id, { column: row.column }) seals under the current key`,
|
|
76
|
+
docs: ERROR_DOCS_URL,
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The retired ring as a booted process would see it: the real environment first, the committed
|
|
83
|
+
* file second — `installSecrets()`'s own order, without installing anything into this process.
|
|
84
|
+
*/
|
|
85
|
+
async function retiredRing(
|
|
86
|
+
root: string,
|
|
87
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
88
|
+
): Promise<string | undefined> {
|
|
89
|
+
const declared = env[SECRETS_RETIRED_KEYS_ENV];
|
|
90
|
+
if (declared !== undefined && declared !== '') return declared;
|
|
91
|
+
const key = findMasterKey(root, { ...env });
|
|
92
|
+
if (key === undefined || !secretsFileExists(root)) return undefined;
|
|
93
|
+
return (await readSecretsFile(root, key))[SECRETS_RETIRED_KEYS_ENV];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The app's sealed columns, or `undefined` when it would not load. */
|
|
97
|
+
export type DeclaredSealedColumns = (
|
|
98
|
+
root: string,
|
|
99
|
+
) => Promise<readonly SealedColumnFact[] | undefined>;
|
|
100
|
+
|
|
101
|
+
/** `appEntities`' rule (`schema-drift.ts`): a registry short one module is not "none declared". */
|
|
102
|
+
const appSealedColumns: DeclaredSealedColumns = async (root) => {
|
|
103
|
+
const app = await loadApp(root);
|
|
104
|
+
if (app.findings.length > 0) return undefined;
|
|
105
|
+
return registeredEntities().flatMap((entry) =>
|
|
106
|
+
// `core` is absent on a hand-registered entry, which declares no columns to seal.
|
|
107
|
+
(entry.core === undefined ? [] : sealedFields(entry.core)).map((field) => ({
|
|
108
|
+
entity: entry.name,
|
|
109
|
+
column: field.property,
|
|
110
|
+
lookup: field.lookup,
|
|
111
|
+
})),
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export async function sealedKeysProbe(
|
|
116
|
+
root: string,
|
|
117
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
118
|
+
declared: DeclaredSealedColumns = appSealedColumns,
|
|
119
|
+
): Promise<SealedKeysFact> {
|
|
120
|
+
const columns = await declared(root);
|
|
121
|
+
// No sealed column means no question about keys, and no reason to open the secrets file.
|
|
122
|
+
if (columns === undefined || columns.length === 0) return { columns, keys: undefined };
|
|
123
|
+
try {
|
|
124
|
+
const ring = await retiredRing(root, env);
|
|
125
|
+
const source = { ...env, ...(ring === undefined ? {} : { [SECRETS_RETIRED_KEYS_ENV]: ring }) };
|
|
126
|
+
return { columns, keys: await sealKeyIds({ root, env: source }) };
|
|
127
|
+
} catch {
|
|
128
|
+
// Whatever refused — no key, a malformed one, a secrets file this key does not open — the
|
|
129
|
+
// answer for a sealed column is the same: there is no key ring to seal under.
|
|
130
|
+
return { columns, keys: undefined };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Whether importing an app module would bring a DOCUMENT module into this process: a component
|
|
2
|
+
// (`.tsx`) or a stylesheet. A role that renders nothing asks this before each import
|
|
3
|
+
// (`role-load.ts`), so a worker never compiles the app's Sass or evaluates its pages to learn that
|
|
4
|
+
// they register no job.
|
|
5
|
+
|
|
6
|
+
import { resolvedImports } from './module-imports';
|
|
7
|
+
import { hasPathSegment } from './path-segments';
|
|
8
|
+
|
|
9
|
+
/** A component or a stylesheet: what only a process that renders documents evaluates. */
|
|
10
|
+
const DOCUMENT = /\.(?:[jt]sx|s[ac]ss|css)$/;
|
|
11
|
+
|
|
12
|
+
export const isDocumentModule = (path: string): boolean => DOCUMENT.test(path);
|
|
13
|
+
|
|
14
|
+
/** An installed framework package, under either linker: `…/node_modules/@ultimat3/<pkg>/…`. */
|
|
15
|
+
const FRAMEWORK_SCOPE = '/node_modules/@ultimat3/';
|
|
16
|
+
|
|
17
|
+
export interface DocumentGraph {
|
|
18
|
+
/**
|
|
19
|
+
* True when importing `absolute` would evaluate a document module this process has not already
|
|
20
|
+
* loaded. Errs toward `false`: a specifier that will not resolve and a file that will not parse
|
|
21
|
+
* contribute no edge, so the caller imports the module and the import itself reports the fault.
|
|
22
|
+
*/
|
|
23
|
+
reachesDocument(absolute: string): Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `loaded` answers whether a module is already in this process — a module the API index imported
|
|
28
|
+
* costs nothing more, whatever it reaches, so the walk stops there. Injected, so a test decides it.
|
|
29
|
+
*/
|
|
30
|
+
export function createDocumentGraph(loaded: (absolute: string) => boolean): DocumentGraph {
|
|
31
|
+
const edges = new Map<string, readonly string[]>();
|
|
32
|
+
const verdicts = new Map<string, boolean>();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The files importing `path` evaluates, when the walk follows them: the app's own modules and
|
|
36
|
+
* the framework's — `@ultimat3/ui` is a barrel of components, and an app module importing it
|
|
37
|
+
* brings every one of them and their Sass. A third-party package is not walked: it ships no
|
|
38
|
+
* component this framework's loader compiles. A lazy import's target is no edge at all.
|
|
39
|
+
*/
|
|
40
|
+
async function importsOf(path: string): Promise<readonly string[]> {
|
|
41
|
+
const known = edges.get(path);
|
|
42
|
+
if (known !== undefined) return known;
|
|
43
|
+
// Unreadable: no edge, and the caller's own import is what reports it.
|
|
44
|
+
const source = await Bun.file(path)
|
|
45
|
+
.text()
|
|
46
|
+
.catch(() => '');
|
|
47
|
+
const found = resolvedImports(path, source, 'evaluated').filter(
|
|
48
|
+
(target) => !hasPathSegment(target, 'node_modules') || target.includes(FRAMEWORK_SCOPE),
|
|
49
|
+
);
|
|
50
|
+
edges.set(path, found);
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
async reachesDocument(absolute: string): Promise<boolean> {
|
|
56
|
+
const known = verdicts.get(absolute);
|
|
57
|
+
if (known !== undefined) return known;
|
|
58
|
+
// The whole reachable set, never a per-node recursion: inside an import cycle a node's own
|
|
59
|
+
// answer depends on one still being computed, and a `false` memoised there is wrong.
|
|
60
|
+
const seen = new Set<string>();
|
|
61
|
+
const stack = [absolute];
|
|
62
|
+
let reaches = false;
|
|
63
|
+
while (stack.length > 0 && !reaches) {
|
|
64
|
+
const path = stack.pop();
|
|
65
|
+
if (path === undefined || seen.has(path)) continue;
|
|
66
|
+
seen.add(path);
|
|
67
|
+
if (verdicts.get(path) === true || (isDocumentModule(path) && !loaded(path))) {
|
|
68
|
+
reaches = true;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
// Already here, or already cleared: nothing below it is new to this process.
|
|
72
|
+
if (isDocumentModule(path) || loaded(path) || verdicts.get(path) === false) continue;
|
|
73
|
+
stack.push(...(await importsOf(path)));
|
|
74
|
+
}
|
|
75
|
+
// A clean walk clears everything it visited — each one's reachable set is inside this one's.
|
|
76
|
+
// A walk that found a document proves it for the start alone.
|
|
77
|
+
if (reaches) verdicts.set(absolute, true);
|
|
78
|
+
else for (const path of seen) verdicts.set(path, false);
|
|
79
|
+
return reaches;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Single responsibility: which declared tables the drift step expects a migration to have made
|
|
2
|
+
// `REPLICA IDENTITY FULL` — the same set `x db gen` emits the ALTER for (`replicaIdentityTables`),
|
|
3
|
+
// or that command's own refusal as a finding. A tier-3 fact no entity carries, so no source hash
|
|
4
|
+
// moves when a channel with params is added over a table that already exists.
|
|
5
|
+
|
|
6
|
+
import { describeQueries } from '@ultimat3/query';
|
|
7
|
+
import { QuerySubscribesUnknownError, replicaIdentityTables } from './db-subscribes';
|
|
8
|
+
import { MIGRATIONS_DIR } from './migrations';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
import { findingFrom } from './output';
|
|
11
|
+
|
|
12
|
+
/** The tables needing FULL, given the tables the app's entities declare. Injected for tests. */
|
|
13
|
+
export type ReplicaIdentityWanted = (tables: ReadonlySet<string>) => readonly string[];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The app's own answer: its live queries' `subscribes:` plus every params channel's `records`
|
|
17
|
+
* tables, read off the registries `loadApp` filled. One function with `db-generate.ts`, so the gate
|
|
18
|
+
* can never expect an identity the generator would not grant, or miss one it would.
|
|
19
|
+
*/
|
|
20
|
+
export const appReplicaIdentity: ReplicaIdentityWanted = (tables) =>
|
|
21
|
+
replicaIdentityTables(describeQueries(), tables);
|
|
22
|
+
|
|
23
|
+
export type WantedIdentity =
|
|
24
|
+
| { readonly tables: ReadonlySet<string> }
|
|
25
|
+
| { readonly finding: Finding };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `x db gen` refuses a `subscribes:` name no entity declares (`X_QUERY_SUBSCRIBES_UNKNOWN`), and the
|
|
29
|
+
* gate reports the same refusal rather than letting it escape the step: every other throw is a
|
|
30
|
+
* defect in this process and is rethrown as one.
|
|
31
|
+
*/
|
|
32
|
+
export function wantedReplicaIdentity(
|
|
33
|
+
wanted: ReplicaIdentityWanted,
|
|
34
|
+
tables: ReadonlySet<string>,
|
|
35
|
+
): WantedIdentity {
|
|
36
|
+
try {
|
|
37
|
+
return { tables: new Set(wanted(tables)) };
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (!(error instanceof QuerySubscribesUnknownError)) throw error;
|
|
40
|
+
return { finding: { ...findingFrom(error), at: MIGRATIONS_DIR } };
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/error-catalog.ts
CHANGED
|
@@ -46,21 +46,18 @@ export const CATALOG_PACKAGES = [
|
|
|
46
46
|
] as const;
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* The packages the catalog may import WITHOUT `@ultimat3/cli` declaring them
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
49
|
+
* The packages the catalog may import WITHOUT `@ultimat3/cli` declaring them — never one it does
|
|
50
|
+
* declare (`error-catalog.test.ts`), which is why `admin` is not here: `x dev` imports it and
|
|
51
|
+
* `package.json` depends on it. `ui` reaches the CLI only as `admin`'s own dependency, so an
|
|
52
|
+
* installer that keeps transitive packages private reports its codes `unavailable` rather than
|
|
53
|
+
* the CLI declaring a package no command imports. `scraping` is an app's own choice since 22.0.0: `x shot` drives Chrome over raw CDP and imports nothing from it, so its codes
|
|
53
54
|
* answer where the app installed it and are reported `unavailable` where it did not. Every other
|
|
54
55
|
* entry above is a real runtime import and must be a declared dependency — `error-catalog.test.ts`
|
|
55
56
|
* holds the list to exactly that, because an undeclared one resolves through workspace symlinks
|
|
56
57
|
* here and through nothing in an installed app, where `x errors explain X_FLAG_EXPIRED` then
|
|
57
58
|
* refuses a code the wiki promises.
|
|
58
59
|
*/
|
|
59
|
-
export const CATALOG_OPTIONAL_HOSTS: readonly string[] = [
|
|
60
|
-
'@ultimat3/admin',
|
|
61
|
-
'@ultimat3/scraping',
|
|
62
|
-
'@ultimat3/ui',
|
|
63
|
-
];
|
|
60
|
+
export const CATALOG_OPTIONAL_HOSTS: readonly string[] = ['@ultimat3/scraping', '@ultimat3/ui'];
|
|
64
61
|
|
|
65
62
|
export interface ErrorCatalog {
|
|
66
63
|
/** Packages whose codes are now registered. */
|