@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.
- package/CLAUDE.md +84 -1582
- package/README.md +40 -2
- package/package.json +31 -31
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- package/src/app-env.ts +2 -2
- package/src/app-load.ts +37 -5
- package/src/app-openapi.ts +27 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-root.ts +1 -1
- package/src/async-pages.ts +32 -0
- package/src/bin.ts +7 -1
- package/src/boundary-findings.ts +36 -0
- package/src/browser-launcher-fake-html.ts +80 -0
- package/src/browser-launcher-fake.ts +165 -0
- package/src/browser-launcher-port.ts +159 -0
- package/src/browser-launcher.ts +26 -133
- package/src/budgets.ts +56 -13
- package/src/build-errors.ts +54 -0
- package/src/cdp-shot-a11y.ts +77 -0
- package/src/cdp-shot-clock.ts +14 -0
- package/src/cdp-shot-driver.ts +150 -0
- package/src/cdp-shot-element.ts +147 -0
- package/src/cdp-shot-errors.ts +62 -0
- package/src/cdp-shot-keys.ts +152 -0
- package/src/cdp-shot-page.ts +230 -0
- package/src/cdp-shot-watch.ts +241 -0
- package/src/cmd-affected-spec.ts +24 -0
- package/src/cmd-affected.ts +3 -19
- package/src/cmd-build-spec.ts +16 -0
- package/src/cmd-build.ts +46 -20
- package/src/cmd-ci-spec.ts +24 -0
- package/src/cmd-ci.ts +17 -21
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db-spec.ts +99 -0
- package/src/cmd-db.ts +15 -94
- package/src/cmd-deploy-helm.ts +136 -0
- package/src/cmd-deploy-spec.ts +40 -0
- package/src/cmd-deploy.ts +117 -42
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +78 -122
- package/src/cmd-docs-spec.ts +16 -0
- package/src/cmd-docs.ts +2 -11
- package/src/cmd-doctor-spec.ts +21 -0
- package/src/cmd-doctor.ts +4 -18
- package/src/cmd-env-spec.ts +18 -0
- package/src/cmd-env.ts +4 -13
- package/src/cmd-errors-spec.ts +23 -0
- package/src/cmd-errors.ts +3 -17
- package/src/cmd-fix-spec.ts +18 -0
- package/src/cmd-fix.ts +4 -13
- package/src/cmd-generate-spec.ts +31 -0
- package/src/cmd-generate.ts +46 -42
- package/src/cmd-i18n-spec.ts +16 -0
- package/src/cmd-i18n.ts +7 -14
- package/src/cmd-jobs-spec.ts +56 -0
- package/src/cmd-jobs.ts +3 -50
- package/src/cmd-manifest-spec.ts +15 -0
- package/src/cmd-manifest.ts +19 -20
- package/src/cmd-mcp-spec.ts +21 -0
- package/src/cmd-mcp.ts +2 -15
- package/src/cmd-new-spec.ts +33 -0
- package/src/cmd-new.ts +38 -33
- package/src/cmd-policy-spec.ts +13 -0
- package/src/cmd-policy.ts +2 -9
- package/src/cmd-pr-spec.ts +39 -0
- package/src/cmd-pr.ts +5 -65
- package/src/cmd-registries-spec.ts +32 -0
- package/src/cmd-registries.ts +4 -25
- package/src/cmd-routes-spec.ts +12 -0
- package/src/cmd-routes.ts +2 -7
- package/src/cmd-secrets-spec.ts +19 -0
- package/src/cmd-secrets.ts +22 -34
- package/src/cmd-shot-island.ts +12 -13
- package/src/cmd-shot-spec.ts +53 -0
- package/src/cmd-shot.ts +11 -60
- package/src/cmd-tasks-spec.ts +21 -0
- package/src/cmd-tasks.ts +2 -16
- package/src/cmd-test-spec.ts +54 -0
- package/src/cmd-test.ts +4 -49
- package/src/cmd-verify-spec.ts +28 -0
- package/src/cmd-verify.ts +3 -23
- package/src/db-seed.ts +4 -2
- package/src/dev-dashboard.ts +4 -4
- package/src/dev-lock.ts +18 -2
- package/src/dev-port.ts +21 -0
- package/src/dev-route-table.ts +119 -0
- package/src/dispatch.ts +12 -5
- package/src/document-styles.ts +1 -1
- package/src/drift.ts +4 -18
- package/src/error-catalog.ts +16 -9
- package/src/error-codes.ts +33 -28
- package/src/error-pages.ts +19 -7
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +22 -29
- package/src/favicon.ts +2 -2
- package/src/fix-imports.ts +1 -1
- package/src/fix-scan.ts +2 -9
- package/src/flag-reads.ts +1 -2
- package/src/foreign-text.ts +36 -0
- package/src/framework-schema.ts +5 -6
- package/src/generate-feature.ts +42 -0
- package/src/generate-files.ts +7 -3
- package/src/generate-grants.ts +83 -0
- package/src/generate-kinds.ts +56 -4
- package/src/i18n-index.ts +59 -10
- package/src/icon-assets.ts +1 -1
- package/src/image-prepare.ts +14 -0
- package/src/import-scan.ts +63 -0
- package/src/index.ts +67 -334
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +38 -11
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +98 -0
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +2 -2
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/live-routes.ts +82 -42
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +30 -26
- package/src/mcp-host.ts +27 -12
- package/src/mcp-ui-diff.ts +27 -0
- package/src/mcp-ui-inspect.ts +4 -4
- package/src/mcp-ui-interact.ts +21 -15
- package/src/mcp-ui.ts +19 -15
- package/src/measure-database.ts +73 -0
- package/src/measure-paths.ts +74 -0
- package/src/measure-scope.ts +74 -0
- package/src/messages.ts +1 -3
- package/src/metrics-endpoint.ts +1 -1
- package/src/otlp-export.ts +1 -1
- package/src/output.ts +6 -0
- package/src/page-sync.ts +54 -0
- package/src/permission-grants.ts +86 -0
- package/src/prerender-out.ts +25 -0
- package/src/prerender.ts +121 -102
- package/src/pwa-artifacts.ts +3 -3
- package/src/realtime-browser-probe-fixture.ts +2 -2
- package/src/reexport-manifest.ts +2 -1
- package/src/registry.ts +80 -56
- package/src/role-realtime.ts +36 -0
- package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
- package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
- package/src/role-start-types.ts +112 -0
- package/src/{dev-roles.ts → role-start.ts} +42 -115
- package/src/{dev-sync.ts → role-sync.ts} +23 -11
- package/src/root-env.ts +67 -0
- package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
- package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
- package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
- package/src/runtime-jobs.ts +87 -0
- package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
- package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
- package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
- package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
- package/src/runtime-realtime.ts +55 -0
- package/src/{dev-render.ts → runtime-render.ts} +119 -20
- package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
- package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
- package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
- package/src/scaffold-fixture.ts +28 -6
- package/src/scaffold-typecheck.ts +6 -3
- package/src/schema-drift.ts +7 -1
- package/src/script-csp.ts +5 -2
- package/src/secrets-rotation.ts +59 -0
- package/src/serve-boot.ts +192 -0
- package/src/serve-drain.ts +24 -0
- package/src/serve-entry.ts +6 -0
- package/src/serve-env.ts +116 -0
- package/src/serve-types.ts +55 -0
- package/src/serve.ts +44 -338
- package/src/shot-server.ts +2 -2
- package/src/shot-settle.ts +10 -1
- package/src/shot-theme.ts +3 -3
- package/src/shot-verdict.ts +16 -7
- package/src/signal-shred.ts +27 -0
- package/src/solid-loader.ts +26 -2
- package/src/static-report.ts +8 -1
- package/src/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- package/src/templates/action.ts +30 -16
- package/src/templates/entity.ts +12 -7
- package/src/templates/index.ts +1 -1
- package/src/templates/job.ts +10 -7
- package/src/templates/policy.ts +20 -2
- package/src/templates/resource-create.ts +127 -0
- package/src/templates/resource-form-island.ts +76 -30
- package/src/templates/resource.ts +10 -4
- package/src/templates/route.ts +3 -0
- package/src/templates/scaffold-app.ts +4 -1
- package/src/templates/scaffold-auth.ts +3 -1
- package/src/templates/scaffold-container-compose.ts +184 -0
- package/src/templates/scaffold-container.ts +25 -145
- package/src/templates/scaffold-dashboard-example.ts +2 -2
- package/src/templates/scaffold-db-package.ts +16 -3
- package/src/templates/scaffold-demo-org.ts +41 -0
- package/src/templates/scaffold-entries.ts +1 -1
- package/src/templates/scaffold-env.ts +6 -0
- package/src/templates/scaffold-helm-templates.ts +66 -7
- package/src/templates/scaffold-helm.ts +27 -5
- package/src/templates/scaffold-i18n.ts +15 -10
- package/src/templates/scaffold-repo.ts +13 -10
- package/src/templates/scaffold-roles.ts +38 -10
- package/src/templates/slice-foundation.ts +1 -1
- package/src/templates/wrap.ts +4 -1
- package/src/test-passes.ts +2 -1
- package/src/test-workers.ts +26 -0
- package/src/ts-scan.ts +3 -6
- package/src/tsconfig-references.ts +1 -2
- package/src/verify-checks.ts +41 -39
- package/src/verify-e2e.ts +41 -0
- package/src/verify-run.ts +115 -50
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +22 -30
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/worker-bundle.ts +192 -0
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -100
- package/src/cdp-connection.ts +0 -211
- package/src/cdp-e2e-page.ts +0 -209
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -139
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -97
- package/src/e2e-errors.ts +0 -103
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -150
- package/src/e2e-selection.ts +0 -182
- package/src/measurement-actor.ts +0 -26
- /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
|
-
# ----------
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
{{-
|
|
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
|
|
118
|
-
framework's SIGTERM drain: in-flight requests, open websockets
|
|
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
|
-
{{/*
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
|
67
|
-
#
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
32
|
-
* locale a run actually added
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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(
|
|
39
|
-
return [
|
|
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: '
|
|
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
|
|
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>;
|