@ultimat3/cli 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/naming.ts +28 -0
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +144 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
- package/src/verify-errors.ts +0 -49
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The modules a set of entry files can EXECUTE, followed name by name through barrels. A package
|
|
2
|
+
// barrel re-exports its whole surface by name (the house forbids `export *`), so a closure taken
|
|
3
|
+
// module by module reaches every server file of every package an island touches — `@ultimat3/action`'s
|
|
4
|
+
// barrel reaches `http.ts` — while a bundler with honest `sideEffects` keeps only the modules the
|
|
5
|
+
// imported NAMES live in. This follows the names, which is what makes "browser-reachable" a
|
|
6
|
+
// question with a useful answer instead of "everything".
|
|
7
|
+
//
|
|
8
|
+
// Pure over an injected reader, so a test's fixture tree is a Map rather than files on disk.
|
|
9
|
+
|
|
10
|
+
import { stripComments } from '@ultimat3/core';
|
|
11
|
+
|
|
12
|
+
/** What a module asks of another: named bindings, or the whole module (`*`). */
|
|
13
|
+
export type Wanted = '*' | ReadonlySet<string>;
|
|
14
|
+
|
|
15
|
+
export interface ModuleShape {
|
|
16
|
+
/** `import … from`, `import('x')` — followed only when this module's body runs. */
|
|
17
|
+
readonly imports: readonly { readonly spec: string; readonly names: Wanted }[];
|
|
18
|
+
/** `export { local as exported } from 'x'` — followed only for the names somebody wants. */
|
|
19
|
+
readonly reexports: readonly {
|
|
20
|
+
readonly spec: string;
|
|
21
|
+
readonly map: ReadonlyMap<string, string>;
|
|
22
|
+
}[];
|
|
23
|
+
/** `export * from 'x'` — searched for a wanted name this module does not name itself. */
|
|
24
|
+
readonly stars: readonly string[];
|
|
25
|
+
/** `import 'x'` — runs whenever this module is touched at all, which is what a barrel's is for. */
|
|
26
|
+
readonly effects: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ClosureHost {
|
|
30
|
+
/** The file's text, or `undefined` when there is no such file. Paths are root-relative POSIX. */
|
|
31
|
+
read(path: string): string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* A bare specifier's file (`@ultimat3/core` → `packages/core/src/index.ts`), or `undefined` —
|
|
34
|
+
* which ENDS the walk there: an installed package is past the boundary this closure reads.
|
|
35
|
+
*/
|
|
36
|
+
alias(spec: string, from: string): string | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const IMPORT_FROM = /\bimport\s+(?!type\s)([^;'"]*?)\s*from\s*(['"])([^'"]+)\2/g;
|
|
40
|
+
const IMPORT_BARE = /\bimport\s*(['"])([^'"]+)\1/g;
|
|
41
|
+
const IMPORT_DYNAMIC = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
|
|
42
|
+
const EXPORT_FROM = /\bexport\s+(?!type\s)\{([^}]*)\}\s*from\s*(['"])([^'"]+)\2/g;
|
|
43
|
+
const EXPORT_STAR = /\bexport\s+\*\s*(?:as\s+[\w$]+\s*)?from\s*(['"])([^'"]+)\1/g;
|
|
44
|
+
|
|
45
|
+
/** `a, type b, c as d` → the value names, `type` members dropped. */
|
|
46
|
+
const members = (list: string): readonly (readonly [string, string])[] =>
|
|
47
|
+
list
|
|
48
|
+
.split(',')
|
|
49
|
+
.map((part) => part.trim())
|
|
50
|
+
.filter((part) => part.length > 0 && !part.startsWith('type '))
|
|
51
|
+
.map((part) => {
|
|
52
|
+
const [local = part, exported = local] = part.split(/\s+as\s+/);
|
|
53
|
+
return [local.trim(), exported.trim()] as const;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** The names an import clause binds: `default`, the braced members, or `*` for a namespace. */
|
|
57
|
+
function clauseNames(clause: string): Wanted {
|
|
58
|
+
if (/\*\s*as\s/.test(clause)) return '*';
|
|
59
|
+
const names = new Set<string>();
|
|
60
|
+
const braced = /\{([^}]*)\}/.exec(clause);
|
|
61
|
+
for (const [local] of members(braced?.[1] ?? '')) names.add(local);
|
|
62
|
+
const head = clause
|
|
63
|
+
.replace(/\{[^}]*\}/, '')
|
|
64
|
+
.replace(/,/g, ' ')
|
|
65
|
+
.trim();
|
|
66
|
+
if (head.length > 0) names.add('default');
|
|
67
|
+
return names;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseModule(source: string): ModuleShape {
|
|
71
|
+
const code = stripComments(source);
|
|
72
|
+
const imports: { spec: string; names: Wanted }[] = [];
|
|
73
|
+
for (const found of code.matchAll(IMPORT_FROM)) {
|
|
74
|
+
const names = clauseNames(found[1] ?? '');
|
|
75
|
+
// `import type { A }` is caught above; `import { type A }` alone binds nothing at runtime.
|
|
76
|
+
if (names !== '*' && names.size === 0) continue;
|
|
77
|
+
imports.push({ spec: found[3] ?? '', names });
|
|
78
|
+
}
|
|
79
|
+
const effects = [...code.matchAll(IMPORT_BARE)].map((found) => found[2] ?? '');
|
|
80
|
+
for (const found of code.matchAll(IMPORT_DYNAMIC)) {
|
|
81
|
+
imports.push({ spec: found[2] ?? '', names: '*' });
|
|
82
|
+
}
|
|
83
|
+
const reexports = [...code.matchAll(EXPORT_FROM)].map((found) => ({
|
|
84
|
+
spec: found[3] ?? '',
|
|
85
|
+
map: new Map(members(found[1] ?? '').map(([local, exported]) => [exported, local])),
|
|
86
|
+
}));
|
|
87
|
+
const stars = [...code.matchAll(EXPORT_STAR)].map((found) => found[2] ?? '');
|
|
88
|
+
return { imports, reexports, stars, effects };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const CANDIDATES = ['', '.ts', '.tsx', '/index.ts', '/index.tsx'];
|
|
92
|
+
|
|
93
|
+
/** A relative specifier against the importing file, extension-less as TypeScript writes it. */
|
|
94
|
+
function resolveRelative(host: ClosureHost, from: string, spec: string): string | undefined {
|
|
95
|
+
const base = from.split('/').slice(0, -1);
|
|
96
|
+
for (const part of spec.split('/')) {
|
|
97
|
+
if (part === '.' || part === '') continue;
|
|
98
|
+
if (part === '..') base.pop();
|
|
99
|
+
else base.push(part);
|
|
100
|
+
}
|
|
101
|
+
const joined = base.join('/').replace(/\.js$/, '');
|
|
102
|
+
for (const suffix of CANDIDATES) {
|
|
103
|
+
const path = `${joined}${suffix}`;
|
|
104
|
+
if (/\.tsx?$/.test(path) && host.read(path) !== undefined) return path;
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function resolveSpec(host: ClosureHost, from: string, spec: string): string | undefined {
|
|
110
|
+
return spec.startsWith('.') ? resolveRelative(host, from, spec) : host.alias(spec, from);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every module whose BODY an entry set can run. A module reached only for names it re-exports is
|
|
115
|
+
* passed through, not included: its own statements are the re-export lines and nothing else.
|
|
116
|
+
*/
|
|
117
|
+
export function importClosure(host: ClosureHost, entries: readonly string[]): readonly string[] {
|
|
118
|
+
const shapes = new Map<string, ModuleShape>();
|
|
119
|
+
const bodies = new Set<string>();
|
|
120
|
+
const asked = new Map<string, Set<string>>();
|
|
121
|
+
const queue: { path: string; names: Wanted }[] = entries.map((path) => ({ path, names: '*' }));
|
|
122
|
+
|
|
123
|
+
const shapeOf = (path: string): ModuleShape | undefined => {
|
|
124
|
+
const known = shapes.get(path);
|
|
125
|
+
if (known !== undefined) return known;
|
|
126
|
+
const source = host.read(path);
|
|
127
|
+
if (source === undefined) return undefined;
|
|
128
|
+
const shape = parseModule(source);
|
|
129
|
+
shapes.set(path, shape);
|
|
130
|
+
return shape;
|
|
131
|
+
};
|
|
132
|
+
const follow = (from: string, spec: string, names: Wanted): void => {
|
|
133
|
+
const path = resolveSpec(host, from, spec);
|
|
134
|
+
if (path !== undefined) queue.push({ path, names });
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
|
138
|
+
const { path, names } = next;
|
|
139
|
+
const shape = shapeOf(path);
|
|
140
|
+
if (shape === undefined) continue;
|
|
141
|
+
let needsBody = names === '*';
|
|
142
|
+
const seen = asked.get(path) ?? new Set<string>();
|
|
143
|
+
if (!asked.has(path)) for (const effect of shape.effects) follow(path, effect, '*');
|
|
144
|
+
asked.set(path, seen);
|
|
145
|
+
for (const name of names === '*' ? [] : names) {
|
|
146
|
+
if (seen.has(name)) continue;
|
|
147
|
+
seen.add(name);
|
|
148
|
+
const source = shape.reexports.find((entry) => entry.map.has(name));
|
|
149
|
+
if (source !== undefined) {
|
|
150
|
+
follow(path, source.spec, new Set([source.map.get(name) ?? name]));
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
needsBody = true;
|
|
154
|
+
for (const star of shape.stars) follow(path, star, new Set([name]));
|
|
155
|
+
}
|
|
156
|
+
if (names === '*') {
|
|
157
|
+
for (const entry of shape.reexports) follow(path, entry.spec, new Set(entry.map.values()));
|
|
158
|
+
for (const star of shape.stars) follow(path, star, '*');
|
|
159
|
+
}
|
|
160
|
+
if (!needsBody || bodies.has(path)) continue;
|
|
161
|
+
bodies.add(path);
|
|
162
|
+
for (const entry of shape.imports) follow(path, entry.spec, entry.names);
|
|
163
|
+
}
|
|
164
|
+
return [...bodies].sort();
|
|
165
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
import './error-codes';
|
|
9
9
|
|
|
10
10
|
/** The app's own API over HTTP — the one table `x dev` and a container both mount. */
|
|
11
|
-
export { apiRoutes } from './api-routes';
|
|
11
|
+
export { apiMountRoutes, apiRoutes } from './api-routes';
|
|
12
12
|
export type { SourceFile } from './app-boundaries';
|
|
13
13
|
export {
|
|
14
14
|
appImportGraph,
|
|
15
|
-
checkAppBoundaries,
|
|
16
15
|
readAppSources,
|
|
17
16
|
resolveSpecifier,
|
|
18
17
|
scanRuntimeImports,
|
|
@@ -22,7 +21,7 @@ export { appManifest, writeAppManifest } from './app-manifest';
|
|
|
22
21
|
export { requireAppRoot } from './app-root';
|
|
23
22
|
export type { RouteStats } from './budgets';
|
|
24
23
|
export { checkBudgets, FRAMEWORK_INLINE_SCRIPTS, FRAMEWORK_SCRIPTS } from './budgets';
|
|
25
|
-
export { BUILD_ENTRY, BUILD_TARGETS, readTarget } from './cmd-build';
|
|
24
|
+
export { BUILD_ENTRY, BUILD_TARGETS, BuildEntryMissingError, readTarget } from './cmd-build';
|
|
26
25
|
export { dbCommand } from './cmd-db';
|
|
27
26
|
export { planDeploy } from './cmd-deploy';
|
|
28
27
|
export { startDev } from './cmd-dev';
|
|
@@ -46,6 +45,11 @@ export type { BranchRow } from './db-branch';
|
|
|
46
45
|
export { BRANCH_SUBCOMMANDS, branchDatabaseName, branchNameOf } from './db-branch';
|
|
47
46
|
export type { GeneratedFiles } from './db-generate';
|
|
48
47
|
export { generateAppMigration } from './db-generate';
|
|
48
|
+
// The schema dump's two verbs for a caller that holds an app root and no command context —
|
|
49
|
+
// `scripts/schema-dumps.ts` regenerates and checks every tracked app's through them.
|
|
50
|
+
export { SCHEMA_DUMP_DIR } from './db-schema-dump';
|
|
51
|
+
export type { SchemaDumpRefresh } from './db-schema-refresh';
|
|
52
|
+
export { refreshSchemaDump } from './db-schema-refresh';
|
|
49
53
|
export { QuerySubscribesUnknownError, replicaIdentityTables } from './db-subscribes';
|
|
50
54
|
export { dispatch } from './dispatch';
|
|
51
55
|
export { checkSourceDrift, reconcileSchemaHash, writeSchemaHash } from './drift';
|
|
@@ -65,10 +69,8 @@ export {
|
|
|
65
69
|
export { checkErrorCodesThrown } from './error-unthrown';
|
|
66
70
|
export {
|
|
67
71
|
BadFlagError,
|
|
68
|
-
BuildEntryMissingError,
|
|
69
72
|
BunVersionError,
|
|
70
73
|
CatalogExistsError,
|
|
71
|
-
CliNotImplementedError,
|
|
72
74
|
DeclarationUnknownError,
|
|
73
75
|
ErrorCodeUnknownError,
|
|
74
76
|
FixTargetUnknownError,
|
|
@@ -99,6 +101,10 @@ export { checkFlagReads } from './flag-reads';
|
|
|
99
101
|
// The framework's own tables, as data. Exported so `scripts/` can read the applier's list without
|
|
100
102
|
// re-deriving it — the shape a ratchet over declared-but-never-applied DDL needs.
|
|
101
103
|
export { FRAMEWORK_SCHEMA } from './framework-schema';
|
|
104
|
+
// What a guard's `check(root, sources)` is handed: the run's one read of the app. Types an app's
|
|
105
|
+
// `guards/*.ts` names, and the constructor a test of one needs.
|
|
106
|
+
export type { GuardFile, GuardSources, GuardStylesheet } from './guard-sources';
|
|
107
|
+
export { guardSources } from './guard-sources';
|
|
102
108
|
export type { Guard } from './guards';
|
|
103
109
|
export { findingProblem, guardFindings, guardPaths } from './guards';
|
|
104
110
|
export { ICON_BASE_PATH, ICON_SOURCE } from './icon-assets';
|
|
@@ -134,6 +140,7 @@ export type { RunningServices } from './runtime-services';
|
|
|
134
140
|
export { startServices } from './runtime-services';
|
|
135
141
|
// The drift a hash cannot see, and the composition both the gate step and `x doctor` read.
|
|
136
142
|
export { checkMigrationDrift, checkSnapshotDrift } from './schema-drift';
|
|
143
|
+
export { checkSchemaDump } from './schema-dump-drift';
|
|
137
144
|
export { FrameworkSchemaFailedError } from './schema-errors';
|
|
138
145
|
export type { MigratedApp, ServeOptions } from './serve';
|
|
139
146
|
export {
|
package/src/island-bundle.ts
CHANGED
|
@@ -22,8 +22,7 @@
|
|
|
22
22
|
// POSIX by construction), `join`/`basename` the filesystem side.
|
|
23
23
|
import { basename, join, posix, relative, sep } from 'node:path';
|
|
24
24
|
import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
|
|
25
|
-
import {
|
|
26
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
25
|
+
import { loadAppConfig } from './app-config-load';
|
|
27
26
|
import { IslandBuildFailedError } from './errors';
|
|
28
27
|
import { describeBuildError, sourcesContentOf, stableChunk, stripDebugId } from './island-identity';
|
|
29
28
|
import type { BuiltOutput, LinkedFile } from './island-link';
|
|
@@ -357,20 +356,9 @@ export interface BuildIslandsOptions {
|
|
|
357
356
|
readonly sharedChunks?: boolean;
|
|
358
357
|
}
|
|
359
358
|
|
|
360
|
-
/**
|
|
361
|
-
* `islands.sharedChunks` as `defineConfig` validated it — `false` for an app with no config file or
|
|
362
|
-
* none of it. Structural, never `instanceof`, for `loadNavigation`'s reason.
|
|
363
|
-
*/
|
|
359
|
+
/** `islands.sharedChunks` off the one loader (`app-config-load.ts`) — `false` with no config file. */
|
|
364
360
|
export async function loadSharedChunks(root: string): Promise<boolean> {
|
|
365
|
-
|
|
366
|
-
if (!(await Bun.file(configPath).exists())) return false;
|
|
367
|
-
const config: unknown = ((await import(configPath)) as Record<string, unknown>)[
|
|
368
|
-
APP_CONFIG_EXPORT
|
|
369
|
-
];
|
|
370
|
-
if (typeof config !== 'object' || config === null) return false;
|
|
371
|
-
const islands: unknown = (config as Record<string, unknown>)['islands'];
|
|
372
|
-
if (typeof islands !== 'object' || islands === null) return false;
|
|
373
|
-
return (islands as Record<string, unknown>)['sharedChunks'] === true;
|
|
361
|
+
return (await loadAppConfig(root))?.islands.sharedChunks ?? false;
|
|
374
362
|
}
|
|
375
363
|
|
|
376
364
|
/** Build every island in the app. An app with none returns an empty bundle and costs one glob. */
|
package/src/island-store.ts
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
// The island chunks a container serves, built ONCE
|
|
2
|
-
//
|
|
3
|
-
// JSX transform, a minifier — for every island,
|
|
4
|
-
// URL is a hash of its sources was rebuilt from sources that could not have changed.
|
|
1
|
+
// The island chunks a container serves, built ONCE inside the image build by
|
|
2
|
+
// `x build --target prebuilt` and read back at boot. Without it every web pod's boot ran
|
|
3
|
+
// `Bun.build` — Babel, the JSX transform, a minifier — for every island, on every start, and a
|
|
4
|
+
// chunk whose URL is a hash of its sources was rebuilt from sources that could not have changed.
|
|
5
5
|
//
|
|
6
6
|
// Load-and-VERIFY, never load-and-trust: a store written by another framework or Bun version, one
|
|
7
7
|
// that names a different set of islands than the app has, or a chunk whose bytes do not hash to
|
|
8
|
-
// what the index recorded is refused, and the boot builds instead —
|
|
8
|
+
// what the index recorded is refused, and the boot builds instead — and says so.
|
|
9
9
|
|
|
10
10
|
import { join, relative, sep } from 'node:path'; // why: Bun ships no path-join primitive.
|
|
11
|
-
import { frameworkVersion
|
|
11
|
+
import { frameworkVersion } from '@ultimat3/core';
|
|
12
12
|
import { contentHash, loadStylesheet } from '@ultimat3/render/server';
|
|
13
13
|
import type { IslandBundle, IslandChunk, SharedChunk } from './island-bundle';
|
|
14
14
|
import { buildIslands, discoverIslands, islandBundle } from './island-bundle';
|
|
15
15
|
import { islandStylesheets } from './island-styles';
|
|
16
|
+
import { PREBUILT_ISLANDS_DIR } from './serve-prebuilt-paths';
|
|
16
17
|
|
|
17
|
-
/** App-root-relative
|
|
18
|
-
export const ISLAND_STORE_DIR =
|
|
18
|
+
/** App-root-relative, inside the image's prebuilt store (`serve-prebuilt-paths.ts` says why there). */
|
|
19
|
+
export const ISLAND_STORE_DIR = PREBUILT_ISLANDS_DIR;
|
|
19
20
|
const INDEX = 'index.json';
|
|
20
21
|
|
|
21
22
|
interface StoredChunk {
|
|
@@ -200,16 +201,23 @@ export async function readIslandStore(root: string): Promise<StoreRead> {
|
|
|
200
201
|
return { bundle: islandBundle(chunks, shared) };
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
/** The container's islands, and — when this boot had to build them — why and how many. */
|
|
205
|
+
export interface LoadedIslands {
|
|
206
|
+
readonly bundle: IslandBundle;
|
|
207
|
+
readonly built?: { readonly chunks: number; readonly reason: string };
|
|
208
|
+
}
|
|
209
|
+
|
|
203
210
|
/**
|
|
204
211
|
* The container's islands: the verified store, or a build when there is none to trust. A build is
|
|
205
|
-
* correct and only slower, so a stale store is a
|
|
212
|
+
* correct and only slower, so a stale store is never a refusal — the caller is handed the reason,
|
|
213
|
+
* and `serve-boot.ts` logs it once with everything else the boot built (`X_IMAGE_NOT_PREBUILT`).
|
|
206
214
|
*/
|
|
207
|
-
export async function loadOrBuildIslands(root: string): Promise<
|
|
215
|
+
export async function loadOrBuildIslands(root: string): Promise<LoadedIslands> {
|
|
208
216
|
const read = await readIslandStore(root);
|
|
209
|
-
if (read.bundle !== undefined) return read.bundle;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
217
|
+
if (read.bundle !== undefined) return { bundle: read.bundle };
|
|
218
|
+
const bundle = await buildIslands(root);
|
|
219
|
+
return {
|
|
220
|
+
bundle,
|
|
221
|
+
built: { chunks: bundle.chunks.length + bundle.shared.length, reason: read.stale },
|
|
222
|
+
};
|
|
215
223
|
}
|
package/src/jobs-drain.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// it is the only command here that WRITES to two drivers at once, and the ordering rule that makes
|
|
3
3
|
// that safe — lease, copy steps, enqueue, then ack — has to be readable in one screen.
|
|
4
4
|
|
|
5
|
-
import { uuid } from '@ultimat3/core';
|
|
6
|
-
import type { JobDriver, JobRecord, JobState } from '@ultimat3/jobs';
|
|
7
|
-
import { inspectJobList } from '@ultimat3/jobs';
|
|
5
|
+
import { logger, uuid } from '@ultimat3/core';
|
|
6
|
+
import type { ClaimedJob, JobDriver, JobRecord, JobState } from '@ultimat3/jobs';
|
|
7
|
+
import { claimOf, inspectJobList, jobCursor, MAX_JOB_PAGE } from '@ultimat3/jobs';
|
|
8
8
|
import type { Finding } from './output';
|
|
9
9
|
import { findingFrom } from './output';
|
|
10
10
|
|
|
@@ -12,10 +12,10 @@ import { findingFrom } from './output';
|
|
|
12
12
|
const PENDING_STATES: readonly JobState[] = ['ready', 'delayed', 'suspended'];
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* The lease has to outlive the
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* The lease has to outlive the transfer of the whole BATCH it covers: a batch is claimed up front,
|
|
16
|
+
* and a lease expiring mid-batch would hand a half-transferred job back to a source worker. A
|
|
17
|
+
* batch is at most `MAX_JOB_PAGE` rows, so this is a ceiling over a couple of hundred sequential
|
|
18
|
+
* enqueues rather than a guess about however many the queue holds.
|
|
19
19
|
*/
|
|
20
20
|
const DRAIN_LEASE_MS = 300_000;
|
|
21
21
|
|
|
@@ -54,19 +54,56 @@ export interface DrainOutcome {
|
|
|
54
54
|
* left alone. `queues` is always explicit because the pg driver reads an empty list as
|
|
55
55
|
* `['default']` rather than as "every queue".
|
|
56
56
|
*/
|
|
57
|
-
function
|
|
57
|
+
function leaseBatch(
|
|
58
58
|
source: JobDriver,
|
|
59
59
|
candidates: readonly JobRecord[],
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
limit: number,
|
|
61
|
+
): Promise<readonly ClaimedJob[]> {
|
|
62
|
+
if (candidates.length === 0 || limit <= 0) return Promise.resolve([]);
|
|
62
63
|
return source.claim({
|
|
63
64
|
queues: [...new Set(candidates.map((record) => record.queue))],
|
|
64
|
-
limit
|
|
65
|
+
limit,
|
|
65
66
|
visibilityTimeoutMs: DRAIN_LEASE_MS,
|
|
66
67
|
workerId: `x-jobs-drain:${uuid()}`,
|
|
68
|
+
// A claim BURIES a row whose lease lapsed on its final attempt, whoever makes it — this one
|
|
69
|
+
// too. The drain is an operator's tool with no worker context, so it runs no `onSettled`
|
|
70
|
+
// (at most once across a crash, as documented) and names no `dropExhausted`: said out loud
|
|
71
|
+
// here, because an unlistened burial is a row that went `dead` with nothing anywhere saying so.
|
|
72
|
+
onExhausted: (dead) => {
|
|
73
|
+
for (const row of dead) {
|
|
74
|
+
logger.error('jobs.claim.exhausted', {
|
|
75
|
+
workerId: 'x-jobs-drain',
|
|
76
|
+
job: row.name,
|
|
77
|
+
jobId: row.id,
|
|
78
|
+
attempt: row.attempt,
|
|
79
|
+
maxAttempts: row.maxAttempts,
|
|
80
|
+
error: row.lastError ?? '',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
},
|
|
67
84
|
});
|
|
68
85
|
}
|
|
69
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Every row in `state`, page by page on the keyset cursor until a short page: one page was the
|
|
89
|
+
* drivers' default 100, so a drain of 350 ready jobs moved 100 and left 250 on the source.
|
|
90
|
+
*/
|
|
91
|
+
async function everyRow(source: JobDriver, state: JobState): Promise<readonly JobRecord[]> {
|
|
92
|
+
const rows: JobRecord[] = [];
|
|
93
|
+
let after: string | undefined;
|
|
94
|
+
for (;;) {
|
|
95
|
+
const page = await inspectJobList(source, {
|
|
96
|
+
state,
|
|
97
|
+
limit: MAX_JOB_PAGE,
|
|
98
|
+
...(after === undefined ? {} : { after }),
|
|
99
|
+
});
|
|
100
|
+
rows.push(...page);
|
|
101
|
+
const last = page.at(-1);
|
|
102
|
+
if (page.length < MAX_JOB_PAGE || last === undefined) return rows;
|
|
103
|
+
after = jobCursor(last);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
70
107
|
/** Steps carry their own `runId`, so a copy lands under the same key the target job resumes on. */
|
|
71
108
|
async function copySteps(source: JobDriver, target: JobDriver, runId: string): Promise<void> {
|
|
72
109
|
for (const record of await source.steps.list(runId)) await target.steps.put(record);
|
|
@@ -82,9 +119,14 @@ async function copySteps(source: JobDriver, target: JobDriver, runId: string): P
|
|
|
82
119
|
* `countsAsAttempt: false` was the only bit the drivers had and `step.sleep` had claimed it;
|
|
83
120
|
* `NackOptions.park` now carries the suspension, so the two callers no longer share one meaning.
|
|
84
121
|
*/
|
|
85
|
-
async function releaseLease(source: JobDriver,
|
|
122
|
+
async function releaseLease(source: JobDriver, leased: ClaimedJob): Promise<void> {
|
|
86
123
|
try {
|
|
87
|
-
|
|
124
|
+
// Fenced on the drain's own claim, like every settle: a lease that lapsed is not handed back.
|
|
125
|
+
await source.nack(leased.id, {
|
|
126
|
+
...claimOf(leased),
|
|
127
|
+
delayMs: 0,
|
|
128
|
+
countsAsAttempt: false,
|
|
129
|
+
});
|
|
88
130
|
} catch {
|
|
89
131
|
// The lease expires on its own. Masking the transfer's real error with this one helps nobody.
|
|
90
132
|
}
|
|
@@ -109,18 +151,49 @@ export async function drainJobs(
|
|
|
109
151
|
target: JobDriver,
|
|
110
152
|
dryRun: boolean,
|
|
111
153
|
): Promise<DrainOutcome> {
|
|
112
|
-
const lists = await Promise.all(PENDING_STATES.map((state) =>
|
|
154
|
+
const lists = await Promise.all(PENDING_STATES.map((state) => everyRow(source, state)));
|
|
113
155
|
const candidates = lists.flat();
|
|
114
156
|
const base = { from: source.name, to: target.name, candidates };
|
|
115
157
|
if (dryRun) return { ...base, dryRun: true, moved: [], skipped: [], failures: [] };
|
|
116
158
|
|
|
117
|
-
const leased = await leaseCandidates(source, candidates);
|
|
118
|
-
const held = new Set(leased.map((record) => record.id));
|
|
119
159
|
const found = new Map(candidates.map((record) => [record.id, record]));
|
|
120
|
-
const
|
|
121
|
-
|
|
160
|
+
const held = new Set<string>();
|
|
122
161
|
const moved: JobRecord[] = [];
|
|
123
162
|
const failures: DrainFailure[] = [];
|
|
163
|
+
// Batch by batch, each under its own lease, until the claims stop or the snapshot's count is
|
|
164
|
+
// spent — a claim may lease a row enqueued after the snapshot, and that row spends the budget
|
|
165
|
+
// too, so a queue that keeps filling cannot keep the drain running.
|
|
166
|
+
let budget = candidates.length;
|
|
167
|
+
while (budget > 0) {
|
|
168
|
+
const leased = await leaseBatch(source, candidates, Math.min(budget, MAX_JOB_PAGE));
|
|
169
|
+
if (leased.length === 0) break;
|
|
170
|
+
// A row the target already refused is tried once per drain: handed straight back, spending
|
|
171
|
+
// no budget and adding no second failure for the same id.
|
|
172
|
+
const refused = new Set(failures.map((failure) => failure.id));
|
|
173
|
+
const fresh = leased.filter((record) => !refused.has(record.id));
|
|
174
|
+
for (const record of leased) if (refused.has(record.id)) await releaseLease(source, record);
|
|
175
|
+
if (fresh.length === 0) break;
|
|
176
|
+
budget -= fresh.length;
|
|
177
|
+
for (const record of fresh) held.add(record.id);
|
|
178
|
+
const before = moved.length;
|
|
179
|
+
await transfer(source, target, fresh, found, moved, failures);
|
|
180
|
+
// A batch that moved nothing handed its leases back, and the next claim would take the same
|
|
181
|
+
// rows to the same refusing target: stop, and let the failures already counted say why.
|
|
182
|
+
if (moved.length === before) break;
|
|
183
|
+
}
|
|
184
|
+
const skipped = candidates.filter((record) => !held.has(record.id)).map(toSkip);
|
|
185
|
+
return { ...base, dryRun: false, moved, skipped, failures };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** One leased batch onto the target: steps, then the job, then the ack — per record. */
|
|
189
|
+
async function transfer(
|
|
190
|
+
source: JobDriver,
|
|
191
|
+
target: JobDriver,
|
|
192
|
+
leased: readonly ClaimedJob[],
|
|
193
|
+
found: ReadonlyMap<string, JobRecord>,
|
|
194
|
+
moved: JobRecord[],
|
|
195
|
+
failures: DrainFailure[],
|
|
196
|
+
): Promise<void> {
|
|
124
197
|
for (const record of leased) {
|
|
125
198
|
let enqueued = false;
|
|
126
199
|
try {
|
|
@@ -141,16 +214,17 @@ export async function drainJobs(
|
|
|
141
214
|
// Only now is the ack the drain's to make: the lease proves no source worker holds this
|
|
142
215
|
// job, and the target already has both the row and its steps. A crash between the two
|
|
143
216
|
// leaves the job live on both drivers, where `idempotencyKey` dedupes it.
|
|
144
|
-
|
|
217
|
+
// `counted: false`: the row is finished HERE because it left, and a job that was moved is
|
|
218
|
+
// not a job that completed — it must add nothing to the source's `done` history.
|
|
219
|
+
await source.ack(record.id, { ...claimOf(record), counted: false });
|
|
145
220
|
// Report the row as the drain FOUND it: `claim()` returns it mid-lease (`running`, one
|
|
146
221
|
// attempt higher), a state nothing on either driver is in once this returns.
|
|
147
222
|
moved.push(found.get(record.id) ?? record);
|
|
148
223
|
} catch (error) {
|
|
149
224
|
// A failed enqueue left nothing on the target, so the lease goes back. A failed ack did
|
|
150
225
|
// not: the job is already live there, and releasing it would race the target's worker.
|
|
151
|
-
if (!enqueued) await releaseLease(source, record
|
|
226
|
+
if (!enqueued) await releaseLease(source, record);
|
|
152
227
|
failures.push({ id: record.id, name: record.name, finding: findingFrom(error) });
|
|
153
228
|
}
|
|
154
229
|
}
|
|
155
|
-
return { ...base, dryRun: false, moved, skipped, failures };
|
|
156
230
|
}
|
package/src/jobs-json.ts
CHANGED
|
@@ -7,9 +7,11 @@ import type {
|
|
|
7
7
|
DeadLetterEntry,
|
|
8
8
|
JobRecord,
|
|
9
9
|
JobTrace,
|
|
10
|
+
PausedName,
|
|
10
11
|
QueueDepthReport,
|
|
11
12
|
QueueStats,
|
|
12
13
|
StepTrace,
|
|
14
|
+
WorkerRecord,
|
|
13
15
|
} from '@ultimat3/jobs';
|
|
14
16
|
import type { DrainFailure, DrainSkip } from './jobs-drain';
|
|
15
17
|
import type { JsonValue } from './output';
|
|
@@ -59,15 +61,61 @@ export function jobTraceToJson(trace: JobTrace): JsonValue {
|
|
|
59
61
|
runId: trace.runId,
|
|
60
62
|
runAt: trace.runAt,
|
|
61
63
|
lastError: trace.lastError,
|
|
64
|
+
// The failure's stack, bounded by the queue. `null` when the thrown value carried none.
|
|
65
|
+
stack: trace.stack,
|
|
66
|
+
// The payload with every key the app declared secret already replaced (`redactInput`), so it
|
|
67
|
+
// is the queue's promise that this is safe to print — never this file's.
|
|
68
|
+
input: toJson(trace.input),
|
|
69
|
+
progress:
|
|
70
|
+
trace.progress === null
|
|
71
|
+
? null
|
|
72
|
+
: {
|
|
73
|
+
done: trace.progress.done,
|
|
74
|
+
total: trace.progress.total,
|
|
75
|
+
note: trace.progress.note ?? null,
|
|
76
|
+
at: trace.progress.at,
|
|
77
|
+
},
|
|
62
78
|
tenantId: trace.tenantId,
|
|
63
79
|
steps: trace.steps.map(stepTraceToJson),
|
|
64
80
|
retryDelaysMs: trace.retryDelaysMs.map((ms) => ms),
|
|
81
|
+
// `concurrency.key(input)` for a keyed job, `null` for every other: which lease this run waits on.
|
|
82
|
+
concurrencyKey: trace.concurrencyKey,
|
|
65
83
|
// `null` for every job that is not a `backfill()` pass. Dropped, `x jobs show <id> --json`
|
|
66
84
|
// would answer "how far has it got" with silence for the one job kind that can say.
|
|
67
85
|
backfill: trace.backfill === null ? null : backfillToJson(trace.backfill),
|
|
68
86
|
};
|
|
69
87
|
}
|
|
70
88
|
|
|
89
|
+
/**
|
|
90
|
+
* An app payload as plain JSON. A round trip through the serialiser is the proof: whatever a
|
|
91
|
+
* payload held that JSON cannot carry — a bigint, a function, a cycle — degrades to `null` rather
|
|
92
|
+
* than failing the command that was asked to show it.
|
|
93
|
+
*/
|
|
94
|
+
function toJson(value: unknown): JsonValue {
|
|
95
|
+
try {
|
|
96
|
+
const text: string | undefined = JSON.stringify(value);
|
|
97
|
+
return text === undefined ? null : (JSON.parse(text) as JsonValue);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function pausedToJson(paused: PausedName): JsonValue {
|
|
104
|
+
return { name: paused.name, pausedAt: paused.pausedAt };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function workerToJson(worker: WorkerRecord): JsonValue {
|
|
108
|
+
return {
|
|
109
|
+
id: worker.id,
|
|
110
|
+
host: worker.host,
|
|
111
|
+
startedAt: worker.startedAt,
|
|
112
|
+
heartbeatAt: worker.heartbeatAt,
|
|
113
|
+
queues: [...worker.queues],
|
|
114
|
+
concurrency: worker.concurrency,
|
|
115
|
+
inFlight: [...worker.inFlight],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
71
119
|
/**
|
|
72
120
|
* `input` is deliberately excluded: it is `unknown` at the driver boundary (an app-defined
|
|
73
121
|
* payload, not something this package can prove is JSON-safe), and `JobTrace` already sets the
|
|
@@ -100,6 +148,7 @@ function queueStatsToJson(stats: QueueStats): JsonValue {
|
|
|
100
148
|
delayed: stats.delayed,
|
|
101
149
|
running: stats.running,
|
|
102
150
|
suspended: stats.suspended,
|
|
151
|
+
failed: stats.failed,
|
|
103
152
|
dead: stats.dead,
|
|
104
153
|
oldestReadyMs: stats.oldestReadyMs,
|
|
105
154
|
};
|
|
@@ -114,6 +163,7 @@ export function depthToJson(depth: QueueDepthReport): JsonValue {
|
|
|
114
163
|
delayed: depth.totals.delayed,
|
|
115
164
|
running: depth.totals.running,
|
|
116
165
|
suspended: depth.totals.suspended,
|
|
166
|
+
failed: depth.totals.failed,
|
|
117
167
|
dead: depth.totals.dead,
|
|
118
168
|
},
|
|
119
169
|
oldestReadyMs: depth.oldestReadyMs,
|
package/src/jobs-report.ts
CHANGED
|
@@ -72,6 +72,8 @@ export interface JobsListFilter {
|
|
|
72
72
|
readonly state?: string | undefined;
|
|
73
73
|
readonly name?: string | undefined;
|
|
74
74
|
readonly limit?: string | undefined;
|
|
75
|
+
/** `jobCursor(lastRow)` of the page before — the keyset the next page seeks from. */
|
|
76
|
+
readonly after?: string | undefined;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
export interface JobsListResult {
|
|
@@ -103,6 +105,7 @@ export async function listJobs(
|
|
|
103
105
|
...(filter.name === undefined ? {} : { name: filter.name }),
|
|
104
106
|
...(state === undefined ? {} : { state }),
|
|
105
107
|
...(limit === undefined ? {} : { limit }),
|
|
108
|
+
...(filter.after === undefined ? {} : { after: filter.after }),
|
|
106
109
|
};
|
|
107
110
|
const [depth, rows, deadLetters, backfills] = await Promise.all([
|
|
108
111
|
inspectQueues(driver),
|
|
@@ -124,10 +127,10 @@ export async function showJob(driver: JobDriver, id: string): Promise<JobTrace>
|
|
|
124
127
|
// ── retry ─────────────────────────────────────────────────────────────────
|
|
125
128
|
|
|
126
129
|
/**
|
|
127
|
-
* Existence is checked up front so an unknown id always surfaces as `X_JOB_UNKNOWN`:
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
130
|
+
* Existence is checked up front so an unknown id always surfaces as `X_JOB_UNKNOWN`: both
|
|
131
|
+
* drivers answer `X_JOB_NOT_FOUND` from inside `requeue()` for a missing row, and that code is
|
|
132
|
+
* the driver's contract, not this command's — `retryFromStep`'s documented `undefined` return
|
|
133
|
+
* is, and the driver never reaches it once the row is already known absent.
|
|
131
134
|
*/
|
|
132
135
|
export async function retryJob(
|
|
133
136
|
driver: JobDriver,
|