@ultimat3/cli 5.0.1 → 6.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.
@@ -58,12 +58,29 @@ unitTest('${feature.pascal}NotFoundError carries a code, a cause and a fix', ()
58
58
  });
59
59
  `;
60
60
 
61
+ /**
62
+ * The generated component reaches strings through the APP's catalog module — the one that calls
63
+ * `defineCatalogs()` — so a component that renders a string depends on the module that registers
64
+ * them. `t` from `@ultimat3/i18n` renders while depending on nothing, which is how a shipped app
65
+ * served every string as a loud miss with a green gate (issue #249). An app with no catalog module
66
+ * keeps the framework import: emitting one that cannot resolve is worse than the wrong idiom.
67
+ */
68
+ const catalogImport = (module: string | undefined): string =>
69
+ module === undefined
70
+ ? "import { t } from '@ultimat3/i18n';"
71
+ : `import { useT } from '${module}';`;
72
+
73
+ /** `useT()` is per render, so each component binds it in its own body. */
74
+ const translatorBinding = (module: string | undefined): string =>
75
+ module === undefined ? '' : '\n const t = useT();\n';
76
+
61
77
  const uiSource = (
62
78
  feature: NameSet,
79
+ module: string | undefined,
63
80
  ): string => `// Presentation only. No fetching, no business logic: the list arrives as a prop from the route,
64
81
  // which got it from the live query.
65
82
 
66
- import { t } from '@ultimat3/i18n';
83
+ ${catalogImport(module)}
67
84
  import { For } from 'solid-js';
68
85
  import type { ${feature.pascal} } from './entity';
69
86
  import styles from './ui.module.scss';
@@ -72,7 +89,7 @@ export interface ${feature.pascal}ListProps {
72
89
  readonly rows: readonly ${feature.pascal}[];
73
90
  }
74
91
 
75
- export function ${feature.pascal}List(props: ${feature.pascal}ListProps) {
92
+ export function ${feature.pascal}List(props: ${feature.pascal}ListProps) {${translatorBinding(module)}
76
93
  return (
77
94
  <ul class={styles.list}>
78
95
  <For each={props.rows} fallback={<li>{t('app.${feature.kebab}.empty')}</li>}>
@@ -103,10 +120,11 @@ const uiStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
103
120
 
104
121
  const cardSource = (
105
122
  feature: NameSet,
123
+ module: string | undefined,
106
124
  ): string => `// One ${feature.camel} rendered on its own — the list's \`item\` shown outside a list, so a
107
125
  // detail route and a search result render the identical markup.
108
126
 
109
- import { t } from '@ultimat3/i18n';
127
+ ${catalogImport(module)}
110
128
  import type { ${feature.pascal} } from '../entity';
111
129
  import styles from '../ui.module.scss';
112
130
 
@@ -114,7 +132,7 @@ export interface ${feature.pascal}CardProps {
114
132
  readonly row: ${feature.pascal};
115
133
  }
116
134
 
117
- export function ${feature.pascal}Card(props: ${feature.pascal}CardProps) {
135
+ export function ${feature.pascal}Card(props: ${feature.pascal}CardProps) {${translatorBinding(module)}
118
136
  return (
119
137
  <article class={styles.item}>
120
138
  <h3>{props.row.title}</h3>
@@ -126,10 +144,11 @@ export function ${feature.pascal}Card(props: ${feature.pascal}CardProps) {
126
144
 
127
145
  const formSource = (
128
146
  feature: NameSet,
147
+ module: string | undefined,
129
148
  ): string => `// Presentation only: the mutator this submits to owns validation server-side, so this form
130
149
  // never re-implements the invariant — a blank title fails at the boundary, not in the DOM.
131
150
 
132
- import { t } from '@ultimat3/i18n';
151
+ ${catalogImport(module)}
133
152
  import { createSignal } from 'solid-js';
134
153
  import styles from '../ui.module.scss';
135
154
 
@@ -138,7 +157,7 @@ export interface ${feature.pascal}FormProps {
138
157
  }
139
158
 
140
159
  export function ${feature.pascal}Form(props: ${feature.pascal}FormProps) {
141
- const [title, setTitle] = createSignal('');
160
+ const [title, setTitle] = createSignal('');${translatorBinding(module)}
142
161
  return (
143
162
  <form
144
163
  class={styles.item}
@@ -174,6 +193,11 @@ export interface ResourceOptions extends FeatureTarget {
174
193
  readonly admin?: boolean;
175
194
  /** Every locale the feature's catalog ships for. Defaults to `['en']`. */
176
195
  readonly locales?: readonly string[];
196
+ /**
197
+ * The app's own catalog module — `@<app>/i18n`, read off `packages/i18n/package.json` by
198
+ * `resolveCatalogModule`. Absent only for an app that ships no such package.
199
+ */
200
+ readonly catalogModule?: string;
177
201
  }
178
202
 
179
203
  export function resourceFiles(rawName: string, target: ResourceOptions): readonly GeneratedFile[] {
@@ -190,10 +214,16 @@ export function resourceFiles(rawName: string, target: ResourceOptions): readonl
190
214
  ...jobFiles(`reindex-${feature.kebab}`, slice),
191
215
  { path: `${dir}/service.ts`, contents: serviceSource(feature) },
192
216
  { path: `${dir}/service.test.ts`, contents: serviceTest(feature) },
193
- { path: `${dir}/ui.tsx`, contents: uiSource(feature) },
217
+ { path: `${dir}/ui.tsx`, contents: uiSource(feature, target.catalogModule) },
194
218
  { path: `${dir}/ui.module.scss`, contents: uiStyle() },
195
- { path: `${dir}/ui/${feature.kebab}-card.tsx`, contents: cardSource(feature) },
196
- { path: `${dir}/ui/${feature.kebab}-form.tsx`, contents: formSource(feature) },
219
+ {
220
+ path: `${dir}/ui/${feature.kebab}-card.tsx`,
221
+ contents: cardSource(feature, target.catalogModule),
222
+ },
223
+ {
224
+ path: `${dir}/ui/${feature.kebab}-form.tsx`,
225
+ contents: formSource(feature, target.catalogModule),
226
+ },
197
227
  ...locales.map((locale) => ({
198
228
  path: catalogPath(locale),
199
229
  contents: catalogSource(feature),
@@ -69,7 +69,27 @@ export const routeParams = (path: string): readonly string[] =>
69
69
  const routeDir = (surface: Surface, path: string): string =>
70
70
  `apps/web/${surface}/${segmentsOf(path).join('/')}`;
71
71
 
72
- const pageSource = (surface: Surface, path: string): string => {
72
+ /**
73
+ * How the generated page reaches a string, and it is the whole reason this generator takes an app
74
+ * module at all. `useT()` comes from the app's own catalog module — the one that calls
75
+ * `defineCatalogs()` — so a page that renders a string DEPENDS on the module that registers them.
76
+ * Reaching straight for `t` in `@ultimat3/i18n` renders strings while depending on nothing, which
77
+ * is how a shipped app served `⟦app.play.title⟧` on every page with a green gate (issue #249), and
78
+ * this generator is where that idiom came from.
79
+ *
80
+ * An app with no catalog module keeps the framework import: emitting one that cannot resolve would
81
+ * trade a wrong idiom for a file that does not compile.
82
+ */
83
+ const catalogImport = (module: string | undefined): string =>
84
+ module === undefined
85
+ ? "import { t } from '@ultimat3/i18n';"
86
+ : `import { useT } from '${module}';`;
87
+
88
+ /** `useT()` is per render, so the body binds it; `meta` takes the router's own `t`. */
89
+ const translatorBinding = (module: string | undefined): string =>
90
+ module === undefined ? '' : '\n const t = useT();\n';
91
+
92
+ const pageSource = (surface: Surface, path: string, module: string | undefined): string => {
73
93
  const name = pascal(
74
94
  path
75
95
  .split('/')
@@ -79,7 +99,7 @@ const pageSource = (surface: Surface, path: string): string => {
79
99
  return `// Route: /${path} on the ${surface} surface. Config first: render mode, offline
80
100
  // strategy and budget are declarations, not runtime choices.
81
101
 
82
- import { t } from '@ultimat3/i18n';
102
+ ${catalogImport(module)}
83
103
  import { defineRoute } from '@ultimat3/render';
84
104
  import styles from './page.module.scss';
85
105
 
@@ -88,13 +108,13 @@ export const config = defineRoute({
88
108
  hydrate: '${HYDRATE[surface]}',
89
109
  offline: '${OFFLINE[surface]}',
90
110
  budget: ${budgetLiteral(surface)},
91
- meta: () => ({
111
+ meta: ({ t }) => ({
92
112
  title: t('${titleKey(path)}'),
93
113
  description: t('${titleKey(path).replace('.title', '.description')}'),
94
114
  }),
95
115
  });
96
116
 
97
- export function ${name}Page() {
117
+ export function ${name}Page() {${translatorBinding(module)}
98
118
  return (
99
119
  <main class={styles.page}>
100
120
  <h1>{t('${titleKey(path)}')}</h1>
@@ -202,6 +222,12 @@ export interface RouteOptions {
202
222
  readonly surface: Surface;
203
223
  /** Every locale the catalog entry ships for. Defaults to `['en']` — an app narrows or grows it. */
204
224
  readonly locales?: readonly string[];
225
+ /**
226
+ * The app's own catalog module — `@<app>/i18n`, read off `packages/i18n/package.json` by
227
+ * `resolveCatalogModule`. Absent for an app that ships no such package, and only then does the
228
+ * generated page fall back to importing `t` from `@ultimat3/i18n`.
229
+ */
230
+ readonly catalogModule?: string;
205
231
  }
206
232
 
207
233
  export function routeFiles(rawPath: string, options: RouteOptions): readonly GeneratedFile[] {
@@ -209,7 +235,7 @@ export function routeFiles(rawPath: string, options: RouteOptions): readonly Gen
209
235
  const dir = routeDir(options.surface, path);
210
236
  const locales = resolveLocales(options.locales);
211
237
  return [
212
- { path: `${dir}/page.tsx`, contents: pageSource(options.surface, path) },
238
+ { path: `${dir}/page.tsx`, contents: pageSource(options.surface, path, options.catalogModule) },
213
239
  { path: `${dir}/page.module.scss`, contents: styleSource() },
214
240
  { path: `${dir}/page.test.ts`, contents: routeTest(options.surface, path) },
215
241
  { path: `${dir}/page.e2e.test.ts`, contents: routeE2eTest(path) },
@@ -7,6 +7,10 @@ import { apiFiles } from './scaffold-api';
7
7
  import { icon } from './scaffold-icon';
8
8
  import { rolesFiles } from './scaffold-roles';
9
9
 
10
+ // The one dependency this manifest names, and it is not decoration: every page below reads its
11
+ // strings through `@<app>/i18n`'s `useT()`, so the surface that renders a string DEPENDS on the
12
+ // module that registers the catalogs. An undeclared workspace dependency resolves through the root
13
+ // symlink and then breaks the day the app is built anywhere else.
10
14
  const webPackage = (app: NameSet): string => `{
11
15
  "name": "@${app.kebab}/web",
12
16
  "version": "0.0.0",
@@ -18,6 +22,9 @@ const webPackage = (app: NameSet): string => `{
18
22
  },
19
23
  "scripts": {
20
24
  "typecheck": "tsc --noEmit -p tsconfig.json"
25
+ },
26
+ "dependencies": {
27
+ "@${app.kebab}/i18n": "0.0.0"
21
28
  }
22
29
  }
23
30
  `;
@@ -34,7 +41,12 @@ const tsconfig = (): string => `{
34
41
  const sitePage = (
35
42
  app: NameSet,
36
43
  ): string => `// The landing page. site/ is 0kb JS: static render, hydrate never, no framework script tag.
37
- import { t } from '@ultimat3/i18n';
44
+ //
45
+ // Strings come from \`useT()\` — this app's own catalog module — and never from
46
+ // \`t\` in @ultimat3/i18n. That import is what puts the module holding \`defineCatalogs()\` in
47
+ // this page's graph, so rendering a string is what registers the catalogs. A page that reached
48
+ // past it shipped every string as \`\u27e6key\u27e7\` with \`x verify\` green (issue #249).
49
+ import { useT } from '@${app.kebab}/i18n';
38
50
  import { defineRoute } from '@ultimat3/render';
39
51
  import styles from './page.module.scss';
40
52
 
@@ -43,13 +55,17 @@ export const config = defineRoute({
43
55
  hydrate: 'never',
44
56
  offline: 'precache',
45
57
  budget: { js: '0kb' },
46
- meta: () => ({
58
+ // \`t\` is handed to \`meta\` by the router — one translator per render, resolved against the
59
+ // request's locale before the head is built.
60
+ meta: ({ t }) => ({
47
61
  title: t('site.home.title'),
48
62
  description: t('site.home.description'),
49
63
  }),
50
64
  });
51
65
 
52
66
  export function HomePage() {
67
+ const t = useT();
68
+
53
69
  return (
54
70
  <main class={styles.hero}>
55
71
  <h1>{t('site.home.title')}</h1>
@@ -103,11 +119,13 @@ unitTest('the landing page ships zero JS and declares metadata', async () => {
103
119
  });
104
120
  `;
105
121
 
106
- const dashboardPage =
107
- (): string => `// The authed dashboard. app/ streams: a static shell is flushed instantly and the holes arrive
122
+ const dashboardPage = (
123
+ app: NameSet,
124
+ ): string => `// The authed dashboard. app/ streams: a static shell is flushed instantly and the holes arrive
108
125
  // as their data resolves.
109
126
 
110
- import { t } from '@ultimat3/i18n';
127
+ // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
128
+ import { useT } from '@${app.kebab}/i18n';
111
129
  import { defineRoute } from '@ultimat3/render';
112
130
  import styles from './page.module.scss';
113
131
 
@@ -123,10 +141,15 @@ export const config = defineRoute({
123
141
  // Auth is a policy, never a route-local flag: one authz system, evaluated everywhere.
124
142
  policy: { permission: 'dashboard:read' },
125
143
  budget: { js: '60kb' },
126
- meta: () => ({ title: t('app.dashboard.title'), description: t('app.dashboard.description') }),
144
+ meta: ({ t }) => ({
145
+ title: t('app.dashboard.title'),
146
+ description: t('app.dashboard.description'),
147
+ }),
127
148
  });
128
149
 
129
150
  export function DashboardPage() {
151
+ const t = useT();
152
+
130
153
  return (
131
154
  <section class={styles.panel}>
132
155
  <h1>{t('app.dashboard.title')}</h1>
@@ -157,14 +180,18 @@ unitTest('the dashboard renders on the server, is gated, and has an offline stra
157
180
  });
158
181
  `;
159
182
 
160
- const offlineFallback =
161
- (): string => `// The offline fallback. Every app/ route with offline: 'runtime' falls back here, so a train
183
+ const offlineFallback = (
184
+ app: NameSet,
185
+ ): string => `// The offline fallback. Every app/ route with offline: 'runtime' falls back here, so a train
162
186
  // tunnel shows the product's own shell instead of the browser's error page.
163
187
 
164
- import { t } from '@ultimat3/i18n';
188
+ // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
189
+ import { useT } from '@${app.kebab}/i18n';
165
190
  import styles from './offline.module.scss';
166
191
 
167
192
  export function OfflineFallback() {
193
+ const t = useT();
194
+
168
195
  return (
169
196
  <main class={styles.offline}>
170
197
  <h1>{t('app.offline.title')}</h1>
@@ -267,6 +294,8 @@ unitTest('holds answers from the role map, and an anonymous actor holds nothing'
267
294
  });
268
295
  `;
269
296
 
297
+ // Same one dependency as `apps/web`, and for the same reason: `app/admin/page.tsx` reads its
298
+ // strings through `@<app>/i18n`'s `useT()`.
270
299
  const adminPackage = (app: NameSet): string => `{
271
300
  "name": "@${app.kebab}/admin",
272
301
  "version": "0.0.0",
@@ -278,28 +307,36 @@ const adminPackage = (app: NameSet): string => `{
278
307
  },
279
308
  "scripts": {
280
309
  "typecheck": "tsc --noEmit -p tsconfig.json"
310
+ },
311
+ "dependencies": {
312
+ "@${app.kebab}/i18n": "0.0.0"
281
313
  }
282
314
  }
283
315
  `;
284
316
 
285
- const adminPage =
286
- (): string => `// The generated admin dashboard. It ships an MCP surface over the app's own actions, so the
317
+ const adminPage = (
318
+ app: NameSet,
319
+ ): string => `// The generated admin dashboard. It ships an MCP surface over the app's own actions, so the
287
320
  // user's agents can drive the user's product with the user's permissions.
288
321
 
289
- import { t } from '@ultimat3/i18n';
322
+ // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
323
+ import { useT } from '@${app.kebab}/i18n';
290
324
  import { defineRoute } from '@ultimat3/render';
291
325
 
292
326
  export const config = defineRoute({
293
- render: 'spa',
327
+ render: 'ssr',
294
328
  hydrate: 'idle',
295
329
  offline: 'network-only',
296
- // A spa renders no data, so the shell itself must be gated — @ultimat3/render requires it.
330
+ // Behind auth, and \`ssr\` is the one mode that can be: it renders per request, so the guard runs
331
+ // on the server before the page does. \`static\` and \`isr\` refuse a policy outright.
297
332
  policy: { permission: 'admin:read' },
298
333
  budget: { js: '120kb' },
299
- meta: () => ({ title: t('admin.home.title'), description: t('admin.home.description') }),
334
+ meta: ({ t }) => ({ title: t('admin.home.title'), description: t('admin.home.description') }),
300
335
  });
301
336
 
302
337
  export function AdminHome() {
338
+ const t = useT();
339
+
303
340
  return <h1>{t('admin.home.title')}</h1>;
304
341
  }
305
342
  `;
@@ -402,10 +439,10 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
402
439
  { path: 'apps/web/site/page.tsx', contents: sitePage(app) },
403
440
  { path: 'apps/web/site/page.module.scss', contents: siteStyle() },
404
441
  { path: 'apps/web/site/page.test.ts', contents: sitePageTest() },
405
- { path: 'apps/web/app/dashboard/page.tsx', contents: dashboardPage() },
442
+ { path: 'apps/web/app/dashboard/page.tsx', contents: dashboardPage(app) },
406
443
  { path: 'apps/web/app/dashboard/page.module.scss', contents: dashboardStyle() },
407
444
  { path: 'apps/web/app/dashboard/page.test.ts', contents: dashboardTest() },
408
- { path: 'apps/web/app/offline.tsx', contents: offlineFallback() },
445
+ { path: 'apps/web/app/offline.tsx', contents: offlineFallback(app) },
409
446
  { path: 'apps/web/app/offline.module.scss', contents: offlineStyle() },
410
447
  // The third surface, and the one call that registers what the app declares — `scaffold-api.ts`.
411
448
  ...apiFiles(example),
@@ -425,7 +462,7 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
425
462
  // `apps/web/site/page.tsx` — `x dev` loads both surfaces into one route table and the
426
463
  // scaffolded app failed its own `x routes` with X_ROUTE_DUPLICATE. `/admin` also matches
427
464
  // @ultimat3/admin's own `basePath` default, so the two agree instead of merely not clashing.
428
- { path: 'apps/admin/app/admin/page.tsx', contents: adminPage() },
465
+ { path: 'apps/admin/app/admin/page.tsx', contents: adminPage(app) },
429
466
  { path: 'apps/mobile/README.md', contents: placeholder('mobile', app) },
430
467
  { path: 'apps/desktop/README.md', contents: placeholder('desktop', app) },
431
468
  ];
@@ -21,7 +21,7 @@ const dockerfile = (
21
21
  # syntax=docker/dockerfile:1
22
22
 
23
23
  # ---------- deps: runtime dependencies only, cached on the workspace manifests ----------
24
- FROM oven/bun:1.3-alpine AS deps
24
+ FROM oven/bun:1.4-alpine AS deps
25
25
  WORKDIR /app
26
26
  COPY package.json bun.lock ./
27
27
  # The workspace members' manifests are what \`bun install\` resolves against; their sources are not.
@@ -34,7 +34,7 @@ RUN bun install --frozen-lockfile --production
34
34
  # before it ever calls \`docker build\`. Re-running typecheck and lint here would need the
35
35
  # devDependencies the \`--production\` install above deliberately leaves out — which is exactly how
36
36
  # a build stage came to run \`tsc\` and \`biome\` against a tree that had neither.
37
- FROM oven/bun:1.3-alpine AS runtime
37
+ FROM oven/bun:1.4-alpine AS runtime
38
38
  WORKDIR /app
39
39
  COPY --from=deps /app/node_modules ./node_modules
40
40
  COPY . .
@@ -28,20 +28,6 @@ export * as schema from './schema';
28
28
  const SCHEMA_HEADER = `// Every entity the app declares, re-exported here. This list is what the migration generator
29
29
  // reads, so an entity that is not exported here does not exist as far as the database is concerned.`;
30
30
 
31
- /**
32
- * `bun run db:seed`'s entry point. Identical either way — only the rows differ. Interpolated, not
33
- * nested, so it carries exactly the escaping a single template literal needs.
34
- */
35
- const SEED_MAIN = `
36
-
37
- if (import.meta.main) {
38
- const count = await seed();
39
- // Bun's stdout, not process.stdout: one runtime, one API. Awaited because the write resolves
40
- // asynchronously, and this JSON line is the whole output of \`bun run db:seed\`.
41
- await Bun.stdout.write(\`\${JSON.stringify({ ok: true, seeded: count })}\\n\`);
42
- }
43
- `;
44
-
45
31
  const dbSchema = (app: NameSet, example: boolean): string =>
46
32
  example
47
33
  ? `${SCHEMA_HEADER}
@@ -52,34 +38,66 @@ export { post } from '@${app.kebab}/web/app/post/entity';
52
38
  export {};
53
39
  `;
54
40
 
41
+ /**
42
+ * The seed, as a `defineSeed()` — which is what `x db seed` discovers and what the framework has
43
+ * meant by "a seed" since 2.0.0.
44
+ *
45
+ * It used to be a plain `export async function seed()` with an `import.meta.main` block, run by a
46
+ * `bun run db:seed` npm script, and that shipped two defects at once. `x db seed` discovers
47
+ * every `seed*.ts` under a package's `src` and looks for an exported `Seed`, so the scaffold's
48
+ * own seed was invisible to its own command — `x db seed` on a fresh app answered "no seed
49
+ * matched".
50
+ * And `bun run db:seed` reaches the database through `@ultimat3/db`'s `db()`, which reads
51
+ * `DATABASE_URL` and speaks `postgres:` only, so on a clone with no Postgres it cannot see the
52
+ * embedded PGlite that `x db migrate` had just migrated in process. `bin/setup` therefore printed
53
+ * `✓ migrations applied` and then died on `X_DB_UNAVAILABLE`, whose `fix:` says "run `x dev` to use
54
+ * the embedded PGlite" — naming the mechanism that had just worked one line above.
55
+ *
56
+ * `x db seed` owns the connection, the tier and the per-seed transaction. One runner, one answer.
57
+ */
55
58
  const dbSeed = (app: NameSet, example: boolean): string =>
56
59
  example
57
- ? `// Deterministic seed: same rows every time, so a test and a demo see the same database.
58
- import { db, sql } from '@ultimat3/db';
59
-
60
- const ORG = '00000000-0000-0000-0000-000000000002';
60
+ ? `// Deterministic fixtures: the same rows every time, so a test, a demo and a branch database
61
+ // all see the same content.
62
+ //
63
+ // \`x db seed\` is the runner — it discovers every exported \`defineSeed()\` in a package's
64
+ // \`src/seed*.ts\`, opens the database exactly as \`x db migrate\` does (embedded PGlite
65
+ // included), and wraps each seed in its own transaction. Never a plain \`bun run\` script: that
66
+ // reaches the database through \`db()\`, which needs a \`postgres:\` \`DATABASE_URL\` and so cannot
67
+ // see the embedded database at all.
68
+ import { defineSeed } from '@ultimat3/entity';
69
+ import { post } from './schema';
61
70
 
62
- export async function seed(): Promise<number> {
63
- const rows = [
64
- { id: '00000000-0000-0000-0000-000000000101', title: 'Hello ${app.pascal}', minor: 0 },
65
- { id: '00000000-0000-0000-0000-000000000102', title: 'Second post', minor: 1900 },
66
- ];
67
- for (const row of rows) {
68
- // Idempotent by primary key, so re-seeding a branch database is a no-op rather than a crash.
69
- await db().execute(sql\`
70
- insert into posts (id, org_id, title, price_minor, price_currency)
71
- values (\${row.id}, \${ORG}, \${row.title}, \${row.minor}, 'USD')
72
- on conflict (id) do nothing\`);
73
- }
74
- return rows.length;
75
- }${SEED_MAIN}`
76
- : `// Deterministic seed: same rows every time, so a test and a demo see the same database.
77
- // No entity is declared yet, so there is nothing to insert — the shape stays, so the first
78
- // \`x g entity\` has one obvious place to seed from.
71
+ /** Stable across runs: \`id('post:hello')\` is a UUID v5 of the label, not a random one. */
72
+ export const ${app.camel}Seed = defineSeed('${app.kebab}', async ({ insert, id }) => {
73
+ await insert(post, [
74
+ {
75
+ id: id('post:hello'),
76
+ orgId: id('org:demo'),
77
+ title: 'Hello ${app.pascal}',
78
+ price: { minor: 0, currency: 'USD' },
79
+ },
80
+ {
81
+ id: id('post:second'),
82
+ orgId: id('org:demo'),
83
+ title: 'Second post',
84
+ price: { minor: 1900, currency: 'USD' },
85
+ },
86
+ ]);
87
+ });
88
+ `
89
+ : `// Deterministic fixtures, run by \`x db seed\`. No entity is declared yet, so there is nothing
90
+ // to insert — the shape stays so the first \`x g entity\` has one obvious place to seed from.
91
+ //
92
+ // \`x db seed\` discovers every exported \`defineSeed()\` in a package's \`src/seed*.ts\` and opens
93
+ // the database the way \`x db migrate\` does, embedded PGlite included. Never a plain \`bun run\`
94
+ // script: that needs a \`postgres:\` \`DATABASE_URL\` and cannot see the embedded database.
95
+ import { defineSeed } from '@ultimat3/entity';
79
96
 
80
- export async function seed(): Promise<number> {
81
- return 0;
82
- }${SEED_MAIN}`;
97
+ export const ${app.camel}Seed = defineSeed('${app.kebab}', async () => {
98
+ // \`await insert(<entity>, [...])\` once an entity exists.
99
+ });
100
+ `;
83
101
 
84
102
  /** Every file the `packages/db` workspace ships, in the order `x new` writes them. */
85
103
  export const dbPackageFiles = (app: NameSet, example: boolean): readonly GeneratedFile[] => [
@@ -88,7 +88,11 @@ bun install
88
88
  # this script is documented idempotent, and the guard is what makes that true here.
89
89
  ls packages/db/migrations/*.sql >/dev/null 2>&1 || bunx x db gen "initial"
90
90
  bunx x db migrate "$@"
91
- bun run db:seed
91
+ # \`x db seed\`, never \`bun run\`: the CLI owns the connection, so this reaches the same embedded
92
+ # PGlite the migration above just wrote to. A plain script goes through \`db()\`, which needs a
93
+ # \`postgres:\` DATABASE_URL and so dies on a clone with no Postgres — one line after reporting a
94
+ # successful migration.
95
+ bunx x db seed
92
96
  echo "setup complete — next: x dev"
93
97
  `;
94
98
 
@@ -103,6 +107,19 @@ const binCheck = (): string => `#!/usr/bin/env bash
103
107
  # The gate. Same steps as CI, because a check that lives only in CI cannot be run locally.
104
108
  set -euo pipefail
105
109
  cd "$(dirname "$0")/.."
110
+ # The build FIRST, and not as a convenience: \`x verify\`'s budgets step compares declared limits
111
+ # against measured bytes in .x/build-stats.json, so with no build it reports X_BUDGET_UNMEASURED and
112
+ # the very first gate anyone runs on a brand-new app is red for a reason that has nothing to do with
113
+ # their code. Cheap on a warm tree, and it makes "green" reachable from a fresh clone.
114
+ #
115
+ # \`--json\` is forwarded to BOTH, or the contract breaks: \`bin/check --json\` would otherwise print
116
+ # the build's human renderer to stdout and then the gate's JSON, and a machine consumer reading one
117
+ # document off stdout gets neither. Both commands emit one object; a reader takes the last line.
118
+ build_flags=""
119
+ for arg in "$@"; do
120
+ case "$arg" in --json|-j) build_flags="--json" ;; esac
121
+ done
122
+ bunx x build --target static $build_flags
106
123
  exec bunx x verify "$@"
107
124
  `;
108
125
 
@@ -87,8 +87,15 @@ export type AppCatalog = typeof en;
87
87
  export type TranslationKey = KeyOf<AppCatalog>;
88
88
 
89
89
  /**
90
- * Use this, never \`useI18n()\` directly — the type parameter is what makes an unknown key a
91
- * compile error instead of a \`⟦key⟧\` someone notices in production.
90
+ * The app's ONE way to read a string. Never \`useI18n()\` directly and never \`t\` from
91
+ * \`@ultimat3/i18n\`, for two independent reasons:
92
+ *
93
+ * 1. the type parameter makes an unknown key a compile error instead of a \`⟦key⟧\` someone
94
+ * notices in production;
95
+ * 2. importing THIS module is what registers the catalogs — \`defineCatalogs()\` above runs on
96
+ * import and nowhere else. A page that reached past it rendered every string as \`⟦key⟧\`
97
+ * with \`x verify\` green, because nothing in the app depended on the module that registers
98
+ * (issue #249). \`x i18n check\` now refuses that app; this import is why it never happens.
92
99
  */
93
100
  export const useT = (): Translator<AppCatalog> => useI18n<AppCatalog>();
94
101
  `;
@@ -45,12 +45,12 @@ const rootPackage = (app: NameSet, version: string): string => `{
45
45
  "lint": "biome check .",
46
46
  "test": "bun test",
47
47
  "db:migrate": "x db migrate",
48
- "db:seed": "bun run packages/db/src/seed.ts"
48
+ "db:seed": "x db seed"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@biomejs/biome": "${BIOME_VERSION}",
52
52
  "@electric-sql/pglite": "^0.5.4",
53
- "@types/bun": "^1.3.14",
53
+ "@types/bun": "^1.4.0",
54
54
  "@ultimat3/testing": "^${version}",
55
55
  "typescript": "^6.0.3"
56
56
  },