@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.
Files changed (267) hide show
  1. package/CLAUDE.md +36 -13
  2. package/README.md +31 -8
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/affected.ts +27 -35
  7. package/src/api-registration.ts +125 -11
  8. package/src/app-artifacts.ts +1 -1
  9. package/src/app-auth.ts +4 -27
  10. package/src/app-boundaries.ts +6 -20
  11. package/src/app-config-load.ts +51 -0
  12. package/src/app-env.ts +5 -11
  13. package/src/app-load.ts +55 -11
  14. package/src/app-manifest.ts +16 -8
  15. package/src/app-mcp.ts +10 -23
  16. package/src/app-permissions-borrowed.ts +129 -0
  17. package/src/app-permissions-site.ts +24 -0
  18. package/src/app-permissions.ts +0 -0
  19. package/src/app-reload-graph.ts +7 -30
  20. package/src/app-transport.ts +219 -0
  21. package/src/boundary-findings.ts +16 -4
  22. package/src/browser-transport.ts +160 -0
  23. package/src/budgets.ts +27 -34
  24. package/src/cdp-shot-driver.ts +3 -1
  25. package/src/cmd-auth-spec.ts +16 -0
  26. package/src/cmd-auth.ts +65 -0
  27. package/src/cmd-build-spec.ts +10 -3
  28. package/src/cmd-build.ts +107 -11
  29. package/src/cmd-db-backfill.ts +25 -12
  30. package/src/cmd-db.ts +66 -16
  31. package/src/cmd-deploy-helm.ts +7 -6
  32. package/src/cmd-deploy.ts +4 -4
  33. package/src/cmd-dev.ts +9 -8
  34. package/src/cmd-doctor-spec.ts +2 -9
  35. package/src/cmd-doctor.ts +75 -24
  36. package/src/cmd-env.ts +26 -2
  37. package/src/cmd-generate.ts +195 -47
  38. package/src/cmd-help.ts +19 -6
  39. package/src/cmd-i18n.ts +10 -9
  40. package/src/cmd-jobs-spec.ts +19 -3
  41. package/src/cmd-jobs.ts +136 -7
  42. package/src/cmd-manifest-spec.ts +1 -1
  43. package/src/cmd-manifest.ts +6 -25
  44. package/src/cmd-mcp.ts +46 -15
  45. package/src/cmd-new.ts +15 -1
  46. package/src/cmd-planned.ts +15 -8
  47. package/src/cmd-routes.ts +27 -3
  48. package/src/cmd-secrets-ring.ts +76 -0
  49. package/src/cmd-secrets-spec.ts +10 -2
  50. package/src/cmd-secrets.ts +95 -18
  51. package/src/cmd-shot-matrix.ts +1 -3
  52. package/src/cmd-shot.ts +12 -3
  53. package/src/cmd-tasks.ts +84 -15
  54. package/src/cmd-test-spec.ts +1 -7
  55. package/src/cmd-test.ts +13 -29
  56. package/src/cmd-verify.ts +13 -5
  57. package/src/comment-safe.ts +16 -0
  58. package/src/coverage-floor.ts +334 -0
  59. package/src/coverage-lcov.ts +193 -0
  60. package/src/coverage-source.ts +136 -0
  61. package/src/db-object-drift.ts +30 -0
  62. package/src/db-replay-engine.ts +115 -0
  63. package/src/db-schema-dump.ts +150 -0
  64. package/src/db-schema-refresh.ts +122 -0
  65. package/src/db-subscribes.ts +12 -4
  66. package/src/dev-dashboard-guard.ts +76 -0
  67. package/src/dev-dashboard.ts +51 -29
  68. package/src/dev-lock.ts +4 -7
  69. package/src/dev-route-table.ts +9 -0
  70. package/src/dispatch.ts +3 -6
  71. package/src/doctor-app-url.ts +39 -0
  72. package/src/doctor-auth.ts +95 -0
  73. package/src/doctor-coverage.ts +39 -0
  74. package/src/doctor-guards.ts +54 -0
  75. package/src/doctor-sealed.ts +132 -0
  76. package/src/document-graph.ts +82 -0
  77. package/src/drift-replica-identity.ts +42 -0
  78. package/src/error-catalog.ts +6 -9
  79. package/src/error-codes.ts +55 -3
  80. package/src/error-unthrown.ts +29 -11
  81. package/src/errors.ts +54 -83
  82. package/src/framework-schema-apply.ts +78 -0
  83. package/src/framework-schema-stamp.ts +96 -0
  84. package/src/framework-schema.ts +28 -2
  85. package/src/generate-files.ts +79 -12
  86. package/src/generate-grant-findings.ts +76 -0
  87. package/src/generate-grants.ts +77 -3
  88. package/src/generate-kinds.ts +52 -1
  89. package/src/generate-shadow.ts +46 -0
  90. package/src/generate-write.ts +62 -40
  91. package/src/generated-imports.ts +59 -0
  92. package/src/gh-target.ts +12 -7
  93. package/src/guard-sources.ts +104 -0
  94. package/src/guards.ts +23 -7
  95. package/src/handle-registration.ts +217 -0
  96. package/src/i18n-audit.ts +4 -1
  97. package/src/i18n-registration.ts +36 -0
  98. package/src/image-prepare.ts +51 -8
  99. package/src/import-closure.ts +165 -0
  100. package/src/index.ts +12 -5
  101. package/src/island-bundle.ts +3 -15
  102. package/src/island-store.ts +24 -16
  103. package/src/jobs-drain.ts +95 -21
  104. package/src/jobs-json.ts +50 -0
  105. package/src/jobs-report.ts +7 -4
  106. package/src/json-merge.ts +46 -0
  107. package/src/loopback-host.ts +13 -0
  108. package/src/manifest-staleness.ts +62 -0
  109. package/src/mcp-errors.ts +41 -4
  110. package/src/mcp-host.ts +16 -9
  111. package/src/mcp-test-run.ts +38 -0
  112. package/src/messages.ts +35 -0
  113. package/src/metrics-endpoint.ts +95 -24
  114. package/src/migration-extensions.ts +29 -0
  115. package/src/module-imports.ts +47 -0
  116. package/src/output.ts +24 -9
  117. package/src/page-navigation.ts +7 -23
  118. package/src/page-speculation.ts +5 -20
  119. package/src/permission-grants.ts +41 -9
  120. package/src/prerender-out.ts +34 -7
  121. package/src/prerender.ts +8 -5
  122. package/src/publish-closure.ts +101 -0
  123. package/src/pwa-artifacts.ts +26 -94
  124. package/src/registry.ts +2 -0
  125. package/src/revalidated-response.ts +4 -3
  126. package/src/role-load.ts +143 -0
  127. package/src/role-replicator.ts +16 -0
  128. package/src/role-start.ts +27 -5
  129. package/src/role-sync.ts +55 -20
  130. package/src/role-wake.ts +45 -0
  131. package/src/runtime-admin.ts +141 -0
  132. package/src/runtime-assets.ts +11 -7
  133. package/src/runtime-bindings.ts +4 -4
  134. package/src/runtime-cache.ts +38 -47
  135. package/src/runtime-hooks.ts +5 -2
  136. package/src/runtime-idempotency-scope.ts +23 -0
  137. package/src/runtime-isr.ts +36 -0
  138. package/src/runtime-jobs.ts +8 -33
  139. package/src/runtime-mfa-warning.ts +29 -0
  140. package/src/runtime-notify-retention.ts +10 -46
  141. package/src/runtime-overrides.ts +1 -1
  142. package/src/runtime-purge.ts +38 -16
  143. package/src/runtime-queue.ts +39 -11
  144. package/src/runtime-realtime.ts +9 -45
  145. package/src/runtime-render.ts +68 -35
  146. package/src/runtime-services.ts +22 -18
  147. package/src/runtime-storage.ts +10 -5
  148. package/src/scaffold-typecheck.ts +1 -0
  149. package/src/schema-diff.ts +16 -0
  150. package/src/schema-drift.ts +45 -15
  151. package/src/schema-dump-drift.ts +64 -0
  152. package/src/seo-routes.ts +80 -9
  153. package/src/serve-boot.ts +114 -158
  154. package/src/serve-drain.ts +9 -33
  155. package/src/serve-env.ts +9 -6
  156. package/src/serve-prebuilt-paths.ts +19 -0
  157. package/src/serve-prebuilt.ts +84 -0
  158. package/src/serve-web.ts +169 -0
  159. package/src/serve.ts +6 -9
  160. package/src/server-barrels.ts +54 -0
  161. package/src/shot-locale.ts +5 -23
  162. package/src/site-assets.ts +3 -5
  163. package/src/site-config.ts +9 -48
  164. package/src/site-seo.ts +1 -1
  165. package/src/sitemap-lastmod.ts +5 -0
  166. package/src/solid-loader.ts +12 -3
  167. package/src/source-edit.ts +49 -0
  168. package/src/source-files.ts +6 -0
  169. package/src/stored-object-headers.ts +43 -0
  170. package/src/sync-url.ts +2 -2
  171. package/src/tasks-facts.ts +3 -26
  172. package/src/templates/action.ts +147 -11
  173. package/src/templates/admin-catalog.ts +58 -0
  174. package/src/templates/admin-page.ts +36 -8
  175. package/src/templates/admin.ts +57 -17
  176. package/src/templates/backfill.ts +121 -87
  177. package/src/templates/entity.ts +140 -44
  178. package/src/templates/guard-animated-layout-property.ts +4 -11
  179. package/src/templates/guard-bare-error.ts +9 -13
  180. package/src/templates/guard-focus-visible.ts +4 -11
  181. package/src/templates/guard-image-dimensions.ts +6 -6
  182. package/src/templates/guard-island-without-states.ts +2 -4
  183. package/src/templates/guard-raw-breakpoint.ts +210 -0
  184. package/src/templates/guard-raw-colour.ts +4 -11
  185. package/src/templates/guard-raw-length.ts +375 -0
  186. package/src/templates/guard-raw-motion.ts +213 -0
  187. package/src/templates/guard-raw-shadow.ts +187 -0
  188. package/src/templates/guard-raw-z-index.ts +166 -0
  189. package/src/templates/guard-repo-raw-sql.ts +345 -0
  190. package/src/templates/guard-semantic-interactive.ts +6 -6
  191. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  192. package/src/templates/guard-undefined-style-class.ts +215 -0
  193. package/src/templates/guard-untranslated-string.ts +6 -6
  194. package/src/templates/guard-unzoned-date.ts +9 -13
  195. package/src/templates/index.ts +3 -2
  196. package/src/templates/island.ts +34 -51
  197. package/src/templates/job-body-test.ts +121 -0
  198. package/src/templates/job.ts +23 -8
  199. package/src/templates/naming.ts +28 -0
  200. package/src/templates/policy.ts +16 -9
  201. package/src/templates/query.ts +242 -26
  202. package/src/templates/resource-create.ts +62 -18
  203. package/src/templates/resource-form-island.ts +58 -80
  204. package/src/templates/resource-page.ts +269 -0
  205. package/src/templates/resource-service.ts +96 -0
  206. package/src/templates/resource.ts +86 -61
  207. package/src/templates/route.ts +41 -35
  208. package/src/templates/scaffold-api.ts +55 -1
  209. package/src/templates/scaffold-app.ts +177 -53
  210. package/src/templates/scaffold-auth.ts +83 -19
  211. package/src/templates/scaffold-browser-client.ts +90 -0
  212. package/src/templates/scaffold-container-compose.ts +10 -1
  213. package/src/templates/scaffold-container.ts +20 -2
  214. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  215. package/src/templates/scaffold-dashboard-example.ts +124 -31
  216. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  217. package/src/templates/scaffold-dashboard.ts +1 -2
  218. package/src/templates/scaffold-db-client.ts +144 -0
  219. package/src/templates/scaffold-db-package.ts +90 -8
  220. package/src/templates/scaffold-docs.ts +50 -5
  221. package/src/templates/scaffold-guards.ts +54 -11
  222. package/src/templates/scaffold-helm-fences.ts +76 -0
  223. package/src/templates/scaffold-helm-templates.ts +32 -7
  224. package/src/templates/scaffold-helm.ts +31 -1
  225. package/src/templates/scaffold-http.ts +9 -7
  226. package/src/templates/scaffold-repo.ts +61 -3
  227. package/src/templates/scaffold-roles.ts +56 -9
  228. package/src/templates/scaffold-shell.ts +74 -105
  229. package/src/templates/scaffold-site.ts +45 -19
  230. package/src/templates/scaffold-ui-package.ts +18 -0
  231. package/src/templates/slice-foundation.ts +46 -8
  232. package/src/test-counts.ts +14 -2
  233. package/src/test-ignores.ts +25 -0
  234. package/src/test-passes.ts +0 -15
  235. package/src/test-select.ts +5 -14
  236. package/src/test-shards.ts +41 -93
  237. package/src/test-workers.ts +26 -4
  238. package/src/theme-boot.ts +6 -31
  239. package/src/transport-calls.ts +105 -0
  240. package/src/trusted-hops-error.ts +17 -0
  241. package/src/unscanned-admin.ts +54 -0
  242. package/src/unthrown-codes.ts +26 -0
  243. package/src/verify-checks.ts +43 -53
  244. package/src/verify-coverage-run.ts +190 -0
  245. package/src/verify-deadline.ts +353 -0
  246. package/src/verify-floor.ts +36 -9
  247. package/src/verify-merge.ts +171 -45
  248. package/src/verify-part-step.ts +90 -0
  249. package/src/verify-progress.ts +25 -0
  250. package/src/verify-role-load-probe.ts +54 -0
  251. package/src/verify-role-load.ts +148 -0
  252. package/src/verify-run.ts +116 -37
  253. package/src/verify-shard.ts +33 -11
  254. package/src/verify-stalled.ts +152 -0
  255. package/src/verify-step.ts +42 -1
  256. package/src/verify-tests.ts +83 -8
  257. package/src/workspace-checks.ts +50 -8
  258. package/src/workspace-dep-edit.ts +127 -0
  259. package/src/build-errors.ts +0 -54
  260. package/src/cmd-dev-fixture.ts +0 -204
  261. package/src/dev-live-fixture.ts +0 -122
  262. package/src/policy-fixture.ts +0 -78
  263. package/src/realtime-browser-probe-fixture.ts +0 -9
  264. package/src/role-start-fixture.ts +0 -78
  265. package/src/scaffold-fixture.ts +0 -160
  266. package/src/templates/island-fixture.ts +0 -76
  267. 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 {
@@ -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 { APP_CONFIG_EXPORT } from './app-auth';
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
- const configPath = join(root, APP_CONFIG_FILE);
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. */
@@ -1,21 +1,22 @@
1
- // The island chunks a container serves, built ONCE by `x build --target docker` into
2
- // `.x/islands/` and read back at boot. Without it every role's boot ran `Bun.build` — Babel, the
3
- // JSX transform, a minifier — for every island, in the image, on every start, and a chunk whose
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 — with the reason logged.
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, logger } from '@ultimat3/core';
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: `COPY . .` carries it into the image with the rest of `.x/`. */
18
- export const ISLAND_STORE_DIR = '.x/islands';
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 warning with its reason, never a refusal.
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<IslandBundle> {
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
- logger.warn('islands built at boot', {
211
- reason: read.stale,
212
- fix: 'x build --target docker',
213
- });
214
- return buildIslands(root);
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 WHOLE transfer loop, not one record: the batch is claimed up
16
- * front, and a lease expiring mid-drain would hand a half-transferred job back to a source
17
- * worker. The snapshot is bounded by the drivers' own 100-row-per-state list cap, so this is a
18
- * ceiling over a few hundred sequential enqueues rather than a guess about one.
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 leaseCandidates(
57
+ function leaseBatch(
58
58
  source: JobDriver,
59
59
  candidates: readonly JobRecord[],
60
- ): Promise<readonly JobRecord[]> {
61
- if (candidates.length === 0) return Promise.resolve([]);
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: candidates.length,
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, id: string): Promise<void> {
122
+ async function releaseLease(source: JobDriver, leased: ClaimedJob): Promise<void> {
86
123
  try {
87
- await source.nack(id, { delayMs: 0, countsAsAttempt: false });
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) => inspectJobList(source, { 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 skipped = candidates.filter((record) => !held.has(record.id)).map(toSkip);
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
- await source.ack(record.id);
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.id);
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,
@@ -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`: the
128
- * concrete drivers (pg, memory) throw their own error from inside `requeue()` for a missing
129
- * row, and that error is not this command's contract — `retryFromStep`'s documented `undefined`
130
- * return is, and the driver never reaches it once the row is already known absent.
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,