@ultimat3/cli 22.14.0 → 23.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CLAUDE.md +25 -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 +35 -3
  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-dev.ts +2 -2
  20. package/src/cmd-doctor.ts +35 -11
  21. package/src/cmd-generate.ts +118 -17
  22. package/src/cmd-jobs-spec.ts +19 -3
  23. package/src/cmd-jobs.ts +120 -3
  24. package/src/cmd-new.ts +15 -1
  25. package/src/cmd-routes.ts +15 -1
  26. package/src/cmd-secrets-ring.ts +76 -0
  27. package/src/cmd-secrets-spec.ts +10 -2
  28. package/src/cmd-secrets.ts +48 -12
  29. package/src/cmd-tasks.ts +84 -15
  30. package/src/cmd-verify.ts +10 -2
  31. package/src/comment-safe.ts +16 -0
  32. package/src/coverage-floor.ts +334 -0
  33. package/src/coverage-lcov.ts +193 -0
  34. package/src/coverage-source.ts +136 -0
  35. package/src/db-object-drift.ts +30 -0
  36. package/src/db-replay-engine.ts +115 -0
  37. package/src/db-schema-dump.ts +150 -0
  38. package/src/db-schema-refresh.ts +122 -0
  39. package/src/dev-dashboard.ts +2 -2
  40. package/src/dev-route-table.ts +30 -6
  41. package/src/doctor-coverage.ts +39 -0
  42. package/src/doctor-guards.ts +54 -0
  43. package/src/doctor-sealed.ts +132 -0
  44. package/src/document-graph.ts +82 -0
  45. package/src/document-options.ts +64 -0
  46. package/src/error-codes.ts +32 -0
  47. package/src/framework-schema.ts +15 -0
  48. package/src/generate-files.ts +76 -10
  49. package/src/generate-grant-findings.ts +76 -0
  50. package/src/generate-grants.ts +77 -3
  51. package/src/generate-kinds.ts +19 -0
  52. package/src/generate-write.ts +15 -27
  53. package/src/generated-imports.ts +59 -0
  54. package/src/guard-sources.ts +104 -0
  55. package/src/guards.ts +23 -7
  56. package/src/handle-registration.ts +217 -0
  57. package/src/i18n-registration.ts +36 -0
  58. package/src/image-prepare.ts +51 -8
  59. package/src/import-closure.ts +165 -0
  60. package/src/index.ts +11 -2
  61. package/src/island-store.ts +24 -16
  62. package/src/jobs-drain.ts +14 -7
  63. package/src/jobs-json.ts +50 -0
  64. package/src/jobs-report.ts +3 -0
  65. package/src/json-merge.ts +46 -0
  66. package/src/mcp-errors.ts +29 -1
  67. package/src/messages.ts +33 -0
  68. package/src/metrics-endpoint.ts +71 -24
  69. package/src/migration-extensions.ts +29 -0
  70. package/src/module-imports.ts +47 -0
  71. package/src/output.ts +18 -9
  72. package/src/page-speculation.ts +124 -0
  73. package/src/permission-grants.ts +41 -9
  74. package/src/prerender.ts +9 -5
  75. package/src/publish-closure.ts +101 -0
  76. package/src/role-load.ts +143 -0
  77. package/src/role-start.ts +18 -1
  78. package/src/role-wake.ts +45 -0
  79. package/src/runtime-admin.ts +141 -0
  80. package/src/runtime-hooks.ts +5 -2
  81. package/src/runtime-queue.ts +8 -1
  82. package/src/runtime-render.ts +36 -60
  83. package/src/runtime-services.ts +2 -1
  84. package/src/scaffold-typecheck.ts +1 -0
  85. package/src/schema-dump-drift.ts +64 -0
  86. package/src/serve-boot.ts +85 -147
  87. package/src/serve-prebuilt-paths.ts +19 -0
  88. package/src/serve-prebuilt.ts +84 -0
  89. package/src/serve-web.ts +164 -0
  90. package/src/serve.ts +6 -9
  91. package/src/server-barrels.ts +54 -0
  92. package/src/source-edit.ts +49 -0
  93. package/src/static-document.ts +108 -0
  94. package/src/tasks-facts.ts +4 -1
  95. package/src/templates/action.ts +144 -11
  96. package/src/templates/admin-catalog.ts +58 -0
  97. package/src/templates/admin-page.ts +36 -8
  98. package/src/templates/admin.ts +57 -17
  99. package/src/templates/backfill.ts +121 -87
  100. package/src/templates/entity.ts +140 -44
  101. package/src/templates/guard-animated-layout-property.ts +4 -11
  102. package/src/templates/guard-bare-error.ts +9 -13
  103. package/src/templates/guard-focus-visible.ts +4 -11
  104. package/src/templates/guard-image-dimensions.ts +6 -6
  105. package/src/templates/guard-island-without-states.ts +2 -4
  106. package/src/templates/guard-raw-breakpoint.ts +210 -0
  107. package/src/templates/guard-raw-colour.ts +4 -11
  108. package/src/templates/guard-raw-length.ts +375 -0
  109. package/src/templates/guard-raw-motion.ts +213 -0
  110. package/src/templates/guard-raw-shadow.ts +187 -0
  111. package/src/templates/guard-raw-z-index.ts +166 -0
  112. package/src/templates/guard-repo-raw-sql.ts +345 -0
  113. package/src/templates/guard-semantic-interactive.ts +6 -6
  114. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  115. package/src/templates/guard-undefined-style-class.ts +215 -0
  116. package/src/templates/guard-untranslated-string.ts +6 -6
  117. package/src/templates/guard-unzoned-date.ts +9 -13
  118. package/src/templates/index.ts +3 -2
  119. package/src/templates/island.ts +34 -51
  120. package/src/templates/job-body-test.ts +121 -0
  121. package/src/templates/job.ts +23 -8
  122. package/src/templates/policy.ts +16 -9
  123. package/src/templates/query.ts +242 -26
  124. package/src/templates/resource-create.ts +62 -18
  125. package/src/templates/resource-form-island.ts +58 -80
  126. package/src/templates/resource-page.ts +269 -0
  127. package/src/templates/resource-service.ts +96 -0
  128. package/src/templates/resource.ts +86 -61
  129. package/src/templates/route.ts +38 -29
  130. package/src/templates/scaffold-api.ts +55 -1
  131. package/src/templates/scaffold-app.ts +171 -51
  132. package/src/templates/scaffold-auth.ts +83 -19
  133. package/src/templates/scaffold-browser-client.ts +90 -0
  134. package/src/templates/scaffold-container.ts +16 -0
  135. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  136. package/src/templates/scaffold-dashboard-example.ts +124 -31
  137. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  138. package/src/templates/scaffold-dashboard.ts +1 -2
  139. package/src/templates/scaffold-db-client.ts +142 -0
  140. package/src/templates/scaffold-db-package.ts +90 -8
  141. package/src/templates/scaffold-docs.ts +50 -5
  142. package/src/templates/scaffold-guards.ts +54 -11
  143. package/src/templates/scaffold-http.ts +9 -7
  144. package/src/templates/scaffold-repo.ts +52 -2
  145. package/src/templates/scaffold-roles.ts +56 -9
  146. package/src/templates/scaffold-shell.ts +36 -70
  147. package/src/templates/scaffold-site.ts +45 -19
  148. package/src/templates/scaffold-ui-package.ts +18 -0
  149. package/src/templates/slice-foundation.ts +46 -8
  150. package/src/test-counts.ts +18 -2
  151. package/src/transport-calls.ts +105 -0
  152. package/src/unscanned-admin.ts +54 -0
  153. package/src/verify-checks.ts +26 -7
  154. package/src/verify-coverage-run.ts +190 -0
  155. package/src/verify-deadline.ts +256 -0
  156. package/src/verify-errors.ts +15 -6
  157. package/src/verify-floor.ts +36 -9
  158. package/src/verify-merge.ts +117 -24
  159. package/src/verify-progress.ts +25 -0
  160. package/src/verify-role-load-probe.ts +54 -0
  161. package/src/verify-role-load.ts +148 -0
  162. package/src/verify-run.ts +93 -35
  163. package/src/verify-shard.ts +20 -9
  164. package/src/verify-step.ts +42 -1
  165. package/src/verify-tests.ts +78 -6
  166. package/src/workspace-checks.ts +50 -8
  167. package/src/workspace-dep-edit.ts +127 -0
  168. package/src/cmd-dev-fixture.ts +0 -204
  169. package/src/dev-live-fixture.ts +0 -122
  170. package/src/policy-fixture.ts +0 -78
  171. package/src/realtime-browser-probe-fixture.ts +0 -9
  172. package/src/role-start-fixture.ts +0 -78
  173. package/src/scaffold-fixture.ts +0 -160
  174. package/src/templates/island-fixture.ts +0 -76
@@ -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
+ }
@@ -0,0 +1,108 @@
1
+ // How a served process answers a `render: 'static'` page: a 304 when the browser already holds
2
+ // these bytes, and — in the container — the document it rendered the first time, from memory.
3
+ // A static page is one file in the export; the served process answering it must not cost a render
4
+ // per request, nor a body per revalidation. No other mode comes through here.
5
+
6
+ import type { UltimateRequest } from '@ultimat3/http';
7
+ import { html } from '@ultimat3/http';
8
+ import type { RenderResult, RouteEntry } from '@ultimat3/render';
9
+ import { etagMatches } from './runtime-storage';
10
+
11
+ /** A rendered static document: always a string body, never a stream. */
12
+ export type StaticResult = RenderResult & { readonly body: string };
13
+
14
+ /**
15
+ * The entries one process keeps. A static route has no params here and no query, so the real count
16
+ * is routes × locales; the cap is what a forged `Host` (part of the key when the app declares no
17
+ * origin) can cost — past it a page is rendered per request, as before, never refused.
18
+ */
19
+ export const STATIC_MEMO_LIMIT = 1024;
20
+
21
+ /**
22
+ * The BYTES one process keeps, the cache tiers' `maxBytes` idea: a count alone lets 1,024 rotated
23
+ * `Host` headers retain 1,024 whole documents, whatever each weighs. Past it, as past the count, a
24
+ * page is rendered per request.
25
+ */
26
+ export const STATIC_MEMO_MAX_BYTES = 32 * 1024 * 1024;
27
+
28
+ export interface StaticMemo {
29
+ get(key: string): StaticResult | undefined;
30
+ set(key: string, result: StaticResult): void;
31
+ readonly size: number;
32
+ /** UTF-8 bytes of the kept keys and bodies. */
33
+ readonly bytes: number;
34
+ }
35
+
36
+ const encoder = new TextEncoder();
37
+ const weigh = (key: string, result: StaticResult): number =>
38
+ encoder.encode(key).byteLength + encoder.encode(result.body).byteLength;
39
+
40
+ export function createStaticMemo(
41
+ limit: number = STATIC_MEMO_LIMIT,
42
+ maxBytes: number = STATIC_MEMO_MAX_BYTES,
43
+ ): StaticMemo {
44
+ const kept = new Map<string, { readonly result: StaticResult; readonly weight: number }>();
45
+ let bytes = 0;
46
+ return {
47
+ get: (key) => kept.get(key)?.result,
48
+ set(key, result) {
49
+ // Only a 200 is the page: a loader's 404 or 500 is an answer about one moment.
50
+ if (result.status !== 200) return;
51
+ const held = kept.get(key);
52
+ if (held === undefined && kept.size >= limit) return;
53
+ const weight = weigh(key, result);
54
+ // A replacement gives its old weight back first; one that no longer fits is not kept, and
55
+ // neither is the stale document it would have replaced.
56
+ const after = bytes - (held?.weight ?? 0) + weight;
57
+ if (after > maxBytes) {
58
+ if (held !== undefined) {
59
+ kept.delete(key);
60
+ bytes -= held.weight;
61
+ }
62
+ return;
63
+ }
64
+ kept.set(key, { result, weight });
65
+ bytes = after;
66
+ },
67
+ get size() {
68
+ return kept.size;
69
+ },
70
+ get bytes() {
71
+ return bytes;
72
+ },
73
+ };
74
+ }
75
+
76
+ /**
77
+ * The key a request's document is kept under, or `undefined` when it must be rendered: a query
78
+ * string is part of what `meta` reads (`data.url`), and a dynamic segment is an unbounded set an
79
+ * anonymous visitor chooses. The origin is in the key because canonical and `og:url` are absolute
80
+ * against the request's own when the app declares none.
81
+ */
82
+ export function staticMemoKey(
83
+ entry: Pick<RouteEntry, 'config' | 'pattern'>,
84
+ url: URL,
85
+ locale: string,
86
+ ): string | undefined {
87
+ if (entry.config.render !== 'static') return undefined;
88
+ if (entry.pattern.keys.length > 0 || url.search !== '') return undefined;
89
+ return `${locale}\n${url.origin}${url.pathname}`;
90
+ }
91
+
92
+ /**
93
+ * The document, or a bodiless 304 when `If-None-Match` names its ETag. The 304 repeats the
94
+ * validators and the cache posture (RFC 9110 §15.4.5) and drops `content-type`: it has no content.
95
+ * Only a 200 revalidates — a 404 rendered under `static` is sent whole.
96
+ */
97
+ export function staticResponse(request: UltimateRequest, result: StaticResult): Response {
98
+ const etag = result.headers['etag'];
99
+ if (
100
+ result.status === 200 &&
101
+ etag !== undefined &&
102
+ etagMatches(request.header('if-none-match'), etag)
103
+ ) {
104
+ const { 'content-type': _dropped, ...headers } = result.headers;
105
+ return new Response(null, { status: 304, headers });
106
+ }
107
+ return html(result.body, { status: result.status, headers: result.headers });
108
+ }
@@ -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
@@ -1,50 +1,90 @@
1
- // `x g resource <name> --admin` — the per-entity half of the admin screen. `defineAdmin()` derives
2
- // list/detail/create/edit from the entity itself; this file is only the override an app author
3
- // would otherwise hand-write — title, list columns, page size. Wiring it in is one line in
4
- // `apps/admin/src/index.ts` (`entities: [..., ${feature}]`, `resources: { ${feature}: ... }`),
5
- // the same "define once, register once" shape as `packages/db/src/schema.ts`.
1
+ // `x g resource <name> --admin` — the per-entity admin override. `defineAdmin()` already serves
2
+ // list/detail/create/edit for the entity with nothing written; this file is only what an app
3
+ // author would otherwise hand-write on top — the list columns, a page size, and from there scopes,
4
+ // a row scope and computed columns. `admin-registration.ts` lists it under `resources:` in the
5
+ // app's `defineAdmin()` call in the same run, so nothing is left to wire.
6
6
 
7
7
  import type { FeatureTarget } from './entity';
8
+ import { entityFiles } from './entity';
8
9
  import type { GeneratedFile, NameSet } from './naming';
9
10
  import { names } from './naming';
11
+ import { wrapList } from './wrap';
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
+ const TENANT = /^\s*tenant: '([^']+)'/m;
16
+
17
+ /**
18
+ * The list columns, read off the ENTITY the generator writes for this name — never a second list
19
+ * typed here. It used to be `['id', 'title', 'createdAt']`, which named whatever the entity
20
+ * template held on the day it was written: a column renamed there became
21
+ * `X_ADMIN_FIELD_UNSUPPORTED` on the first request to an app that had just been generated.
22
+ *
23
+ * The key and the tenant column are left out: the list's first column already links the row by
24
+ * its id, and every row an operator sees carries the same tenant.
25
+ */
26
+ export function adminListFields(rawName: string, target: FeatureTarget): readonly string[] {
27
+ const source = String(
28
+ entityFiles(rawName, target).find((file) => file.path.endsWith('/entity.ts'))?.contents ?? '',
29
+ );
30
+ const tenant = TENANT.exec(source)?.[1];
31
+ return [...source.matchAll(COLUMN_LINE)]
32
+ .filter((line) => !line[0].includes('.primaryKey()') && line[1] !== tenant)
33
+ .map((line) => line[1] ?? '');
34
+ }
10
35
 
11
36
  const resourceSource = (
12
37
  feature: NameSet,
13
- ): string => `// Admin override for ${feature.pluralKebab}. Everything not set here — fields, operations,
14
- // detail layout — is derived from the entity. Wire it in once, importing ${feature.camel}AdminResource
15
- // through this app's own tsconfig path alias for apps/web/app/${feature.kebab}/admin/resource:
16
- // defineAdmin({ entities: [..., ${feature.camel}], resources: { ${feature.table}: ${feature.camel}AdminResource } })
38
+ listFields: readonly string[],
39
+ ): string => `// Admin override for ${feature.pluralKebab}. The admin already serves /admin/${feature.table} with nothing written
40
+ // here — its fields, filters, operations and forms are derived from the entity. This file is what
41
+ // the entity cannot say, listed under \`resources: { ${feature.table}: … }\` in the app's defineAdmin().
17
42
 
18
43
  import type { AdminResourceOptions, AdminRow } from '@ultimat3/admin';
19
44
 
20
45
  export const ${feature.camel}AdminResource: AdminResourceOptions<AdminRow> = {
21
- titleKey: 'admin.${feature.kebab}.title',
22
- listFields: ['id', 'title', 'createdAt'],
46
+ // Columns of the entity, in its own order. A name that is not one is X_ADMIN_FIELD_UNSUPPORTED
47
+ // at boot — delete the line to let the admin pick them.
48
+ ${wrapList(
49
+ ' ',
50
+ 'listFields: [',
51
+ listFields.map((field) => `'${field}'`),
52
+ '],',
53
+ )}
23
54
  pageSize: 25,
24
55
  };
25
56
  `;
26
57
 
27
58
  const resourceTest = (
28
59
  feature: NameSet,
29
- ): string => `// The ${feature.kebab} admin override says what the entity cannot derive: a title key, the list
30
- // columns, a bounded page size. Everything unset here is derived, and needs no test.
60
+ ): string => `// The ${feature.kebab} admin override says what the entity cannot derive: the list columns and a
61
+ // bounded page size. The columns it lists must BE columns — a name that is not one is
62
+ // X_ADMIN_FIELD_UNSUPPORTED on the first request.
31
63
  import { expect, unitTest } from '@ultimat3/testing';
64
+ import { ${feature.camel} } from '../entity';
32
65
  import { ${feature.camel}AdminResource } from './resource';
33
66
 
34
67
  const resource = ${feature.camel}AdminResource;
35
68
 
36
- unitTest('${feature.camel}AdminResource sets a title key and list fields', () => {
37
- expect(resource.titleKey).toBe('admin.${feature.kebab}.title');
38
- expect(resource.listFields?.length).toBeGreaterThan(0);
69
+ unitTest('${feature.camel}AdminResource sets a bounded page size', () => {
39
70
  expect(resource.pageSize).toBeGreaterThan(0);
40
71
  });
72
+
73
+ unitTest('every list field is a column of the ${feature.camel} entity', () => {
74
+ const columns = Object.keys(${feature.camel}.$columns);
75
+ expect(resource.listFields?.length).toBeGreaterThan(0);
76
+ for (const field of resource.listFields ?? []) expect(columns).toContain(field);
77
+ });
41
78
  `;
42
79
 
43
80
  export function adminFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] {
44
81
  const feature = names(rawName.length > 0 ? rawName : target.feature);
45
82
  const dir = `${target.surfaceDir}/${target.feature}/admin`;
46
83
  return [
47
- { path: `${dir}/resource.ts`, contents: resourceSource(feature) },
84
+ {
85
+ path: `${dir}/resource.ts`,
86
+ contents: resourceSource(feature, adminListFields(rawName, target)),
87
+ },
48
88
  { path: `${dir}/resource.test.ts`, contents: resourceTest(feature) },
49
89
  ];
50
90
  }