@ultimat3/cli 22.15.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.
Files changed (169) hide show
  1. package/CLAUDE.md +23 -8
  2. package/README.md +28 -5
  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/api-registration.ts +36 -11
  7. package/src/app-boundaries.ts +6 -20
  8. package/src/app-load.ts +55 -11
  9. package/src/app-manifest.ts +14 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-reload-graph.ts +7 -30
  12. package/src/app-transport.ts +219 -0
  13. package/src/boundary-findings.ts +16 -4
  14. package/src/browser-transport.ts +160 -0
  15. package/src/budgets.ts +25 -1
  16. package/src/cmd-build-spec.ts +10 -3
  17. package/src/cmd-build.ts +56 -9
  18. package/src/cmd-db.ts +60 -11
  19. package/src/cmd-doctor.ts +35 -11
  20. package/src/cmd-generate.ts +118 -17
  21. package/src/cmd-jobs-spec.ts +19 -3
  22. package/src/cmd-jobs.ts +120 -3
  23. package/src/cmd-new.ts +15 -1
  24. package/src/cmd-routes.ts +15 -1
  25. package/src/cmd-secrets-ring.ts +76 -0
  26. package/src/cmd-secrets-spec.ts +10 -2
  27. package/src/cmd-secrets.ts +48 -12
  28. package/src/cmd-tasks.ts +84 -15
  29. package/src/cmd-verify.ts +10 -2
  30. package/src/comment-safe.ts +16 -0
  31. package/src/coverage-floor.ts +334 -0
  32. package/src/coverage-lcov.ts +193 -0
  33. package/src/coverage-source.ts +136 -0
  34. package/src/db-object-drift.ts +30 -0
  35. package/src/db-replay-engine.ts +115 -0
  36. package/src/db-schema-dump.ts +150 -0
  37. package/src/db-schema-refresh.ts +122 -0
  38. package/src/dev-dashboard.ts +2 -2
  39. package/src/dev-route-table.ts +9 -0
  40. package/src/doctor-coverage.ts +39 -0
  41. package/src/doctor-guards.ts +54 -0
  42. package/src/doctor-sealed.ts +132 -0
  43. package/src/document-graph.ts +82 -0
  44. package/src/error-codes.ts +32 -0
  45. package/src/framework-schema.ts +15 -0
  46. package/src/generate-files.ts +76 -10
  47. package/src/generate-grant-findings.ts +76 -0
  48. package/src/generate-grants.ts +77 -3
  49. package/src/generate-kinds.ts +19 -0
  50. package/src/generate-write.ts +15 -27
  51. package/src/generated-imports.ts +59 -0
  52. package/src/guard-sources.ts +104 -0
  53. package/src/guards.ts +23 -7
  54. package/src/handle-registration.ts +217 -0
  55. package/src/i18n-registration.ts +36 -0
  56. package/src/image-prepare.ts +51 -8
  57. package/src/import-closure.ts +165 -0
  58. package/src/index.ts +11 -2
  59. package/src/island-store.ts +24 -16
  60. package/src/jobs-drain.ts +14 -7
  61. package/src/jobs-json.ts +50 -0
  62. package/src/jobs-report.ts +3 -0
  63. package/src/json-merge.ts +46 -0
  64. package/src/mcp-errors.ts +29 -1
  65. package/src/messages.ts +33 -0
  66. package/src/metrics-endpoint.ts +71 -24
  67. package/src/migration-extensions.ts +29 -0
  68. package/src/module-imports.ts +47 -0
  69. package/src/output.ts +18 -9
  70. package/src/permission-grants.ts +41 -9
  71. package/src/publish-closure.ts +101 -0
  72. package/src/role-load.ts +143 -0
  73. package/src/role-start.ts +18 -1
  74. package/src/role-wake.ts +45 -0
  75. package/src/runtime-admin.ts +141 -0
  76. package/src/runtime-hooks.ts +5 -2
  77. package/src/runtime-queue.ts +8 -1
  78. package/src/runtime-render.ts +6 -0
  79. package/src/runtime-services.ts +2 -1
  80. package/src/scaffold-typecheck.ts +1 -0
  81. package/src/schema-dump-drift.ts +64 -0
  82. package/src/serve-boot.ts +85 -157
  83. package/src/serve-prebuilt-paths.ts +19 -0
  84. package/src/serve-prebuilt.ts +84 -0
  85. package/src/serve-web.ts +164 -0
  86. package/src/serve.ts +6 -9
  87. package/src/server-barrels.ts +54 -0
  88. package/src/source-edit.ts +49 -0
  89. package/src/tasks-facts.ts +4 -1
  90. package/src/templates/action.ts +144 -11
  91. package/src/templates/admin-catalog.ts +58 -0
  92. package/src/templates/admin-page.ts +36 -8
  93. package/src/templates/admin.ts +57 -17
  94. package/src/templates/backfill.ts +121 -87
  95. package/src/templates/entity.ts +140 -44
  96. package/src/templates/guard-animated-layout-property.ts +4 -11
  97. package/src/templates/guard-bare-error.ts +9 -13
  98. package/src/templates/guard-focus-visible.ts +4 -11
  99. package/src/templates/guard-image-dimensions.ts +6 -6
  100. package/src/templates/guard-island-without-states.ts +2 -4
  101. package/src/templates/guard-raw-breakpoint.ts +210 -0
  102. package/src/templates/guard-raw-colour.ts +4 -11
  103. package/src/templates/guard-raw-length.ts +375 -0
  104. package/src/templates/guard-raw-motion.ts +213 -0
  105. package/src/templates/guard-raw-shadow.ts +187 -0
  106. package/src/templates/guard-raw-z-index.ts +166 -0
  107. package/src/templates/guard-repo-raw-sql.ts +345 -0
  108. package/src/templates/guard-semantic-interactive.ts +6 -6
  109. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  110. package/src/templates/guard-undefined-style-class.ts +215 -0
  111. package/src/templates/guard-untranslated-string.ts +6 -6
  112. package/src/templates/guard-unzoned-date.ts +9 -13
  113. package/src/templates/index.ts +3 -2
  114. package/src/templates/island.ts +34 -51
  115. package/src/templates/job-body-test.ts +121 -0
  116. package/src/templates/job.ts +23 -8
  117. package/src/templates/policy.ts +16 -9
  118. package/src/templates/query.ts +242 -26
  119. package/src/templates/resource-create.ts +62 -18
  120. package/src/templates/resource-form-island.ts +58 -80
  121. package/src/templates/resource-page.ts +269 -0
  122. package/src/templates/resource-service.ts +96 -0
  123. package/src/templates/resource.ts +86 -61
  124. package/src/templates/route.ts +38 -29
  125. package/src/templates/scaffold-api.ts +55 -1
  126. package/src/templates/scaffold-app.ts +171 -51
  127. package/src/templates/scaffold-auth.ts +83 -19
  128. package/src/templates/scaffold-browser-client.ts +90 -0
  129. package/src/templates/scaffold-container.ts +16 -0
  130. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  131. package/src/templates/scaffold-dashboard-example.ts +124 -31
  132. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  133. package/src/templates/scaffold-dashboard.ts +1 -2
  134. package/src/templates/scaffold-db-client.ts +142 -0
  135. package/src/templates/scaffold-db-package.ts +90 -8
  136. package/src/templates/scaffold-docs.ts +50 -5
  137. package/src/templates/scaffold-guards.ts +54 -11
  138. package/src/templates/scaffold-http.ts +9 -7
  139. package/src/templates/scaffold-repo.ts +52 -2
  140. package/src/templates/scaffold-roles.ts +56 -9
  141. package/src/templates/scaffold-shell.ts +36 -70
  142. package/src/templates/scaffold-site.ts +45 -19
  143. package/src/templates/scaffold-ui-package.ts +18 -0
  144. package/src/templates/slice-foundation.ts +46 -8
  145. package/src/test-counts.ts +18 -2
  146. package/src/transport-calls.ts +105 -0
  147. package/src/unscanned-admin.ts +54 -0
  148. package/src/verify-checks.ts +26 -7
  149. package/src/verify-coverage-run.ts +190 -0
  150. package/src/verify-deadline.ts +256 -0
  151. package/src/verify-errors.ts +15 -6
  152. package/src/verify-floor.ts +36 -9
  153. package/src/verify-merge.ts +117 -24
  154. package/src/verify-progress.ts +25 -0
  155. package/src/verify-role-load-probe.ts +54 -0
  156. package/src/verify-role-load.ts +148 -0
  157. package/src/verify-run.ts +93 -35
  158. package/src/verify-shard.ts +20 -9
  159. package/src/verify-step.ts +42 -1
  160. package/src/verify-tests.ts +78 -6
  161. package/src/workspace-checks.ts +50 -8
  162. package/src/workspace-dep-edit.ts +127 -0
  163. package/src/cmd-dev-fixture.ts +0 -204
  164. package/src/dev-live-fixture.ts +0 -122
  165. package/src/policy-fixture.ts +0 -78
  166. package/src/realtime-browser-probe-fixture.ts +0 -9
  167. package/src/role-start-fixture.ts +0 -78
  168. package/src/scaffold-fixture.ts +0 -160
  169. package/src/templates/island-fixture.ts +0 -76
@@ -0,0 +1,164 @@
1
+ // What only the web role serves: its islands, its documents and every route beside them. A file
2
+ // of its own so the roles that render nothing never import it — `serve-boot.ts` loads it with
3
+ // `await import()` for `ROLE=web` alone, and `serve-graph.test.ts` pins what every other role is
4
+ // spared: render's server half, the PWA, SEO, MCP and island subsystems.
5
+
6
+ import type { Route } from '@ultimat3/http';
7
+ import { describeRoutes } from '@ultimat3/render';
8
+ import { createIsrController } from '@ultimat3/render/server';
9
+ import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
10
+ import { loadSignInPath } from './app-auth';
11
+ import { mountAppMcp } from './app-mcp';
12
+ import { errorPageStyleSources } from './error-page-csp';
13
+ import { islandRoutes } from './island-routes';
14
+ import type { LoadedIslands } from './island-store';
15
+ import { loadOrBuildIslands } from './island-store';
16
+ import { loadNavigation, pageNavigation } from './page-navigation';
17
+ import { loadSpeculation, pageSpeculation } from './page-speculation';
18
+ import { pageSync } from './page-sync';
19
+ import { loadPwaArtifacts } from './pwa-artifacts';
20
+ import { adminMountRoutes } from './runtime-admin';
21
+ import { assetRoutes } from './runtime-assets';
22
+ import { appRoutes } from './runtime-render';
23
+ import type { RunningServices } from './runtime-services';
24
+ import { servedStorage, storageRoutes } from './runtime-storage';
25
+ import { seoRoutes } from './seo-routes';
26
+ import type { ServeOptions } from './serve-types';
27
+ import { loadSiteSettings, publicOrigin } from './site-config';
28
+ import { styleBundle } from './style-bundle';
29
+ import { styleRoutes } from './style-routes';
30
+ import { serviceWorkerArtifacts } from './sw-artifacts';
31
+ import { serviceWorkerRoutes } from './sw-routes';
32
+ import { loadThemeMode, themeBoot } from './theme-boot';
33
+
34
+ /** Everything `startRoles` is handed that only a process serving documents has. */
35
+ export interface WebSurface {
36
+ readonly routes: readonly Route[];
37
+ /** The theme boot and the speculation rules: the two inline bodies every document may carry. */
38
+ readonly inlineScripts: readonly string[];
39
+ /** The app's own error pages' `<style>` bodies, which the enforced policy must admit. */
40
+ readonly inlineStyles: readonly string[];
41
+ /** Where a browser that opened a guarded page is sent — the declaration `x dev` reads. */
42
+ readonly signInPath: string | null;
43
+ /** Present when this boot built the islands itself: how many chunks, and why not the store. */
44
+ readonly islandsBuilt?: LoadedIslands['built'];
45
+ }
46
+
47
+ export async function webSurface(
48
+ options: ServeOptions,
49
+ runtime: RunningServices,
50
+ buildId: string,
51
+ ): Promise<WebSurface> {
52
+ // Read from the store the image build wrote and VERIFIED against this app and this runtime
53
+ // (`island-store.ts`); built here only when there is no store to trust, which is correct and
54
+ // slower — the reason travels back to the boot, which reports it.
55
+ const { bundle: islands, built: islandsBuilt } = await loadOrBuildIslands(options.root);
56
+ // The same two strings `x dev` resolves, from the same reader: a `<link rel="manifest">` served
57
+ // on a laptop and absent in the image is exactly the dev/prod difference this file exists to
58
+ // prevent, and it is the one an operator cannot see without installing the app.
59
+ const pwa = await loadPwaArtifacts(options.root);
60
+ const theme = themeBoot(await loadThemeMode(options.root));
61
+ // `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
62
+ const site = await loadSiteSettings(options.root);
63
+ const origin = publicOrigin(options.env, site);
64
+ // The page's sync target and its scripts — the same call `x dev` makes, so the two cannot differ.
65
+ // Before the service worker, which precaches those scripts.
66
+ const sync = await pageSync(options.root, options.env, buildId, runtime.realtime);
67
+ // The client router, when a surface opted in (`navigation.client`) — the same call in both boots.
68
+ const declared = await loadNavigation(options.root);
69
+ const navigation = await pageNavigation(options.root, declared, buildId);
70
+ // The browser's own prefetch, for the documents that carry no router (`navigation.speculation`).
71
+ const speculation = pageSpeculation({
72
+ config: await loadSpeculation(options.root),
73
+ client: declared.surfaces,
74
+ });
75
+ // The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
76
+ // the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
77
+ // route added here cannot be missing from the precache manifest.
78
+ const serviceWorker =
79
+ pwa === undefined
80
+ ? undefined
81
+ : serviceWorkerArtifacts({
82
+ pwa,
83
+ buildId,
84
+ routes: describeRoutes(),
85
+ islands,
86
+ styles: styleBundle(),
87
+ scripts: [
88
+ ...sync.scripts,
89
+ ...(navigation.script === undefined ? [] : [navigation.script]),
90
+ ],
91
+ });
92
+ // The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
93
+ const mcpMount = await mountAppMcp(options.root);
94
+ // The app's own disks when it declared any (`defineStorage` in an app module), else this boot's.
95
+ const served = servedStorage(runtime.storage);
96
+ const routes: readonly Route[] = [
97
+ ...apiRoutes(),
98
+ // The bearer doors onto a cut of the same routes (`defineApi({ http: { mounts } })`), counted
99
+ // on the store this boot's limiter uses, so one token's allowance is one number fleet-wide.
100
+ ...apiMountRoutes(options.runtime?.rateLimitStore ?? runtime.rateLimitStore),
101
+ ...mcpMount.routes,
102
+ // The app's own plain routes (`apps/<app>/runtime.ts` `routes`), before any page can shadow one.
103
+ ...(options.runtime?.routes ?? []),
104
+ ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
105
+ ...assetRoutes({
106
+ root: options.root,
107
+ storage: served,
108
+ ...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
109
+ ...(pwa === undefined ? {} : { pwa }),
110
+ }),
111
+ ...storageRoutes({ storage: served }),
112
+ ...islandRoutes(() => islands),
113
+ // The surface stylesheets the documents link. Built from the registry the `loadApp` above
114
+ // filled, so this process serves exactly the CSS it renders against.
115
+ ...styleRoutes(() => styleBundle()),
116
+ // `robots.txt` and `sitemap.xml`, the same two files the static export writes (`site-seo.ts`).
117
+ ...seoRoutes({ env: options.env, site, root: options.root }),
118
+ // The page's one socket: its worker script, served beside the islands for their reason.
119
+ ...sync.routes,
120
+ ...navigation.routes,
121
+ // A page's `POST`, bound to an action (`defineRoute({ post })`) — beside the page's `GET`.
122
+ ...pagePostRoutes(),
123
+ // The app's generated admin, when it declared one (`defineAdmin`): every screen under its base
124
+ // path, served through the same document builder as the pages below. Nothing for an app with
125
+ // no admin — the registry is asked, the package is not imported.
126
+ ...adminMountRoutes({
127
+ buildId: buildId,
128
+ themeHead: theme.head,
129
+ ...(origin === undefined ? {} : { origin }),
130
+ }),
131
+ ...appRoutes({
132
+ buildId,
133
+ resolveIsland: (file) => islands.resolverFor(file),
134
+ ...(sync.head === undefined ? {} : { sync: sync.head }),
135
+ persisted: sync.persisted,
136
+ ...(navigation.head === undefined ? {} : { navigation: navigation.head }),
137
+ themeHead: theme.head,
138
+ ...(speculation === undefined ? {} : { speculationHead: speculation.head }),
139
+ // A `static` page is the same bytes for every request of this process: rendered once.
140
+ memoStatic: true,
141
+ ...(origin === undefined ? {} : { origin }),
142
+ ...(pwa === undefined
143
+ ? {}
144
+ : { pwaHead: (locale: string) => pwa.headFor(locale) + (serviceWorker?.head ?? '') }),
145
+ // Only when a store was supplied. `createIsrController` defaults to a per-process memory
146
+ // store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
147
+ // the fleet while the other eleven keep serving the page it just invalidated.
148
+ ...(options.runtime?.isrStore === undefined
149
+ ? {}
150
+ : { isr: createIsrController({ buildId, store: options.runtime.isrStore }) }),
151
+ }),
152
+ ];
153
+ return {
154
+ routes,
155
+ inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
156
+ // The enforced policy this process sends must admit the app's own error pages' `<style>` and
157
+ // the theme boot the documents carry; `x dev` is report-only, so only here was it a blank page.
158
+ inlineStyles: await errorPageStyleSources(options.root),
159
+ // Same declaration `x dev` reads. Without it a container answers a browser that opened a
160
+ // guarded page with the problem document, rendered as raw JSON in the viewport.
161
+ signInPath: await loadSignInPath(options.root),
162
+ ...(islandsBuilt === undefined ? {} : { islandsBuilt }),
163
+ };
164
+ }
package/src/serve.ts CHANGED
@@ -13,8 +13,6 @@ import { startMetricsEndpoint } from './metrics-endpoint';
13
13
  import { readMigrations } from './migrations';
14
14
  import { resolveServices } from './runtime-bindings';
15
15
  import { startQueue } from './runtime-queue';
16
- import { startServices } from './runtime-services';
17
- import { bootRoles } from './serve-boot';
18
16
  import { containerBinding, metricsPortFor, portFromEnv, roleFromEnv } from './serve-env';
19
17
  import type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve-types';
20
18
 
@@ -144,17 +142,16 @@ export async function serveApp(input: ServeOptions): Promise<ServedApp> {
144
142
  const metrics = startMetricsEndpoint({
145
143
  port: metricsPortFor(options.env, port, options.metricsPort),
146
144
  hostname: containerBinding(options.env, options.hostname).hostname,
145
+ role,
147
146
  });
148
147
  // Everything acquired from here down, in order, so a throw anywhere below gives it all back.
149
148
  const acquired: (() => void | Promise<void>)[] = [() => metrics.stop()];
150
149
  try {
151
- const runtime = await startServices(
152
- resolveServices(options.root, options.env),
153
- options.env,
154
- options.runtime,
155
- );
156
- acquired.push(() => runtime.stop());
157
- return await bootRoles({ options, role, runtime, acquired, metrics });
150
+ // Behind `await import()`: `ROLE=migrate` runs in this module too, applies SQL files and
151
+ // exits, and the services and the role boot are ~240 modules it never calls
152
+ // (`serve-graph.test.ts` pins the count each role is left with).
153
+ const { bootServing } = await import('./serve-boot');
154
+ return await bootServing({ options, role, acquired, metrics });
158
155
  } catch (error) {
159
156
  await releaseBoot(acquired);
160
157
  throw error;
@@ -0,0 +1,54 @@
1
+ // A package barrel a browser must not import, and the entry it imports instead. `@ultimat3/entity`'s
2
+ // barrel reaches the SQL renderer and `@ultimat3/db` — about a megabyte in an island — while
3
+ // `@ultimat3/entity/record` is the browser half. DERIVED from each manifest's `exports`: a package
4
+ // that publishes a `./record`, `./browser` or `./client` subpath has said its barrel is the server
5
+ // half, so a new browser entry enters this rule by being published, never by being listed here.
6
+
7
+ import { stripComments } from '@ultimat3/core';
8
+ import { lineIndex } from './ts-scan';
9
+
10
+ /** The subpath names that declare "this is the browser entry of the package". */
11
+ export const BROWSER_ENTRY_SUBPATHS: ReadonlySet<string> = new Set(['record', 'browser', 'client']);
12
+
13
+ /** Barrel specifier → the browser entry that replaces it, read off the published specifiers. */
14
+ export function serverBarrels(specifiers: Iterable<string>): ReadonlyMap<string, string> {
15
+ const barrels = new Map<string, string>();
16
+ for (const spec of specifiers) {
17
+ const match = /^(@[\w-]+\/[\w-]+)\/([\w-]+)$/.exec(spec);
18
+ if (match?.[1] === undefined || !BROWSER_ENTRY_SUBPATHS.has(match[2] ?? '')) continue;
19
+ barrels.set(match[1], spec);
20
+ }
21
+ return barrels;
22
+ }
23
+
24
+ export interface BarrelImport {
25
+ readonly line: number;
26
+ readonly barrel: string;
27
+ readonly entry: string;
28
+ }
29
+
30
+ /** `import` / `export … from` / `import()` of a VALUE from the barrel. `import type` runs nothing. */
31
+ const IMPORTS =
32
+ /\b(?:import|export)\s+(?!type\s)[^;'"]*?\bfrom\s*(['"])([^'"]+)\1|\bimport\s*\(\s*(['"])([^'"]+)\3\s*\)/g;
33
+
34
+ export function barrelImports(
35
+ source: string,
36
+ barrels: ReadonlyMap<string, string>,
37
+ ): readonly BarrelImport[] {
38
+ // Most files import no barrel at all; say so before paying for a comment-stripped copy.
39
+ if (![...barrels.keys()].some((barrel) => source.includes(barrel))) return [];
40
+ const code = stripComments(source);
41
+ const lineAt = lineIndex(code);
42
+ const found: BarrelImport[] = [];
43
+ for (const match of code.matchAll(IMPORTS)) {
44
+ const spec = match[2] ?? match[4] ?? '';
45
+ const entry = barrels.get(spec);
46
+ if (entry === undefined) continue;
47
+ // `import { type A, type B } from …` binds nothing at runtime either.
48
+ const clause = /\{([^}]*)\}/.exec(match[0])?.[1];
49
+ const typesOnly = clause?.split(',').every((part) => /^\s*(?:type\s|$)/.test(part));
50
+ if (typesOnly === true) continue;
51
+ found.push({ line: lineAt(match.index), barrel: spec, entry });
52
+ }
53
+ return found;
54
+ }
@@ -0,0 +1,49 @@
1
+ // The two text edits a generator makes to a file the app owns: one keyed line added to an object
2
+ // literal, and one import added where the formatter's sorted block would have it. Shared by the
3
+ // registrars (`handle-registration.ts`, `admin-registration.ts`) so "in key order, expanded" means
4
+ // one thing in both.
5
+
6
+ const IDENTIFIER = '[A-Za-z_$][\\w$]*';
7
+
8
+ const keyOf = (item: string): string => item.split(':')[0]?.trim() ?? '';
9
+
10
+ /** Whether the object body already holds `key` — as `key: value` or as a shorthand. */
11
+ export const holdsKey = (body: string, key: string): boolean =>
12
+ new RegExp(`(^|[\\s,{])${key}\\s*[:,]`).test(`${body},`);
13
+
14
+ /**
15
+ * An object literal's body with `line` on its own row, in key order. A body the author keeps on
16
+ * one line is expanded — an expanded literal is the shape a formatter leaves alone at any length.
17
+ * `indent` is what an entry of this literal is indented by.
18
+ */
19
+ export function withKeyedLine(body: string, key: string, line: string, indent: string): string {
20
+ if (!body.includes('\n')) {
21
+ const items = body
22
+ .split(',')
23
+ .map((item) => item.trim())
24
+ .filter((item) => item !== '');
25
+ const lines = [...items.map((item) => `${indent}${item},`), line].sort((a, b) =>
26
+ keyOf(a) < keyOf(b) ? -1 : 1,
27
+ );
28
+ return `\n${lines.join('\n')}\n${indent.slice(2)}`;
29
+ }
30
+ const lines = body.split('\n');
31
+ const isEntry = (text: string): boolean => new RegExp(`^\\s*${IDENTIFIER}\\s*[:,]`).test(text);
32
+ const after = lines.findIndex((text) => isEntry(text) && keyOf(text) > key);
33
+ // Past the last entry when none sorts later: the row before the closing brace's own indent.
34
+ lines.splice(after === -1 ? lines.length - 1 : after, 0, line);
35
+ return lines.join('\n');
36
+ }
37
+
38
+ /** `source` with `line` imported, placed where a sorted import block would have it. */
39
+ export function withSortedImport(source: string, line: string, specifier: string): string {
40
+ if (source.includes(`from '${specifier}'`)) return source;
41
+ const imports = [...source.matchAll(/^import\b[^;]*?from\s+'([^']+)';\n/gm)];
42
+ // Packages only: a relative import is a LATER group, so it is never what this line sorts against.
43
+ const packages = imports.filter((match) => !(match[1] ?? '').startsWith('.'));
44
+ const later = packages.find((match) => (match[1] ?? '') > specifier);
45
+ const last = packages.at(-1);
46
+ const first = imports[0]?.index ?? 0;
47
+ const at = later?.index ?? (last === undefined ? first : last.index + last[0].length);
48
+ return `${source.slice(0, at)}${line}\n${source.slice(at)}`;
49
+ }
@@ -26,8 +26,11 @@ const pad2 = (value: number): string => String(value).padStart(2, '0');
26
26
  * UTC-`Z` only, `isoDateInZone` is date-only, and `formatWithOffset` renders locale prose, not
27
27
  * ISO. So this composes the package's own instant→zoned-wall-clock conversion (`toZoned`) and
28
28
  * zone-label helper (`offsetLabel`) rather than a fresh `Intl.DateTimeFormat` call here.
29
+ *
30
+ * Exported for `cmd-tasks.ts`'s `last` column: the occurrence a task last fired and the one it
31
+ * fires next are one clock, so they are one function.
29
32
  */
30
- function isoInZone(ms: number, zone: string): string {
33
+ export function isoInZone(ms: number, zone: string): string {
31
34
  const zoned = toZoned(fromEpochMs(ms), zone);
32
35
  const date = `${String(zoned.year).padStart(4, '0')}-${pad2(zoned.month)}-${pad2(zoned.day)}`;
33
36
  const time = `${pad2(zoned.hour)}:${pad2(zoned.minute)}:${pad2(zoned.second)}`;
@@ -3,9 +3,11 @@
3
3
  // job handle, an MCP tool and these tests; the generator writes the declaration and the tests.
4
4
 
5
5
  import type { FeatureTarget } from './entity';
6
+ import { sortedImports } from './imports';
6
7
  import type { GeneratedFile, NameSet } from './naming';
7
8
  import { names } from './naming';
8
- import { sliceExports, sliceFoundation } from './slice-foundation';
9
+ import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
10
+ import { sliceExports, sliceFoundation, sliceTakesScaffoldRow } from './slice-foundation';
9
11
  import { wrapImport } from './wrap';
10
12
 
11
13
  /**
@@ -104,6 +106,10 @@ ${
104
106
  });
105
107
  `;
106
108
 
109
+ /** The code the slice's `errors.ts` declares for a missing row. */
110
+ const notFoundCode = (feature: NameSet): string =>
111
+ `X_${feature.kebab.toUpperCase().split('-').join('_')}_NOT_FOUND`;
112
+
107
113
  const ID = '00000000-0000-4000-8000-000000000001';
108
114
  const ORG = '00000000-0000-4000-8000-000000000002';
109
115
  const OTHER_ORG = '00000000-0000-4000-8000-000000000009';
@@ -160,20 +166,137 @@ const outsider = testActor('outsider', {
160
166
  : ''
161
167
  }`;
162
168
 
163
- const actionUnitTest = (
164
- name: NameSet,
165
- feature: NameSet,
166
- isMutator: boolean,
167
- ): string => `// ${name.camel}: its declared shape and the input it refuses. Both are answered by the declaration
168
- // alone, so they belong to the \`unit\` step — the contract projections are next door.
169
- ${preamble(name, feature, isMutator, 'expect, unitTest', false)}
170
- ${shapeTest(name, isMutator)}
169
+ /** What the unit test may do with the slice the declaration lives in. */
170
+ interface HandlerShape {
171
+ readonly isMutator: boolean;
172
+ /** The handler reads a row by id and throws `<Feature>NotFoundError` when there is none. */
173
+ readonly lookup: boolean;
174
+ /** The slice still takes the row `x g entity` scaffolds, so the test may store one. */
175
+ readonly storesRow: boolean;
176
+ readonly dbModule: string;
177
+ }
178
+
179
+ /**
180
+ * The optimistic half, run against the client store `@ultimat3/testing` ships. A twin is replayed
181
+ * on every rebase, so the two facts worth failing on are the row one application leaves and that a
182
+ * second leaves the same one — and that a row the store never held is not invented.
183
+ */
184
+ const localTwinTests = (): string => `
185
+ unitTest('the local twin marks the row pending, and a replay lands in the same place', () => {
186
+ const store = memoryLocalTx({ [TABLE]: { [id]: { id, title: 'before', pending: false } } });
187
+ target.local(store.tx, input);
188
+ const once = store.rows(TABLE);
189
+ expect(once).toEqual({ [id]: { id, title: 'a title', pending: true } });
190
+ // A rebase replays the queued write: twice through must equal once through.
191
+ target.local(store.tx, input);
192
+ expect(store.rows(TABLE)).toEqual(once);
193
+ });
194
+
195
+ unitTest('the local twin invents no row the store never held', () => {
196
+ const store = memoryLocalTx();
197
+ target.local(store.tx, input);
198
+ expect(store.rows(TABLE)).toEqual({});
199
+ });
200
+ `;
201
+
202
+ /** The authoritative half's cases, refusal first, in the shape the slice earns. */
203
+ const handlerTests = (feature: NameSet, shape: HandlerShape): string => {
204
+ const answer = shape.isMutator ? "{ id, title: 'a title' }" : '{ id }';
205
+ if (!shape.lookup) {
206
+ return `
207
+ unitTest('an actor without the grant is refused before the handler runs', async () => {
208
+ const stranger = testActor('stranger', { orgId }).actor;
209
+ const refused = await target.as(stranger, input).catch((error: unknown) => error);
210
+ expect(refused).toBeUltimateError('X_FORBIDDEN');
211
+ });
212
+
213
+ unitTest('the handler answers a writer in the org', async () => {
214
+ expect(await target.as(writer, input)).toEqual(${answer});
215
+ });
216
+ `;
217
+ }
218
+ const found = shape.isMutator
219
+ ? "{ id: row.id, title: 'a title' }"
220
+ : "{ id: row.id, title: 'kept' }";
221
+ const call = shape.isMutator
222
+ ? "{ id: row.id, orgId, title: 'a title' }"
223
+ : '{ id: row.id, orgId }';
224
+ return `
225
+ unitTest('the handler refuses an id nothing holds', async () => {
226
+ const refused = await target.as(writer, input).catch((error: unknown) => error);
227
+ expect(refused).toBeUltimateError('${notFoundCode(feature)}');
228
+ });
229
+ ${
230
+ shape.storesRow
231
+ ? `
232
+ unitTest('the handler answers the row it found', async () => {
233
+ const draft = { orgId, title: 'kept', price: { minor: 0, currency: 'USD' } };
234
+ const row = await runWithContext(createContext({ actor: writer }), () => repo.insert(draft));
235
+ const answer = await target.as(writer, ${call});
236
+ expect(answer).toEqual(${found});
237
+ });
238
+ `
239
+ : ''
240
+ }`;
241
+ };
242
+
243
+ /**
244
+ * The unit test: the declaration's shape, the input it refuses, and the HANDLER — run through
245
+ * `.as()`, the one execution path, against the in-memory driver. A mutator's local twin runs
246
+ * against `memoryLocalTx`. The contract projections are next door, in their own suite.
247
+ *
248
+ * A row is stored only in a slice whose row this generator can spell (`sliceTakesScaffoldRow`): a
249
+ * slice an author reshaped has its own columns, and a test that inserted `{ title, price }` into
250
+ * it would be a guess about someone else's table.
251
+ */
252
+ const actionUnitTest = (name: NameSet, feature: NameSet, shape: HandlerShape): string => {
253
+ const stores = shape.lookup && shape.storesRow;
254
+ const testing = [
255
+ ...(stores ? ['afterEach'] : []),
256
+ 'expect',
257
+ ...(shape.isMutator ? ['memoryLocalTx'] : []),
258
+ 'unitTest',
259
+ ].join(', ');
260
+ return `// ${name.camel}: its declared shape, the input it refuses, and what its handler answers — in
261
+ // process, so all three belong to the \`unit\` step. The contract projections are next door.
262
+ ${sortedImports([
263
+ ...(stores ? ["import { createContext, runWithContext } from '@ultimat3/core';"] : []),
264
+ "import { testActor } from '@ultimat3/policy';",
265
+ `import { ${testing} } from '@ultimat3/testing';`,
266
+ ...(stores ? [`import { driver } from '${shape.dbModule}';`] : []),
267
+ ])}${stores ? "\nimport * as repo from '../repo';" : ''}
268
+ import { ${name.camel} } from './${name.kebab}';
269
+
270
+ const id = '${ID}';
271
+ const orgId = '${ORG}';
272
+ const input = { id, orgId${shape.isMutator ? ", title: 'a title'" : ''} };
273
+ ${shape.isMutator ? `\n// The client store's table: the entity's own, so the twin and the server row share one key.\nconst TABLE = '${feature.table}';\n` : ''}
274
+ // Named here because every projection needs a stable name and this file does not boot the app.
275
+ // At boot \`registerActions(await import('./actions'))\` stamps the same name onto the same
276
+ // object, so \`${name.camel}.tool()\` works there with nothing to remember.
277
+ const target = ${name.camel}.named('${name.camel}');
278
+
279
+ // Holds the grant in the org the input names: past the policy, so what answers is the handler.
280
+ const grant = ['${feature.kebab}:write'];
281
+ const writer = testActor('writer', { orgId, permissions: grant }).actor;
282
+ ${
283
+ stores
284
+ ? `
285
+ // One store per process: without this, one test's rows are the next test's fixtures.
286
+ afterEach(() => {
287
+ driver.reset?.();
288
+ });
289
+ `
290
+ : ''
291
+ }
292
+ ${shapeTest(name, shape.isMutator)}
171
293
 
172
294
  unitTest('${name.camel} rejects input that is not a uuid', async () => {
173
295
  await expect(target.input).toRejectInput({ ...input, id: 'not-a-uuid' });
174
296
  await expect(target.input).toAcceptInput(input);
175
297
  });
176
- `;
298
+ ${shape.isMutator ? localTwinTests() : ''}${handlerTests(feature, shape)}`;
299
+ };
177
300
 
178
301
  const actionContractTest = (
179
302
  name: NameSet,
@@ -218,6 +341,8 @@ export interface ActionOptions extends FeatureTarget {
218
341
  * data. `x g resource` passes the entity it is writing in the same run.
219
342
  */
220
343
  readonly sliceEntity?: string;
344
+ /** The slice's `repo.ts` as it stands on disk, or absent alongside `sliceEntity`. */
345
+ readonly sliceRepo?: string;
221
346
  }
222
347
 
223
348
  export function actionFiles(rawName: string, target: ActionOptions): readonly GeneratedFile[] {
@@ -246,7 +371,15 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
246
371
  // suites: the input parse is a `unit` assertion and the three projections are `contract` ones.
247
372
  // Emitted as one file, the contract half ran under `unit` and `x test contract` answered
248
373
  // X_TEST_NO_FILES; renaming that one file would have put the `unitTest` in the same bind.
249
- { path: `${dir}/${name.kebab}.test.ts`, contents: actionUnitTest(name, feature, isMutator) },
374
+ {
375
+ path: `${dir}/${name.kebab}.test.ts`,
376
+ contents: actionUnitTest(name, feature, {
377
+ isMutator,
378
+ lookup,
379
+ storesRow: sliceTakesScaffoldRow(target, target.sliceEntity, target.sliceRepo),
380
+ dbModule: target.dbModule ?? PLACEHOLDER_DB_MODULE,
381
+ }),
382
+ },
250
383
  {
251
384
  path: `${dir}/${name.kebab}.contract.test.ts`,
252
385
  contents: actionContractTest(name, feature, isMutator),
@@ -0,0 +1,58 @@
1
+ // The catalog keys the admin reads for a generated entity. An entity in the app's handle IS an
2
+ // admin screen — `defineAdmin({ entities: adminEntitiesOf(db) })` — and that screen resolves
3
+ // `admin.<table>.title` and one `admin.<table>.field.<column>` per column. Nothing emitted them, so
4
+ // a generated list rendered `⟦admin.widgets.field.title⟧` in every header.
5
+
6
+ import { catalogJson } from './catalog-json';
7
+ import type { FeatureTarget } from './entity';
8
+ import { entityFiles } from './entity';
9
+ import { catalogPath, resolveLocales } from './locales';
10
+ import type { GeneratedFile } from './naming';
11
+ import { kebab, names } from './naming';
12
+
13
+ /** ` title: text({ max: 200 }),` — one column of the entity this same run writes. */
14
+ const COLUMN_LINE = /^ {4}([A-Za-z_$][\w$]*): [A-Za-z_$][\w$]*\(/gm;
15
+
16
+ /** `createdAt` → `Created at`, `orgId` → `Org ID`: a label a person reads, in sentence case. */
17
+ export const columnLabel = (property: string): string => {
18
+ const words = kebab(property)
19
+ .split('-')
20
+ .map((word) => (word === 'id' ? 'ID' : word));
21
+ const [first = '', ...rest] = words;
22
+ return [`${first[0]?.toUpperCase() ?? ''}${first.slice(1)}`, ...rest].join(' ');
23
+ };
24
+
25
+ /**
26
+ * The entries, keyed exactly as `@ultimat3/admin` derives them: both read the entity's own name
27
+ * (`$name`, the table) and its property names. The columns come off the entity this run writes —
28
+ * never a second list typed here — so a column added to the template is a label added with it.
29
+ */
30
+ export function adminCatalogEntries(
31
+ rawName: string,
32
+ target: FeatureTarget,
33
+ ): Readonly<Record<string, string>> {
34
+ const name = names(rawName);
35
+ const source = String(
36
+ entityFiles(rawName, target).find((file) => file.path.endsWith('/entity.ts'))?.contents ?? '',
37
+ );
38
+ const entries: Record<string, string> = {
39
+ [`admin.${name.table}.title`]: columnLabel(name.plural),
40
+ };
41
+ for (const match of source.matchAll(COLUMN_LINE)) {
42
+ const property = match[1] ?? '';
43
+ entries[`admin.${name.table}.field.${property}`] = columnLabel(property);
44
+ }
45
+ return entries;
46
+ }
47
+
48
+ /** One mergeable catalog file per locale — for `x g entity`, which has no other catalog entry. */
49
+ export const adminCatalogFiles = (
50
+ rawName: string,
51
+ target: FeatureTarget,
52
+ locales: readonly string[] | undefined,
53
+ ): readonly GeneratedFile[] =>
54
+ resolveLocales(locales).map((locale) => ({
55
+ path: catalogPath(locale),
56
+ contents: catalogJson(adminCatalogEntries(rawName, target)),
57
+ merge: 'json' as const,
58
+ }));
@@ -4,15 +4,27 @@
4
4
  // table and `guardedPage()` is the one thing that decides it. A scaffold that wrote a route
5
5
  // declaration here would hand back the unguarded second way in that seam exists to close.
6
6
 
7
+ import { ADMIN_FILE } from '../admin-registration';
7
8
  import { catalogJson } from './catalog-json';
8
9
  import { sortedImports } from './imports';
9
10
  import { catalogPath, resolveLocales } from './locales';
10
11
  import type { GeneratedFile } from './naming';
11
12
  import { camel, kebab, pascal } from './naming';
12
- import { LINE_WIDTH } from './wrap';
13
+ import { LINE_WIDTH, wrapImport } from './wrap';
13
14
 
14
- /** Where an admin lives when the caller does not say. `x new` scaffolds this layout. */
15
- export const DEFAULT_ADMIN_PAGE_DIR = 'apps/admin/src/pages';
15
+ /** The directory `defineAdmin` is declared in — the admin's one home, inside the app scan. */
16
+ const ADMIN_HOME = ADMIN_FILE.slice(0, ADMIN_FILE.lastIndexOf('/'));
17
+
18
+ /**
19
+ * Where a page lands when the caller does not say: beside the declaration `x new` writes, under the
20
+ * directory the app scan imports. `apps/admin/src/pages` was outside it, and an admin kept there was
21
+ * never mounted (`X_ADMIN_UNSCANNED`).
22
+ */
23
+ export const DEFAULT_ADMIN_PAGE_DIR = `${ADMIN_HOME}/pages`;
24
+
25
+ /** The import a `defineAdmin` in `ADMIN_FILE` writes for a page in `dir`, when one can be derived. */
26
+ const specifierFor = (dir: string, name: string): string =>
27
+ dir.startsWith(`${ADMIN_HOME}/`) ? `./${dir.slice(ADMIN_HOME.length + 1)}/${name}` : `./${name}`;
16
28
 
17
29
  export interface AdminPageOptions {
18
30
  /** The permission the page's own work needs. `admin:read` is composed in front of it. */
@@ -115,8 +127,8 @@ const pageSource = (
115
127
  // so a route declaration in this file would be a second, unguarded way in.
116
128
  //
117
129
  // Wire it in once, and add \`navGroup\` to link it in the sidebar — this file is ${dir}/${name}.tsx,
118
- // so the specifier is relative to wherever \`defineAdmin\` lives:
119
- // import { ${declaration}Page } from './${name}';
130
+ // so the specifier is relative to wherever \`defineAdmin\` lives (${ADMIN_FILE} in a scaffolded app):
131
+ // import { ${declaration}Page } from '${specifierFor(dir, name)}';
120
132
  // defineAdmin({ …, pages: […, ${declaration}Page] })
121
133
  //
122
134
  // The permission below is declared here AND has to be decided: an authz map built from a fixed
@@ -146,11 +158,27 @@ export const ${declaration}Page: AdminCustomPage = {
146
158
 
147
159
  const pageTest = (name: string, permission: string): string => {
148
160
  const declaration = camel(name);
161
+ const Name = pascal(name);
162
+ const url = `http://localhost/admin/${name}`;
149
163
  return `// The ${name} admin page is guarded and owns no route of its own — the two facts that separate an
150
- // admin screen from a page, and the two an edit here is most likely to break.
164
+ // admin screen from a page, and the two an edit here is most likely to break — and it renders
165
+ // from the request it is handed.
166
+ import { adminTestCtx } from '@ultimat3/admin';
151
167
  import { knownPermissions } from '@ultimat3/policy';
152
- import { expect, unitTest } from '@ultimat3/testing';
153
- import { ${declaration}Page } from './${name}';
168
+ import { expect, renderView, unitTest } from '@ultimat3/testing';
169
+ ${wrapImport([`${Name}Page`, `${declaration}Page`], `./${name}`)}
170
+
171
+ // The component, called the way the admin's frame calls it once the guard has passed: a ctx for
172
+ // an actor holding the page's own permission, the route params, and the request URL.
173
+ unitTest('the ${name} admin page renders', async () => {
174
+ const view = await renderView(${Name}Page, {
175
+ ctx: adminTestCtx({ granted: ['admin:read', '${permission}'] }),
176
+ params: {},
177
+ url: '${url}',
178
+ });
179
+ expect(view.html).toContain('<h1>');
180
+ expect(view.text).toContain('${url}');
181
+ });
154
182
 
155
183
  // Both facts the frame reads off the declaration, so both are decidable without a request: a path
156
184
  // that is not rooted is X_ADMIN_PAGE_PATH_INVALID, and no permission at all is