@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
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { SECRETS_KEY_FILE } from '@ultimat3/core';
10
10
  import type { GeneratedFile, NameSet } from './naming';
11
+ import { composeProdFile, PROD_ENV_FILE } from './scaffold-container-compose';
11
12
  import { helmFiles } from './scaffold-helm';
12
13
 
13
14
  const dockerfile = (
@@ -21,13 +22,24 @@ const dockerfile = (
21
22
  #
22
23
  # syntax=docker/dockerfile:1
23
24
 
24
- # ---------- deps: runtime dependencies only, cached on the workspace manifests ----------
25
+ # ---------- manifests: every workspace package.json, and nothing else ----------
26
+ # The install below must be keyed on the MANIFESTS, not on the source: copying \`apps\` and
27
+ # \`packages\` ahead of \`bun install\` made every edit to a page a full reinstall. The set is derived
28
+ # from the context rather than listed, so a workspace glob added later is not a lockfile the frozen
29
+ # install refuses. \`bunfig.toml\` survives because its \`[install]\` section is input to the install,
30
+ # and so does a \`.tgz\`: a \`file:\` tarball dependency (a vendored or private package) is resolved
31
+ # from the file itself, and without it the frozen install cannot find what the lockfile names.
32
+ FROM oven/bun:1.4-alpine AS manifests
33
+ WORKDIR /app
34
+ COPY . .
35
+ RUN find . ! -type d ! -name package.json ! -name bunfig.toml ! -name '*.tgz' -delete \\
36
+ && find . -type d -empty -delete
37
+
38
+ # ---------- deps: runtime dependencies only, cached on the lockfile and the manifests ----------
25
39
  FROM oven/bun:1.4-alpine AS deps
26
40
  WORKDIR /app
27
- COPY package.json bun.lock ./
28
- # The workspace members' manifests are what \`bun install\` resolves against; their sources are not.
29
- COPY apps ./apps
30
- COPY packages ./packages
41
+ COPY --from=manifests /app ./
42
+ COPY bun.lock ./
31
43
  RUN bun install --frozen-lockfile --production
32
44
 
33
45
  # ---------- runtime ----------
@@ -35,9 +47,13 @@ RUN bun install --frozen-lockfile --production
35
47
  # before it ever calls \`docker build\`. Re-running typecheck and lint here would need the
36
48
  # devDependencies the \`--production\` install above deliberately leaves out — which is exactly how
37
49
  # a build stage came to run \`tsc\` and \`biome\` against a tree that had neither.
38
- FROM oven/bun:1.4-alpine AS runtime
50
+ #
51
+ # Built ON the install stage, so the WHOLE installed tree comes with it — Bun's isolated linker
52
+ # links a workspace's dependencies under that workspace's own \`node_modules\`, and copying only the
53
+ # root one left \`apps/web\`'s behind — and the source lands on top (the ignore file drops every host
54
+ # \`node_modules\`, so the two merge). The layer cache is the same as a COPY from it would be.
55
+ FROM deps AS runtime
39
56
  WORKDIR /app
40
- COPY --from=deps /app/node_modules ./node_modules
41
57
  COPY . .
42
58
 
43
59
  # The immutable content hash this image serves. Stamped by CI (\`--build-arg BUILD_ID=$(git rev-parse HEAD)\`);
@@ -112,142 +128,6 @@ coverage
112
128
  **/playwright-report
113
129
  `;
114
130
 
115
- const composeProd = (
116
- app: NameSet,
117
- ): string => `# The production topology: one service per role, one image, differing only by ROLE and replicas.
118
- # What \`x deploy --method compose\` runs. migrate runs to completion before anything serves.
119
- #
120
- # IMAGE=ghcr.io/you/${app.kebab}:1.2.3 x deploy --image ghcr.io/you/${app.kebab}:1.2.3
121
- #
122
- # A published host port has exactly one binder, so \`web\` and \`sync\` run at 1 here. Compose is one
123
- # box; horizontal scaling of those two belongs to an orchestrator — \`docker/helm\`, beside this
124
- # file, is the chart \`x deploy --method helm\` installs. To scale them on one box anyway, drop
125
- # \`ports:\` and put your own proxy on this network — the service name resolves to every replica
126
- # over the compose DNS round robin.
127
- name: ${app.kebab}
128
-
129
- x-image: &image
130
- image: \${IMAGE:-${app.kebab}:dev}
131
- env_file: [../.env.production]
132
- restart: unless-stopped
133
- stop_grace_period: 30s # SIGTERM → drain in-flight requests, jobs and sockets
134
- depends_on:
135
- db: { condition: service_healthy }
136
-
137
- # The image's own HEALTHCHECK fetches \`/readyz\` on $PORT, and only \`web\` and \`sync\` open an HTTP
138
- # socket — every other role gets the scrape listener and nothing else. A service that inherits that
139
- # probe is fetching a port it never binds: it reports \`unhealthy\` for its whole life and anything
140
- # gated on it never starts. Probes follow the role here, exactly as they do in \`docker/helm\`.
141
- x-metrics-probe: &metrics-probe
142
- 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))"]
143
- interval: 10s
144
- timeout: 3s
145
- start_period: 30s
146
- retries: 3
147
-
148
- # Run-once services exit. A probe against an exited container reports \`unhealthy\` forever, and
149
- # nothing waits on their health — \`service_completed_successfully\` is what the others gate on.
150
- x-run-once-probe: &run-once-probe
151
- disable: true
152
-
153
- services:
154
- db:
155
- image: postgres:17-alpine
156
- environment:
157
- POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
158
- POSTGRES_DB: ${app.kebab}
159
- volumes: ['pgdata:/var/lib/postgresql/data']
160
- healthcheck:
161
- test: ['CMD-SHELL', 'pg_isready -U postgres']
162
- interval: 5s
163
- restart: unless-stopped
164
-
165
- # The release phase. Applies pending migrations under an advisory lock and exits; every serving
166
- # role waits for it to complete, so no replica ever serves against a schema it does not ship.
167
- migrate:
168
- <<: *image
169
- environment: [ROLE=migrate]
170
- restart: 'no'
171
- healthcheck: *run-once-probe
172
-
173
- # Run-once, AFTER the new version serves. Deliberately NOT part of the release gate: a slow
174
- # UPDATE there holds the deploy open against a database still serving the previous version.
175
- # Dry run is the default, so \`--write\` is explicit.
176
- backfill:
177
- <<: *image
178
- # The image's ENTRYPOINT is \`bun apps/web/server.ts\`, and that entry reads ROLE and PORT and
179
- # NOTHING ELSE — argv never reaches a parser. A bare \`command:\` is appended to it and silently
180
- # discarded, so this service used to serve HTTP as ROLE=web under a name that said otherwise.
181
- # Overriding the entrypoint is what makes the words below a command. The file path, not
182
- # \`node_modules/.bin/x\`: it needs no bin symlink and no executable bit inside the image.
183
- entrypoint: ['bun', 'node_modules/@ultimat3/cli/src/bin.ts']
184
- command: ['db', 'backfill', '--all', '--write', '--json']
185
- depends_on:
186
- db: { condition: service_healthy }
187
- migrate: { condition: service_completed_successfully }
188
- # The barrier, not the ordering. \`docker compose up -d\` returns when a container STARTS, so
189
- # listing this last would only look like "after". The image's HEALTHCHECK is what makes it true.
190
- web: { condition: service_healthy }
191
- restart: 'no'
192
- healthcheck: *run-once-probe
193
-
194
- web:
195
- <<: *image
196
- environment: [ROLE=web]
197
- depends_on:
198
- db: { condition: service_healthy }
199
- migrate: { condition: service_completed_successfully }
200
- deploy: { replicas: 1 } # stateless, scales on RPS — pinned by the published port
201
- ports: ['3000:3000']
202
-
203
- sync:
204
- <<: *image
205
- environment: [ROLE=sync]
206
- depends_on:
207
- db: { condition: service_healthy }
208
- migrate: { condition: service_completed_successfully }
209
- deploy: { replicas: 1 } # scales on concurrent websockets, no sticky sessions — pinned by the port
210
- # The sync role binds PORT + 1. PORT is unset here, so it is 3000 and this listens on 3001.
211
- ports: ['3001:3001']
212
- # ...which is why the image's own HEALTHCHECK cannot be inherited here. It fetches $PORT —
213
- # 3000 — and this role never binds it, so the container reports \`unhealthy\` from
214
- # \`start_period\` onward and never recovers, and anything gated on \`sync: service_healthy\`
215
- # would never start. Literal 3001 rather than an expression, for the same reason \`ports:\`
216
- # above is literal: PORT is unset in this file, and two ways of saying one number drift.
217
- # \`docker/helm\` states the same rule as \`PORT = .port - 1\`.
218
- healthcheck:
219
- test: ['CMD', 'bun', '--eval', "fetch('http://127.0.0.1:3001/readyz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
220
- interval: 10s
221
- timeout: 3s
222
- start_period: 30s
223
- retries: 3
224
-
225
- worker:
226
- <<: *image
227
- environment: [ROLE=worker]
228
- depends_on:
229
- db: { condition: service_healthy }
230
- migrate: { condition: service_completed_successfully }
231
- healthcheck: *metrics-probe
232
- deploy: { replicas: 1 } # scales on queue depth
233
-
234
- scheduler:
235
- <<: *image
236
- environment: [ROLE=scheduler]
237
- depends_on:
238
- db: { condition: service_healthy }
239
- migrate: { condition: service_completed_successfully }
240
- # Fixed 1. Leadership is an EXPIRING LEASE ROW in \`x_scheduler_leader\` (dev-roles.ts,
241
- # driver-pg-ddl.ts), NOT an advisory lock: that grant belongs to the session, not to the
242
- # process — it outlives every transaction and no pooled node can renew it or prove it still
243
- # holds one. A second instance is harmless but idle.
244
- healthcheck: *metrics-probe
245
- deploy: { replicas: 1 }
246
-
247
- volumes:
248
- pgdata:
249
- `;
250
-
251
131
  const readme = (app: NameSet): string => `# docker
252
132
 
253
133
  One image, every role. \`ROLE\` selects behaviour at start, so there is one artifact to promote and
@@ -276,7 +156,7 @@ docker run -p 3000:3000 -e DATABASE_URL=postgres://... ${app.kebab}:dev
276
156
  ## One box, every role
277
157
 
278
158
  \`\`\`sh
279
- docker compose -f docker/docker-compose.prod.yml up -d # db → migrate → the rest
159
+ docker compose --env-file ${PROD_ENV_FILE} -f docker/docker-compose.prod.yml up -d # db → migrate → the rest
280
160
  x deploy --image ${app.kebab}:dev --dry-run --json # the same plan, printed
281
161
  \`\`\`
282
162
 
@@ -383,7 +263,7 @@ export function containerFiles(app: NameSet): readonly GeneratedFile[] {
383
263
  return [
384
264
  { path: 'docker/Dockerfile', contents: dockerfile(app) },
385
265
  { path: 'docker/Dockerfile.dockerignore', contents: dockerignore() },
386
- { path: 'docker/docker-compose.prod.yml', contents: composeProd(app) },
266
+ { path: 'docker/docker-compose.prod.yml', contents: composeProdFile(app) },
387
267
  { path: 'docker/README.md', contents: readme(app) },
388
268
  // The other deploy method's topology, on the same terms as the compose file above: `x deploy`
389
269
  // is one `helm upgrade --install` against this directory, and a chart that shipped in no npm
@@ -19,7 +19,6 @@ const examplePage = (
19
19
  ${sortedImports([
20
20
  `import { useT } from '@${app.kebab}/i18n';`,
21
21
  "import { isUltimateError } from '@ultimat3/core';",
22
- "import { seedId } from '@ultimat3/entity';",
23
22
  "import { currentLocale } from '@ultimat3/i18n';",
24
23
  "import { defineRoute, island } from '@ultimat3/render';",
25
24
  [
@@ -35,6 +34,7 @@ ${sortedImports([
35
34
  "} from '@ultimat3/ui';",
36
35
  ].join('\n'),
37
36
  ])}
37
+ import { DEMO_ORG_ID } from '../../shared/demo-org';
38
38
  import { Shell } from '../../shared/shell';
39
39
  import * as repo from '../post/repo';
40
40
  import {
@@ -53,7 +53,7 @@ import styles from './page.module.scss';
53
53
  * one \`packages/db/src/seed.ts\` writes. The day sessions exist, this becomes the actor's org and
54
54
  * the read becomes \`postList.as(actor, …)\` — the query already declares the tenancy rule.
55
55
  */
56
- const DEMO_ORG = seedId('org:demo');
56
+ const DEMO_ORG = DEMO_ORG_ID;
57
57
 
58
58
  /** The stat row counts what it can see. Past this many posts, write an aggregate query. */
59
59
  const ROW_LIMIT = 500;
@@ -94,6 +94,19 @@ export {};
94
94
  *
95
95
  * `x db seed` owns the connection, the tier and the per-seed transaction. One runner, one answer.
96
96
  */
97
+ /**
98
+ * The seed's two package imports in the order Biome sorts them: the app's own scope can sort on
99
+ * either side of \`@ultimat3\`, and a fixed order is a lint failure for half of all app names.
100
+ */
101
+ const seedImports = (app: NameSet): string =>
102
+ [
103
+ [`@${app.kebab}/web/shared/demo-org`, 'DEMO_ORG_LABEL'],
104
+ ['@ultimat3/entity', 'defineSeed'],
105
+ ]
106
+ .sort(([a = ''], [b = '']) => (a < b ? -1 : a > b ? 1 : 0))
107
+ .map(([from, name]) => `import { ${name} } from '${from}';`)
108
+ .join('\n');
109
+
97
110
  const dbSeed = (app: NameSet, example: boolean): string =>
98
111
  example
99
112
  ? `// Deterministic fixtures: the same rows every time, so a test, a demo and a branch database
@@ -104,7 +117,7 @@ const dbSeed = (app: NameSet, example: boolean): string =>
104
117
  // included), and wraps each seed in its own transaction. Never a plain \`bun run\` script: that
105
118
  // reaches the database through \`db()\`, which needs a \`postgres:\` \`DATABASE_URL\` and so cannot
106
119
  // see the embedded database at all.
107
- import { defineSeed } from '@ultimat3/entity';
120
+ ${seedImports(app)}
108
121
  import { post } from './schema';
109
122
 
110
123
  /** Stable across runs: \`id('post:hello')\` is a UUID v5 of the label, not a random one. */
@@ -112,13 +125,13 @@ export const ${app.camel}Seed = defineSeed('${app.kebab}', async ({ insert, id }
112
125
  await insert(post, [
113
126
  {
114
127
  id: id('post:hello'),
115
- orgId: id('org:demo'),
128
+ orgId: id(DEMO_ORG_LABEL),
116
129
  title: 'Hello ${app.pascal}',
117
130
  price: { minor: 0, currency: 'USD' },
118
131
  },
119
132
  {
120
133
  id: id('post:second'),
121
- orgId: id('org:demo'),
134
+ orgId: id(DEMO_ORG_LABEL),
122
135
  title: 'Second post',
123
136
  price: { minor: 1900, currency: 'USD' },
124
137
  },
@@ -0,0 +1,41 @@
1
+ // `apps/web/shared/demo-org.ts`: the ONE org this app has until it issues sessions. The dev actor
2
+ // named `'dev-org'`, the seed wrote `id('org:demo')` and the dashboard computed a third copy — so a
3
+ // generated tenant policy (`actor.orgId === input.orgId`, a `t.uuid`) could never pass for the dev
4
+ // actor, and every generated write answered 403 under a green gate.
5
+
6
+ import type { GeneratedFile } from './naming';
7
+
8
+ const demoOrg =
9
+ (): string => `// The org every development-only fact belongs to: the dev actor's \`orgId\`, the org the seed
10
+ // writes its rows under, and the org the dashboard aggregates. One constant, so the three cannot
11
+ // disagree — a policy deciding \`actor.orgId === input.orgId\` passes for the dev actor on the rows
12
+ // the seed wrote. Replace its readers when this app issues real sessions.
13
+ import { seedId } from '@ultimat3/entity';
14
+
15
+ /** The label \`defineSeed\`'s \`id()\` derives the uuid from — the same uuid on every run. */
16
+ export const DEMO_ORG_LABEL = 'org:demo';
17
+
18
+ /** \`seedId(DEMO_ORG_LABEL)\`: a uuid v5, exactly what the seed's \`id(DEMO_ORG_LABEL)\` answers. */
19
+ export const DEMO_ORG_ID = seedId(DEMO_ORG_LABEL);
20
+ `;
21
+
22
+ const demoOrgTest =
23
+ (): string => `// The dev actor, the seed and the dashboard all read DEMO_ORG_ID. If it stops being the uuid the
24
+ // seed's \`id()\` derives, a generated tenant policy denies the dev actor again.
25
+ import { seedId } from '@ultimat3/entity';
26
+ import { expect, unitTest } from '@ultimat3/testing';
27
+ import { DEMO_ORG_ID, DEMO_ORG_LABEL } from './demo-org';
28
+
29
+ const UUID_V5 = /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
30
+
31
+ unitTest('the demo org is the uuid the seed writes', () => {
32
+ expect(DEMO_ORG_ID).toBe(seedId(DEMO_ORG_LABEL));
33
+ expect(DEMO_ORG_ID).toMatch(UUID_V5);
34
+ });
35
+ `;
36
+
37
+ /** Written by `x new`, with or without the example slice: the dev actor always reads it. */
38
+ export const demoOrgFiles = (): readonly GeneratedFile[] => [
39
+ { path: 'apps/web/shared/demo-org.ts', contents: demoOrg() },
40
+ { path: 'apps/web/shared/demo-org.test.ts', contents: demoOrgTest() },
41
+ ];
@@ -15,7 +15,7 @@ const server =
15
15
  // shares the host's network namespace (\`--network host\`), or through a sidecar and \`ssh -L\`.
16
16
 
17
17
  import { join } from 'node:path';
18
- import { runRole } from '@ultimat3/cli';
18
+ import { runRole } from '@ultimat3/cli/serve';
19
19
 
20
20
  // MORE THAN ONE REPLICA? Add these two lines, above \`runRole\`:
21
21
  //
@@ -29,6 +29,12 @@ export const SCAFFOLD_ENV_SCHEMA = {
29
29
  role: 'sync',
30
30
  description: 'Realtime fan-out cluster. Only the sync role is asked for it.',
31
31
  },
32
+ SYNC_URL: {
33
+ type: 'string',
34
+ required: false,
35
+ role: 'web',
36
+ description: 'Where a page dials the sync socket. Compose: ws://<host>:3001/_x/sync',
37
+ },
32
38
  SESSION_SECRET: {
33
39
  type: 'string',
34
40
  required: false,
@@ -12,8 +12,18 @@ const helpers = (
12
12
  {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
13
13
  {{- end -}}
14
14
 
15
+ {{/*
16
+ helm create's rule: a release whose name already contains the chart's is the full name on its own.
17
+ x deploy --method helm names the release after the app, which IS the chart name, so without it
18
+ every object would be ${app.kebab}-${app.kebab}-web.
19
+ */}}
15
20
  {{- define "${app.kebab}.fullname" -}}
16
- {{- printf "%s-%s" .Release.Name (include "${app.kebab}.name" .) | trunc 63 | trimSuffix "-" -}}
21
+ {{- $name := include "${app.kebab}.name" . -}}
22
+ {{- if contains $name .Release.Name -}}
23
+ {{- .Release.Name | trunc 63 | trimSuffix "-" -}}
24
+ {{- else -}}
25
+ {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
26
+ {{- end -}}
17
27
  {{- end -}}
18
28
 
19
29
  {{/*
@@ -92,15 +102,38 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
92
102
  HTTP and get both. worker, scheduler and replicator open no HTTP socket at all — the scrape
93
103
  listener is their only port — so they take liveness on it and NO readiness: nothing routes to
94
104
  them, and a readiness flap would drop the pod out of the Service and so out of the scrape.
105
+
106
+ Every one of them takes a startupProbe too, because no listener opens early: the server builds
107
+ the app's islands before any role binds a port, and a liveness probe counting from container
108
+ start restarts a pod that is merely booting. 30 x 5s = 150s of boot, then the ordinary checks.
95
109
  */}}
96
110
  {{- if $cfg.port }}
111
+ startupProbe:
112
+ httpGet: { path: /healthz, port: http }
113
+ periodSeconds: 5
114
+ failureThreshold: 30
97
115
  readinessProbe:
98
116
  httpGet: { path: /readyz, port: http }
99
117
  periodSeconds: 5
100
118
  livenessProbe:
101
119
  httpGet: { path: /healthz, port: http }
102
120
  periodSeconds: 15
121
+ {{- /*
122
+ Holds SIGTERM back while the pod is already out of its Service's endpoints, so a proxy that has
123
+ not caught up still reaches a listener that answers. lifecycle.preStop.sleep exists from
124
+ Kubernetes 1.30 and this chart's floor is 1.27, so it renders only where the API server knows it;
125
+ below that the framework's own readiness grace (/readyz at 503, listener still open) covers it.
126
+ */}}
127
+ {{- if semverCompare ">=1.30-0" $root.Capabilities.KubeVersion.Version }}
128
+ lifecycle:
129
+ preStop:
130
+ sleep: { seconds: {{ $root.Values.drain.preStopSleepSeconds | int }} }
131
+ {{- end }}
103
132
  {{- else if $scraped }}
133
+ startupProbe:
134
+ httpGet: { path: /metrics, port: metrics }
135
+ periodSeconds: 5
136
+ failureThreshold: 30
104
137
  livenessProbe:
105
138
  httpGet: { path: /metrics, port: metrics }
106
139
  periodSeconds: 15
@@ -114,8 +147,9 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
114
147
  `;
115
148
 
116
149
  const deployments = (app: NameSet): string => `{{/*
117
- One Deployment per enabled role, from one image. terminationGracePeriodSeconds matches the
118
- framework's SIGTERM drain: in-flight requests, open websockets and running job steps finish.
150
+ One Deployment per enabled role, from one image. terminationGracePeriodSeconds covers the preStop
151
+ sleep, the framework's readiness grace and its SIGTERM drain: in-flight requests, open websockets
152
+ and running job steps finish.
119
153
  */}}
120
154
  {{- range $role, $cfg := .Values.roles }}
121
155
  {{- if $cfg.enabled }}
@@ -151,9 +185,11 @@ spec:
151
185
  code that reaches the filesystem, which for a web role is one path traversal. */}}
152
186
  automountServiceAccountToken: false
153
187
  securityContext: {{- toYaml $.Values.podSecurityContext | nindent 8 }}
154
- {{/* At least the framework's drain deadline (25s by default), which is what
155
- X_SHUTDOWN_TIMEOUT's fix line tells an operator. Raise this WITH configureLifecycle({
156
- deadlineMs }), never instead of it: the drain abandons its hooks at its own deadline. */}}
188
+ {{/* Spent in order: the preStop sleep (drain.preStopSleepSeconds, 1.30+), the framework's
189
+ readiness grace (5s outside local environments) and its drain deadline (25s, which is what
190
+ X_SHUTDOWN_TIMEOUT's fix line names) — 35s, so 45 leaves 10s before SIGKILL. Raise this WITH
191
+ configureLifecycle({ deadlineMs, readinessGraceMs }), never instead of it: the drain
192
+ abandons its hooks at its own deadline. */}}
157
193
  terminationGracePeriodSeconds: 45
158
194
  containers:
159
195
  {{- include "${app.kebab}.container" (dict "role" $role "cfg" $cfg "root" $) | nindent 8 }}
@@ -259,12 +295,16 @@ spec:
259
295
  paths:
260
296
  {{- /* The path the sync node actually serves. Routing /_sync instead sends every
261
297
  websocket to the web role, which answers no upgrade. */}}
298
+ {{- /* Only when the sync role runs: a rule naming a Service the chart did not render is an
299
+ ingress that 503s every websocket instead of letting it reach nothing at all. */}}
300
+ {{- if .Values.roles.sync.enabled }}
262
301
  - path: /_x/sync
263
302
  pathType: Prefix
264
303
  backend:
265
304
  service:
266
305
  name: {{ include "${app.kebab}.fullname" . }}-sync
267
306
  port: { name: http }
307
+ {{- end }}
268
308
  - path: /
269
309
  pathType: Prefix
270
310
  backend:
@@ -277,8 +317,13 @@ spec:
277
317
  const hpa = (app: NameSet): string => `{{/*
278
318
  Per-role autoscalers, off until you turn one on. Each role scales on the signal that predicts ITS
279
319
  saturation — CPU is a lagging proxy for all three and scales the wrong thing at the wrong time.
280
- A Pods metric needs a metrics adapter in the cluster; without one the HPA reads <unknown> and
320
+ Either metric type needs a metrics adapter in the cluster; without one the HPA reads <unknown> and
281
321
  holds at minReplicas, which is why these are opt-in rather than a default nobody wired.
322
+
323
+ autoscaling.type says whose number it is. Pods (the default) is a per-pod series — rps, open
324
+ sockets — averaged across pods. External is ONE series for the whole role, divided by the replica
325
+ count: queue_depth is that shape, because every worker publishes the same global backlog, and read
326
+ as Pods it asked for N times the workers the queue needed. Any other type fails the render.
282
327
  */}}
283
328
  {{- range $role, $cfg := .Values.roles }}
284
329
  {{- if and $cfg.enabled $cfg.autoscaling $cfg.autoscaling.enabled }}
@@ -297,7 +342,20 @@ spec:
297
342
  name: {{ include "${app.kebab}.fullname" $ }}-{{ $role }}
298
343
  minReplicas: {{ $cfg.autoscaling.minReplicas }}
299
344
  maxReplicas: {{ $cfg.autoscaling.maxReplicas }}
345
+ {{- $type := default "Pods" $cfg.autoscaling.type }}
346
+ {{- if not (has $type (list "Pods" "External")) }}
347
+ {{- fail (printf "roles.%s.autoscaling.type is %q — set it to Pods (a per-pod series) or External (one series for the whole role, e.g. queue_depth)" $role $type) }}
348
+ {{- end }}
300
349
  metrics:
350
+ {{- if eq $type "External" }}
351
+ - type: External
352
+ external:
353
+ metric:
354
+ name: {{ $cfg.autoscaling.metric }}
355
+ target:
356
+ type: AverageValue
357
+ averageValue: {{ $cfg.autoscaling.targetAverageValue | quote }}
358
+ {{- else }}
301
359
  - type: Pods
302
360
  pods:
303
361
  metric:
@@ -305,6 +363,7 @@ spec:
305
363
  target:
306
364
  type: AverageValue
307
365
  averageValue: {{ $cfg.autoscaling.targetAverageValue | quote }}
366
+ {{- end }}
308
367
  behavior:
309
368
  scaleUp:
310
369
  stabilizationWindowSeconds: 30
@@ -34,7 +34,7 @@ image:
34
34
  env:
35
35
  NODE_ENV: production
36
36
 
37
- existingSecret: ${app.kebab}-secrets # DATABASE_URL, NATS_URL, S3_*, AUTH_SECRET
37
+ existingSecret: ${app.kebab}-secrets # DATABASE_URL, NATS_URL, S3_*, AUTH_SECRET, ULTIMATE_CURSOR_SECRET
38
38
 
39
39
  # The scrape listener every serving role opens, on its own port and never the app's: the ingress
40
40
  # routes / to web, so /metrics beside /healthz on 3000 is /metrics on the internet. This is
@@ -51,6 +51,12 @@ securityContext:
51
51
  readOnlyRootFilesystem: true
52
52
  capabilities: { drop: [ALL] }
53
53
 
54
+ # The stop sequence: out of the Service, then this preStop sleep (Kubernetes 1.30+ only), then
55
+ # SIGTERM, then the framework's readiness grace and drain. terminationGracePeriodSeconds in
56
+ # templates/deployments.yaml is sized to all three, so move them together.
57
+ drain:
58
+ preStopSleepSeconds: 5
59
+
54
60
  # The release phase. Runs to completion before any serving role starts.
55
61
  migrate:
56
62
  enabled: true
@@ -63,8 +69,10 @@ migrate:
63
69
  # PORT: they differ for sync, which binds PORT + 1, and the chart derives the env from this number
64
70
  # so there is only ever one to move.
65
71
  #
66
- # Autoscaling is off until you wire a metrics adapter — each \`metric\` below is a Pods metric the
67
- # role exports, and an HPA with no adapter behind it reads <unknown> and holds at minReplicas.
72
+ # Autoscaling is off until you wire a metrics adapter — each \`metric\` below is a series the role
73
+ # exports, and an HPA with no adapter behind it reads <unknown> and holds at minReplicas.
74
+ # \`type\` is Pods (the default: a per-pod series averaged across pods) or External (one series for
75
+ # the whole role, divided by the replica count).
68
76
  roles:
69
77
  web:
70
78
  enabled: true
@@ -81,7 +89,14 @@ roles:
81
89
  targetAverageValue: "50"
82
90
 
83
91
  sync:
84
- enabled: true
92
+ # OFF by default. A sync pod on a real database hears committed changes only from a replicator
93
+ # it can reach, and a fresh deploy has none: booted anyway it is refused (X_REALTIME_TOPOLOGY).
94
+ # To turn live queries and channels on: declare realtime: { enabled: true, transport: 'nats',
95
+ # urlEnv: 'NATS_URL' } in app.config.ts (a NATS_URL under transport 'memory' is refused, and
96
+ # 'nats' without it is too); run NATS and set the SAME NATS_URL for web, sync and the
97
+ # replicator; enable exactly one replicator (below) against a Postgres started with
98
+ # wal_level=logical and a publication for the entity tables; then set this to true.
99
+ enabled: false
85
100
  replicas: 2
86
101
  port: 3001
87
102
  resources:
@@ -104,6 +119,10 @@ roles:
104
119
  enabled: false
105
120
  minReplicas: 1
106
121
  maxReplicas: 50
122
+ # External: every worker publishes the SAME global backlog, so averaged as a Pods metric it
123
+ # asked for N times the workers the queue needed. Expose it from your adapter deduplicated —
124
+ # max(queue_depth), never sum, which would multiply it by the pod count again.
125
+ type: External
107
126
  metric: queue_depth # jobs waiting, exported by the worker role
108
127
  targetAverageValue: "100"
109
128
 
@@ -112,9 +131,12 @@ roles:
112
131
  # Fixed 1, and a second replica is safe but pointless: leadership is an expiring row in
113
132
  # x_scheduler_leader, so the extra pod stands by.
114
133
  replicas: 1
134
+ # 512Mi, not 256Mi: a scaffolded app measured ~325 MiB at boot as ROLE=scheduler (2026-09-23),
135
+ # when every role still built the app's islands first; at 256Mi the pod was OOMKilled on every
136
+ # boot. Only web builds islands now, so the peak is lower but unmeasured, and the limit stays.
115
137
  resources:
116
138
  requests: { cpu: 50m, memory: 128Mi }
117
- limits: { memory: 256Mi }
139
+ limits: { memory: 512Mi }
118
140
 
119
141
  replicator:
120
142
  enabled: false # exactly one per database; enable when the change feed is in use
@@ -28,27 +28,31 @@ const i18nPackage = (app: NameSet, version: string): string => `{
28
28
  `;
29
29
 
30
30
  /**
31
- * `en` first, then every other locale alphabetically — a stable order so a diff shows only the
32
- * locale a run actually added, never a reshuffle. `en` is always included: `default: 'en'` below
33
- * requires it to be a registered locale, and every real catalog set already has one from `x new`
34
- * scaffold time.
31
+ * The default locale first, then every other one alphabetically — a stable order so a diff shows
32
+ * only the locale a run actually added. The default is `en` when the set holds it (and when the set
33
+ * is empty, which is `x new`), else the first tag: importing an `en.json` the app does not have is
34
+ * a file that does not compile.
35
35
  */
36
+ export const defaultLocaleOf = (locales: readonly string[]): string =>
37
+ locales.length === 0 || locales.includes('en') ? 'en' : ([...locales].sort()[0] ?? 'en');
38
+
36
39
  const orderedLocales = (locales: readonly string[]): readonly string[] => {
40
+ const first = defaultLocaleOf(locales);
37
41
  const rest = new Set(locales);
38
- rest.delete('en');
39
- return ['en', ...[...rest].sort()];
42
+ rest.delete(first);
43
+ return [first, ...[...rest].sort()];
40
44
  };
41
45
 
42
46
  /** A locale tag is not always a valid JS binding (`zh-hant`) — `camel()` is the one identifier
43
47
  * derivation every generated file already uses for names, so the import agrees with the rest of
44
48
  * the app's own naming instead of inventing a second casing rule. */
45
- const localeImport = (locale: string): string =>
49
+ export const localeImport = (locale: string): string =>
46
50
  `import ${camel(locale)} from '../catalogs/${locale}.json';`;
47
51
 
48
52
  /** The object-literal entry for one locale: shorthand when the binding IS the tag (`en`, `es`, …),
49
53
  * `'tag': binding` when `camel()` had to reshape it (`zh-hant` → `zhHant`) — `defineCatalogs` reads
50
54
  * the locale from the key, never the identifier, so the quoted form is what keeps it addressable. */
51
- const localeEntry = (locale: string): string => {
55
+ export const localeEntry = (locale: string): string => {
52
56
  const binding = camel(locale);
53
57
  return binding === locale ? binding : `'${locale}': ${binding}`;
54
58
  };
@@ -61,6 +65,7 @@ const localeEntry = (locale: string): string => {
61
65
  */
62
66
  export function i18nIndex(locales: readonly string[]): string {
63
67
  const ordered = orderedLocales(locales);
68
+ const fallback = defaultLocaleOf(locales);
64
69
  const imports = ordered.map(localeImport).join('\n');
65
70
  const entries = ordered.map(localeEntry).join(', ');
66
71
  return `// The app's catalog, registered once and typed against English. Every surface resolves strings
@@ -75,13 +80,13 @@ import {
75
80
  } from '@ultimat3/i18n';
76
81
  ${imports}
77
82
 
78
- export const catalogs = defineCatalogs({ default: 'en', locales: { ${entries} } });
83
+ export const catalogs = defineCatalogs({ default: '${fallback}', locales: { ${entries} } });
79
84
 
80
85
  /**
81
86
  * English is the source of truth for the key space — a second locale must match it exactly, or
82
87
  * \`x verify\` fails.
83
88
  */
84
- export type AppCatalog = typeof en;
89
+ export type AppCatalog = typeof ${camel(fallback)};
85
90
 
86
91
  /** Every key this app's catalog defines — dot-paths, plus the stem of each plural family. */
87
92
  export type TranslationKey = KeyOf<AppCatalog>;