@ultimat3/cli 23.0.0 → 25.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 +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
package/src/runtime-bindings.ts
CHANGED
|
@@ -24,19 +24,39 @@ export interface DevServices {
|
|
|
24
24
|
readonly storage: ServiceBinding;
|
|
25
25
|
readonly stateDir: string;
|
|
26
26
|
/**
|
|
27
|
-
* The app directory itself, carried rather than re-derived from `stateDir`. A boot that needs
|
|
28
|
-
* read the app's own `app.config.ts` — `
|
|
29
|
-
* `join(root, '.x')` above, and a `dirname` that silently disagrees with this
|
|
30
|
-
* path bug nothing would catch.
|
|
27
|
+
* The app directory itself, carried rather than re-derived from `stateDir`. A boot that needs
|
|
28
|
+
* to read the app's own `app.config.ts` — `startServices`' `loadAppConfig` does — otherwise has
|
|
29
|
+
* to undo the `join(root, '.x')` above, and a `dirname` that silently disagrees with this
|
|
30
|
+
* file's join is a path bug nothing would catch.
|
|
31
31
|
*/
|
|
32
32
|
readonly root: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export type Env = Readonly<Record<string, string | undefined>>;
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* An embedded binding's `url`: `<scheme>://` and then the HOST path, verbatim — a scheme-tagged
|
|
39
|
+
* path, NOT a URL. `pglite://D:\\app\\.x\\pgdata` is what Windows gets, and no URL parser reads it
|
|
40
|
+
* back (a `\\` is not a separator to one, a space would need encoding). Nothing parses it: db's
|
|
41
|
+
* `pgliteDataDir` and `startStorage` strip the prefix, and the report goes through `safeUrlLabel`,
|
|
42
|
+
* which answers the binding's name for anything that is not a URL. `Bun.pathToFileURL` was the
|
|
43
|
+
* other option, and would have made both readers decode as well as strip.
|
|
44
|
+
*/
|
|
45
|
+
export const embeddedLocation = (scheme: 'pglite' | 'file', path: string): string =>
|
|
46
|
+
`${scheme}://${path}`;
|
|
47
|
+
|
|
37
48
|
const nonEmpty = (value: string | undefined): string | undefined =>
|
|
38
49
|
value === undefined || value.trim().length === 0 ? undefined : value;
|
|
39
50
|
|
|
51
|
+
const s3EndpointOf = (env: Env): string | undefined => nonEmpty(env['S3_ENDPOINT']);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* THE storage choice: a non-empty `S3_ENDPOINT` is object storage, anything else the embedded disk
|
|
55
|
+
* — which alone mints its own signed URLs and so needs `STORAGE_SIGNING_SECRET`. Exported so the
|
|
56
|
+
* deploy contract (`framework-env.ts`) asks this function rather than restating the rule.
|
|
57
|
+
*/
|
|
58
|
+
export const storageIsExternal = (env: Env): boolean => s3EndpointOf(env) !== undefined;
|
|
59
|
+
|
|
40
60
|
/**
|
|
41
61
|
* Embedded Postgres is PGlite on disk under `.x/`, so a restart keeps the data and a `x db reset`
|
|
42
62
|
* is a directory delete rather than a container dance.
|
|
@@ -51,7 +71,7 @@ export function resolveServices(
|
|
|
51
71
|
// (`e2e-app.ts`) instead of resetting the developer's own, beside a running `x dev`.
|
|
52
72
|
const stateDir = nonEmpty(env['ULTIMATE_STATE_DIR']) ?? join(root, '.x');
|
|
53
73
|
const databaseUrl = nonEmpty(env['DATABASE_URL']);
|
|
54
|
-
const s3Endpoint =
|
|
74
|
+
const s3Endpoint = s3EndpointOf(env);
|
|
55
75
|
// Created only when something will actually live in it: the embedded database and the local disk
|
|
56
76
|
// do, in-process events never touch the disk. A container whose bindings are external runs
|
|
57
77
|
// non-root over a read-only app directory, and a mkdir there is an EACCES at boot for a directory
|
|
@@ -67,7 +87,7 @@ export function resolveServices(
|
|
|
67
87
|
? {
|
|
68
88
|
name: 'db',
|
|
69
89
|
mode: 'embedded',
|
|
70
|
-
url:
|
|
90
|
+
url: embeddedLocation('pglite', join(stateDir, 'pgdata')),
|
|
71
91
|
detail: 'PGlite in this process — set DATABASE_URL to use a real Postgres',
|
|
72
92
|
}
|
|
73
93
|
: { name: 'db', mode: 'external', url: databaseUrl, detail: 'DATABASE_URL' },
|
|
@@ -77,7 +97,7 @@ export function resolveServices(
|
|
|
77
97
|
? {
|
|
78
98
|
name: 'storage',
|
|
79
99
|
mode: 'embedded',
|
|
80
|
-
url:
|
|
100
|
+
url: embeddedLocation('file', join(stateDir, 'storage')),
|
|
81
101
|
detail: 'local directory — set S3_ENDPOINT to use S3',
|
|
82
102
|
}
|
|
83
103
|
: { name: 'storage', mode: 'external', url: s3Endpoint, detail: 'S3_ENDPOINT' },
|
package/src/runtime-cache.ts
CHANGED
|
@@ -4,26 +4,22 @@
|
|
|
4
4
|
// and lru unconditionally, redis on `REDIS_URL` and cdn on any real purge driver, so the key was
|
|
5
5
|
// declared, validated at boot, documented, and read by nothing.
|
|
6
6
|
|
|
7
|
-
import { existsSync } from 'node:fs';
|
|
8
|
-
import { join } from 'node:path';
|
|
9
7
|
import type { CacheTier, PurgeDriver } from '@ultimat3/cache';
|
|
10
8
|
import {
|
|
11
9
|
CacheDriverUnavailableError,
|
|
12
|
-
|
|
13
|
-
createLruTier,
|
|
14
|
-
createMemoTier,
|
|
15
|
-
createRedisTier,
|
|
10
|
+
cdnTier,
|
|
16
11
|
isNoopPurgeDriver,
|
|
12
|
+
lruTier,
|
|
13
|
+
memoTier,
|
|
17
14
|
receiveInvalidationBroadcast,
|
|
15
|
+
redisTier,
|
|
18
16
|
registerInvalidationBroadcast,
|
|
19
17
|
registerTier,
|
|
20
18
|
resetTiers,
|
|
21
19
|
} from '@ultimat3/cache';
|
|
22
|
-
import type { CacheTierName } from '@ultimat3/core';
|
|
23
|
-
import {
|
|
20
|
+
import type { AppConfig, CacheTierName } from '@ultimat3/core';
|
|
21
|
+
import { backoffDelay, defineConfig, logger, renderThrowable } from '@ultimat3/core';
|
|
24
22
|
import type { Transport, TransportSubscription } from '@ultimat3/realtime/server';
|
|
25
|
-
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
26
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
27
23
|
import type { Env } from './runtime-bindings';
|
|
28
24
|
|
|
29
25
|
/**
|
|
@@ -41,11 +37,17 @@ export interface CacheTiersOptions {
|
|
|
41
37
|
/**
|
|
42
38
|
* `config.cache.tiers`, verbatim. REQUIRED, and that is the enforcement (axiom 3): a boot that
|
|
43
39
|
* has not read the app's declaration cannot call this function at all — it is a type error, not
|
|
44
|
-
* a convention to remember. `
|
|
40
|
+
* a convention to remember. `cacheTiersOf` is what a boot holding the loaded config calls for it.
|
|
45
41
|
*/
|
|
46
42
|
readonly tiers: readonly CacheTierName[];
|
|
43
|
+
/** The wait before re-subscribing after `attempt` failures (1-based). Injected by a test. */
|
|
44
|
+
readonly subscribeRetryMs?: ((attempt: number) => number) | undefined;
|
|
47
45
|
}
|
|
48
46
|
|
|
47
|
+
/** 1 s doubling to 30 s, full jitter: a bus that is down is retried without a fleet in lockstep. */
|
|
48
|
+
const defaultSubscribeRetryMs = (attempt: number): number =>
|
|
49
|
+
backoffDelay({ attempt, base: 1_000, max: 30_000, jitter: 'full' });
|
|
50
|
+
|
|
49
51
|
/**
|
|
50
52
|
* The rungs an app that declares none gets — ASKED of `defineConfig` rather than written out, for
|
|
51
53
|
* two reasons that point the same way: `defaults()` is private to `@ultimat3/core`, and a second
|
|
@@ -56,32 +58,9 @@ export const DEFAULT_CACHE_TIERS: readonly CacheTierName[] = defineConfig({
|
|
|
56
58
|
name: 'cache-defaults',
|
|
57
59
|
}).cache.tiers;
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const isTierList = (value: unknown): value is readonly CacheTierName[] =>
|
|
63
|
-
Array.isArray(value) && value.every((entry) => CACHE_TIERS.some((name) => name === entry));
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* `cache.tiers` out of the app's own `app.config.ts` — the sibling of `app-auth.ts`'s
|
|
67
|
-
* `loadSignInPath`, and structural for the same reason: `defineConfig` returns a plain object, and
|
|
68
|
-
* a config that resolved through an older core simply has no `cache` section.
|
|
69
|
-
*
|
|
70
|
-
* A list this refuses cannot come from `defineConfig` — `validate()` rejects an unknown rung one
|
|
71
|
-
* `await import` above this line — so the fallback is for a hand-written config object, and the
|
|
72
|
-
* two rungs every app starts from are the honest answer for one.
|
|
73
|
-
*/
|
|
74
|
-
export async function loadCacheTiers(root: string): Promise<readonly CacheTierName[]> {
|
|
75
|
-
const configPath = join(root, APP_CONFIG_FILE);
|
|
76
|
-
if (!existsSync(configPath)) return DEFAULT_CACHE_TIERS;
|
|
77
|
-
const module = (await import(configPath)) as Record<string, unknown>;
|
|
78
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
79
|
-
if (!isRecord(config)) return DEFAULT_CACHE_TIERS;
|
|
80
|
-
const cache = config['cache'];
|
|
81
|
-
if (!isRecord(cache)) return DEFAULT_CACHE_TIERS;
|
|
82
|
-
const { tiers } = cache;
|
|
83
|
-
return isTierList(tiers) ? tiers : DEFAULT_CACHE_TIERS;
|
|
84
|
-
}
|
|
61
|
+
/** `cache.tiers` out of the config `startServices` loads once; the defaults for a root with none. */
|
|
62
|
+
export const cacheTiersOf = (config: AppConfig | undefined): readonly CacheTierName[] =>
|
|
63
|
+
config?.cache.tiers ?? DEFAULT_CACHE_TIERS;
|
|
85
64
|
|
|
86
65
|
/**
|
|
87
66
|
* `REDIS_URL` is the same "an unset variable means the embedded default" law the db, events,
|
|
@@ -106,9 +85,9 @@ function redisUrl(env: Env): string | undefined {
|
|
|
106
85
|
function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
|
|
107
86
|
switch (name) {
|
|
108
87
|
case 'request-memo':
|
|
109
|
-
return
|
|
88
|
+
return memoTier();
|
|
110
89
|
case 'lru':
|
|
111
|
-
return
|
|
90
|
+
return lruTier();
|
|
112
91
|
case 'redis':
|
|
113
92
|
if (redisUrl(options.env) === undefined) {
|
|
114
93
|
throw new CacheDriverUnavailableError({
|
|
@@ -118,7 +97,7 @@ function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
|
|
|
118
97
|
fix: 'set REDIS_URL in .env, or drop the redis tier from cache.tiers in app.config.ts',
|
|
119
98
|
});
|
|
120
99
|
}
|
|
121
|
-
return
|
|
100
|
+
return redisTier();
|
|
122
101
|
case 'cdn':
|
|
123
102
|
// A noop tier would put a `cdn` line in every invalidation report claiming keys an edge that
|
|
124
103
|
// does not exist had accepted — and the `/_x` cache panel renders those reports, so the lie
|
|
@@ -131,7 +110,7 @@ function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
|
|
|
131
110
|
fix: 'set FASTLY_API_TOKEN and FASTLY_SERVICE_ID in .env, or CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID, or drop the cdn tier from cache.tiers in app.config.ts',
|
|
132
111
|
});
|
|
133
112
|
}
|
|
134
|
-
return
|
|
113
|
+
return cdnTier({ purge: options.purge });
|
|
135
114
|
}
|
|
136
115
|
}
|
|
137
116
|
|
|
@@ -176,24 +155,36 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
|
|
|
176
155
|
registerInvalidationBroadcast(async (wireTags) => {
|
|
177
156
|
await options.transport.publish(CACHE_INVALIDATE_SUBJECT, JSON.stringify(wireTags));
|
|
178
157
|
});
|
|
179
|
-
// Not awaited HERE: the boot must not block on a subscribe
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
158
|
+
// Not awaited HERE: the boot must not block on a subscribe. The PROMISE is held rather than a
|
|
159
|
+
// handle assigned inside a `.then`, because the release ran first whenever `stop()` beat the round
|
|
160
|
+
// trip and the subscription that landed afterwards was live with nobody left holding it.
|
|
161
|
+
// RETRIED until it lands or the release runs (s1-con #8): a refused boot subscribe used to be
|
|
162
|
+
// logged once and never asked again, so the process missed every peer invalidation for its life.
|
|
163
|
+
const released = Promise.withResolvers<undefined>();
|
|
164
|
+
let stopped = false;
|
|
165
|
+
const retryMs = options.subscribeRetryMs ?? defaultSubscribeRetryMs;
|
|
166
|
+
const subscribing = (async (): Promise<TransportSubscription | undefined> => {
|
|
167
|
+
for (let attempt = 1; !stopped; attempt += 1) {
|
|
168
|
+
try {
|
|
169
|
+
return await options.transport.subscribe(CACHE_INVALIDATE_SUBJECT, (payload: string) => {
|
|
170
|
+
void applyBroadcast(payload);
|
|
171
|
+
});
|
|
172
|
+
} catch (error) {
|
|
173
|
+
logger.warn('cache.broadcast.subscribe-failed', {
|
|
174
|
+
error: broadcastErrorText(error),
|
|
175
|
+
attempt,
|
|
176
|
+
});
|
|
177
|
+
await Promise.race([Bun.sleep(retryMs(attempt)), released.promise]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
})();
|
|
193
182
|
|
|
194
183
|
// `resetTiers()` drops the registry AND the broadcast in one call: this boot is the only thing
|
|
195
184
|
// that registers either, and a tier left behind would purge for a process that has stopped.
|
|
196
185
|
return async () => {
|
|
186
|
+
stopped = true;
|
|
187
|
+
released.resolve(undefined);
|
|
197
188
|
(await subscribing)?.unsubscribe();
|
|
198
189
|
resetTiers();
|
|
199
190
|
};
|
package/src/runtime-hooks.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// decides for pages only, from the SAME `Policy` object every other surface evaluates — the route
|
|
3
3
|
// table's declared permission — so a denial in `x dev` is the one production produces.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { explainActionPathMiss } from '@ultimat3/action';
|
|
6
|
+
import { actorOf } from '@ultimat3/core';
|
|
6
7
|
import type { AuthzDecision, ServerHooks } from '@ultimat3/http';
|
|
7
8
|
import { asCtx, configuredAuthenticator } from '@ultimat3/http';
|
|
8
9
|
import type { KnownPermission, Policy } from '@ultimat3/policy';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// A container boot over an idempotency store that keeps its records per process. The boot installs
|
|
2
|
+
// the shared Postgres store (`runtime-queue.ts`); an app module that swapped in a per-process one
|
|
3
|
+
// after it left `idempotent: true` meaning "once per replica" — with nothing to say so, because
|
|
4
|
+
// `assertIdempotencyScope` only fires on a `'shared'` DECLARATION and nothing infers a replica count.
|
|
5
|
+
|
|
6
|
+
import { getIdempotencyStore, type IdempotencyStore } from '@ultimat3/action';
|
|
7
|
+
import { logger } from '@ultimat3/core';
|
|
8
|
+
|
|
9
|
+
/** `true` when it warned. Said, not refused: one replica is a real deployment and is correct. */
|
|
10
|
+
export function warnIfIdempotencyProcessScoped(
|
|
11
|
+
store: IdempotencyStore = getIdempotencyStore(),
|
|
12
|
+
): boolean {
|
|
13
|
+
if (store.scope === 'shared') return false;
|
|
14
|
+
logger.warn('X_CONFIG_INVALID', {
|
|
15
|
+
code: 'X_CONFIG_INVALID',
|
|
16
|
+
cause:
|
|
17
|
+
'the idempotency store this process ended its boot with keeps records per process, so on more than one replica a retried idempotent action that lands on another replica runs its handler again — docker/helm/values.yaml runs roles.web.replicas: 3',
|
|
18
|
+
// Declaring the scope is the enforcement: `assertIdempotencyScope` then refuses the store.
|
|
19
|
+
fix: "configureIdempotency({ scope: 'shared' })",
|
|
20
|
+
scope: store.scope ?? 'undeclared',
|
|
21
|
+
});
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// What one `isr` request is answered with: a page from the store, or the redirect ITS OWN `load`
|
|
2
|
+
// decided. A returned discriminated outcome, never a throw: the controller's regeneration treats
|
|
3
|
+
// anything thrown as a failure and logs it, and a redirect is an ordinary answer, not a failure.
|
|
4
|
+
|
|
5
|
+
import type { RedirectIntent, RequestContext, RouteParams } from '@ultimat3/http';
|
|
6
|
+
import { asCtx, takeRedirect } from '@ultimat3/http';
|
|
7
|
+
import type { RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
|
|
8
|
+
import { routeDataFor, routeStatusOf } from '@ultimat3/render';
|
|
9
|
+
import type { IsrController, IsrEntry } from '@ultimat3/render/server';
|
|
10
|
+
import { isrKey } from '@ultimat3/render/server';
|
|
11
|
+
|
|
12
|
+
export type IsrOutcome =
|
|
13
|
+
| { readonly kind: 'page'; readonly result: RenderResult }
|
|
14
|
+
| { readonly kind: 'redirect'; readonly to: RedirectIntent };
|
|
15
|
+
|
|
16
|
+
export interface IsrRequest {
|
|
17
|
+
readonly entry: RouteEntry;
|
|
18
|
+
/** What `load` and `meta` are given; `url` is a string because that is what `ld.*` embeds. */
|
|
19
|
+
readonly data: { readonly url: string; readonly params: RouteParams } & Record<string, unknown>;
|
|
20
|
+
readonly ctx: RequestContext;
|
|
21
|
+
readonly isr: IsrController;
|
|
22
|
+
/** The route's whole document for what `load` returned (`runtime-render.ts`'s `documentFrom`). */
|
|
23
|
+
readonly document: (loaded: RouteData) => Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The producer's answer when `load` redirected. A 3xx status is never a page — `withStatus` refuses
|
|
28
|
+
* one by name, because a document has no `Location` — so a 3xx entry can only be this marker, and a
|
|
29
|
+
* request that JOINED the flight reads it as "the leader's load redirected for the leader".
|
|
30
|
+
*/
|
|
31
|
+
const isRedirectMarker = (entry: IsrEntry): boolean => {
|
|
32
|
+
// An entry stored before statuses were (`status` absent) is a 200, and never this marker.
|
|
33
|
+
const { status } = entry;
|
|
34
|
+
return status !== undefined && status >= 300 && status < 400;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A redirect means the key holds no page. Deleting drops the stale copy, so the next request is a
|
|
39
|
+
* miss and decides again. `markStale` records an invalidation of the key AFTER the regeneration's
|
|
40
|
+
* fence was sampled, and the controller refuses to store a render its fence no longer covers — so
|
|
41
|
+
* the marker this producer returns is never published, and never served from the store.
|
|
42
|
+
*/
|
|
43
|
+
function dropPage(isr: IsrController, key: string): void {
|
|
44
|
+
isr.store().delete(key);
|
|
45
|
+
isr.markStale(key);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `isr`: a store hit is answered WITHOUT `load` — resolving it before the mode choice, as every
|
|
50
|
+
* other mode does, made each hit cost a full SSR's database work. So `load` and its redirect live
|
|
51
|
+
* inside the producer, which runs on a miss and on a stale page's regeneration.
|
|
52
|
+
*
|
|
53
|
+
* Concurrent misses on one key share ONE producer run, and only a PAGE may be shared. A redirect is
|
|
54
|
+
* the request's own (its `load` may have read a cookie), so a request that joined a run whose load
|
|
55
|
+
* redirected decides for itself, exactly as the static single-flight in `appRoutes` does.
|
|
56
|
+
*/
|
|
57
|
+
export async function isrOutcome(request: IsrRequest): Promise<IsrOutcome> {
|
|
58
|
+
const { entry, data, ctx, isr } = request;
|
|
59
|
+
// `isrKey(url, locale)`, never `url.pathname`: the query is part of what was rendered — `meta`
|
|
60
|
+
// reads `data.url` — so two URLs differing only in their query are two documents (#171). The
|
|
61
|
+
// locale is `ctx.locale`, the one the `locale` stage negotiated for THIS request.
|
|
62
|
+
const key = isrKey(new URL(data.url), asCtx(ctx).locale);
|
|
63
|
+
let decided: RedirectIntent | undefined;
|
|
64
|
+
// `{ html, status }`, never the bare string: the entry stores the status beside the HTML and
|
|
65
|
+
// serves it on every hit, so a 404 under `isr` is a 404 for its whole TTL.
|
|
66
|
+
const served = await isr.serve(key, async () => {
|
|
67
|
+
const loaded = await routeDataFor(entry.config, data);
|
|
68
|
+
const to = takeRedirect(ctx);
|
|
69
|
+
if (to === undefined)
|
|
70
|
+
return { html: await request.document(loaded), status: routeStatusOf(loaded) };
|
|
71
|
+
// Set by THIS request's producer only. On a stale page it runs behind the answer, and when it
|
|
72
|
+
// has already decided by the time `serve` returns, the redirect is still this request's own.
|
|
73
|
+
decided = to;
|
|
74
|
+
dropPage(isr, key);
|
|
75
|
+
return { html: '', status: to.status };
|
|
76
|
+
});
|
|
77
|
+
if (decided !== undefined) return { kind: 'redirect', to: decided };
|
|
78
|
+
if (!isRedirectMarker(served.entry)) return { kind: 'page', result: served.result };
|
|
79
|
+
return ownOutcome(request);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A joiner whose leader's load redirected: its own `load`, answered for this request alone. A page
|
|
84
|
+
* here is NOT stored — the store was just told the key has none — so it is `private, no-store`;
|
|
85
|
+
* the next request to miss stores one.
|
|
86
|
+
*/
|
|
87
|
+
async function ownOutcome(request: IsrRequest): Promise<IsrOutcome> {
|
|
88
|
+
const loaded = await routeDataFor(request.entry.config, request.data);
|
|
89
|
+
const to = takeRedirect(request.ctx);
|
|
90
|
+
if (to !== undefined) return { kind: 'redirect', to };
|
|
91
|
+
const body = await request.document(loaded);
|
|
92
|
+
return {
|
|
93
|
+
kind: 'page',
|
|
94
|
+
result: {
|
|
95
|
+
status: routeStatusOf(loaded),
|
|
96
|
+
headers: { 'cache-control': 'private, no-store' },
|
|
97
|
+
body,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The ISR controller a boot serves pages through: built, ATTACHED as the framework's revalidator,
|
|
2
|
+
// and handed back with the detach the boot puts on its stop list. One function for `x dev` and the
|
|
3
|
+
// container, so neither can build a controller `invalidateTags` never reaches (plan 101, s2-con #1).
|
|
4
|
+
|
|
5
|
+
import type { IsrController, IsrStore } from '@ultimat3/render/server';
|
|
6
|
+
import { isrController } from '@ultimat3/render/server';
|
|
7
|
+
|
|
8
|
+
export interface AttachedIsr {
|
|
9
|
+
readonly isr: IsrController;
|
|
10
|
+
/** Detaches the revalidator and drops this controller's graph edges; idempotent. */
|
|
11
|
+
readonly release: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Unattached, a `revalidate: { tags }` page (no TTL) is fresh until invalidated — and nothing
|
|
16
|
+
* invalidates it: `invalidateTags` calls the registered revalidator, which was nobody.
|
|
17
|
+
*/
|
|
18
|
+
export function attachedIsr(options: {
|
|
19
|
+
readonly buildId: string;
|
|
20
|
+
readonly store?: IsrStore;
|
|
21
|
+
}): AttachedIsr {
|
|
22
|
+
const isr = isrController({
|
|
23
|
+
buildId: options.buildId,
|
|
24
|
+
...(options.store === undefined ? {} : { store: options.store }),
|
|
25
|
+
});
|
|
26
|
+
const detach = isr.attach();
|
|
27
|
+
let released = false;
|
|
28
|
+
return {
|
|
29
|
+
isr,
|
|
30
|
+
release: () => {
|
|
31
|
+
if (released) return;
|
|
32
|
+
released = true;
|
|
33
|
+
detach();
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/runtime-jobs.ts
CHANGED
|
@@ -1,85 +1,120 @@
|
|
|
1
|
-
// `jobs.queues`, `jobs.concurrency` and
|
|
2
|
-
// `
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// The queues a `worker` serves and the slots each gets: `jobs.queues`, `jobs.concurrency` and
|
|
2
|
+
// `jobs.visibilityTimeoutMs` off the app's own `app.config.ts`, and `WORKER_QUEUES` off the
|
|
3
|
+
// Deployment's env — the one per-Deployment answer, since every Deployment runs the same image and
|
|
4
|
+
// the same config (issues #673, #676).
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import { join } from 'node:path';
|
|
6
|
+
import type { AppConfig, JobsConcurrency } from '@ultimat3/core';
|
|
7
|
+
import { ConfigInvalidError, logger, renderCauseValue, renderFixShellArg } from '@ultimat3/core';
|
|
9
8
|
import { registeredJobs, type WorkerOptions } from '@ultimat3/jobs';
|
|
10
|
-
import {
|
|
11
|
-
|
|
9
|
+
import type { Env } from './runtime-bindings';
|
|
10
|
+
|
|
11
|
+
/** The env key a worker Deployment names its queues with: `WORKER_QUEUES=banks,banks-long`. */
|
|
12
|
+
export const WORKER_QUEUES_ENV = 'WORKER_QUEUES';
|
|
12
13
|
|
|
13
14
|
/** What the worker takes from the config. An absent number keeps the worker's own default. */
|
|
14
15
|
export interface WorkerConfig {
|
|
15
16
|
readonly queues: readonly string[];
|
|
16
|
-
|
|
17
|
+
/**
|
|
18
|
+
* `true` when `WORKER_QUEUES` named `queues`: they are served EXACTLY, never widened by the
|
|
19
|
+
* queues registered jobs are enqueued on. `false` is the union `workerQueuesFor` explains.
|
|
20
|
+
*/
|
|
21
|
+
readonly exact: boolean;
|
|
22
|
+
readonly concurrency: JobsConcurrency | undefined;
|
|
17
23
|
readonly visibilityTimeoutMs: number | undefined;
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
/** A root with no `app.config.ts`: no queue of its own, and the worker's own timings. */
|
|
20
27
|
const NO_WORKER_CONFIG: WorkerConfig = Object.freeze({
|
|
21
28
|
queues: [],
|
|
29
|
+
exact: false,
|
|
22
30
|
concurrency: undefined,
|
|
23
31
|
visibilityTimeoutMs: undefined,
|
|
24
32
|
});
|
|
25
33
|
|
|
26
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
27
|
-
typeof value === 'object' && value !== null;
|
|
28
|
-
|
|
29
34
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
35
|
+
* `WORKER_QUEUES`, read: `undefined` when unset or blank (`ROLE`'s rule — an empty value is a
|
|
36
|
+
* template that said nothing), else its names trimmed and deduplicated in the order written. An
|
|
37
|
+
* empty entry (`banks,,long`) is REFUSED, not skipped: it is a queue somebody meant to name, and
|
|
38
|
+
* the worker that dropped it would leave that queue's jobs waiting with nothing in the log.
|
|
32
39
|
*/
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
export function workerQueuesFromEnv(env: Env): readonly string[] | undefined {
|
|
41
|
+
const raw = env[WORKER_QUEUES_ENV];
|
|
42
|
+
if (raw === undefined || raw.trim() === '') return undefined;
|
|
43
|
+
const names = raw.split(',').map((name) => name.trim());
|
|
44
|
+
const named = [...new Set(names.filter((name) => name !== ''))];
|
|
45
|
+
if (!names.includes('')) return named;
|
|
46
|
+
throw new ConfigInvalidError({
|
|
47
|
+
cause: `${WORKER_QUEUES_ENV} ${renderCauseValue(raw)} contains an empty queue name — every comma-separated entry must name a queue`,
|
|
48
|
+
fix: `WORKER_QUEUES=${renderFixShellArg(named.join(','), '<queue>,<queue>')} on this Deployment — or unset WORKER_QUEUES to serve jobs.queues and every registered job's queue`,
|
|
49
|
+
meta: { key: WORKER_QUEUES_ENV },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
35
52
|
|
|
36
|
-
export
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
concurrency: numberOf(jobs['concurrency']),
|
|
49
|
-
visibilityTimeoutMs: numberOf(jobs['visibilityTimeoutMs']),
|
|
50
|
-
};
|
|
53
|
+
export function workerConfigOf(config: AppConfig | undefined, env: Env = {}): WorkerConfig {
|
|
54
|
+
const fromEnv = workerQueuesFromEnv(env);
|
|
55
|
+
const base =
|
|
56
|
+
config === undefined
|
|
57
|
+
? NO_WORKER_CONFIG
|
|
58
|
+
: {
|
|
59
|
+
queues: config.jobs.queues,
|
|
60
|
+
exact: false,
|
|
61
|
+
concurrency: config.jobs.concurrency,
|
|
62
|
+
visibilityTimeoutMs: config.jobs.visibilityTimeoutMs,
|
|
63
|
+
};
|
|
64
|
+
return fromEnv === undefined ? base : { ...base, queues: fromEnv, exact: true };
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
/**
|
|
54
68
|
* The queues a worker serves: the configured ones, then every queue a registered job is enqueued
|
|
55
69
|
* on. The union and not the list, decided 2026-09-23: `x new` and the deployed demo configure
|
|
56
70
|
* `['<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. `
|
|
58
|
-
*
|
|
71
|
+
* would leave every such job queued for ever — the silent direction. `exact` (a Deployment that
|
|
72
|
+
* set `WORKER_QUEUES`) is the list alone: that operator split the queues across Deployments on
|
|
73
|
+
* purpose, and widening one to the rest is what made their grace periods mean nothing (#673).
|
|
74
|
+
* `undefined` when nothing is served, which is `jobWorker`'s own `['default']`.
|
|
59
75
|
*/
|
|
60
76
|
export function workerQueuesFor(
|
|
61
77
|
configured: readonly string[],
|
|
62
78
|
registered: readonly string[],
|
|
79
|
+
exact = false,
|
|
63
80
|
): readonly string[] | undefined {
|
|
64
|
-
const served = [...new Set([...configured, ...registered])];
|
|
81
|
+
const served = [...new Set(exact ? configured : [...configured, ...registered])];
|
|
65
82
|
return served.length === 0 ? undefined : served;
|
|
66
83
|
}
|
|
67
84
|
|
|
85
|
+
/**
|
|
86
|
+
* An exact worker's one boot line about what it leaves: the queues registered jobs are enqueued
|
|
87
|
+
* on that it will never claim. A warning and not a refusal — another Deployment serving them is
|
|
88
|
+
* the whole point of the split — but this process cannot see that one, so it says what to check.
|
|
89
|
+
*/
|
|
90
|
+
function reportUnserved(served: readonly string[], registered: readonly string[]): void {
|
|
91
|
+
const queues = [...new Set(registered)].filter((queue) => !served.includes(queue)).sort();
|
|
92
|
+
if (queues.length === 0) return;
|
|
93
|
+
logger.warn('jobs.worker.queue-unserved', {
|
|
94
|
+
queues,
|
|
95
|
+
served,
|
|
96
|
+
fix: `x jobs ls --json — every queue listed with no worker needs one: WORKER_QUEUES=<queue>,<queue> on another worker Deployment, or add it to this one's`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
68
100
|
type WorkerConfigOptions = Pick<WorkerOptions, 'queues' | 'concurrency' | 'visibilityTimeoutMs'>;
|
|
69
101
|
|
|
70
102
|
/**
|
|
71
|
-
* The `
|
|
103
|
+
* The `jobWorker` options the config supplies, read at START so every job the app registered
|
|
72
104
|
* on import is counted. A key the config leaves unset is omitted, never passed as `undefined`, so
|
|
73
105
|
* the worker's own default applies (`exactOptionalPropertyTypes`).
|
|
74
106
|
*/
|
|
75
107
|
export function workerOptionsFor(config: WorkerConfig | undefined): WorkerConfigOptions {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
);
|
|
108
|
+
const registered = registeredJobs().map((handle) => handle.queue);
|
|
109
|
+
const exact = config?.exact === true;
|
|
110
|
+
const queues = workerQueuesFor(config?.queues ?? [], registered, exact);
|
|
111
|
+
if (exact) reportUnserved(queues ?? [], registered);
|
|
112
|
+
// Passed as written: `jobWorker` reads a table by own key and runs a queue it does not name at
|
|
113
|
+
// core's `JOBS_CONCURRENCY_DEFAULT` — the one slot default, so no second table is built here.
|
|
114
|
+
const concurrency = config?.concurrency;
|
|
80
115
|
return {
|
|
81
116
|
...(queues === undefined ? {} : { queues }),
|
|
82
|
-
...(
|
|
117
|
+
...(concurrency === undefined ? {} : { concurrency }),
|
|
83
118
|
...(config?.visibilityTimeoutMs === undefined
|
|
84
119
|
? {}
|
|
85
120
|
: { visibilityTimeoutMs: config.visibilityTimeoutMs }),
|
package/src/runtime-live-feed.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Where a `sync` node's changes come from when this process boots one. Production decodes the
|
|
2
|
-
// write-ahead log (`x dev --role replicator`, `
|
|
2
|
+
// write-ahead log (`x dev --role replicator`, `postgresChangeFeed()`, a real `DATABASE_URL`).
|
|
3
3
|
// The embedded database has no walsender, and until 2026-09-05 nothing stood in for it: a live
|
|
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.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The `mail` section of `app.config.ts` the boot obeys: `mail.retainMime`, handed to
|
|
2
|
+
// `@ultimat3/mail`'s `selectMailDriver` — the one option an environment cannot carry. Selection
|
|
3
|
+
// still reads env for WHICH transport; the config only says what the chosen one keeps.
|
|
4
|
+
|
|
5
|
+
import type { AppConfig } from '@ultimat3/core';
|
|
6
|
+
import type { MailEnvironment, MailSelection, MailSelectOptions } from '@ultimat3/mail';
|
|
7
|
+
import { selectMailDriver } from '@ultimat3/mail';
|
|
8
|
+
|
|
9
|
+
/** No config file, or `retainMime: false`, is no option at all — never `retainMime: undefined`. */
|
|
10
|
+
export function mailSelectOptionsOf(config: AppConfig | undefined): MailSelectOptions {
|
|
11
|
+
const retain = config?.mail.retainMime ?? false;
|
|
12
|
+
if (retain === false) return {};
|
|
13
|
+
return { retainMime: retain.maxBytes === undefined ? {} : { maxBytes: retain.maxBytes } };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The boot's mail selection. Pure, as `selectMailDriver` is: it builds a transport and dials
|
|
18
|
+
* nothing, so a cap above mail's ceiling or retention asked of Resend refuses the boot here.
|
|
19
|
+
*/
|
|
20
|
+
export const selectAppMailDriver = (
|
|
21
|
+
env: MailEnvironment,
|
|
22
|
+
config: AppConfig | undefined,
|
|
23
|
+
): MailSelection =>
|
|
24
|
+
// `retainMimeKey`: mail judges the cap before it picks a transport — the ceiling is its number —
|
|
25
|
+
// and names the `app.config.ts` key in the refusal, so the boot restates no check of its own.
|
|
26
|
+
selectMailDriver(env, { ...mailSelectOptionsOf(config), retainMimeKey: 'mail.retainMime' });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The web role's boot asks once whether `x auth seal-mfa` still has work to do (wiki/Upgrading.md,
|
|
2
|
+
// the operator step for sealed MFA secrets). `x doctor` asks the same question only when someone
|
|
3
|
+
// runs it; an unsealed secret is a user refused at the second factor, so the boot says so too.
|
|
4
|
+
|
|
5
|
+
import { countUnsealedMfaSecrets, postgresAuthAdapter } from '@ultimat3/auth';
|
|
6
|
+
import { logger } from '@ultimat3/core';
|
|
7
|
+
import type { DbClient } from '@ultimat3/db';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Answers the count it logged, `0` when there was nothing to say or nothing could be asked. Never
|
|
11
|
+
* throws: a database that cannot answer this is the readiness check's to report, not a reason for
|
|
12
|
+
* a pod to refuse traffic its other users can be served.
|
|
13
|
+
*/
|
|
14
|
+
export async function warnUnsealedMfaSecrets(client: DbClient): Promise<number> {
|
|
15
|
+
let unsealed = 0;
|
|
16
|
+
try {
|
|
17
|
+
unsealed = await countUnsealedMfaSecrets({ adapter: postgresAuthAdapter(client) });
|
|
18
|
+
} catch {
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
if (unsealed === 0) return 0;
|
|
22
|
+
logger.warn('X_MFA_SECRET_UNSEALED', {
|
|
23
|
+
code: 'X_MFA_SECRET_UNSEALED',
|
|
24
|
+
cause: `${unsealed} 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`,
|
|
25
|
+
fix: 'x auth seal-mfa --json',
|
|
26
|
+
unsealed,
|
|
27
|
+
});
|
|
28
|
+
return unsealed;
|
|
29
|
+
}
|