@ultimat3/cli 22.14.0 → 23.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 +25 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -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 +35 -3
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-dev.ts +2 -2
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- 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/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +30 -6
- 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/document-options.ts +64 -0
- package/src/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- 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 +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/page-speculation.ts +124 -0
- package/src/permission-grants.ts +41 -9
- package/src/prerender.ts +9 -5
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +36 -60
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -147
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/static-document.ts +108 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -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/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 +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- 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 +142 -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-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- 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 +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- 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 +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- 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/output.ts
CHANGED
|
@@ -37,7 +37,12 @@ export interface StepResult {
|
|
|
37
37
|
readonly durationMs: number;
|
|
38
38
|
readonly skipped?: boolean;
|
|
39
39
|
readonly findings: readonly Finding[];
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Captured stdout/stderr, for a reader working out what a step DID — carried by both renderers
|
|
42
|
+
* on failure or with `--verbose`, and by neither on a quiet green run. Never the home of a fact:
|
|
43
|
+
* a number a passing step must report by default goes in `CommandResult.data` (the `unit` step's
|
|
44
|
+
* coverage is `data.coverage.unit`), where `--json` and the terminal both always have it.
|
|
45
|
+
*/
|
|
41
46
|
readonly output?: string;
|
|
42
47
|
/** Worker processes the step used; `1` means it ran serially. Absent for a non-test step. */
|
|
43
48
|
readonly workers?: number;
|
|
@@ -208,6 +213,10 @@ const why = (step: StepResult): string => {
|
|
|
208
213
|
return ` ${msg('cli.verify.allSkipped', { skipped: tests.skipped })}`;
|
|
209
214
|
};
|
|
210
215
|
|
|
216
|
+
/** The one rule for a step's captured output, so the two renderers cannot answer differently. */
|
|
217
|
+
const showsOutput = (step: StepResult, verbose: boolean): boolean =>
|
|
218
|
+
step.output !== undefined && step.output.length > 0 && (verbose || !step.ok);
|
|
219
|
+
|
|
211
220
|
export function renderHuman(result: CommandResult, verbose = false): string {
|
|
212
221
|
const out: string[] = [];
|
|
213
222
|
for (const step of result.steps ?? []) {
|
|
@@ -218,8 +227,8 @@ export function renderHuman(result: CommandResult, verbose = false): string {
|
|
|
218
227
|
// NOT escaped, and that is the one exception: `output` is this process's own captured
|
|
219
228
|
// subprocess stdout — `bun test`'s colour is the reason a human reads it at all, and it is
|
|
220
229
|
// already split on its real newlines rather than carrying them inside one entry.
|
|
221
|
-
if (step
|
|
222
|
-
for (const line of step.output.trimEnd().split('\n')) out.push(` | ${line}`);
|
|
230
|
+
if (showsOutput(step, verbose)) {
|
|
231
|
+
for (const line of (step.output ?? '').trimEnd().split('\n')) out.push(` | ${line}`);
|
|
223
232
|
}
|
|
224
233
|
}
|
|
225
234
|
// Every free-text line through the SAME `singleLine` the 3-line format runs, because this is
|
|
@@ -234,7 +243,7 @@ export function renderHuman(result: CommandResult, verbose = false): string {
|
|
|
234
243
|
return out.join('\n');
|
|
235
244
|
}
|
|
236
245
|
|
|
237
|
-
export function renderJson(result: CommandResult): string {
|
|
246
|
+
export function renderJson(result: CommandResult, verbose = false): string {
|
|
238
247
|
const steps = (result.steps ?? []).map((step) => ({
|
|
239
248
|
name: step.name,
|
|
240
249
|
ok: step.ok,
|
|
@@ -261,10 +270,10 @@ export function renderJson(result: CommandResult): string {
|
|
|
261
270
|
// fix line — the failing test's name and its assertion diff existed and were thrown away, so
|
|
262
271
|
// the only way to learn what broke was to re-run it somewhere else. CI output is a prompt:
|
|
263
272
|
// whoever reads it next, agent or human, must be able to act without reproducing first.
|
|
264
|
-
// Success stays quiet
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
273
|
+
// Success stays quiet so a green run is not a wall of text — unless `--verbose` asked, which
|
|
274
|
+
// is the same opt-in the terminal honours: a green step's output used to be reachable from the
|
|
275
|
+
// human render and from nowhere under `--json`.
|
|
276
|
+
...(showsOutput(step, verbose) ? { output: step.output } : {}),
|
|
268
277
|
}));
|
|
269
278
|
const payload = {
|
|
270
279
|
ok: result.ok,
|
|
@@ -278,7 +287,7 @@ export function renderJson(result: CommandResult): string {
|
|
|
278
287
|
}
|
|
279
288
|
|
|
280
289
|
export function render(result: CommandResult, json: boolean, verbose = false): string {
|
|
281
|
-
return json ? renderJson(result) : renderHuman(result, verbose);
|
|
290
|
+
return json ? renderJson(result, verbose) : renderHuman(result, verbose);
|
|
282
291
|
}
|
|
283
292
|
|
|
284
293
|
export function exitCodeFor(result: CommandResult): number {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Speculation Rules for the documents that carry NO client router, composed ONCE for `x dev`, the
|
|
2
|
+
// container and the static export: `navigation.speculation` from `app.config.ts`, the pages the
|
|
3
|
+
// route table knows to be a pure read, the one `<script type="speculationrules">` every such
|
|
4
|
+
// document carries, and the `script-src` source that admits exactly that body. A browser fetches
|
|
5
|
+
// the next page's document on pointer rest, so a full-page navigation between prerendered pages
|
|
6
|
+
// paints from memory — and the page still ships 0kb of JavaScript (axiom 6).
|
|
7
|
+
|
|
8
|
+
// why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
|
|
9
|
+
// necessity `theme-boot.ts` records.
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import type { NavigationSurface, SpeculationConfig } from '@ultimat3/core';
|
|
12
|
+
import { DEFAULT_SPECULATION, localeSegment, resolveSpeculation } from '@ultimat3/core';
|
|
13
|
+
import { cspHashSource } from '@ultimat3/http';
|
|
14
|
+
import { localeConfig, routedLocales } from '@ultimat3/i18n';
|
|
15
|
+
import type { RouteDescriptor, RouteEntry } from '@ultimat3/render';
|
|
16
|
+
import {
|
|
17
|
+
describeRoutes,
|
|
18
|
+
renderHead,
|
|
19
|
+
routeEntries,
|
|
20
|
+
speculationPattern,
|
|
21
|
+
speculationRulesBody,
|
|
22
|
+
speculationRulesTag,
|
|
23
|
+
} from '@ultimat3/render';
|
|
24
|
+
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
25
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
26
|
+
|
|
27
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
28
|
+
typeof value === 'object' && value !== null;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `navigation.speculation` — the default (on, `'moderate'`) when the app says nothing or has no
|
|
32
|
+
* config file. Structural, for `loadThemeMode`'s reason, and so possibly NOT what `defineConfig`
|
|
33
|
+
* validated: what it finds goes through core's `resolveSpeculation`, the one validator, which
|
|
34
|
+
* REFUSES (`X_CONFIG_INVALID`) a value `defineConfig` would refuse. Never coerced: an `'eager'`
|
|
35
|
+
* quietly served as `'moderate'` is a rule the app did not write.
|
|
36
|
+
*/
|
|
37
|
+
export async function loadSpeculation(root: string): Promise<SpeculationConfig> {
|
|
38
|
+
const configPath = join(root, APP_CONFIG_FILE);
|
|
39
|
+
if (!(await Bun.file(configPath).exists())) return DEFAULT_SPECULATION;
|
|
40
|
+
const module = (await import(configPath)) as Record<string, unknown>;
|
|
41
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
42
|
+
const navigation = isRecord(config) ? config['navigation'] : undefined;
|
|
43
|
+
return resolveSpeculation(isRecord(navigation) ? navigation['speculation'] : undefined);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a browser may fetch this page before anyone asked for it. An ALLOW-list, never "every
|
|
48
|
+
* link but these": a prefetch is a real GET with the visitor's cookies, so the only safe candidates
|
|
49
|
+
* are pages the table itself says are a pure read.
|
|
50
|
+
*
|
|
51
|
+
* - On a surface WITHOUT the client router: a prerendered, shareable document — `static` or `isr`,
|
|
52
|
+
* no policy, no `no-store`. Such a page ran its `load` at build time, for nobody. An `ssr` page
|
|
53
|
+
* there has no way to declare that its GET records something (`navigation` is refused on a
|
|
54
|
+
* surface that did not opt in), so it is never a candidate.
|
|
55
|
+
* - On a client-routed surface: only a page that declared `navigation: 'prefetch'` — the app's own
|
|
56
|
+
* statement that its GET is a pure read, the same one the router's prefetch obeys.
|
|
57
|
+
* - Never an `api/` route, a `navigation: 'document'` page, or an `offline: 'network-only'` one.
|
|
58
|
+
*/
|
|
59
|
+
export function speculationCandidate(
|
|
60
|
+
entry: Pick<RouteEntry, 'surface' | 'config'>,
|
|
61
|
+
described: Pick<RouteDescriptor, 'mode' | 'offline' | 'personal'>,
|
|
62
|
+
client: ReadonlySet<string>,
|
|
63
|
+
): boolean {
|
|
64
|
+
if (entry.surface !== 'site' && entry.surface !== 'app') return false;
|
|
65
|
+
if (entry.config.navigation === 'document' || described.offline === 'network-only') return false;
|
|
66
|
+
if (client.has(entry.surface)) return entry.config.navigation === 'prefetch';
|
|
67
|
+
return (described.mode === 'static' || described.mode === 'isr') && !described.personal;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PageSpeculation {
|
|
71
|
+
/** The rendered `<script type="speculationrules">`, for `DocumentOptions.speculationHead`. */
|
|
72
|
+
readonly head: string;
|
|
73
|
+
/** The text between its tags — what a browser hashes. */
|
|
74
|
+
readonly body: string;
|
|
75
|
+
/** The `script-src` source that admits exactly that body. */
|
|
76
|
+
readonly cspSource: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface PageSpeculationInput {
|
|
80
|
+
readonly config: SpeculationConfig;
|
|
81
|
+
/** `navigation.client`: the surfaces whose documents carry the router instead. */
|
|
82
|
+
readonly client: readonly NavigationSurface[];
|
|
83
|
+
readonly entries?: readonly RouteEntry[];
|
|
84
|
+
readonly described?: readonly RouteDescriptor[];
|
|
85
|
+
/** The NON-default routed locales' URL segments. Read from `@ultimat3/i18n` when absent. */
|
|
86
|
+
readonly localeSegments?: readonly string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const otherLocaleSegments = (): readonly string[] => {
|
|
90
|
+
const fallback = localeSegment(localeConfig().fallback);
|
|
91
|
+
return routedLocales()
|
|
92
|
+
.map(localeSegment)
|
|
93
|
+
.filter((segment) => segment !== fallback);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Tag and hash from ONE body, as `themeBoot` does. `undefined` when the app turned it off or no
|
|
98
|
+
* page is a candidate — then no document carries a tag and the policy admits nothing new. Called
|
|
99
|
+
* after the app is loaded: the route table and the routed locales are what it reads.
|
|
100
|
+
*/
|
|
101
|
+
export function pageSpeculation(input: PageSpeculationInput): PageSpeculation | undefined {
|
|
102
|
+
if (input.config.prefetch === false) return undefined;
|
|
103
|
+
const entries = input.entries ?? routeEntries();
|
|
104
|
+
const described = new Map((input.described ?? describeRoutes()).map((one) => [one.file, one]));
|
|
105
|
+
const client = new Set<string>(input.client);
|
|
106
|
+
const segments = input.localeSegments ?? otherLocaleSegments();
|
|
107
|
+
const include = entries
|
|
108
|
+
.filter((entry) => {
|
|
109
|
+
const facts = described.get(entry.file);
|
|
110
|
+
return facts !== undefined && speculationCandidate(entry, facts, client);
|
|
111
|
+
})
|
|
112
|
+
.map((entry) => speculationPattern(entry.path, segments));
|
|
113
|
+
const body = speculationRulesBody({
|
|
114
|
+
eagerness: input.config.prefetch,
|
|
115
|
+
include,
|
|
116
|
+
exclude: input.config.exclude,
|
|
117
|
+
});
|
|
118
|
+
if (body === undefined) return undefined;
|
|
119
|
+
return {
|
|
120
|
+
head: renderHead([speculationRulesTag(body)]),
|
|
121
|
+
body,
|
|
122
|
+
cspSource: cspHashSource(body),
|
|
123
|
+
};
|
|
124
|
+
}
|
package/src/permission-grants.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// The `policy` step's second question: every permission an action, a query
|
|
2
|
-
// one some role GRANTS. `x g resource customer` declared `customer:read`/`customer:write`, the
|
|
1
|
+
// The `policy` step's second question: every permission an action, a query, a route or a MOUNTED
|
|
2
|
+
// screen (the admin's) REQUIRES is one some role GRANTS. `x g resource customer` declared `customer:read`/`customer:write`, the
|
|
3
3
|
// actions required them, no role held them, and `x verify` was green over an app whose every
|
|
4
4
|
// generated endpoint answered 403 to the dev actor (plan 101 slice 11 a).
|
|
5
5
|
|
|
@@ -7,14 +7,14 @@ import { listActions } from '@ultimat3/action';
|
|
|
7
7
|
import type { Policy } from '@ultimat3/policy';
|
|
8
8
|
import { roleDefinitions, rolesGranting } from '@ultimat3/policy';
|
|
9
9
|
import { listQueries } from '@ultimat3/query';
|
|
10
|
-
import { routeEntries } from '@ultimat3/render';
|
|
10
|
+
import { describeRoutes, routeEntries } from '@ultimat3/render';
|
|
11
11
|
import type { Finding } from './output';
|
|
12
12
|
import { quoteArg } from './shell-quote';
|
|
13
13
|
|
|
14
14
|
/** One rule that cannot pass for any actor the role map mints. */
|
|
15
15
|
export interface UngrantedRequirement {
|
|
16
16
|
readonly permission: string;
|
|
17
|
-
/** `action createCustomer`, `query customerList`, or
|
|
17
|
+
/** `action createCustomer`, `query customerList`, the route file, or a mounted route's URL. */
|
|
18
18
|
readonly by: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -43,6 +43,28 @@ export function requirements(): readonly UngrantedRequirement[] {
|
|
|
43
43
|
const permission = entry.config.policy?.permission;
|
|
44
44
|
if (permission !== undefined) rules.push({ permission, by: entry.file });
|
|
45
45
|
}
|
|
46
|
+
// A route a package MOUNTED — the admin's — is in no file and so in no `routeEntries()` row. Its
|
|
47
|
+
// screen decides on every permission of the mount, not only the coarse gate its route config
|
|
48
|
+
// carries, so each one is a requirement: a hand-written entity nobody granted `<table>:read`
|
|
49
|
+
// for was a green gate and a 403 on its first open. `describeRoutes()` is memoized by the
|
|
50
|
+
// registry, so this loop reads a list every other step already built.
|
|
51
|
+
// One requirement per permission per mount, naming the first route that asks it: `posts:write`
|
|
52
|
+
// gates the create form and the edit form, and one missing grant is one finding.
|
|
53
|
+
const asked = new Map<string, { permission: string; by: string; first: string; more: number }>();
|
|
54
|
+
for (const route of describeRoutes()) {
|
|
55
|
+
if (route.mount === undefined) continue;
|
|
56
|
+
for (const permission of route.mount.permissions) {
|
|
57
|
+
const key = `${route.mount.by} ${permission}`;
|
|
58
|
+
const held = asked.get(key);
|
|
59
|
+
if (held === undefined) {
|
|
60
|
+
asked.set(key, { permission, by: route.mount.by, first: route.path, more: 0 });
|
|
61
|
+
} else held.more += 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const { permission, by, first, more } of asked.values()) {
|
|
65
|
+
const rest = more === 0 ? '' : `, and ${String(more)} more of its routes`;
|
|
66
|
+
rules.push({ permission, by: `${first} (mounted by ${by}${rest})` });
|
|
67
|
+
}
|
|
46
68
|
return rules;
|
|
47
69
|
}
|
|
48
70
|
|
|
@@ -73,14 +95,24 @@ export function ungrantedRequirements(
|
|
|
73
95
|
);
|
|
74
96
|
}
|
|
75
97
|
|
|
76
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Where the SCAFFOLD keeps the role map — the file `x g policy` and `x g resource` edit. Never what
|
|
100
|
+
* a finding names: an app may declare its roles anywhere (the reference app's are in
|
|
101
|
+
* `shared/policies.ts`), so `ungrantedFinding` takes the file the role map was declared in.
|
|
102
|
+
*/
|
|
77
103
|
export const ROLES_FILE = 'apps/web/shared/roles.ts';
|
|
78
104
|
|
|
79
|
-
|
|
80
|
-
|
|
105
|
+
/**
|
|
106
|
+
* `rolesAt` is the app-relative file `defineRoles` ran in (`app-permissions.ts`'s `roleMapFile`),
|
|
107
|
+
* or `undefined` when no site under the app root can be read — then the fix names the call, not a
|
|
108
|
+
* path this function would have to guess.
|
|
109
|
+
*/
|
|
110
|
+
export function ungrantedFinding(rule: UngrantedRequirement, rolesAt: string | undefined): Finding {
|
|
111
|
+
const where = rolesAt ?? 'the module that calls defineRoles({ ... })';
|
|
112
|
+
const finding: Finding = {
|
|
81
113
|
code: 'X_PERMISSION_UNGRANTED',
|
|
82
114
|
cause: `${rule.by} requires ${quoteArg(rule.permission)}, and no role in the app's role map grants it — no actor a role mints can ever pass`,
|
|
83
|
-
fix: `add '${rule.permission}' to the grants of a role in ${
|
|
84
|
-
at: ROLES_FILE,
|
|
115
|
+
fix: `add '${rule.permission}' to the grants of a role in ${where}, then x verify --only policy`,
|
|
85
116
|
};
|
|
117
|
+
return rolesAt === undefined ? finding : { ...finding, at: rolesAt };
|
|
86
118
|
}
|
package/src/prerender.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { measureDatabase } from './measure-database';
|
|
|
28
28
|
import { measurePaths } from './measure-paths';
|
|
29
29
|
import { measureScope, withAppUrl } from './measure-scope';
|
|
30
30
|
import { loadNavigation, pageNavigation } from './page-navigation';
|
|
31
|
+
import { loadSpeculation, pageSpeculation } from './page-speculation';
|
|
31
32
|
import { localizedArtifacts } from './prerender-locales';
|
|
32
33
|
import { clearPrerenderOut } from './prerender-out';
|
|
33
34
|
import { loadPwaArtifacts, writePwaIcons } from './pwa-artifacts';
|
|
@@ -237,11 +238,13 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
237
238
|
// The client router, for the surfaces that opted in: written BEFORE any document names it, for
|
|
238
239
|
// the register's reason below — `measureDocumentJs` weighs it off disk, and it is charged to
|
|
239
240
|
// the route (the interactivity the app asked for), so it must be there to be weighed.
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
241
|
+
const declared = await loadNavigation(options.root);
|
|
242
|
+
const navigation = await pageNavigation(options.root, declared, buildId);
|
|
243
|
+
// The same rules the served process writes, so the exported file and the served document agree.
|
|
244
|
+
const speculation = pageSpeculation({
|
|
245
|
+
config: await loadSpeculation(options.root),
|
|
246
|
+
client: declared.surfaces,
|
|
247
|
+
});
|
|
245
248
|
if (navigation.script !== undefined) {
|
|
246
249
|
await Bun.write(join(options.out, navigation.script.url.slice(1)), navigation.script.code);
|
|
247
250
|
}
|
|
@@ -305,6 +308,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
305
308
|
routeDocument(entry, data, {
|
|
306
309
|
resolveIsland: (file: string) => islands.resolverFor(file),
|
|
307
310
|
themeHead: theme.head,
|
|
311
|
+
...(speculation === undefined ? {} : { speculationHead: speculation.head }),
|
|
308
312
|
origin,
|
|
309
313
|
...(navigation.head === undefined ? {} : { navigation: navigation.head }),
|
|
310
314
|
...(pwa === undefined
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// `package-shape`'s other half of "a fixture does not ship": what a published package's ENTRY
|
|
2
|
+
// POINTS can load, held against the files its tarball leaves out. `files` negates
|
|
3
|
+
// `src/**/*-fixture.ts`, so a module an entry reaches under that suffix is a published package
|
|
4
|
+
// that fails on import — and the defect is the name, not the exclusion.
|
|
5
|
+
|
|
6
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
|
+
import type { ClosureHost } from './import-closure';
|
|
8
|
+
import { importClosure } from './import-closure';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
|
|
11
|
+
/** The suffix `FIXTURE_EXCLUSION` removes from a tarball. */
|
|
12
|
+
export const FIXTURE_SUFFIX = '-fixture.ts';
|
|
13
|
+
|
|
14
|
+
/** A package directory, read by package-relative POSIX path. */
|
|
15
|
+
export type PackageReader = Pick<ClosureHost, 'read'>;
|
|
16
|
+
|
|
17
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
18
|
+
typeof value === 'object' && value !== null;
|
|
19
|
+
|
|
20
|
+
/** Every string under `exports` / `bin`, whatever the nesting: a condition map is still a file. */
|
|
21
|
+
const targetsOf = (value: unknown): readonly string[] => {
|
|
22
|
+
if (typeof value === 'string') return [value];
|
|
23
|
+
return isRecord(value) ? Object.values(value).flatMap(targetsOf) : [];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The modules a consumer can import or run: `exports` and `bin`, package-relative. A subpath
|
|
28
|
+
* pattern (`./icons/*`) names no one file and a stylesheet is not a module, so neither is an
|
|
29
|
+
* entry to walk from.
|
|
30
|
+
*/
|
|
31
|
+
export function entryFilesOf(manifest: unknown): readonly string[] {
|
|
32
|
+
if (!isRecord(manifest)) return [];
|
|
33
|
+
const targets = [...targetsOf(manifest['exports']), ...targetsOf(manifest['bin'])];
|
|
34
|
+
const files = targets
|
|
35
|
+
.map((target) => target.replace(/^\.\//, ''))
|
|
36
|
+
.filter((target) => /\.tsx?$/.test(target) && !target.includes('*'));
|
|
37
|
+
return [...new Set(files)].sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every `*-fixture.ts` whose body an entry can run. Bare specifiers end the walk: another package
|
|
42
|
+
* is that package's tarball, held by its own run of this rule.
|
|
43
|
+
*/
|
|
44
|
+
export function fixturesReachedFrom(
|
|
45
|
+
reader: PackageReader,
|
|
46
|
+
entries: readonly string[],
|
|
47
|
+
): readonly string[] {
|
|
48
|
+
const host: ClosureHost = { read: reader.read, alias: () => undefined };
|
|
49
|
+
return importClosure(host, entries).filter((path) => path.endsWith(FIXTURE_SUFFIX));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `src/a/driver-fixture.ts` → `driver-fixture`; an `index.ts` is named by its directory. */
|
|
53
|
+
const stemOf = (path: string): string => {
|
|
54
|
+
const parts = path.replace(/\.tsx?$/, '').split('/');
|
|
55
|
+
const last = parts.at(-1) ?? '';
|
|
56
|
+
return last === 'index' ? (parts.at(-2) ?? last) : last;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const escaped = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The cheap question, asked before the walk: COULD an entry reach a fixture? Backwards from each
|
|
63
|
+
* fixture, by the last segment of a relative specifier, over text alone — so it over-answers (a
|
|
64
|
+
* type-only import, two files of one name) and never under-answers. `false` is final; `true`
|
|
65
|
+
* earns the real closure. Measured 2026-10: the closure alone was ~650 ms across the 12 packages
|
|
66
|
+
* that hold a fixture, and all but one of them are answered here.
|
|
67
|
+
*/
|
|
68
|
+
export function mayReachFixture(
|
|
69
|
+
sources: ReadonlyMap<string, string>,
|
|
70
|
+
entries: readonly string[],
|
|
71
|
+
): boolean {
|
|
72
|
+
const entrySet = new Set(entries);
|
|
73
|
+
const seen = new Set<string>();
|
|
74
|
+
let frontier = [...sources.keys()].filter((path) => path.endsWith(FIXTURE_SUFFIX));
|
|
75
|
+
while (frontier.length > 0) {
|
|
76
|
+
const names = new RegExp(
|
|
77
|
+
`/(?:${frontier.map(stemOf).map(escaped).join('|')})(?:\\.[jt]sx?)?['"]`,
|
|
78
|
+
);
|
|
79
|
+
const next: string[] = [];
|
|
80
|
+
for (const [path, text] of sources) {
|
|
81
|
+
if (seen.has(path) || path.endsWith(FIXTURE_SUFFIX) || !names.test(text)) continue;
|
|
82
|
+
if (entrySet.has(path)) return true;
|
|
83
|
+
seen.add(path);
|
|
84
|
+
next.push(path);
|
|
85
|
+
}
|
|
86
|
+
frontier = next;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const fixtureReachFinding = (
|
|
92
|
+
dir: string,
|
|
93
|
+
fixture: string,
|
|
94
|
+
entries: readonly string[],
|
|
95
|
+
): Finding => ({
|
|
96
|
+
code: 'X_PACKAGE_SHAPE',
|
|
97
|
+
cause: `packages/${dir}/${fixture} is loaded from the package's entry points (${entries.join(', ')}), and "files" leaves every *${FIXTURE_SUFFIX} out of the tarball — the published package would fail on import`,
|
|
98
|
+
fix: `rename packages/${dir}/${fixture} so it does not end in ${FIXTURE_SUFFIX} — it is shipped source, not test code — and update the imports that name it`,
|
|
99
|
+
docs: ERROR_DOCS_URL,
|
|
100
|
+
at: `packages/${dir}/${fixture}`,
|
|
101
|
+
});
|
package/src/role-load.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// What a container role imports of the app. `web`, `sync` and `replicator` answer for registries a
|
|
2
|
+
// request, a subscription or the WAL names — routes, queries, entities — so they import every
|
|
3
|
+
// module. `worker` and `scheduler` run jobs and tasks: they import the API index and every module
|
|
4
|
+
// that brings no document (a component, a stylesheet) with it, and nothing else.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path API; the API index is named app-root-relative and imported absolute.
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import type { Role } from '@ultimat3/core';
|
|
9
|
+
import { isJsonObject, logger, UltimateError } from '@ultimat3/core';
|
|
10
|
+
import { registeredJobs, registeredTasks } from '@ultimat3/jobs';
|
|
11
|
+
import type { AppScan } from './app-load';
|
|
12
|
+
import { scanAppModules } from './app-load';
|
|
13
|
+
import { API_INDEX } from './app-root';
|
|
14
|
+
import { createDocumentGraph } from './document-graph';
|
|
15
|
+
|
|
16
|
+
/** `everything`: the scan every tool runs. `background`: no document a job does not import. */
|
|
17
|
+
export type RoleLoad = 'everything' | 'background';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Typed over `Role`, so a seventh role does not compile until somebody decides what it imports.
|
|
21
|
+
* `migrate` never reaches a load — `runMigrations` reads SQL files — and says `everything` so a
|
|
22
|
+
* caller that boots it by hand is given the whole app rather than a guess.
|
|
23
|
+
*/
|
|
24
|
+
const DECLARED: Readonly<Record<Role, RoleLoad>> = {
|
|
25
|
+
web: 'everything',
|
|
26
|
+
// A subscription names a live query, and a page module may export one.
|
|
27
|
+
sync: 'everything',
|
|
28
|
+
// The slot decodes every table an entity declares, wherever the entity is declared.
|
|
29
|
+
replicator: 'everything',
|
|
30
|
+
worker: 'background',
|
|
31
|
+
scheduler: 'background',
|
|
32
|
+
migrate: 'everything',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** A `Map`, so the read below is an own-key lookup — never a member of `Object.prototype`. */
|
|
36
|
+
export const ROLE_LOADS: ReadonlyMap<Role, RoleLoad> = new Map(
|
|
37
|
+
Object.entries(DECLARED) as [Role, RoleLoad][],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** What `role` imports. A value that is no role — an untyped caller's — is given everything. */
|
|
41
|
+
export const roleLoadFor = (role: Role): RoleLoad => ROLE_LOADS.get(role) ?? 'everything';
|
|
42
|
+
|
|
43
|
+
/** The file the gate holds fresh (`manifest` step) and every image carries beside the source. */
|
|
44
|
+
const MANIFEST_FILE = 'x.manifest.json';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A background load that left out a job or task the committed manifest names. Logged, never
|
|
48
|
+
* thrown: the boot answers it by importing the rest of the app, so the role runs everything it
|
|
49
|
+
* ran before — it only pays for the documents this load exists to leave out.
|
|
50
|
+
*/
|
|
51
|
+
export class RoleLoadIncompleteError extends UltimateError {
|
|
52
|
+
constructor(input: { role: Role; missing: readonly string[] }) {
|
|
53
|
+
super({
|
|
54
|
+
code: 'X_ROLE_LOAD_INCOMPLETE',
|
|
55
|
+
cause: `the ${input.role} role imported ${API_INDEX} and every module that reaches no component or stylesheet, and ${MANIFEST_FILE} names ${String(input.missing.length)} job(s) or task(s) that load did not register (${input.missing.join(', ')}), so the whole app was imported instead`,
|
|
56
|
+
fix: `add import * as <module> from the file that declares it and <module> to jobs: [...] or tasks: [...] in ${API_INDEX}, then x manifest`,
|
|
57
|
+
meta: { role: input.role, missing: input.missing },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `name` of every entry of one manifest list — `[]` for a list that is absent or malformed. */
|
|
63
|
+
function namesIn(manifest: Record<string, unknown>, key: 'jobs' | 'tasks'): readonly string[] {
|
|
64
|
+
const list = manifest[key];
|
|
65
|
+
if (!Array.isArray(list)) return [];
|
|
66
|
+
return list.flatMap((entry: unknown) =>
|
|
67
|
+
isJsonObject(entry) && typeof entry['name'] === 'string' ? [entry['name']] : [],
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every job and task the committed manifest names that this process has not registered —
|
|
73
|
+
* `undefined` when there is no manifest to check against, which is not the same answer as "none".
|
|
74
|
+
*/
|
|
75
|
+
export async function unregisteredFromManifest(
|
|
76
|
+
root: string,
|
|
77
|
+
): Promise<readonly string[] | undefined> {
|
|
78
|
+
const parsed: unknown = await Bun.file(join(root, MANIFEST_FILE))
|
|
79
|
+
.json()
|
|
80
|
+
.catch(() => undefined);
|
|
81
|
+
if (!isJsonObject(parsed)) return undefined;
|
|
82
|
+
const jobs = new Set(registeredJobs().map((job) => job.name));
|
|
83
|
+
const tasks = new Set(registeredTasks().map((task) => task.name));
|
|
84
|
+
return [
|
|
85
|
+
...namesIn(parsed, 'jobs').filter((name) => !jobs.has(name)),
|
|
86
|
+
...namesIn(parsed, 'tasks').filter((name) => !tasks.has(name)),
|
|
87
|
+
].sort();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The background scan alone, with no proof and no fallback: the API index and every module that
|
|
92
|
+
* brings no document. What a worker imports when nothing is wrong — and what the gate imports in a
|
|
93
|
+
* child to compare against the whole app (`verify-role-load.ts`).
|
|
94
|
+
*/
|
|
95
|
+
export async function scanBackground(root: string): Promise<AppScan> {
|
|
96
|
+
const index = resolve(root, API_INDEX);
|
|
97
|
+
// why `require.cache`: Bun 1.4 lists every evaluated ES module there, by absolute path — the
|
|
98
|
+
// same table `app-reload-graph.ts` evicts from.
|
|
99
|
+
const loaded = (absolute: string): boolean => absolute in require.cache;
|
|
100
|
+
const graph = createDocumentGraph(loaded);
|
|
101
|
+
return scanAppModules(root, {
|
|
102
|
+
track: false,
|
|
103
|
+
// The index first (`appModulePaths` leads with it): what it imports is what the app runs.
|
|
104
|
+
// Then every module already here, and every one that brings no document with it — a service,
|
|
105
|
+
// a catalog, a storage declaration registers on import and no job names it.
|
|
106
|
+
include: async (absolute) =>
|
|
107
|
+
absolute === index || loaded(absolute) || !(await graph.reachesDocument(absolute)),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The background load, then the proof it was enough. A job runs by NAME — the queue row carries
|
|
113
|
+
* it — so the one thing this load must not do is leave a named job unregistered. The gate already
|
|
114
|
+
* requires every job and task to be named from a `defineApi()` call (`X_JOB_UNREGISTERED`); the
|
|
115
|
+
* manifest is the list that call produced, and a name it holds that this load did not register is
|
|
116
|
+
* answered by importing everything, loudly.
|
|
117
|
+
*/
|
|
118
|
+
async function loadBackground(root: string, role: Role): Promise<AppScan> {
|
|
119
|
+
const scan = await scanBackground(root);
|
|
120
|
+
const missing = await unregisteredFromManifest(root);
|
|
121
|
+
if (missing !== undefined && missing.length === 0) return scan;
|
|
122
|
+
if (missing === undefined) {
|
|
123
|
+
logger.warn('ultimate role load unverified', {
|
|
124
|
+
role,
|
|
125
|
+
cause: `${MANIFEST_FILE} is absent or unreadable, so nothing names the jobs this role must run and the whole app was imported`,
|
|
126
|
+
fix: 'x manifest',
|
|
127
|
+
});
|
|
128
|
+
} else {
|
|
129
|
+
const error = new RoleLoadIncompleteError({ role, missing });
|
|
130
|
+
logger.error(error.format(), { code: error.code, role, missing });
|
|
131
|
+
}
|
|
132
|
+
return scanAppModules(root, { track: false });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* What `serve-boot.ts` calls instead of `loadApp`: the scan a role needs and none of what a tool
|
|
137
|
+
* reads from it afterwards — no reload graph, no error-code walk of the app's source.
|
|
138
|
+
*/
|
|
139
|
+
export async function loadAppForRole(root: string, role: Role): Promise<AppScan> {
|
|
140
|
+
return roleLoadFor(role) === 'background'
|
|
141
|
+
? loadBackground(root, role)
|
|
142
|
+
: scanAppModules(root, { track: false });
|
|
143
|
+
}
|
package/src/role-start.ts
CHANGED
|
@@ -29,12 +29,17 @@ import {
|
|
|
29
29
|
import type { SyncWs } from '@ultimat3/realtime/server';
|
|
30
30
|
import { errorPageHook } from './error-pages';
|
|
31
31
|
import { BadFlagError, PortInvalidError, RuntimeDriverSplitError } from './errors';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_METRICS_PORT,
|
|
34
|
+
startMetricsEndpoint,
|
|
35
|
+
whenMetricsPortTaken,
|
|
36
|
+
} from './metrics-endpoint';
|
|
33
37
|
import { rolesUnderRealtime } from './role-realtime';
|
|
34
38
|
import { startReplicator } from './role-replicator';
|
|
35
39
|
import type { RunningRoles, StartRolesOptions } from './role-start-types';
|
|
36
40
|
import { DEV_ROLES } from './role-start-types';
|
|
37
41
|
import { prepareSync, type RunningSync } from './role-sync';
|
|
42
|
+
import { startWorkerWake } from './role-wake';
|
|
38
43
|
import type { Env } from './runtime-bindings';
|
|
39
44
|
import { devHooks } from './runtime-hooks';
|
|
40
45
|
import { workerOptionsFor } from './runtime-jobs';
|
|
@@ -304,6 +309,9 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
304
309
|
startMetricsEndpoint({
|
|
305
310
|
port: options.metricsPort ?? (options.port === 0 ? 0 : DEFAULT_METRICS_PORT),
|
|
306
311
|
hostname: binding.hostname,
|
|
312
|
+
// `x dev` is one process running several roles: its `process_info` says which.
|
|
313
|
+
role: selected.join('+'),
|
|
314
|
+
whenTaken: whenMetricsPortTaken(binding.dev, options.env),
|
|
307
315
|
});
|
|
308
316
|
started.push(async () => metrics.stop());
|
|
309
317
|
|
|
@@ -350,6 +358,13 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
350
358
|
// one hands the failure rollback the same window a dropped `await` gives the teardown below.
|
|
351
359
|
if (relay !== null) started.push(() => relay.stop());
|
|
352
360
|
|
|
361
|
+
// The cross-process wake: one LISTEN session, so a job another pod committed starts now
|
|
362
|
+
// instead of at the next poll. On `worker` alone — it is the role whose two loops (the claim
|
|
363
|
+
// and the relay above) hear it; a web or scheduler pod holding a session would pin a
|
|
364
|
+
// connection per replica to wake nothing (`role-wake.ts`).
|
|
365
|
+
const wake = selected.includes('worker') ? startWorkerWake(options.runtime.db) : null;
|
|
366
|
+
if (wake !== null) started.push(() => wake.stop());
|
|
367
|
+
|
|
353
368
|
// `state` and `leader`, not the defaults. `createMemorySchedulerState` forgets every watermark
|
|
354
369
|
// on restart, so a rolling deploy re-fires or skips whatever was due across it, and
|
|
355
370
|
// `soleLeader()` makes every replica the leader — three `scheduler` pods, three of every task.
|
|
@@ -413,6 +428,8 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
413
428
|
// and AWAITED, because a pass is a `driver.enqueue` followed by a `markPublished`. Dropped,
|
|
414
429
|
// this returns between the two and the lines below close the pool under the row it was
|
|
415
430
|
// about to mark: re-published next boot at best, a rejection against a closed pool at worst.
|
|
431
|
+
// The session first: a notification arriving after this wakes a loop that is about to stop.
|
|
432
|
+
await wake?.stop();
|
|
416
433
|
await relay?.stop();
|
|
417
434
|
await worker?.stop('x dev stopped');
|
|
418
435
|
await sync?.stop();
|
package/src/role-wake.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The worker role's cross-process wake, and the one series that says whether it is PROVEN. The
|
|
2
|
+
// mechanism is `@ultimat3/jobs`' (`startQueueWake`); this decides who holds the LISTEN session —
|
|
3
|
+
// the worker, and only over a client that can listen — and publishes `queue_wake_live`, because a
|
|
4
|
+
// transaction-pooling proxy disables the wake in silence and the poll hides that it did.
|
|
5
|
+
|
|
6
|
+
import { gauge } from '@ultimat3/core';
|
|
7
|
+
import type { DbClient } from '@ultimat3/db';
|
|
8
|
+
import { canListen } from '@ultimat3/db';
|
|
9
|
+
import type { QueueWake } from '@ultimat3/jobs';
|
|
10
|
+
import { startQueueWake } from '@ultimat3/jobs';
|
|
11
|
+
import { pgExecutorFor } from './runtime-queue';
|
|
12
|
+
|
|
13
|
+
// The wake the gauge reads. One observer for the life of the process: a gauge redeclared with a
|
|
14
|
+
// different `observe` is refused, and `x dev` starts the roles again on every restart-in-place.
|
|
15
|
+
let current: QueueWake | undefined;
|
|
16
|
+
let declared = false;
|
|
17
|
+
|
|
18
|
+
const wakeLive = (): number => (current?.live() === true ? 1 : 0);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Starts the wake for a worker, or answers `null` when the client cannot hold a session (a
|
|
22
|
+
* hand-built runtime): the claim loop and the relay then keep the poll they always had.
|
|
23
|
+
*/
|
|
24
|
+
export function startWorkerWake(db: DbClient): QueueWake | null {
|
|
25
|
+
if (!canListen(db)) return null;
|
|
26
|
+
if (!declared) {
|
|
27
|
+
declared = true;
|
|
28
|
+
gauge('queue_wake_live', {
|
|
29
|
+
unit: '1',
|
|
30
|
+
description:
|
|
31
|
+
'1 while a notification has crossed the worker LISTEN session, 0 while jobs start on the poll alone',
|
|
32
|
+
observe: wakeLive,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const wake = startQueueWake({ listener: db, executor: pgExecutorFor(db) });
|
|
36
|
+
current = wake;
|
|
37
|
+
return {
|
|
38
|
+
live: () => wake.live(),
|
|
39
|
+
async stop(): Promise<void> {
|
|
40
|
+
// Only the wake still published: a stop of one that was already replaced silences nothing.
|
|
41
|
+
if (current === wake) current = undefined;
|
|
42
|
+
await wake.stop();
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|