@ultimat3/cli 20.2.1 → 22.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 (237) hide show
  1. package/CLAUDE.md +84 -1582
  2. package/README.md +40 -2
  3. package/package.json +31 -31
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-env.ts +2 -2
  8. package/src/app-load.ts +37 -5
  9. package/src/app-openapi.ts +27 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-root.ts +1 -1
  12. package/src/async-pages.ts +32 -0
  13. package/src/bin.ts +7 -1
  14. package/src/boundary-findings.ts +36 -0
  15. package/src/browser-launcher-fake-html.ts +80 -0
  16. package/src/browser-launcher-fake.ts +165 -0
  17. package/src/browser-launcher-port.ts +159 -0
  18. package/src/browser-launcher.ts +26 -133
  19. package/src/budgets.ts +56 -13
  20. package/src/build-errors.ts +54 -0
  21. package/src/cdp-shot-a11y.ts +77 -0
  22. package/src/cdp-shot-clock.ts +14 -0
  23. package/src/cdp-shot-driver.ts +150 -0
  24. package/src/cdp-shot-element.ts +147 -0
  25. package/src/cdp-shot-errors.ts +62 -0
  26. package/src/cdp-shot-keys.ts +152 -0
  27. package/src/cdp-shot-page.ts +230 -0
  28. package/src/cdp-shot-watch.ts +241 -0
  29. package/src/cmd-affected-spec.ts +24 -0
  30. package/src/cmd-affected.ts +3 -19
  31. package/src/cmd-build-spec.ts +16 -0
  32. package/src/cmd-build.ts +46 -20
  33. package/src/cmd-ci-spec.ts +24 -0
  34. package/src/cmd-ci.ts +17 -21
  35. package/src/cmd-db-branch.ts +2 -2
  36. package/src/cmd-db-spec.ts +99 -0
  37. package/src/cmd-db.ts +15 -94
  38. package/src/cmd-deploy-helm.ts +136 -0
  39. package/src/cmd-deploy-spec.ts +40 -0
  40. package/src/cmd-deploy.ts +117 -42
  41. package/src/cmd-dev-spec.ts +25 -0
  42. package/src/cmd-dev.ts +78 -122
  43. package/src/cmd-docs-spec.ts +16 -0
  44. package/src/cmd-docs.ts +2 -11
  45. package/src/cmd-doctor-spec.ts +21 -0
  46. package/src/cmd-doctor.ts +4 -18
  47. package/src/cmd-env-spec.ts +18 -0
  48. package/src/cmd-env.ts +4 -13
  49. package/src/cmd-errors-spec.ts +23 -0
  50. package/src/cmd-errors.ts +3 -17
  51. package/src/cmd-fix-spec.ts +18 -0
  52. package/src/cmd-fix.ts +4 -13
  53. package/src/cmd-generate-spec.ts +31 -0
  54. package/src/cmd-generate.ts +46 -42
  55. package/src/cmd-i18n-spec.ts +16 -0
  56. package/src/cmd-i18n.ts +7 -14
  57. package/src/cmd-jobs-spec.ts +56 -0
  58. package/src/cmd-jobs.ts +3 -50
  59. package/src/cmd-manifest-spec.ts +15 -0
  60. package/src/cmd-manifest.ts +19 -20
  61. package/src/cmd-mcp-spec.ts +21 -0
  62. package/src/cmd-mcp.ts +2 -15
  63. package/src/cmd-new-spec.ts +33 -0
  64. package/src/cmd-new.ts +38 -33
  65. package/src/cmd-policy-spec.ts +13 -0
  66. package/src/cmd-policy.ts +2 -9
  67. package/src/cmd-pr-spec.ts +39 -0
  68. package/src/cmd-pr.ts +5 -65
  69. package/src/cmd-registries-spec.ts +32 -0
  70. package/src/cmd-registries.ts +4 -25
  71. package/src/cmd-routes-spec.ts +12 -0
  72. package/src/cmd-routes.ts +2 -7
  73. package/src/cmd-secrets-spec.ts +19 -0
  74. package/src/cmd-secrets.ts +22 -34
  75. package/src/cmd-shot-island.ts +12 -13
  76. package/src/cmd-shot-spec.ts +53 -0
  77. package/src/cmd-shot.ts +11 -60
  78. package/src/cmd-tasks-spec.ts +21 -0
  79. package/src/cmd-tasks.ts +2 -16
  80. package/src/cmd-test-spec.ts +54 -0
  81. package/src/cmd-test.ts +4 -49
  82. package/src/cmd-verify-spec.ts +28 -0
  83. package/src/cmd-verify.ts +3 -23
  84. package/src/db-seed.ts +4 -2
  85. package/src/dev-dashboard.ts +4 -4
  86. package/src/dev-lock.ts +18 -2
  87. package/src/dev-port.ts +21 -0
  88. package/src/dev-route-table.ts +119 -0
  89. package/src/dispatch.ts +12 -5
  90. package/src/document-styles.ts +1 -1
  91. package/src/drift.ts +4 -18
  92. package/src/error-catalog.ts +16 -9
  93. package/src/error-codes.ts +33 -28
  94. package/src/error-pages.ts +19 -7
  95. package/src/error-unthrown.ts +130 -0
  96. package/src/errors.ts +22 -29
  97. package/src/favicon.ts +2 -2
  98. package/src/fix-imports.ts +1 -1
  99. package/src/fix-scan.ts +2 -9
  100. package/src/flag-reads.ts +1 -2
  101. package/src/foreign-text.ts +36 -0
  102. package/src/framework-schema.ts +5 -6
  103. package/src/generate-feature.ts +42 -0
  104. package/src/generate-files.ts +7 -3
  105. package/src/generate-grants.ts +83 -0
  106. package/src/generate-kinds.ts +56 -4
  107. package/src/i18n-index.ts +59 -10
  108. package/src/icon-assets.ts +1 -1
  109. package/src/image-prepare.ts +14 -0
  110. package/src/import-scan.ts +63 -0
  111. package/src/index.ts +67 -334
  112. package/src/invocation-flags.ts +26 -0
  113. package/src/island-bundle.ts +38 -11
  114. package/src/island-capture.ts +6 -6
  115. package/src/island-harness-script.ts +6 -2
  116. package/src/island-realtime.ts +98 -0
  117. package/src/island-shot.ts +5 -0
  118. package/src/island-store.ts +131 -0
  119. package/src/island-verdict.ts +2 -2
  120. package/src/job-registration.ts +42 -0
  121. package/src/jobs-driver.ts +2 -2
  122. package/src/live-routes.ts +82 -42
  123. package/src/load-findings.ts +51 -0
  124. package/src/mcp-db-target.ts +1 -1
  125. package/src/mcp-errors.ts +30 -26
  126. package/src/mcp-host.ts +27 -12
  127. package/src/mcp-ui-diff.ts +27 -0
  128. package/src/mcp-ui-inspect.ts +4 -4
  129. package/src/mcp-ui-interact.ts +21 -15
  130. package/src/mcp-ui.ts +19 -15
  131. package/src/measure-database.ts +73 -0
  132. package/src/measure-paths.ts +74 -0
  133. package/src/measure-scope.ts +74 -0
  134. package/src/messages.ts +1 -3
  135. package/src/metrics-endpoint.ts +1 -1
  136. package/src/otlp-export.ts +1 -1
  137. package/src/output.ts +6 -0
  138. package/src/page-sync.ts +54 -0
  139. package/src/permission-grants.ts +86 -0
  140. package/src/prerender-out.ts +25 -0
  141. package/src/prerender.ts +121 -102
  142. package/src/pwa-artifacts.ts +3 -3
  143. package/src/realtime-browser-probe-fixture.ts +2 -2
  144. package/src/reexport-manifest.ts +2 -1
  145. package/src/registry.ts +80 -56
  146. package/src/role-realtime.ts +36 -0
  147. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  148. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  149. package/src/role-start-types.ts +112 -0
  150. package/src/{dev-roles.ts → role-start.ts} +42 -115
  151. package/src/{dev-sync.ts → role-sync.ts} +23 -11
  152. package/src/root-env.ts +67 -0
  153. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  154. package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
  155. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  156. package/src/runtime-jobs.ts +87 -0
  157. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
  158. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  159. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  160. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  161. package/src/runtime-realtime.ts +55 -0
  162. package/src/{dev-render.ts → runtime-render.ts} +119 -20
  163. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  164. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  165. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  166. package/src/scaffold-fixture.ts +28 -6
  167. package/src/scaffold-typecheck.ts +6 -3
  168. package/src/schema-drift.ts +7 -1
  169. package/src/script-csp.ts +5 -2
  170. package/src/secrets-rotation.ts +59 -0
  171. package/src/serve-boot.ts +192 -0
  172. package/src/serve-drain.ts +24 -0
  173. package/src/serve-entry.ts +6 -0
  174. package/src/serve-env.ts +116 -0
  175. package/src/serve-types.ts +55 -0
  176. package/src/serve.ts +44 -338
  177. package/src/shot-server.ts +2 -2
  178. package/src/shot-settle.ts +10 -1
  179. package/src/shot-theme.ts +3 -3
  180. package/src/shot-verdict.ts +16 -7
  181. package/src/signal-shred.ts +27 -0
  182. package/src/solid-loader.ts +26 -2
  183. package/src/static-report.ts +8 -1
  184. package/src/sw-artifacts.ts +13 -3
  185. package/src/sync-url.ts +31 -0
  186. package/src/templates/action.ts +30 -16
  187. package/src/templates/entity.ts +12 -7
  188. package/src/templates/index.ts +1 -1
  189. package/src/templates/job.ts +10 -7
  190. package/src/templates/policy.ts +20 -2
  191. package/src/templates/resource-create.ts +127 -0
  192. package/src/templates/resource-form-island.ts +76 -30
  193. package/src/templates/resource.ts +10 -4
  194. package/src/templates/route.ts +3 -0
  195. package/src/templates/scaffold-app.ts +4 -1
  196. package/src/templates/scaffold-auth.ts +3 -1
  197. package/src/templates/scaffold-container-compose.ts +184 -0
  198. package/src/templates/scaffold-container.ts +25 -145
  199. package/src/templates/scaffold-dashboard-example.ts +2 -2
  200. package/src/templates/scaffold-db-package.ts +16 -3
  201. package/src/templates/scaffold-demo-org.ts +41 -0
  202. package/src/templates/scaffold-entries.ts +1 -1
  203. package/src/templates/scaffold-env.ts +6 -0
  204. package/src/templates/scaffold-helm-templates.ts +66 -7
  205. package/src/templates/scaffold-helm.ts +27 -5
  206. package/src/templates/scaffold-i18n.ts +15 -10
  207. package/src/templates/scaffold-repo.ts +13 -10
  208. package/src/templates/scaffold-roles.ts +38 -10
  209. package/src/templates/slice-foundation.ts +1 -1
  210. package/src/templates/wrap.ts +4 -1
  211. package/src/test-passes.ts +2 -1
  212. package/src/test-workers.ts +26 -0
  213. package/src/ts-scan.ts +3 -6
  214. package/src/tsconfig-references.ts +1 -2
  215. package/src/verify-checks.ts +41 -39
  216. package/src/verify-e2e.ts +41 -0
  217. package/src/verify-run.ts +115 -50
  218. package/src/verify-step.ts +3 -3
  219. package/src/verify-tests.ts +22 -30
  220. package/src/verify-typecheck.ts +28 -0
  221. package/src/web-binding.ts +2 -2
  222. package/src/worker-bundle.ts +192 -0
  223. package/src/workspace-graph.ts +10 -33
  224. package/src/cdp-browser.ts +0 -100
  225. package/src/cdp-connection.ts +0 -211
  226. package/src/cdp-e2e-page.ts +0 -209
  227. package/src/cdp-errors.ts +0 -56
  228. package/src/cdp-launch.ts +0 -139
  229. package/src/e2e-dom-fixture.ts +0 -117
  230. package/src/e2e-driver.ts +0 -97
  231. package/src/e2e-errors.ts +0 -103
  232. package/src/e2e-evaluate.ts +0 -156
  233. package/src/e2e-locator.ts +0 -86
  234. package/src/e2e-page.ts +0 -150
  235. package/src/e2e-selection.ts +0 -182
  236. package/src/measurement-actor.ts +0 -26
  237. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
@@ -32,11 +32,14 @@ const formIslandSource = (
32
32
  // the PAGE, which is one directory across from this one:
33
33
  // const ${feature.pascal}Form = island({
34
34
  // src: '${specifier}',
35
- // props: ['endpoint', 'locale', 'labels'],
35
+ // props: ['endpoint', 'locale', 'currency', 'labels'],
36
36
  // });
37
- // <${feature.pascal}Form endpoint={derivePath('create${feature.pascal}').path} locale={locale} labels={labels} />
37
+ // <${feature.pascal}Form endpoint={derivePath('create${feature.pascal}').path} locale={locale}
38
+ // currency="USD" labels={labels} />
38
39
  // A string has no import edge, so the page's bundle graph stays the page's (axiom 6).
39
40
 
41
+ import { clientTransport } from '@ultimat3/core';
42
+ import { fromDecimal } from '@ultimat3/money';
40
43
  import { Button, Form, Input, setSolidRuntime, UiProvider } from '@ultimat3/ui';
41
44
  import type { JSX } from 'solid-js';
42
45
  import {
@@ -57,8 +60,11 @@ export interface ${feature.pascal}FormProps {
57
60
  readonly endpoint: string;
58
61
  /** The request's own locale. A browser has no ambient one the server ever agreed to. */
59
62
  readonly locale: string;
63
+ /** The ISO code the price is entered in. The amount is typed as a decimal and sent as minor units. */
64
+ readonly currency: string;
60
65
  readonly labels: {
61
66
  readonly title: string;
67
+ readonly price: string;
62
68
  readonly submit: string;
63
69
  readonly saved: string;
64
70
  readonly retry: string;
@@ -71,25 +77,30 @@ type SaveState = 'idle' | 'saved' | 'failed';
71
77
  * Presentation only: the action this submits to owns validation server-side, so the form never
72
78
  * re-implements the invariant — a blank title fails at the boundary, not in the DOM.
73
79
  *
74
- * A plain \`fetch\` to the path the server minted, not the typed client: \`rpc()\` pulls
75
- * \`@ultimat3/action\` into the chunk, which is larger than everything else here put together. The
76
- * naming rule is still the framework's; only the transport is second.
80
+ * \`clientTransport\` — the framework's one browser HTTP function — to the path the server minted.
81
+ * Never a raw \`fetch\`: the transport is what decodes a refusal into its code, fences a sign-out,
82
+ * and hands any entity rows the answer carries to the page's store.
77
83
  */
78
84
  function ${feature.pascal}FormBody(props: ${feature.pascal}FormProps): JSX.Element {
79
85
  const [title, setTitle] = createSignal('');
86
+ const [amount, setAmount] = createSignal('');
80
87
  const [state, setState] = createSignal<SaveState>('idle');
81
88
 
82
- // A rejected \`fetch\` — offline, DNS, an aborted request — is the same OUTCOME as a refused one,
83
- // and it is the one \`retry\` exists for. Without the catch, \`setState\` is never reached: the
84
- // status line stays empty, and the rejection escapes the \`void send()\` below as an unhandled one.
89
+ // A refusal and a request that never got a response both REJECT here — the transport turns a
90
+ // non-2xx into its code and an offline \`fetch\` into X_CLIENT_TRANSPORT_FAILED — and both are the
91
+ // outcome \`retry\` exists for. Without the catch the rejection escapes \`void send()\` unhandled.
92
+ // The body is the create action's input: the entity's own columns, the price as integer minor
93
+ // units. \`fromDecimal\` reads the typed digits as a string, never a float, and refuses what is not
94
+ // a number — the same \`retry\` state, because the server would refuse it too.
85
95
  const send = async (): Promise<void> => {
86
96
  try {
87
- const response = await fetch(props.endpoint, {
97
+ const price = fromDecimal(amount(), props.currency);
98
+ await clientTransport({
88
99
  method: 'POST',
89
- headers: { 'content-type': 'application/json' },
90
- body: JSON.stringify({ title: title() }),
100
+ url: props.endpoint,
101
+ body: { title: title(), price },
91
102
  });
92
- setState(response.ok ? 'saved' : 'failed');
103
+ setState('saved');
93
104
  } catch {
94
105
  setState('failed');
95
106
  }
@@ -113,6 +124,12 @@ function ${feature.pascal}FormBody(props: ${feature.pascal}FormProps): JSX.Eleme
113
124
  value={title()}
114
125
  onInput={(event) => setTitle(event.currentTarget.value)}
115
126
  />
127
+ <Input
128
+ aria-label={props.labels.price}
129
+ inputmode="decimal"
130
+ value={amount()}
131
+ onInput={(event) => setAmount(event.currentTarget.value)}
132
+ />
116
133
  <Button type="submit">{props.labels.submit}</Button>
117
134
  <p data-role="status" role="status" aria-live="polite">
118
135
  {status()}
@@ -177,7 +194,13 @@ const APP_ROOT = join(import.meta.dir, ${upToAppRoot(dir)});
177
194
  const ISLAND = '${dir}/${feature.kebab}-form.island.tsx';
178
195
  const ENDPOINT = '/api/${feature.kebab}/create-${feature.kebab}';
179
196
 
180
- const LABELS = { title: 'Title', submit: 'Save', saved: 'Saved', retry: 'Try again' };
197
+ const LABELS = {
198
+ title: 'Title',
199
+ price: 'Price',
200
+ submit: 'Save',
201
+ saved: 'Saved',
202
+ retry: 'Try again',
203
+ };
181
204
 
182
205
  const calls: { url: string; body: Record<string, unknown> }[] = [];
183
206
 
@@ -194,17 +217,18 @@ beforeAll(async () => {
194
217
  build: buildIslands,
195
218
  root: APP_ROOT,
196
219
  file: ISLAND,
197
- props: { endpoint: ENDPOINT, locale: 'en', labels: LABELS },
220
+ props: { endpoint: ENDPOINT, locale: 'en', currency: 'USD', labels: LABELS },
198
221
  // What the server rendered inside the island's wrapper. \`mount\` replaces it.
199
222
  shell: '<p>Loading</p>',
200
223
  globals: {
201
- fetch: (url: string, init: { body: string }): Promise<{ ok: boolean }> => {
224
+ // The form sends through \`clientTransport\`, which calls \`globalThis.fetch\` — this stub.
225
+ fetch: (url: string, init: { body: string }): Promise<Response> => {
202
226
  calls.push({ url, body: JSON.parse(init.body) as Record<string, unknown> });
203
- // What a browser rejects with when there is no network. Not a response: \`response.ok\`
204
- // is never read on this path, which is exactly why the form has to catch it.
227
+ // What a browser rejects with when there is no network. Not a response, which is exactly
228
+ // why the form has to catch it.
205
229
  return networkFails
206
230
  ? Promise.reject(new TypeError('Failed to fetch'))
207
- : Promise.resolve({ ok: true });
231
+ : Promise.resolve(Response.json({ id: 'created' }));
208
232
  },
209
233
  },
210
234
  });
@@ -221,6 +245,18 @@ afterAll(() => {
221
245
  mounted?.[Symbol.dispose]();
222
246
  });
223
247
 
248
+ /**
249
+ * Until the status line changes, a bounded number of macrotasks: the send is several awaits deep
250
+ * inside \`clientTransport\`, so counting microtasks would pin the transport, not the form.
251
+ */
252
+ async function statusSettled(): Promise<void> {
253
+ const before = mounted.text('[data-role="status"]');
254
+ for (let tick = 0; tick < 50; tick += 1) {
255
+ if (mounted.text('[data-role="status"]') !== before) return;
256
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
257
+ }
258
+ }
259
+
224
260
  /**
225
261
  * One mount, driven as a session: the cases below run in order against the same island, because
226
262
  * building the real chunk costs seconds and repeating it per case would pay them for state each
@@ -234,17 +270,25 @@ describe('the ${feature.kebab} form island', () => {
234
270
  expect(mounted.code).not.toMatch(/\\bReact\\b/);
235
271
  });
236
272
 
237
- test('the field tracks, and submit posts what was typed', async () => {
238
- const field: FakeElement | null = mounted.find('input');
239
- expect(field).not.toBeNull();
240
- if (field !== null) field.value = 'First ${feature.camel}';
273
+ test('the fields track, and submit posts the create input', async () => {
274
+ const title: FakeElement | null = mounted.find('input[aria-label="Title"]');
275
+ const price: FakeElement | null = mounted.find('input[aria-label="Price"]');
276
+ expect(title).not.toBeNull();
277
+ expect(price).not.toBeNull();
278
+ if (title !== null) title.value = 'First ${feature.camel}';
279
+ if (price !== null) price.value = '12.50';
241
280
  // \`false\` means no handler ran — an island whose onInput never reached the DOM looks
242
281
  // identical to a selector typo otherwise.
243
- expect(mounted.fire(field, 'input')).toBe(true);
282
+ expect(mounted.fire(title, 'input')).toBe(true);
283
+ expect(mounted.fire(price, 'input')).toBe(true);
244
284
  expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
245
- await Promise.resolve();
285
+ await statusSettled();
246
286
 
247
- expect(calls).toEqual([{ url: ENDPOINT, body: { title: 'First ${feature.camel}' } }]);
287
+ const body = {
288
+ title: 'First ${feature.camel}',
289
+ price: { minor: 1250, currency: 'USD' },
290
+ };
291
+ expect(calls).toEqual([{ url: ENDPOINT, body }]);
248
292
  });
249
293
 
250
294
  test('the status line answers the response', () => {
@@ -253,12 +297,11 @@ describe('the ${feature.kebab} form island', () => {
253
297
  });
254
298
 
255
299
  test('a request that never got a response still reaches retry', async () => {
256
- // The outcome \`retry\` is FOR. A \`fetch\` that rejects reaches no \`response.ok\`, so without
257
- // the catch in \`send\` the status line stays on its last value and the rejection escapes.
300
+ // The outcome \`retry\` is FOR. A \`fetch\` that rejects produces no answer, so without the
301
+ // catch in \`send\` the status line stays on its last value and the rejection escapes.
258
302
  networkFails = true;
259
303
  expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
260
- await Promise.resolve();
261
- await Promise.resolve();
304
+ await statusSettled();
262
305
 
263
306
  expect(mounted.text('[data-role="status"]')).toBe(LABELS.retry);
264
307
  });
@@ -291,10 +334,12 @@ import type { ${feature.pascal}FormProps } from './${feature.kebab}-form.island'
291
334
 
292
335
  /** What a working render hands the form — the baseline the state below departs from. */
293
336
  const BASE = {
294
- endpoint: '/api/create-${feature.kebab}',
337
+ endpoint: '/api/${feature.pluralKebab}/create',
295
338
  locale: 'en',
339
+ currency: 'USD',
296
340
  labels: {
297
341
  title: 'Title',
342
+ price: 'Price',
298
343
  submit: 'Save',
299
344
  saved: 'Saved',
300
345
  retry: 'That did not save. Try again.',
@@ -318,6 +363,7 @@ export const ${feature.camel}FormStates = defineIslandStates({
318
363
  locale: 'de',
319
364
  labels: {
320
365
  title: 'Bezeichnung des Beitrags',
366
+ price: 'Preis',
321
367
  submit: 'Änderungen speichern',
322
368
  saved: 'Änderungen gespeichert',
323
369
  retry: 'Das konnte nicht gespeichert werden. Bitte erneut versuchen.',
@@ -13,6 +13,7 @@ import type { GeneratedFile, NameSet } from './naming';
13
13
  import { names, pascal } from './naming';
14
14
  import { policyFiles } from './policy';
15
15
  import { queryFiles } from './query';
16
+ import { resourceCreateFiles } from './resource-create';
16
17
  import { formIslandFiles } from './resource-form-island';
17
18
  import { routeDir, routeFiles } from './route';
18
19
 
@@ -178,13 +179,18 @@ export function resourceFiles(rawName: string, target: ResourceOptions): readonl
178
179
  // question and only one of them reaches `routeFiles`.
179
180
  const pageDir = routeDir('app', feature.pluralKebab);
180
181
  const locales = resolveLocales(target.locales);
182
+ const entity = entityFiles(rawName, slice);
183
+ // The entity this same call writes, handed to the generators composed below as if it were on
184
+ // disk: they write into a slice that HAS data, so they read it rather than a neutral body.
185
+ const sliceEntity = String(entity.find((file) => file.path.endsWith('/entity.ts'))?.contents);
181
186
  return [
182
- ...entityFiles(rawName, slice),
187
+ ...entity,
183
188
  ...policyFiles(rawName, slice),
184
- ...actionFiles(`create-${feature.kebab}`, slice),
185
- ...actionFiles(`archive-${feature.kebab}`, slice),
189
+ // Not `x g action`'s body: a resource's create INSERTS (`resource-create.ts`).
190
+ ...resourceCreateFiles(rawName, dir),
191
+ ...actionFiles(`archive-${feature.kebab}`, { ...slice, sliceEntity }),
186
192
  ...queryFiles(`${feature.camel}List`, { ...slice, live: true }),
187
- ...jobFiles(`reindex-${feature.kebab}`, slice),
193
+ ...jobFiles(`reindex-${feature.kebab}`, { ...slice, sliceEntity }),
188
194
  { path: `${dir}/service.ts`, contents: serviceSource(feature) },
189
195
  { path: `${dir}/service.test.ts`, contents: serviceTest(feature) },
190
196
  { path: `${dir}/ui.tsx`, contents: uiSource(feature, target.catalogModule) },
@@ -219,6 +219,9 @@ import { e2eTest, expect } from '@ultimat3/testing';
219
219
  // \`e2eTest\` reports itself skipped, naming the command that builds what it would drive.
220
220
  e2eTest('/${path} renders offline', async ({ page, offline }) => {
221
221
  await page.goto('/${sampleUrl(path)}');
222
+ // \`offline()\` cuts the service worker's network too, so the reload is answered from its cache
223
+ // or not at all — which needs the worker in control of this page before the cut.
224
+ await page.waitForServiceWorker();
222
225
  await offline();
223
226
  await page.reload();
224
227
  expect(await page.title()).not.toBe('');
@@ -7,6 +7,7 @@ import type { GeneratedFile, NameSet } from './naming';
7
7
  import { apiFiles } from './scaffold-api';
8
8
  import { authFiles } from './scaffold-auth';
9
9
  import { dashboardFiles } from './scaffold-dashboard';
10
+ import { demoOrgFiles } from './scaffold-demo-org';
10
11
  import { entryFiles } from './scaffold-entries';
11
12
  import { errorPageFiles } from './scaffold-errors';
12
13
  import { httpFiles } from './scaffold-http';
@@ -309,11 +310,13 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
309
310
  { path: 'apps/web/shared/global.ts', contents: sharedGlobalModule() },
310
311
  { path: 'apps/web/shared/actor.ts', contents: sharedActor() },
311
312
  { path: 'apps/web/shared/actor.test.ts', contents: sharedActorTest() },
313
+ // The one org the dev actor, the seed and the dashboard all name — `scaffold-demo-org.ts`.
314
+ ...demoOrgFiles(),
312
315
  // The app's role map, beside the actor that reads it. `shared/` and not a feature folder:
313
316
  // `defineRoles()` merges, so a per-feature call is legal and is how an app ends up with no
314
317
  // answer to "which roles exist?" — see `scaffold-roles.ts`.
315
318
  ...httpFiles(app),
316
- ...rolesFiles(),
319
+ ...rolesFiles(example),
317
320
  { path: 'apps/admin/package.json', contents: adminPackage(app) },
318
321
  { path: 'apps/admin/tsconfig.json', contents: tsconfig() },
319
322
  // `apps/admin/app/admin/page.tsx`, not `apps/admin/app/page.tsx`: the directory IS the URL,
@@ -35,6 +35,7 @@ const devActor = (
35
35
  // Everything downstream — pages, policies, live subscribers, MCP tools — reads what this returns.
36
36
  import { type Actor, logger, tryResolveEnvironment } from '@ultimat3/core';
37
37
  import { configureAuthenticator, readCookie } from '@ultimat3/http';
38
+ import { DEMO_ORG_ID } from '../../shared/demo-org';
38
39
 
39
40
  /** Set it to a role from \`apps/web/shared/roles.ts\` to browse as that role. */
40
41
  export const DEV_ROLE_COOKIE = '${app.kebab}_dev_role';
@@ -66,7 +67,8 @@ export const devRoleFrom = (cookieHeader: string | null): DevRole => {
66
67
  export const devActorFor = (role: DevRole): Actor => ({
67
68
  kind: 'user',
68
69
  id: 'dev-actor',
69
- orgId: 'dev-org',
70
+ // The seed's org, never an invented string: a generated tenant policy compares it with a uuid.
71
+ orgId: DEMO_ORG_ID,
70
72
  roles: [role],
71
73
  // Both required, and both deliberately empty: \`scopes\` is the framework's own escape hatch
72
74
  // (\`tenancy:cross\`) and \`permissions\` is a DIRECT grant that bypasses the role map — a
@@ -0,0 +1,184 @@
1
+ // The production topology `x new` writes: one compose service per role, one image. Split from
2
+ // scaffold-container.ts, which holds the image and the page that explains it; this file is what
3
+ // `x deploy --method compose` runs, and the single-node rung of the scale ladder.
4
+
5
+ import type { NameSet } from './naming';
6
+
7
+ /**
8
+ * The production env file, relative to the app root. Named once because two readers must agree:
9
+ * the compose file's \`env_file:\` (what the containers see) and \`x deploy\`'s \`--env-file\` (what
10
+ * compose interpolates \`\${VAR:?…}\` from). Two spellings would let them drift apart silently.
11
+ */
12
+ export const PROD_ENV_FILE = '.env.production';
13
+
14
+ /** The production topology `x new` writes at `docker/docker-compose.prod.yml`. */
15
+ export const composeProdFile = (
16
+ app: NameSet,
17
+ ): string => `# The production topology: one service per role, one image, differing only by ROLE and replicas.
18
+ # What \`x deploy --method compose\` runs. migrate runs to completion before anything serves.
19
+ #
20
+ # IMAGE=ghcr.io/you/${app.kebab}:1.2.3 x deploy --image ghcr.io/you/${app.kebab}:1.2.3
21
+ #
22
+ # By hand, always with \`--env-file ${PROD_ENV_FILE}\`: Compose fills \`\${VAR:?…}\` below from the shell
23
+ # and \`--env-file\` only, NEVER from \`env_file:\`, so without it a value set only in that file is
24
+ # "missing" and the parse fails. \`x deploy\` passes it on every step.
25
+ #
26
+ # docker compose --env-file ${PROD_ENV_FILE} -f docker/docker-compose.prod.yml up -d
27
+ #
28
+ # A published host port has exactly one binder, so \`web\` and \`sync\` run at 1 here. Compose is one
29
+ # box; horizontal scaling of those two belongs to an orchestrator — \`docker/helm\`, beside this
30
+ # file, is the chart \`x deploy --method helm\` installs. To scale them on one box anyway, drop
31
+ # \`ports:\` and put your own proxy on this network — the service name resolves to every replica
32
+ # over the compose DNS round robin.
33
+ name: ${app.kebab}
34
+
35
+ # Every service's logs, rotated. Docker's default json-file driver never rotates, so a chatty role
36
+ # on a long-lived box fills the disk the database lives on; 3 x 10 MB per container is the ceiling.
37
+ x-logging: &logging
38
+ driver: json-file
39
+ options: { max-size: 10m, max-file: '3' }
40
+
41
+ x-image: &image
42
+ image: \${IMAGE:-${app.kebab}:dev}
43
+ env_file: [../${PROD_ENV_FILE}]
44
+ restart: unless-stopped
45
+ logging: *logging
46
+ # SIGTERM → /readyz answers 503 for the readiness grace (5s outside local environments), then the
47
+ # drain of in-flight requests, jobs and sockets (25s). 40s leaves 10s before Docker SIGKILLs.
48
+ stop_grace_period: 40s
49
+ # What the chart's securityContext applies, on the rung that had none: a read-only root
50
+ # filesystem, no Linux capabilities (nothing here binds below 1024), no setuid escalation, and a
51
+ # memory ceiling so one runaway role takes down its container rather than the box. /tmp and .x/
52
+ # are tmpfs — .x/ is where a still-embedded binding keeps state, which production never relies on.
53
+ read_only: true
54
+ tmpfs: [/tmp, /app/.x]
55
+ cap_drop: [ALL]
56
+ security_opt: ['no-new-privileges:true']
57
+ mem_limit: 1g
58
+ depends_on:
59
+ db: { condition: service_healthy }
60
+
61
+ # The image's own HEALTHCHECK fetches \`/readyz\` on $PORT, and only \`web\` and \`sync\` open an HTTP
62
+ # socket — every other role gets the scrape listener and nothing else. A service that inherits that
63
+ # probe is fetching a port it never binds: it reports \`unhealthy\` for its whole life and anything
64
+ # gated on it never starts. Probes follow the role here, exactly as they do in \`docker/helm\`.
65
+ x-metrics-probe: &metrics-probe
66
+ test: ['CMD', 'bun', '--eval', "fetch('http://127.0.0.1:'+(process.env.METRICS_PORT||9090)+'/metrics').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
67
+ interval: 10s
68
+ timeout: 3s
69
+ start_period: 30s
70
+ retries: 3
71
+
72
+ # Run-once services exit. A probe against an exited container reports \`unhealthy\` forever, and
73
+ # nothing waits on their health — \`service_completed_successfully\` is what the others gate on.
74
+ x-run-once-probe: &run-once-probe
75
+ disable: true
76
+
77
+ services:
78
+ db:
79
+ image: postgres:17-alpine
80
+ environment:
81
+ POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
82
+ POSTGRES_DB: ${app.kebab}
83
+ volumes: ['pgdata:/var/lib/postgresql/data']
84
+ healthcheck:
85
+ test: ['CMD-SHELL', 'pg_isready -U postgres']
86
+ interval: 5s
87
+ restart: unless-stopped
88
+ logging: *logging
89
+
90
+ # The release phase. Applies pending migrations under an advisory lock and exits; every serving
91
+ # role waits for it to complete, so no replica ever serves against a schema it does not ship.
92
+ migrate:
93
+ <<: *image
94
+ environment: [ROLE=migrate]
95
+ restart: 'no'
96
+ healthcheck: *run-once-probe
97
+
98
+ # Run-once, AFTER the new version serves. Deliberately NOT part of the release gate: a slow
99
+ # UPDATE there holds the deploy open against a database still serving the previous version.
100
+ # Dry run is the default, so \`--write\` is explicit.
101
+ backfill:
102
+ <<: *image
103
+ # The image's ENTRYPOINT is \`bun apps/web/server.ts\`, and that entry reads ROLE and PORT and
104
+ # NOTHING ELSE — argv never reaches a parser. A bare \`command:\` is appended to it and silently
105
+ # discarded, so this service used to serve HTTP as ROLE=web under a name that said otherwise.
106
+ # Overriding the entrypoint is what makes the words below a command. The file path, not
107
+ # \`node_modules/.bin/x\`: it needs no bin symlink and no executable bit inside the image.
108
+ entrypoint: ['bun', 'node_modules/@ultimat3/cli/src/bin.ts']
109
+ command: ['db', 'backfill', '--all', '--write', '--json']
110
+ depends_on:
111
+ db: { condition: service_healthy }
112
+ migrate: { condition: service_completed_successfully }
113
+ # The barrier, not the ordering. \`docker compose up -d\` returns when a container STARTS, so
114
+ # listing this last would only look like "after". The image's HEALTHCHECK is what makes it true.
115
+ web: { condition: service_healthy }
116
+ restart: 'no'
117
+ healthcheck: *run-once-probe
118
+
119
+ web:
120
+ <<: *image
121
+ # The page dials SYNC_URL; unset, it dials /_x/sync on :3000, which web does not serve here.
122
+ # Set it in ${PROD_ENV_FILE}: \`--env-file\` (header) is what lets this line read it there.
123
+ environment: [ROLE=web, 'SYNC_URL=\${SYNC_URL:?set SYNC_URL=ws://<host>:3001/_x/sync, see wiki/Deployment.md}']
124
+ depends_on:
125
+ db: { condition: service_healthy }
126
+ migrate: { condition: service_completed_successfully }
127
+ deploy: { replicas: 1 } # stateless, scales on RPS — pinned by the published port
128
+ ports: ['3000:3000']
129
+
130
+ sync:
131
+ <<: *image
132
+ environment: [ROLE=sync]
133
+ depends_on:
134
+ db: { condition: service_healthy }
135
+ migrate: { condition: service_completed_successfully }
136
+ # OFF by default (zero replicas). A sync node on this database hears committed changes only
137
+ # from a replicator it can reach, and this file runs none: booted anyway it is refused
138
+ # (X_REALTIME_TOPOLOGY). To turn live queries and channels on: declare realtime: { enabled:
139
+ # true, transport: 'nats', urlEnv: 'NATS_URL' } in app.config.ts, add a NATS service and set the
140
+ # SAME NATS_URL for web, sync and a replicator service (ROLE=replicator, exactly one), start
141
+ # Postgres with wal_level=logical and a publication for the entity tables, then set replicas: 1.
142
+ # A NATS_URL under transport 'memory' refuses the boot (X_CONFIG_INVALID), and so does 'nats'
143
+ # without one.
144
+ deploy: { replicas: 0 } # when on: scales on concurrent websockets — pinned to 1 by the port
145
+ # The sync role binds PORT + 1. PORT is unset here, so it is 3000 and this listens on 3001.
146
+ ports: ['3001:3001']
147
+ # ...which is why the image's own HEALTHCHECK cannot be inherited here. It fetches $PORT —
148
+ # 3000 — and this role never binds it, so the container reports \`unhealthy\` from
149
+ # \`start_period\` onward and never recovers, and anything gated on \`sync: service_healthy\`
150
+ # would never start. Literal 3001 rather than an expression, for the same reason \`ports:\`
151
+ # above is literal: PORT is unset in this file, and two ways of saying one number drift.
152
+ # \`docker/helm\` states the same rule as \`PORT = .port - 1\`.
153
+ healthcheck:
154
+ test: ['CMD', 'bun', '--eval', "fetch('http://127.0.0.1:3001/readyz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
155
+ interval: 10s
156
+ timeout: 3s
157
+ start_period: 30s
158
+ retries: 3
159
+
160
+ worker:
161
+ <<: *image
162
+ environment: [ROLE=worker]
163
+ depends_on:
164
+ db: { condition: service_healthy }
165
+ migrate: { condition: service_completed_successfully }
166
+ healthcheck: *metrics-probe
167
+ deploy: { replicas: 1 } # scales on queue depth
168
+
169
+ scheduler:
170
+ <<: *image
171
+ environment: [ROLE=scheduler]
172
+ depends_on:
173
+ db: { condition: service_healthy }
174
+ migrate: { condition: service_completed_successfully }
175
+ # Fixed 1. Leadership is an EXPIRING LEASE ROW in \`x_scheduler_leader\` (role-start.ts,
176
+ # driver-pg-ddl.ts), NOT an advisory lock: that grant belongs to the session, not to the
177
+ # process — it outlives every transaction and no pooled node can renew it or prove it still
178
+ # holds one. A second instance is harmless but idle.
179
+ healthcheck: *metrics-probe
180
+ deploy: { replicas: 1 }
181
+
182
+ volumes:
183
+ pgdata:
184
+ `;