@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
@@ -33,7 +33,7 @@ ${themeIsland}
33
33
 
34
34
  ${routeConfig('')}
35
35
 
36
- export function DashboardPage() {
36
+ export function Page() {
37
37
  const t = useT();
38
38
  const locale = currentLocale();
39
39
  // Read at render, not at import: the registries are filled by the boot scan, after this module.
@@ -174,9 +174,71 @@ unitTest('formatCount follows the locale', () => {
174
174
  });
175
175
  `;
176
176
 
177
- /** The bare dashboard's page, view module and view test. */
177
+ const barePageTest = (
178
+ app: NameSet,
179
+ ): string => `// The dashboard, rendered as a request renders it. Its facts are the framework's registries, so
180
+ // the test registers the one route it renders — the boot scan's job in a running app — and reads
181
+ // the tiles and the route table back off the markup.
182
+ ${sortedImports([
183
+ `import { catalogs, useT } from '@${app.kebab}/i18n';`,
184
+ "import { frameworkVersion } from '@ultimat3/core';",
185
+ "import { clearRoutes, registerRoute } from '@ultimat3/render';",
186
+ "import { afterAll, beforeAll, expect, renderRoute, unitTest } from '@ultimat3/testing';",
187
+ ])}
188
+ import { roles } from '../../shared/roles';
189
+ import * as page from './page';
190
+
191
+ const url = 'https://example.test/dashboard';
192
+
193
+ /** The figure a tile shows, read off the markup by the \`stat\` the page gave it. */
194
+ const stat = (html: string, name: string): string | undefined =>
195
+ html.match(new RegExp(\`data-stat="\${name}">([^<]*)<\`))?.[1];
196
+
197
+ // The route table is process-global and a test process starts with none: this file registers the
198
+ // page it renders, and takes it back out so no later file inherits a route it never declared.
199
+ beforeAll(() => {
200
+ registerRoute({ file: 'apps/web/app/dashboard/page.tsx', config: page.config });
201
+ });
202
+ afterAll(clearRoutes);
203
+
204
+ unitTest('the dashboard is gated, renders per request, and hydrates one island', async () => {
205
+ const view = await renderRoute(page, { url });
206
+ expect(page.config.render).toBe('ssr');
207
+ expect(page.config.policy?.permission).toBe('dashboard:read');
208
+ expect(page.config.offline).toBe('runtime');
209
+ expect(page.config.budget.js).toBe('60kb');
210
+ // What the render emitted, beside what the config declares: the theme toggle, and only it.
211
+ expect(view.islands.map((island) => [island.moduleId, island.strategy])).toEqual([
212
+ ['shared-theme-toggle', 'visible'],
213
+ ]);
214
+ });
215
+
216
+ unitTest('the tiles are the framework registries, counted', async () => {
217
+ const view = await renderRoute(page, { url });
218
+ expect(stat(view.html, 'routes')).toBe('1');
219
+ expect(stat(view.html, 'locales')).toBe(String(catalogs.locales.length));
220
+ expect(stat(view.html, 'roles')).toBe(String(Object.keys(roles).length));
221
+ expect(stat(view.html, 'version')).toBe(frameworkVersion());
222
+ });
223
+
224
+ unitTest('the table is the route table: path, surface and render mode per route', async () => {
225
+ const t = useT();
226
+ const view = await renderRoute(page, { url });
227
+ expect(view.html.match(/<tr data-row=/g)).toHaveLength(1);
228
+ expect(view.html).toMatch(
229
+ /<tr data-row="\\/dashboard"><td>\\/dashboard<\\/td><td>app<\\/td><td>ssr<\\/td>/,
230
+ );
231
+ expect(view.text).toContain(t('app.dashboard.routesCaption'));
232
+ expect(view.meta.title).toBe(t('app.dashboard.title'));
233
+ // The sidebar knows where it is.
234
+ expect(view.html).toMatch(/<a href="\\/dashboard" aria-current="page"/);
235
+ });
236
+ `;
237
+
238
+ /** The bare dashboard's page, view module and the tests beside both. */
178
239
  export const bareDashboardFiles = (app: NameSet): readonly GeneratedFile[] => [
179
240
  { path: `${DASHBOARD_DIR}/page.tsx`, contents: barePage(app) },
180
241
  { path: `${DASHBOARD_DIR}/dashboard-view.ts`, contents: bareView() },
181
242
  { path: `${DASHBOARD_DIR}/dashboard-view.test.ts`, contents: bareViewTest() },
243
+ { path: `${DASHBOARD_DIR}/page.test.ts`, contents: barePageTest(app) },
182
244
  ];
@@ -34,7 +34,6 @@ ${sortedImports([
34
34
  "} from '@ultimat3/ui';",
35
35
  ].join('\n'),
36
36
  ])}
37
- import { DEMO_ORG_ID } from '../../shared/demo-org';
38
37
  import { Shell } from '../../shared/shell';
39
38
  import * as repo from '../post/repo';
40
39
  import {
@@ -47,14 +46,6 @@ import {
47
46
  } from './dashboard-view';
48
47
  import styles from './page.module.scss';
49
48
 
50
- /**
51
- * Whose posts. The route's policy decides who may open this page; which org's rows it aggregates
52
- * is a decision the load makes, and until this app issues sessions the only org with rows is the
53
- * one \`packages/db/src/seed.ts\` writes. The day sessions exist, this becomes the actor's org and
54
- * the read becomes \`postList.as(actor, …)\` — the query already declares the tenancy rule.
55
- */
56
- const DEMO_ORG = DEMO_ORG_ID;
57
-
58
49
  /** The stat row counts what it can see. Past this many posts, write an aggregate query. */
59
50
  const ROW_LIMIT = 500;
60
51
 
@@ -68,29 +59,40 @@ export interface DashboardData {
68
59
  readonly now: string;
69
60
  }
70
61
 
71
- async function load(): Promise<DashboardData> {
62
+ /**
63
+ * What \`load\` answers, with the read as a parameter. The one branch a running app never takes is
64
+ * the build's: \`x build --target static\` renders every route once to measure its JS budget with
65
+ * no database wired, and that render gets the real empty branches — which weigh the same markup —
66
+ * instead of a route the budget step cannot measure. Any other failure still propagates.
67
+ */
68
+ export async function dashboardData(
69
+ read: () => Promise<readonly Parameters<typeof toPostRow>[0][]>,
70
+ ): Promise<DashboardData> {
72
71
  const now = new Date().toISOString();
73
72
  try {
74
- const rows = await repo.listByOrg(DEMO_ORG, ROW_LIMIT);
75
- return { rows: rows.map(toPostRow), now };
73
+ return { rows: (await read()).map(toPostRow), now };
76
74
  } catch (error) {
77
- // \`x build --target static\` renders every route once to measure its JS budget, with no
78
- // database wired — so this is that measurement pass, not a request. Empty rows render the
79
- // real empty branches, which measure the same markup. Any other failure still propagates.
80
75
  if (isUltimateError(error) && error.code === 'X_DB_UNAVAILABLE') return { rows: [], now };
81
76
  throw error;
82
77
  }
83
78
  }
84
79
 
80
+ /**
81
+ * Whose posts: the ACTOR's org. The repo names none — the typed handle scopes every read to the
82
+ * request's actor — so the viewer \`auth/dev-actor.ts\` resolves sees the rows
83
+ * \`packages/db/src/seed.ts\` wrote, and a real session sees its own org's the day one exists.
84
+ */
85
+ const load = (): Promise<DashboardData> => dashboardData(() => repo.list(ROW_LIMIT));
86
+
85
87
  ${themeIsland}
86
88
 
87
89
  ${routeConfig('\n load,')}
88
90
 
89
- export interface DashboardPageProps {
91
+ export interface PageProps {
90
92
  readonly data: DashboardData;
91
93
  }
92
94
 
93
- export function DashboardPage(props: DashboardPageProps) {
95
+ export function Page(props: PageProps) {
94
96
  const t = useT();
95
97
  const locale = currentLocale();
96
98
  const now = new Date(props.data.now);
@@ -176,20 +178,15 @@ export interface PostRow {
176
178
  }
177
179
 
178
180
  /**
179
- * Duck-typed rather than the entity's own row: the page only ever reads these three columns.
180
- *
181
- * BOTH spellings of the timestamp, and that is a measured fact rather than caution: the repo's
182
- * \`select *\` hands back column names as Postgres has them (\`created_at\`) while its row type
183
- * says \`createdAt\`, so the typed property is \`undefined\` on a real row. Until the repo aliases
184
- * its columns — dz-showcase's does — the page reads whichever one arrived.
181
+ * Duck-typed rather than the entity's own row: the page only ever reads these three columns. The
182
+ * repo reads through the typed handle, so \`createdAt\` arrives as the \`Date\` the entity declares.
185
183
  */
186
184
  export function toPostRow(row: {
187
185
  readonly id: string;
188
186
  readonly title: string;
189
- readonly createdAt?: Date | string | undefined;
190
- readonly created_at?: Date | string | undefined;
187
+ readonly createdAt: Date | string;
191
188
  }): PostRow {
192
- const at = row.createdAt ?? row.created_at ?? '';
189
+ const at = row.createdAt;
193
190
  return {
194
191
  id: row.id,
195
192
  title: row.title,
@@ -303,13 +300,10 @@ unitTest('bucketByDay is one bar per day, oldest first, quiet days included', ()
303
300
  expect(bucketByDay([], NOW, 3).map((point) => point.value)).toEqual([0, 0, 0]);
304
301
  });
305
302
 
306
- unitTest('toPostRow serialises a Date, passes ISO text through, and reads the raw column', () => {
303
+ unitTest('toPostRow serialises a Date and passes ISO text through', () => {
307
304
  const at = new Date('2026-01-02T03:04:05.000Z');
308
305
  expect(toPostRow({ id: 'x', title: 't', createdAt: at }).createdAt).toBe(at.toISOString());
309
306
  expect(toPostRow({ id: 'x', title: 't', createdAt: '2026-01-01' }).createdAt).toBe('2026-01-01');
310
- // What \`select *\` really returns: the snake_case column, and no \`createdAt\` at all.
311
- expect(toPostRow({ id: 'x', title: 't', created_at: at }).createdAt).toBe(at.toISOString());
312
- expect(toPostRow({ id: 'x', title: 't' }).createdAt).toBe('');
313
307
  });
314
308
 
315
309
  unitTest('formatCount follows the locale', () => {
@@ -318,9 +312,108 @@ unitTest('formatCount follows the locale', () => {
318
312
  });
319
313
  `;
320
314
 
321
- /** The example dashboard's page, view module and view test. */
315
+ const examplePageTest = (
316
+ app: NameSet,
317
+ ): string => `// The dashboard, rendered as a request renders it: \`load\` reads the actor's org through the
318
+ // slice's repo — the in-memory driver under test — and the page turns the rows into tiles, a chart
319
+ // and a table. Losing the policy is the other regression worth a test: the page still renders, to
320
+ // anyone.
321
+ ${sortedImports([
322
+ `import { driver } from '@${app.kebab}/db';`,
323
+ `import { useT } from '@${app.kebab}/i18n';`,
324
+ "import { createContext, frozenClock, runWithContext } from '@ultimat3/core';",
325
+ "import { testActor } from '@ultimat3/policy';",
326
+ "import { dbUnavailable } from '@ultimat3/db';",
327
+ "import { afterEach, expect, renderRoute, unitTest } from '@ultimat3/testing';",
328
+ ])}
329
+ import { DEMO_ORG_ID } from '../../shared/demo-org';
330
+ import * as repo from '../post/repo';
331
+ import * as page from './page';
332
+
333
+ const url = 'https://example.test/dashboard';
334
+ const viewer = testActor('viewer', { orgId: DEMO_ORG_ID }).actor;
335
+ const stranger = testActor('stranger', { orgId: '00000000-0000-4000-8000-000000000009' }).actor;
336
+
337
+ const DAY_MS = 86_400_000;
338
+
339
+ /**
340
+ * One post in the viewer's org, written the way the app writes one — \`daysAgo\` days before now.
341
+ * The request's clock is what stamps \`createdAt\`, so the test chooses the instant.
342
+ */
343
+ const post = (title: string, daysAgo: number) => {
344
+ const clock = frozenClock(new Date(Date.now() - daysAgo * DAY_MS));
345
+ const draft = { orgId: DEMO_ORG_ID, title, price: { minor: 0, currency: 'USD' } };
346
+ return runWithContext(createContext({ actor: viewer, clock }), () => repo.insert(draft));
347
+ };
348
+
349
+ /** The figure a tile shows, read off the markup by the \`stat\` the page gave it. */
350
+ const stat = (html: string, name: string): string | undefined =>
351
+ html.match(new RegExp(\`data-stat="\${name}">([^<]*)<\`))?.[1];
352
+
353
+ // One store per process: without this, one test's rows are the next test's fixtures.
354
+ afterEach(() => {
355
+ driver.reset?.();
356
+ });
357
+
358
+ unitTest('the dashboard is gated, renders per request, and hydrates one island', async () => {
359
+ const view = await renderRoute(page, { url, actor: viewer });
360
+ expect(page.config.render).toBe('ssr');
361
+ expect(page.config.policy?.permission).toBe('dashboard:read');
362
+ expect(page.config.offline).toBe('runtime');
363
+ expect(page.config.budget.js).toBe('60kb');
364
+ // What the render emitted, beside what the config declares: the theme toggle, and only it.
365
+ expect(view.islands.map((island) => [island.moduleId, island.strategy])).toEqual([
366
+ ['shared-theme-toggle', 'visible'],
367
+ ]);
368
+ });
369
+
370
+ unitTest('it counts and lists the posts of the org that is looking', async () => {
371
+ await post('Last month', 30);
372
+ await post('This week', 3);
373
+ await post('Today', 0);
374
+ const view = await renderRoute(page, { url, actor: viewer });
375
+ // Newest first, as the repo orders them — and each tile counts its own window.
376
+ expect(view.data.rows.map((row) => row.title)).toEqual(['Today', 'This week', 'Last month']);
377
+ expect(stat(view.html, 'posts-total')).toBe('3');
378
+ expect(stat(view.html, 'posts-week')).toBe('2');
379
+ expect(stat(view.html, 'posts-today')).toBe('1');
380
+ // One table row per post, and one bar per day of the window whether or not it has posts.
381
+ expect(view.html.match(/<tr data-row=/g)).toHaveLength(3);
382
+ expect(view.html.match(/data-bar="true"/g)).toHaveLength(14);
383
+ expect(view.text).toContain('This week');
384
+ // The sidebar knows where it is.
385
+ expect(view.html).toMatch(/<a href="\\/dashboard" aria-current="page"/);
386
+ });
387
+
388
+ unitTest('another org sees none of them: the page says so instead of an empty table', async () => {
389
+ const t = useT();
390
+ await post('Not theirs', 0);
391
+ const view = await renderRoute(page, { url, actor: stranger });
392
+ expect(view.data.rows).toEqual([]);
393
+ expect(stat(view.html, 'posts-total')).toBe('0');
394
+ expect(view.html).not.toContain('<tr data-row=');
395
+ expect(view.text).toContain(t('app.dashboard.emptyTitle'));
396
+ expect(view.meta.title).toBe(t('app.dashboard.title'));
397
+ expect(view.meta.description).toBe(t('app.dashboard.description'));
398
+ });
399
+
400
+ unitTest('the build measures the empty page: no database is not a failure there', async () => {
401
+ const measured = await page.dashboardData(() => Promise.reject(dbUnavailable('no database')));
402
+ // The clock is frozen under test, so the instant the windows are cut at is this one.
403
+ expect(measured).toEqual({ rows: [], now: new Date().toISOString() });
404
+ });
405
+
406
+ unitTest('any other failure of the read propagates', async () => {
407
+ const cause = new TypeError('the read broke');
408
+ const read = (): Promise<never> => Promise.reject(cause);
409
+ expect(await page.dashboardData(read).catch((error: unknown) => error)).toBe(cause);
410
+ });
411
+ `;
412
+
413
+ /** The example dashboard's page, view module and the tests beside both. */
322
414
  export const exampleDashboardFiles = (app: NameSet): readonly GeneratedFile[] => [
323
415
  { path: `${DASHBOARD_DIR}/page.tsx`, contents: examplePage(app) },
324
416
  { path: `${DASHBOARD_DIR}/dashboard-view.ts`, contents: exampleView() },
325
417
  { path: `${DASHBOARD_DIR}/dashboard-view.test.ts`, contents: exampleViewTest() },
418
+ { path: `${DASHBOARD_DIR}/page.test.ts`, contents: examplePageTest(app) },
326
419
  ];
@@ -1,6 +1,6 @@
1
1
  // What both dashboards share — `scaffold-dashboard.ts` picks one of `scaffold-dashboard-example.ts`
2
2
  // and `scaffold-dashboard-bare.ts` per invocation, and everything the two must agree on lives here:
3
- // the route declaration, the theme island, the stylesheet and the config test.
3
+ // the route declaration, the theme island and the stylesheet.
4
4
 
5
5
  export const DASHBOARD_DIR = 'apps/web/app/dashboard';
6
6
 
@@ -56,22 +56,3 @@ export const dashboardStyle = (): string => `@use '@ultimat3/ui/tokens' as token
56
56
  color: tokens.role('fg');
57
57
  }
58
58
  `;
59
-
60
- export const dashboardPageTest =
61
- (): string => `// The dashboard renders per request, is gated by a policy, hydrates its one island, and stays
62
- // under its budget. Losing the policy is the interesting regression: the page still renders, to
63
- // anyone.
64
- import { expect, unitTest } from '@ultimat3/testing';
65
- import { config } from './page';
66
-
67
- unitTest('the dashboard renders on the server, is gated, and has an offline strategy', () => {
68
- expect(config.render).toBe('ssr');
69
- expect(config.policy?.permission).toBe('dashboard:read');
70
- expect(config.offline).toBe('runtime');
71
- });
72
-
73
- unitTest('the dashboard hydrates its one island inside a stated budget', () => {
74
- expect(config.hydrate).toBe('visible');
75
- expect(config.budget.js).toBe('60kb');
76
- });
77
- `;
@@ -15,13 +15,12 @@
15
15
  import type { GeneratedFile, NameSet } from './naming';
16
16
  import { bareDashboardFiles } from './scaffold-dashboard-bare';
17
17
  import { exampleDashboardFiles } from './scaffold-dashboard-example';
18
- import { DASHBOARD_DIR, dashboardPageTest, dashboardStyle } from './scaffold-dashboard-shared';
18
+ import { DASHBOARD_DIR, dashboardStyle } from './scaffold-dashboard-shared';
19
19
 
20
20
  /** `apps/web/app/dashboard/`, in the shape the invocation earns. */
21
21
  export function dashboardFiles(app: NameSet, example: boolean): readonly GeneratedFile[] {
22
22
  return [
23
23
  ...(example ? exampleDashboardFiles(app) : bareDashboardFiles(app)),
24
24
  { path: `${DASHBOARD_DIR}/page.module.scss`, contents: dashboardStyle() },
25
- { path: `${DASHBOARD_DIR}/page.test.ts`, contents: dashboardPageTest() },
26
25
  ];
27
26
  }
@@ -0,0 +1,142 @@
1
+ // The generated app's `packages/db/src/client.ts`: the ONE typed database handle, and the entity
2
+ // set `x g entity` / `x g resource` add to. Its own module rather than a section of
3
+ // `scaffold-db-package.ts` because `handle-registration.ts` writes the same file into an app that
4
+ // predates it — one template, two writers.
5
+
6
+ import { sortedImports } from './imports';
7
+ import type { GeneratedFile, NameSet } from './naming';
8
+ import { names } from './naming';
9
+
10
+ /** Where the handle lives, relative to the app root. The generators' one anchor. */
11
+ export const HANDLE_FILE = 'packages/db/src/client.ts';
12
+
13
+ /** The scope a pure call that names no app writes — `x g` and `x new` always name theirs. */
14
+ export const PLACEHOLDER_DB_MODULE = '@app/db';
15
+
16
+ /** One entity in the handle's set. */
17
+ export interface HandleEntry {
18
+ /** The key on the handle — the table's name, camelCase: `db.posts`. */
19
+ readonly key: string;
20
+ /** The entity's exported binding: `post`. */
21
+ readonly binding: string;
22
+ /** Where the binding is imported from: `@myapp/web/app/post/entity`. */
23
+ readonly specifier: string;
24
+ }
25
+
26
+ /** The line an entity takes in the set. Spelled once: the template and the registrar both write it. */
27
+ export const handleEntryLine = (entry: HandleEntry): string => ` ${entry.key}: ${entry.binding},`;
28
+
29
+ /** The import that line needs. */
30
+ export const handleImportLine = (entry: HandleEntry): string =>
31
+ `import { ${entry.binding} } from '${entry.specifier}';`;
32
+
33
+ /**
34
+ * The web workspace's package name, from the db package's: both carry the app's scope, which is
35
+ * how `x new` names them and how the root tsconfig's `paths` resolves them.
36
+ */
37
+ export const webModuleOf = (dbModule: string): string =>
38
+ `${dbModule.slice(0, dbModule.lastIndexOf('/'))}/web`;
39
+
40
+ /**
41
+ * The entry for the entity `x g entity <rawName>` writes into `<surfaceDir>/<feature>/entity.ts`.
42
+ * The key is `NameSet.plural` — the same word `repo.ts` reads the table under.
43
+ */
44
+ export function handleEntryFor(
45
+ rawName: string,
46
+ target: { readonly surfaceDir: string; readonly feature: string; readonly dbModule: string },
47
+ ): HandleEntry {
48
+ const name = names(rawName);
49
+ // `apps/web/app` → `app`: the web workspace's own root is what its package name stands for.
50
+ const inWorkspace = target.surfaceDir.split('/').slice(2).join('/');
51
+ return {
52
+ key: name.plural,
53
+ binding: name.camel,
54
+ specifier: `${webModuleOf(target.dbModule)}/${inWorkspace}/${target.feature}/entity`,
55
+ };
56
+ }
57
+
58
+ const entitySet = (entries: readonly HandleEntry[]): string =>
59
+ entries.length === 0
60
+ ? 'const entities = {};'
61
+ : `const entities = {\n${entries.map(handleEntryLine).join('\n')}\n};`;
62
+
63
+ /**
64
+ * The handle, over `entries`. The set is a named, one-entry-per-line object on purpose: it is the
65
+ * anchor `x g entity` inserts a line into, and an expanded object literal is a shape Biome leaves
66
+ * alone at any length — an inline `database({ a, b }, …)` re-wraps as it grows.
67
+ */
68
+ export const dbClientSource = (
69
+ entries: readonly HandleEntry[],
70
+ ): string => `// The one typed database handle. \`db.<table>\` exists because the entity is in the set below —
71
+ // nobody writes a repository class per entity, and nobody can reach a table that is not listed.
72
+ //
73
+ // \`x g entity\` and \`x g resource\` add their entity to \`entities\`. One written by hand is the
74
+ // same two lines: its import, and \`<tableName>: <entity>,\` in the set.
75
+
76
+ ${sortedImports([
77
+ "import { resolveEnvironment } from '@ultimat3/core';",
78
+ "import { type Driver, database, memoryDriver, postgresDriver } from '@ultimat3/entity';",
79
+ ...entries.map(handleImportLine),
80
+ ])}
81
+
82
+ /**
83
+ * One driver, NAMED rather than defaulted, and exported so a test resets the same object the app
84
+ * reads through (\`driver.reset?.()\`).
85
+ *
86
+ * \`postgresDriver()\` takes no connection: it resolves \`@ultimat3/db\`'s process client, which is
87
+ * the embedded PGlite under \`x dev\` and the \`DATABASE_URL\` pool in a container. \`bun test\`
88
+ * installs no client, so a test gets the in-memory driver — the same contract, no database.
89
+ */
90
+ export const selectDriver = (env: Readonly<Record<string, string | undefined>>): Driver =>
91
+ resolveEnvironment({ env }) === 'test' ? memoryDriver() : postgresDriver();
92
+
93
+ export const driver = selectDriver(Bun.env);
94
+
95
+ /** Every entity the handle serves, keyed by the name its table takes on \`db\`. */
96
+ ${entitySet(entries)}
97
+
98
+ /**
99
+ * Only a feature's \`repo.ts\`, a \`query\`'s \`sql\` or a seed may use this. A route importing it is
100
+ * \`X_BOUNDARY_ROUTE_TO_DB\`.
101
+ */
102
+ export const db = database(entities, { driver });
103
+
104
+ export type Db = typeof db;
105
+ `;
106
+
107
+ const clientTest = (
108
+ app: NameSet,
109
+ ): string => `// Which store the handle reads: memory under \`bun test\`, the process's Postgres client
110
+ // everywhere else. A handle that silently took the memory driver in a container would serve an
111
+ // empty database with a green gate.
112
+ import { expect, unitTest } from '@ultimat3/testing';
113
+ import { db, driver, selectDriver } from './client';
114
+
115
+ unitTest('${app.kebab} reads memory under test and Postgres everywhere else', () => {
116
+ // Only the in-memory driver can empty itself — a Postgres driver that could truncate an app's
117
+ // rows from a test eventually would — so \`reset\` is what tells the two apart.
118
+ expect(selectDriver({ NODE_ENV: 'test' }).reset).toBeDefined();
119
+ expect(selectDriver({ NODE_ENV: 'production' }).reset).toBeUndefined();
120
+ expect(selectDriver({ ULTIMATE_ENV: 'development' }).reset).toBeUndefined();
121
+ });
122
+
123
+ unitTest('the handle is built over the driver this module exports', () => {
124
+ expect(driver.reset).toBeDefined();
125
+ expect(typeof db).toBe('object');
126
+ });
127
+ `;
128
+
129
+ /**
130
+ * `packages/db/src/client.ts` and its test. With the example slice the set holds `post`; under
131
+ * `--no-example` it is empty and the first `x g entity` fills it.
132
+ */
133
+ export const dbClientFiles = (app: NameSet, example: boolean): readonly GeneratedFile[] => {
134
+ const dbModule = `@${app.kebab}/db`;
135
+ const entries = example
136
+ ? [handleEntryFor('post', { surfaceDir: 'apps/web/app', feature: 'post', dbModule })]
137
+ : [];
138
+ return [
139
+ { path: HANDLE_FILE, contents: dbClientSource(entries) },
140
+ { path: 'packages/db/src/client.test.ts', contents: clientTest(app) },
141
+ ];
142
+ };
@@ -2,13 +2,16 @@
2
2
  // and the deterministic seed. No business logic — that is the package's own stated boundary, and it
3
3
  // is why `example` reaches only the two files describing the slice's table.
4
4
  //
5
- // No migration. `x db gen` is the ONE writer of `packages/db/migrations`, and a scaffold that hand-
6
- // wrote `0000_initial.sql` was a second one: it declared a `posts` table the generator had never
7
- // diffed, so the first `x db gen` saw a schema the ledger already claimed and the two disagreed
8
- // about what "initial" meant. `x db gen "initial"` is the app's first command instead — it writes
9
- // the `.sql`, the `.snapshot.json` and the `.hash` together, which no hand-written file can.
5
+ // No migration and no schema dump. `x db gen` is the ONE writer of `packages/db/migrations` and
6
+ // of `packages/db/schema`, and a scaffold that hand-wrote `0000_initial.sql` was a second one: it
7
+ // declared a `posts` table the generator had never diffed, so the first `x db gen` saw a schema
8
+ // the ledger already claimed and the two disagreed about what "initial" meant. `x db gen
9
+ // "initial"` is the app's first command instead — it writes the `.sql`, the `.snapshot.json`, the
10
+ // `.hash` and the dump together, which no hand-written file can.
10
11
 
12
+ import { sortedImports } from './imports';
11
13
  import type { GeneratedFile, NameSet } from './naming';
14
+ import { dbClientFiles } from './scaffold-db-client';
12
15
  import { packageShapeFiles, workspacePackageJson } from './scaffold-package-shape';
13
16
 
14
17
  const DESCRIPTION = 'Entity re-exports and SQL migrations, no business logic';
@@ -45,10 +48,14 @@ const dbPackage = (app: NameSet, example: boolean): string =>
45
48
  : workspacePackageJson(app, 'db', DESCRIPTION);
46
49
 
47
50
  const dbIndex =
48
- (): string => `// Schema and migrations only — no business logic lives in this package. The client itself is
49
- // @ultimat3/db's: one connection pool, sized by ROLE, shared by every package in the app.
51
+ (): string => `// Schema, migrations and the typed handle — no business logic lives in this package.
52
+ // \`db\` is the handle \`./client\` builds over the app's entities: \`db.<table>\`, which is what a
53
+ // feature's repo.ts reads through. \`sql\` and \`withTransaction\` are @ultimat3/db's own, and the
54
+ // connection pool behind all three is one, sized by ROLE.
50
55
  export type { DbClient, SqlFragment } from '@ultimat3/db';
51
- export { db, sql, withTransaction } from '@ultimat3/db';
56
+ export { sql, withTransaction } from '@ultimat3/db';
57
+ export type { Db } from './client';
58
+ export { db, driver, selectDriver } from './client';
52
59
  export * as schema from './schema';
53
60
  `;
54
61
 
@@ -151,11 +158,86 @@ export const ${app.camel}Seed = defineSeed('${app.kebab}', async () => {
151
158
  });
152
159
  `;
153
160
 
161
+ const dbSeedTest = (app: NameSet, example: boolean): string =>
162
+ example
163
+ ? `// The seed, run against the in-memory driver: the rows it writes, whose they are, and that a
164
+ // second run writes nothing. The org is the point — a row seeded under any other id is one the
165
+ // development viewer's tenant policy refuses, and the dashboard then counts zero.
166
+ ${sortedImports([
167
+ `import { DEMO_ORG_ID } from '@${app.kebab}/web/shared/demo-org';`,
168
+ "import { createContext, runWithContext } from '@ultimat3/core';",
169
+ "import { testActor } from '@ultimat3/policy';",
170
+ "import { afterEach, expect, unitTest } from '@ultimat3/testing';",
171
+ ])}
172
+ import { db, driver } from './client';
173
+ import { ${app.camel}Seed } from './seed';
174
+
175
+ /** The development viewer's org: every read below runs as an actor inside it. */
176
+ const asViewer = <T>(run: () => Promise<T>): Promise<T> =>
177
+ runWithContext(createContext({ actor: testActor('viewer', { orgId: DEMO_ORG_ID }).actor }), run);
178
+
179
+ // One store per process: without this, one test's rows are the next test's fixtures.
180
+ afterEach(() => {
181
+ driver.reset?.();
182
+ });
183
+
184
+ unitTest('the seed writes its posts under the demo org', async () => {
185
+ const run = await ${app.camel}Seed.run({ driver });
186
+ expect(run.metrics).toEqual({ inserted: 2, updated: 0, skipped: 0 });
187
+ const rows = await asViewer(() => db.posts.where({ orgId: DEMO_ORG_ID }).orderBy('title').all());
188
+ expect(rows.map((row) => row.title)).toEqual(['Hello ${app.pascal}', 'Second post']);
189
+ expect(rows.map((row) => row.price.minor)).toEqual([0, 1900]);
190
+ });
191
+
192
+ unitTest('a replay writes nothing: its ids are derived from labels, never generated', async () => {
193
+ await ${app.camel}Seed.run({ driver });
194
+ const replay = await ${app.camel}Seed.run({ driver });
195
+ expect(replay.metrics).toEqual({ inserted: 0, updated: 0, skipped: 2 });
196
+ });
197
+ `
198
+ : `// The seed as \`x db seed\` finds it: a declared \`defineSeed()\` under this app's name, which
199
+ // writes nothing until an entity exists. The day it inserts a row, the last assertion says so.
200
+ import { isSeed } from '@ultimat3/entity';
201
+ import { expect, unitTest } from '@ultimat3/testing';
202
+ import { driver } from './client';
203
+ import { ${app.camel}Seed } from './seed';
204
+
205
+ unitTest('the seed is one x db seed discovers, in the dev tier', () => {
206
+ expect(isSeed(${app.camel}Seed)).toBe(true);
207
+ expect(${app.camel}Seed.name).toBe('${app.kebab}');
208
+ // \`dev\`: fixture data, never loaded into production. \`reference\` is the other tier.
209
+ expect(${app.camel}Seed.tier).toBe('dev');
210
+ });
211
+
212
+ unitTest('it writes nothing yet', async () => {
213
+ const run = await ${app.camel}Seed.run({ driver });
214
+ expect(run.metrics).toEqual({ inserted: 0, updated: 0, skipped: 0 });
215
+ });
216
+ `;
217
+
218
+ /**
219
+ * `packages/db/schema/` is written by `x db gen` and held by the `drift` step, so two things about
220
+ * it are git's to know. `linguist-generated` collapses it in a review — the migration is what a
221
+ * human reads; the dump is its projection. `text eol=lf` is not cosmetic: the gate compares bytes,
222
+ * and a checkout that rewrote line endings would be `X_SCHEMA_DUMP_DRIFT` on a correct tree.
223
+ *
224
+ * The attributes ship; the directory does not. `x new` scaffolds no migration, so there is nothing
225
+ * to dump until the first `x db gen` — which creates it.
226
+ */
227
+ export const DB_GITATTRIBUTES = `# Generated by \`x db gen\` from the migrations and held equal to them by \`x verify\`'s drift
228
+ # step (X_SCHEMA_DUMP_DRIFT). Review the migration, not this projection of it.
229
+ schema/** linguist-generated=true text eol=lf
230
+ `;
231
+
154
232
  /** Every file the `packages/db` workspace ships, in the order `x new` writes them. */
155
233
  export const dbPackageFiles = (app: NameSet, example: boolean): readonly GeneratedFile[] => [
156
234
  { path: 'packages/db/package.json', contents: dbPackage(app, example) },
235
+ { path: 'packages/db/.gitattributes', contents: DB_GITATTRIBUTES },
157
236
  ...packageShapeFiles(app, 'db', DESCRIPTION),
158
237
  { path: 'packages/db/src/index.ts', contents: dbIndex() },
238
+ // The typed handle and its test — `scaffold-db-client.ts`, shared with `x g entity`.
239
+ ...dbClientFiles(app, example),
159
240
  { path: 'packages/db/src/schema.ts', contents: dbSchema(app, example) },
160
241
  { path: 'packages/db/src/seed.ts', contents: dbSeed(app, example) },
242
+ { path: 'packages/db/src/seed.test.ts', contents: dbSeedTest(app, example) },
161
243
  ];