@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
|
@@ -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}
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
|
83
|
-
//
|
|
84
|
-
//
|
|
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
|
|
97
|
+
const price = fromDecimal(amount(), props.currency);
|
|
98
|
+
await clientTransport({
|
|
88
99
|
method: 'POST',
|
|
89
|
-
|
|
90
|
-
body:
|
|
100
|
+
url: props.endpoint,
|
|
101
|
+
body: { title: title(), price },
|
|
91
102
|
});
|
|
92
|
-
setState(
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
204
|
-
//
|
|
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({
|
|
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
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
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(
|
|
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
|
|
285
|
+
await statusSettled();
|
|
246
286
|
|
|
247
|
-
|
|
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
|
|
257
|
-
//
|
|
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
|
|
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
|
|
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
|
-
...
|
|
187
|
+
...entity,
|
|
183
188
|
...policyFiles(rawName, slice),
|
|
184
|
-
|
|
185
|
-
...
|
|
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) },
|
package/src/templates/route.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
`;
|