@ultimat3/cli 6.0.0 → 8.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 +65 -5
- package/README.md +8 -3
- package/package.json +25 -24
- package/src/affected.ts +320 -0
- package/src/app-boundaries.ts +55 -5
- package/src/bin.ts +6 -3
- package/src/browser-launcher.ts +109 -0
- package/src/ci-log.ts +0 -0
- package/src/ci-runs.ts +179 -0
- package/src/cmd-affected.ts +109 -0
- package/src/cmd-build.ts +29 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-db-backfill.ts +240 -0
- package/src/cmd-db-branch.ts +3 -2
- package/src/cmd-db.ts +35 -156
- package/src/cmd-deploy.ts +37 -3
- package/src/cmd-dev.ts +7 -1
- package/src/cmd-errors.ts +2 -3
- package/src/cmd-fix.ts +3 -3
- package/src/cmd-i18n.ts +67 -5
- package/src/cmd-jobs.ts +27 -4
- package/src/cmd-mcp.ts +18 -9
- package/src/cmd-new.ts +91 -4
- package/src/cmd-policy.ts +3 -2
- package/src/cmd-pr.ts +359 -0
- package/src/cmd-registries.ts +3 -2
- package/src/cmd-shot.ts +382 -0
- package/src/cmd-tasks.ts +9 -4
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +47 -6
- package/src/dev-cache.ts +1 -1
- package/src/dev-lock.ts +124 -12
- package/src/dev-queue.ts +12 -7
- package/src/dev-replicator.ts +3 -7
- package/src/dev-roles-fixture.ts +1 -1
- package/src/dev-roles.ts +40 -8
- package/src/dev-runtime.ts +96 -4
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- package/src/drift.ts +52 -7
- package/src/error-codes.ts +21 -0
- package/src/framework-scope.ts +57 -5
- package/src/generate-kinds.ts +19 -1
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-registration.ts +67 -4
- package/src/index.ts +38 -1
- package/src/island-bundle.ts +62 -3
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/jobs-report.ts +10 -13
- package/src/mcp-errors.ts +12 -0
- package/src/messages.ts +76 -0
- package/src/output.ts +22 -2
- package/src/parse.ts +81 -37
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/realtime-browser-probe-fixture.ts +9 -0
- package/src/registry.ts +8 -0
- package/src/runtime-overrides.ts +11 -3
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +360 -0
- package/src/static-report.ts +219 -0
- package/src/sync-authenticator.ts +86 -14
- package/src/templates/guard-bare-error.ts +122 -0
- package/src/templates/guard-raw-colour.ts +138 -0
- package/src/templates/guard-untranslated-string.ts +138 -0
- package/src/templates/guard-unzoned-date.ts +142 -0
- package/src/templates/index.ts +4 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +130 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +20 -41
- package/src/templates/route.ts +15 -2
- package/src/templates/scaffold-app.ts +13 -78
- package/src/templates/scaffold-container.ts +30 -4
- package/src/templates/scaffold-db-package.ts +46 -7
- package/src/templates/scaffold-docs.ts +24 -13
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +37 -6
- package/src/test-select.ts +4 -3
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +11 -1
- package/src/verify-run.ts +25 -3
- package/src/verify-step.ts +11 -2
- package/src/verify-tests.ts +11 -3
- package/src/workspace-graph.ts +241 -0
- package/src/write-line.ts +23 -5
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// The slice's client entry: `x g resource <name>` emits its form as an ISLAND, because that is the
|
|
2
|
+
// one shape the framework compiles for a browser. A plain `.tsx` with a signal and an `onSubmit`
|
|
3
|
+
// is not a smaller version of this — the island glob never discovers it, and a server render drops
|
|
4
|
+
// every `on*` prop (`packages/render/src/html.ts`) and reads each signal exactly once.
|
|
5
|
+
|
|
6
|
+
// Bun ships no path API, and the one arithmetic this file does is a specifier: the island's path
|
|
7
|
+
// seen from the page's directory, which `island-bundle.ts` resolves the same way at build time.
|
|
8
|
+
import { posix } from 'node:path';
|
|
9
|
+
import { upToAppRoot } from './island';
|
|
10
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the PAGE has to write, which is not what the island's own directory would suggest.
|
|
14
|
+
*
|
|
15
|
+
* `island({ src })` resolves against the route file, and `x g resource widget` writes its page at
|
|
16
|
+
* `apps/web/app/widgets/` while the island lands in `apps/web/app/widget/` — so the `'./…'` this
|
|
17
|
+
* file used to print resolved to `apps/web/app/widgets/widget-form.island.tsx`, a path the build
|
|
18
|
+
* never bundles, and an author following the comment got `X_ISLAND_INVALID`. Derived from the two
|
|
19
|
+
* directories rather than written down, so it cannot disagree with where the files actually go.
|
|
20
|
+
*/
|
|
21
|
+
export const formIslandSpecifier = (feature: NameSet, dir: string, pageDir: string): string => {
|
|
22
|
+
const relative = posix.join(posix.relative(pageDir, dir), `${feature.kebab}-form.island.tsx`);
|
|
23
|
+
return relative.startsWith('.') ? relative : `./${relative}`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const formIslandSource = (
|
|
27
|
+
feature: NameSet,
|
|
28
|
+
specifier: string,
|
|
29
|
+
): string => `// ${feature.pascal}Form: the only module of the ${feature.kebab} slice a browser downloads.
|
|
30
|
+
//
|
|
31
|
+
// The page names this file by SPECIFIER, never by import — and the specifier is resolved against
|
|
32
|
+
// the PAGE, which is one directory across from this one:
|
|
33
|
+
// const ${feature.pascal}Form = island({
|
|
34
|
+
// src: '${specifier}',
|
|
35
|
+
// props: ['endpoint', 'locale', 'labels'],
|
|
36
|
+
// });
|
|
37
|
+
// <${feature.pascal}Form endpoint={derivePath('create${feature.pascal}').path} locale={locale} labels={labels} />
|
|
38
|
+
// A string has no import edge, so the page's bundle graph stays the page's (axiom 6).
|
|
39
|
+
|
|
40
|
+
import { Button, Form, Input, setSolidRuntime, UiProvider } from '@ultimat3/ui';
|
|
41
|
+
import type { JSX } from 'solid-js';
|
|
42
|
+
import * as solidRuntime from 'solid-js';
|
|
43
|
+
import { createSignal } from 'solid-js';
|
|
44
|
+
import { render } from 'solid-js/web';
|
|
45
|
+
import styles from './ui.module.scss';
|
|
46
|
+
|
|
47
|
+
/** Every string this module renders, and the path it posts to. An island's props cross the seam as
|
|
48
|
+
* JSON inside the document, so \`t()\`'s catalog cannot travel — the server translates. */
|
|
49
|
+
export interface ${feature.pascal}FormProps {
|
|
50
|
+
/** \`derivePath('create${feature.pascal}').path\`, minted on the server: one namer for the route. */
|
|
51
|
+
readonly endpoint: string;
|
|
52
|
+
/** The request's own locale. A browser has no ambient one the server ever agreed to. */
|
|
53
|
+
readonly locale: string;
|
|
54
|
+
readonly labels: {
|
|
55
|
+
readonly title: string;
|
|
56
|
+
readonly submit: string;
|
|
57
|
+
readonly saved: string;
|
|
58
|
+
readonly retry: string;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type SaveState = 'idle' | 'saved' | 'failed';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Presentation only: the action this submits to owns validation server-side, so the form never
|
|
66
|
+
* re-implements the invariant — a blank title fails at the boundary, not in the DOM.
|
|
67
|
+
*
|
|
68
|
+
* A plain \`fetch\` to the path the server minted, not the typed client: \`rpc()\` pulls
|
|
69
|
+
* \`@ultimat3/action\` into the chunk, which is larger than everything else here put together. The
|
|
70
|
+
* naming rule is still the framework's; only the transport is second.
|
|
71
|
+
*/
|
|
72
|
+
function ${feature.pascal}FormBody(props: ${feature.pascal}FormProps): JSX.Element {
|
|
73
|
+
const [title, setTitle] = createSignal('');
|
|
74
|
+
const [state, setState] = createSignal<SaveState>('idle');
|
|
75
|
+
|
|
76
|
+
// A rejected \`fetch\` — offline, DNS, an aborted request — is the same OUTCOME as a refused one,
|
|
77
|
+
// and it is the one \`retry\` exists for. Without the catch, \`setState\` is never reached: the
|
|
78
|
+
// status line stays empty, and the rejection escapes the \`void send()\` below as an unhandled one.
|
|
79
|
+
const send = async (): Promise<void> => {
|
|
80
|
+
try {
|
|
81
|
+
const response = await fetch(props.endpoint, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'content-type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({ title: title() }),
|
|
85
|
+
});
|
|
86
|
+
setState(response.ok ? 'saved' : 'failed');
|
|
87
|
+
} catch {
|
|
88
|
+
setState('failed');
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const status = (): string => {
|
|
93
|
+
if (state() === 'saved') return props.labels.saved;
|
|
94
|
+
return state() === 'failed' ? props.labels.retry : '';
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Form
|
|
99
|
+
class={styles.item}
|
|
100
|
+
onSubmit={(event) => {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
void send();
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<Input
|
|
106
|
+
aria-label={props.labels.title}
|
|
107
|
+
value={title()}
|
|
108
|
+
onInput={(event) => setTitle(event.currentTarget.value)}
|
|
109
|
+
/>
|
|
110
|
+
<Button type="submit">{props.labels.submit}</Button>
|
|
111
|
+
<p data-role="status" role="status" aria-live="polite">
|
|
112
|
+
{status()}
|
|
113
|
+
</p>
|
|
114
|
+
</Form>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The one export the hydration runtime calls — \`import(entry).then((m) => m.mount(el, props))\`.
|
|
120
|
+
*
|
|
121
|
+
* \`setSolidRuntime\` comes FIRST and it is not optional: \`@ultimat3/ui\` imports *types* from
|
|
122
|
+
* solid-js and never a runtime, so the reactive graph a component reaches is the one an entry
|
|
123
|
+
* registers. Delete the line and the first \`<UiProvider>\` render throws X_UI_RUNTIME_MISSING —
|
|
124
|
+
* loud on purpose, because a DOM render that lost its runtime is a theme toggle that does nothing.
|
|
125
|
+
*
|
|
126
|
+
* The shell is cleared first: Solid's \`render\` APPENDS when the container already has children,
|
|
127
|
+
* so without it the server's markup stays on screen above a second, live copy of the same thing.
|
|
128
|
+
*/
|
|
129
|
+
export function mount(el: HTMLElement, props: ${feature.pascal}FormProps): void {
|
|
130
|
+
setSolidRuntime(solidRuntime);
|
|
131
|
+
el.textContent = '';
|
|
132
|
+
render(
|
|
133
|
+
() => (
|
|
134
|
+
<UiProvider locale={props.locale}>
|
|
135
|
+
<${feature.pascal}FormBody {...props} />
|
|
136
|
+
</UiProvider>
|
|
137
|
+
),
|
|
138
|
+
el,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
`;
|
|
142
|
+
|
|
143
|
+
const formIslandTest = (
|
|
144
|
+
feature: NameSet,
|
|
145
|
+
dir: string,
|
|
146
|
+
): string => `// The form the browser actually runs. \`mountIsland\` builds this entry with the same
|
|
147
|
+
// \`buildIslands\` that \`x build\` and \`x dev\` use, imports the emitted chunk the way the hydration
|
|
148
|
+
// runtime does, and drives \`mount\` against a DOM small enough to read.
|
|
149
|
+
//
|
|
150
|
+
// It is the test that keeps this file a CLIENT entry. A generated form that only typechecks is
|
|
151
|
+
// what shipped before: server-rendered, every \`on*\` prop dropped, every signal read once.
|
|
152
|
+
|
|
153
|
+
import { join } from 'node:path';
|
|
154
|
+
import { buildIslands } from '@ultimat3/cli';
|
|
155
|
+
import {
|
|
156
|
+
afterAll,
|
|
157
|
+
beforeAll,
|
|
158
|
+
describe,
|
|
159
|
+
expect,
|
|
160
|
+
type FakeElement,
|
|
161
|
+
type MountedIsland,
|
|
162
|
+
mountIsland,
|
|
163
|
+
test,
|
|
164
|
+
} from '@ultimat3/testing';
|
|
165
|
+
|
|
166
|
+
const APP_ROOT = join(import.meta.dir, ${upToAppRoot(dir)});
|
|
167
|
+
const ISLAND = '${dir}/${feature.kebab}-form.island.tsx';
|
|
168
|
+
const ENDPOINT = '/api/${feature.kebab}/create-${feature.kebab}';
|
|
169
|
+
|
|
170
|
+
const LABELS = { title: 'Title', submit: 'Save', saved: 'Saved', retry: 'Try again' };
|
|
171
|
+
|
|
172
|
+
const calls: { url: string; body: Record<string, unknown> }[] = [];
|
|
173
|
+
|
|
174
|
+
/** One stub, both outcomes: the mount costs seconds, so the network's answer is a switch. */
|
|
175
|
+
let networkFails = false;
|
|
176
|
+
|
|
177
|
+
let mounted: MountedIsland;
|
|
178
|
+
|
|
179
|
+
// The build is a Babel pass plus a browser bundle — seconds, not milliseconds. It lives in
|
|
180
|
+
// \`beforeAll\` with its own timeout because \`test\` takes no third argument: fixtures are resolved
|
|
181
|
+
// per case, so the slow work goes where it can be given one and every case shares the result.
|
|
182
|
+
beforeAll(async () => {
|
|
183
|
+
mounted = await mountIsland({
|
|
184
|
+
build: buildIslands,
|
|
185
|
+
root: APP_ROOT,
|
|
186
|
+
file: ISLAND,
|
|
187
|
+
props: { endpoint: ENDPOINT, locale: 'en', labels: LABELS },
|
|
188
|
+
// What the server rendered inside the island's wrapper. \`mount\` replaces it.
|
|
189
|
+
shell: '<p>Loading</p>',
|
|
190
|
+
globals: {
|
|
191
|
+
fetch: (url: string, init: { body: string }): Promise<{ ok: boolean }> => {
|
|
192
|
+
calls.push({ url, body: JSON.parse(init.body) as Record<string, unknown> });
|
|
193
|
+
// What a browser rejects with when there is no network. Not a response: \`response.ok\`
|
|
194
|
+
// is never read on this path, which is exactly why the form has to catch it.
|
|
195
|
+
return networkFails
|
|
196
|
+
? Promise.reject(new TypeError('Failed to fetch'))
|
|
197
|
+
: Promise.resolve({ ok: true });
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}, 60_000);
|
|
202
|
+
|
|
203
|
+
// The fake \`document\` is process-global: left installed it reaches every LATER FILE in the run.
|
|
204
|
+
//
|
|
205
|
+
// \`?.\` on a binding the type says is always set: TypeScript's definite-assignment analysis does not
|
|
206
|
+
// cross the \`beforeAll\` closure, so a setup that REJECTED leaves this undefined at run time — and
|
|
207
|
+
// bun runs \`afterAll\` regardless. Unguarded, the build failure is followed by a \`TypeError:
|
|
208
|
+
// undefined is not an object\` that says nothing, and that second line is the one a tail reads.
|
|
209
|
+
// Nothing is skipped by the guard: \`mountIsland\` restores the process itself when a mount throws.
|
|
210
|
+
afterAll(() => {
|
|
211
|
+
mounted?.[Symbol.dispose]();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* One mount, driven as a session: the cases below run in order against the same island, because
|
|
216
|
+
* building the real chunk costs seconds and repeating it per case would pay them for state each
|
|
217
|
+
* case sets up anyway. What each one asserts is independent.
|
|
218
|
+
*/
|
|
219
|
+
describe('the ${feature.kebab} form island', () => {
|
|
220
|
+
test('mount replaces the server shell with the editor', () => {
|
|
221
|
+
expect(mounted.find('p')?.getAttribute('data-role')).toBe('status');
|
|
222
|
+
// Solid compiles to real DOM calls; a chunk that fell back to the classic React factory names
|
|
223
|
+
// a global that is not in it, and \`Bun.build\` answers \`success: true\` over that all the same.
|
|
224
|
+
expect(mounted.code).not.toMatch(/\\bReact\\b/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('the field tracks, and submit posts what was typed', async () => {
|
|
228
|
+
const field: FakeElement | null = mounted.find('input');
|
|
229
|
+
expect(field).not.toBeNull();
|
|
230
|
+
if (field !== null) field.value = 'First ${feature.camel}';
|
|
231
|
+
// \`false\` means no handler ran — an island whose onInput never reached the DOM looks
|
|
232
|
+
// identical to a selector typo otherwise.
|
|
233
|
+
expect(mounted.fire(field, 'input')).toBe(true);
|
|
234
|
+
expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
|
|
235
|
+
await Promise.resolve();
|
|
236
|
+
|
|
237
|
+
expect(calls).toEqual([{ url: ENDPOINT, body: { title: 'First ${feature.camel}' } }]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('the status line answers the response', () => {
|
|
241
|
+
// The signal reached the DOM: an eager JSX factory renders '' here and never runs again.
|
|
242
|
+
expect(mounted.text('[data-role="status"]')).toBe(LABELS.saved);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('a request that never got a response still reaches retry', async () => {
|
|
246
|
+
// The outcome \`retry\` is FOR. A \`fetch\` that rejects reaches no \`response.ok\`, so without
|
|
247
|
+
// the catch in \`send\` the status line stays on its last value and the rejection escapes.
|
|
248
|
+
networkFails = true;
|
|
249
|
+
expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
|
|
250
|
+
await Promise.resolve();
|
|
251
|
+
await Promise.resolve();
|
|
252
|
+
|
|
253
|
+
expect(mounted.text('[data-role="status"]')).toBe(LABELS.retry);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
`;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The slice's form, as the one client shape: `<dir>/<feature>-form.island.tsx` plus its test.
|
|
260
|
+
*
|
|
261
|
+
* `pageDir` is the directory of the page that declares it — the caller's, because only the caller
|
|
262
|
+
* runs both generators and knows where the other one put its file.
|
|
263
|
+
*/
|
|
264
|
+
export function formIslandFiles(
|
|
265
|
+
feature: NameSet,
|
|
266
|
+
dir: string,
|
|
267
|
+
pageDir: string,
|
|
268
|
+
): readonly GeneratedFile[] {
|
|
269
|
+
return [
|
|
270
|
+
{
|
|
271
|
+
path: `${dir}/${feature.kebab}-form.island.tsx`,
|
|
272
|
+
contents: formIslandSource(feature, formIslandSpecifier(feature, dir, pageDir)),
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
path: `${dir}/${feature.kebab}-form.island.test.ts`,
|
|
276
|
+
contents: formIslandTest(feature, dir),
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
}
|
|
@@ -13,7 +13,8 @@ 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 {
|
|
16
|
+
import { formIslandFiles } from './resource-form-island';
|
|
17
|
+
import { routeDir, routeFiles } from './route';
|
|
17
18
|
|
|
18
19
|
const serviceSource = (
|
|
19
20
|
feature: NameSet,
|
|
@@ -142,40 +143,6 @@ export function ${feature.pascal}Card(props: ${feature.pascal}CardProps) {${tran
|
|
|
142
143
|
}
|
|
143
144
|
`;
|
|
144
145
|
|
|
145
|
-
const formSource = (
|
|
146
|
-
feature: NameSet,
|
|
147
|
-
module: string | undefined,
|
|
148
|
-
): string => `// Presentation only: the mutator this submits to owns validation server-side, so this form
|
|
149
|
-
// never re-implements the invariant — a blank title fails at the boundary, not in the DOM.
|
|
150
|
-
|
|
151
|
-
${catalogImport(module)}
|
|
152
|
-
import { createSignal } from 'solid-js';
|
|
153
|
-
import styles from '../ui.module.scss';
|
|
154
|
-
|
|
155
|
-
export interface ${feature.pascal}FormProps {
|
|
156
|
-
readonly onSubmit: (title: string) => void;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export function ${feature.pascal}Form(props: ${feature.pascal}FormProps) {
|
|
160
|
-
const [title, setTitle] = createSignal('');${translatorBinding(module)}
|
|
161
|
-
return (
|
|
162
|
-
<form
|
|
163
|
-
class={styles.item}
|
|
164
|
-
onSubmit={(event) => {
|
|
165
|
-
event.preventDefault();
|
|
166
|
-
props.onSubmit(title());
|
|
167
|
-
}}
|
|
168
|
-
>
|
|
169
|
-
<label>
|
|
170
|
-
{t('app.${feature.kebab}.titleLabel')}
|
|
171
|
-
<input value={title()} onInput={(event) => setTitle(event.currentTarget.value)} />
|
|
172
|
-
</label>
|
|
173
|
-
<button type="submit">{t('app.${feature.kebab}.submit')}</button>
|
|
174
|
-
</form>
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
`;
|
|
178
|
-
|
|
179
146
|
// `admin.<feature>.title` is always here, `--admin` or not: `defineAdmin()` resolves that key the
|
|
180
147
|
// moment anyone writes the override, and a missing key renders ⟦key⟧ and fails the i18n gate,
|
|
181
148
|
// while an unused key is only ever reported (`auditCatalogs` fails on `missing`, never `unused`).
|
|
@@ -185,6 +152,8 @@ const catalogSource = (feature: NameSet): string =>
|
|
|
185
152
|
[`app.${feature.kebab}.updated`]: 'Last updated',
|
|
186
153
|
[`app.${feature.kebab}.titleLabel`]: 'Title',
|
|
187
154
|
[`app.${feature.kebab}.submit`]: 'Save',
|
|
155
|
+
[`app.${feature.kebab}.saved`]: 'Saved',
|
|
156
|
+
[`app.${feature.kebab}.retry`]: 'Try again',
|
|
188
157
|
[`admin.${feature.kebab}.title`]: pascal(feature.plural),
|
|
189
158
|
});
|
|
190
159
|
|
|
@@ -204,6 +173,10 @@ export function resourceFiles(rawName: string, target: ResourceOptions): readonl
|
|
|
204
173
|
const feature = names(rawName);
|
|
205
174
|
const slice: FeatureTarget = { surfaceDir: target.surfaceDir, feature: feature.kebab };
|
|
206
175
|
const dir = `${slice.surfaceDir}/${slice.feature}`;
|
|
176
|
+
// The page this same call writes, from the function that decides where a route goes — the island
|
|
177
|
+
// specifier is resolved against it, so re-deriving the path here would be two answers to one
|
|
178
|
+
// question and only one of them reaches `routeFiles`.
|
|
179
|
+
const pageDir = routeDir('app', feature.pluralKebab);
|
|
207
180
|
const locales = resolveLocales(target.locales);
|
|
208
181
|
return [
|
|
209
182
|
...entityFiles(rawName, slice),
|
|
@@ -220,18 +193,24 @@ export function resourceFiles(rawName: string, target: ResourceOptions): readonl
|
|
|
220
193
|
path: `${dir}/ui/${feature.kebab}-card.tsx`,
|
|
221
194
|
contents: cardSource(feature, target.catalogModule),
|
|
222
195
|
},
|
|
223
|
-
|
|
224
|
-
path: `${dir}/ui/${feature.kebab}-form.tsx`,
|
|
225
|
-
contents: formSource(feature, target.catalogModule),
|
|
226
|
-
},
|
|
196
|
+
...formIslandFiles(feature, dir, pageDir),
|
|
227
197
|
...locales.map((locale) => ({
|
|
228
198
|
path: catalogPath(locale),
|
|
229
199
|
contents: catalogSource(feature),
|
|
230
200
|
merge: 'json' as const,
|
|
231
201
|
})),
|
|
232
|
-
// Always an app route: a slice ships a live query, a form and actions, and `generate()`
|
|
202
|
+
// Always an app route: a slice ships a live query, a form island and actions, and `generate()`
|
|
233
203
|
// refuses `--surface site` for a resource rather than emit them behind a 0kb budget.
|
|
234
|
-
|
|
204
|
+
//
|
|
205
|
+
// `catalogModule` travels with it. It did not, so the page a RESOURCE writes imported `t` from
|
|
206
|
+
// `@ultimat3/i18n` while the components beside it imported `useT` from the app's own catalog
|
|
207
|
+
// module — one command, two idioms, and the framework-import one is issue #249's: it renders
|
|
208
|
+
// strings while depending on nothing that registers them.
|
|
209
|
+
...routeFiles(feature.pluralKebab, {
|
|
210
|
+
surface: 'app',
|
|
211
|
+
locales,
|
|
212
|
+
...(target.catalogModule === undefined ? {} : { catalogModule: target.catalogModule }),
|
|
213
|
+
}),
|
|
235
214
|
...(target.admin === true ? adminFiles(rawName, slice) : []),
|
|
236
215
|
];
|
|
237
216
|
}
|
package/src/templates/route.ts
CHANGED
|
@@ -19,6 +19,14 @@ export type Surface = 'site' | 'app';
|
|
|
19
19
|
* that was absent from `x routes`, from the manifest and from `budgets`. Ship the mode that works.
|
|
20
20
|
*/
|
|
21
21
|
const RENDER: Record<Surface, string> = { site: 'isr', app: 'ssr' };
|
|
22
|
+
/**
|
|
23
|
+
* `app` is `'visible'` on a page the generator writes with no island, and that is not an oversight.
|
|
24
|
+
* A declared strategy costs nothing when there is nothing to hydrate — `hydrateRuntime` is emitted
|
|
25
|
+
* per island DIRECTIVE and answers `''` for a page with none (`packages/render/src/hydrate.ts`), so
|
|
26
|
+
* `x routes` reads `visible` while the document ships 0 bytes. What it buys is that adding the
|
|
27
|
+
* first island is one edit: a declared `'never'` beside an island is `X_ISLAND_NOT_HYDRATED`, which
|
|
28
|
+
* `defineRoute` refuses on purpose, so the scaffold would break at the moment it is first used.
|
|
29
|
+
*/
|
|
22
30
|
const HYDRATE: Record<Surface, string> = { site: 'never', app: 'visible' };
|
|
23
31
|
const OFFLINE: Record<Surface, string> = { site: 'precache', app: 'runtime' };
|
|
24
32
|
/**
|
|
@@ -66,7 +74,12 @@ export const routeParams = (path: string): readonly string[] =>
|
|
|
66
74
|
return name === undefined ? [] : [name];
|
|
67
75
|
});
|
|
68
76
|
|
|
69
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Where the page lands. Exported because an island's `src` is resolved relative to the PAGE, and
|
|
79
|
+
* only this function knows where `x g resource`'s page went — a second copy of the formula in the
|
|
80
|
+
* resource generator is how the specifier it prints stops matching the file it writes.
|
|
81
|
+
*/
|
|
82
|
+
export const routeDir = (surface: Surface, path: string): string =>
|
|
70
83
|
`apps/web/${surface}/${segmentsOf(path).join('/')}`;
|
|
71
84
|
|
|
72
85
|
/**
|
|
@@ -125,7 +138,7 @@ export function ${name}Page() {${translatorBinding(module)}
|
|
|
125
138
|
};
|
|
126
139
|
|
|
127
140
|
const styleSource =
|
|
128
|
-
(): string => `// Semantic tokens only — a raw hex here is a dark-theme bug
|
|
141
|
+
(): string => `// Semantic tokens only — a raw hex here is refused by the boundaries step (guards/raw-colour.ts), not by lint — a dark-theme bug in every scheme but the one it was written in.
|
|
129
142
|
@use '@ultimat3/ui/tokens' as tokens;
|
|
130
143
|
|
|
131
144
|
.page {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { GeneratedFile, NameSet } from './naming';
|
|
6
6
|
import { apiFiles } from './scaffold-api';
|
|
7
|
+
import { entryFiles } from './scaffold-entries';
|
|
7
8
|
import { icon } from './scaffold-icon';
|
|
8
9
|
import { rolesFiles } from './scaffold-roles';
|
|
9
10
|
|
|
@@ -136,6 +137,8 @@ export const config = defineRoute({
|
|
|
136
137
|
// therefore failed x routes with X_ROUTE_MODE_INVALID on the first run, printing a fix nobody
|
|
137
138
|
// could follow. Ship the mode that works. Async data needs no boundary: await it in the page.
|
|
138
139
|
render: 'ssr',
|
|
140
|
+
// Stated with no island on the page, deliberately and for free — \`apps/admin/app/admin/page.tsx\`
|
|
141
|
+
// carries the reason.
|
|
139
142
|
hydrate: 'visible',
|
|
140
143
|
offline: 'runtime',
|
|
141
144
|
// Auth is a policy, never a route-local flag: one authz system, evaluated everywhere.
|
|
@@ -325,10 +328,16 @@ import { defineRoute } from '@ultimat3/render';
|
|
|
325
328
|
|
|
326
329
|
export const config = defineRoute({
|
|
327
330
|
render: 'ssr',
|
|
331
|
+
// Stated on a page whose body is one \`<h1>\`, and it costs nothing: the hydration runtime is
|
|
332
|
+
// emitted per island DIRECTIVE, so \`hydrateRuntime([])\` is \`''\` and this document ships 0 bytes
|
|
333
|
+
// (\`packages/render/src/hydrate.ts\`). It buys the first island being ONE file's edit —
|
|
334
|
+
// \`hydrate: 'never'\` beside an island is \`X_ISLAND_NOT_HYDRATED\`, which defineRoute refuses.
|
|
328
335
|
hydrate: 'idle',
|
|
329
336
|
offline: 'network-only',
|
|
330
|
-
// Behind auth,
|
|
331
|
-
//
|
|
337
|
+
// Behind auth, so the mode has to render per request: \`ssr\` and \`stream\` both do, and both take
|
|
338
|
+
// a \`policy\`. \`static\` and \`isr\` refuse one outright — a file on disk has no actor to decide
|
|
339
|
+
// against, and an ISR document is cached per URL, so the first actor's HTML would be served to
|
|
340
|
+
// every later one who passes the same policy.
|
|
332
341
|
policy: { permission: 'admin:read' },
|
|
333
342
|
budget: { js: '120kb' },
|
|
334
343
|
meta: ({ t }) => ({ title: t('admin.home.title'), description: t('admin.home.description') }),
|
|
@@ -341,80 +350,6 @@ export function AdminHome() {
|
|
|
341
350
|
}
|
|
342
351
|
`;
|
|
343
352
|
|
|
344
|
-
// The two entry files a deploy needs. Both are deliberately thin: which role a container is, which
|
|
345
|
-
// port it binds, how it drains and what a static build enumerates are the framework's answers, so
|
|
346
|
-
// an upgrade moves them without a codemod in every app that ever shipped.
|
|
347
|
-
|
|
348
|
-
const server =
|
|
349
|
-
(): string => `// The production entry. \`docker/Dockerfile\` starts this, and \`x build --target binary\` compiles it.
|
|
350
|
-
// ROLE selects what this process is — web, sync, worker, scheduler, replicator, or migrate, which
|
|
351
|
-
// applies the migrations and exits. PORT is bound on every interface, because a container bound to
|
|
352
|
-
// localhost is unreachable through its own port mapping.
|
|
353
|
-
|
|
354
|
-
import { join } from 'node:path';
|
|
355
|
-
import { runRole } from '@ultimat3/cli';
|
|
356
|
-
|
|
357
|
-
// MORE THAN ONE REPLICA? Add these two lines, above \`runRole\`:
|
|
358
|
-
//
|
|
359
|
-
// import { configureIdempotency } from '@ultimat3/action';
|
|
360
|
-
// configureIdempotency({ scope: 'shared' });
|
|
361
|
-
//
|
|
362
|
-
// \`idempotent: true\` on an action promises that a retry does not repeat the work. Under the
|
|
363
|
-
// process-scoped default that promise holds inside ONE process — a client retrying
|
|
364
|
-
// \`POST /api/payments/charge\` after a timeout lands on another replica, which has never seen the
|
|
365
|
-
// key, and charges the card twice, silently, with \`x verify\` green. Declaring \`'shared'\` is what
|
|
366
|
-
// makes that a boot error (\`X_IDEMPOTENCY_NOT_SHARED\`) unless a shared store is installed.
|
|
367
|
-
// \`runRole\` installs the Postgres one for you, on the connection it resolved from \`DATABASE_URL\`,
|
|
368
|
-
// so the declaration is all this app owes. It must run before \`runRole\` imports the actions.
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Where the app is. From this file normally — the image's WORKDIR is not the app root's business.
|
|
372
|
-
* A \`--compile\` binary is the exception: its \`import.meta.dir\` is Bun's virtual filesystem, which
|
|
373
|
-
* holds this module's bundled imports and none of the app's source, and the framework's registries
|
|
374
|
-
* are filled by scanning that source at boot. So a binary reads its root from the directory it is
|
|
375
|
-
* started in — it is a launcher for an app tree, not a self-contained copy of one.
|
|
376
|
-
*/
|
|
377
|
-
const root = import.meta.dir.startsWith('/$bunfs')
|
|
378
|
-
? process.cwd()
|
|
379
|
-
: join(import.meta.dir, '..', '..');
|
|
380
|
-
|
|
381
|
-
// Guarded, because the framework's module scan imports every file under apps/*/ to fill its
|
|
382
|
-
// registries — an unguarded boot would start a server inside \`x verify\`.
|
|
383
|
-
if (import.meta.main) {
|
|
384
|
-
await runRole({ root, env: Bun.env });
|
|
385
|
-
}
|
|
386
|
-
`;
|
|
387
|
-
|
|
388
|
-
const prerender =
|
|
389
|
-
(): string => `// The static entry. \`x build --target static\` runs this with \`--out <dir>\` and it writes one HTML
|
|
390
|
-
// file per \`render: 'static'\` route — a CDN or an object store then serves site/ with no process
|
|
391
|
-
// behind it. Every other render mode needs a running app and is reported as skipped, never emitted.
|
|
392
|
-
//
|
|
393
|
-
// Skipped is not unweighed: a route that declares a \`budget:\` is rendered in memory and measured
|
|
394
|
-
// whatever its mode, so \`x verify\`'s \`budgets\` step has a number for it. \`unmeasured\` is the list
|
|
395
|
-
// this build could not render — each one is an X_BUDGET_UNMEASURED at the gate, and this is where
|
|
396
|
-
// the reason is.
|
|
397
|
-
|
|
398
|
-
import { join } from 'node:path';
|
|
399
|
-
import { prerenderSite } from '@ultimat3/cli';
|
|
400
|
-
|
|
401
|
-
const root = join(import.meta.dir, '..', '..');
|
|
402
|
-
const flag = Bun.argv.indexOf('--out');
|
|
403
|
-
const out = (flag === -1 ? undefined : Bun.argv[flag + 1]) ?? join(root, '.x', 'static');
|
|
404
|
-
// SITE_ORIGIN is what canonical and og:url are built from; the default is only ever a local build.
|
|
405
|
-
// Property access, not \`Bun.env['SITE_ORIGIN']\`: the scaffolded tsconfig does not set
|
|
406
|
-
// \`noPropertyAccessFromIndexSignature\`, so the bracket form is the one biome's useLiteralKeys
|
|
407
|
-
// reports — a diagnostic in an app's first lint run over a file the app never wrote.
|
|
408
|
-
const origin = Bun.env.SITE_ORIGIN;
|
|
409
|
-
|
|
410
|
-
if (import.meta.main) {
|
|
411
|
-
const report = await prerenderSite({ root, out, ...(origin === undefined ? {} : { origin }) });
|
|
412
|
-
await Bun.stdout.write(
|
|
413
|
-
\`\${JSON.stringify({ ok: true, out: report.out, pages: report.pages.length, skipped: report.skipped, unmeasured: report.unmeasured })}\\n\`,
|
|
414
|
-
);
|
|
415
|
-
}
|
|
416
|
-
`;
|
|
417
|
-
|
|
418
353
|
const placeholder = (surface: string, app: NameSet): string => `# ${surface}
|
|
419
354
|
|
|
420
355
|
Placeholder. The monorepo shape exists now so adding ${surface} later is a new directory, not a
|
|
@@ -433,8 +368,8 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
|
|
|
433
368
|
return [
|
|
434
369
|
{ path: 'apps/web/package.json', contents: webPackage(app) },
|
|
435
370
|
{ path: 'apps/web/tsconfig.json', contents: tsconfig() },
|
|
436
|
-
|
|
437
|
-
|
|
371
|
+
// The process a container starts and the artifact a CDN is handed — `scaffold-entries.ts`.
|
|
372
|
+
...entryFiles(),
|
|
438
373
|
{ path: 'apps/web/site/icon.png', contents: icon() },
|
|
439
374
|
{ path: 'apps/web/site/page.tsx', contents: sitePage(app) },
|
|
440
375
|
{ path: 'apps/web/site/page.module.scss', contents: siteStyle() },
|
|
@@ -52,8 +52,10 @@ ENV NODE_ENV=production \\
|
|
|
52
52
|
# own port mapping, its load balancer and every health probe alike.
|
|
53
53
|
EXPOSE 3000
|
|
54
54
|
|
|
55
|
-
#
|
|
56
|
-
# closes, so a rolling restart drains in-flight work instead of dropping it.
|
|
55
|
+
# The probe for the roles that SERVE HTTP — \`web\` and \`sync\`. /readyz flips to 503 on SIGTERM
|
|
56
|
+
# *before* the socket closes, so a rolling restart drains in-flight work instead of dropping it.
|
|
57
|
+
# Every other role opens the scrape listener alone and never binds $PORT, so each one overrides
|
|
58
|
+
# this in docker/docker-compose.prod.yml rather than reporting \`unhealthy\` for its whole life.
|
|
57
59
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 CMD \\
|
|
58
60
|
bun --eval "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/readyz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"
|
|
59
61
|
|
|
@@ -120,6 +122,22 @@ x-image: &image
|
|
|
120
122
|
depends_on:
|
|
121
123
|
db: { condition: service_healthy }
|
|
122
124
|
|
|
125
|
+
# The image's own HEALTHCHECK fetches \`/readyz\` on $PORT, and only \`web\` and \`sync\` open an HTTP
|
|
126
|
+
# socket — every other role gets the scrape listener and nothing else. A service that inherits that
|
|
127
|
+
# probe is fetching a port it never binds: it reports \`unhealthy\` for its whole life and anything
|
|
128
|
+
# gated on it never starts. Probes follow the role here, exactly as they do in \`docker/helm\`.
|
|
129
|
+
x-metrics-probe: &metrics-probe
|
|
130
|
+
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))"]
|
|
131
|
+
interval: 10s
|
|
132
|
+
timeout: 3s
|
|
133
|
+
start_period: 30s
|
|
134
|
+
retries: 3
|
|
135
|
+
|
|
136
|
+
# Run-once services exit. A probe against an exited container reports \`unhealthy\` forever, and
|
|
137
|
+
# nothing waits on their health — \`service_completed_successfully\` is what the others gate on.
|
|
138
|
+
x-run-once-probe: &run-once-probe
|
|
139
|
+
disable: true
|
|
140
|
+
|
|
123
141
|
services:
|
|
124
142
|
db:
|
|
125
143
|
image: postgres:17-alpine
|
|
@@ -138,6 +156,7 @@ services:
|
|
|
138
156
|
<<: *image
|
|
139
157
|
environment: [ROLE=migrate]
|
|
140
158
|
restart: 'no'
|
|
159
|
+
healthcheck: *run-once-probe
|
|
141
160
|
|
|
142
161
|
# Run-once, AFTER the new version serves. Deliberately NOT part of the release gate: a slow
|
|
143
162
|
# UPDATE there holds the deploy open against a database still serving the previous version.
|
|
@@ -158,6 +177,7 @@ services:
|
|
|
158
177
|
# listing this last would only look like "after". The image's HEALTHCHECK is what makes it true.
|
|
159
178
|
web: { condition: service_healthy }
|
|
160
179
|
restart: 'no'
|
|
180
|
+
healthcheck: *run-once-probe
|
|
161
181
|
|
|
162
182
|
web:
|
|
163
183
|
<<: *image
|
|
@@ -184,6 +204,7 @@ services:
|
|
|
184
204
|
depends_on:
|
|
185
205
|
db: { condition: service_healthy }
|
|
186
206
|
migrate: { condition: service_completed_successfully }
|
|
207
|
+
healthcheck: *metrics-probe
|
|
187
208
|
deploy: { replicas: 1 } # scales on queue depth
|
|
188
209
|
|
|
189
210
|
scheduler:
|
|
@@ -192,7 +213,12 @@ services:
|
|
|
192
213
|
depends_on:
|
|
193
214
|
db: { condition: service_healthy }
|
|
194
215
|
migrate: { condition: service_completed_successfully }
|
|
195
|
-
|
|
216
|
+
# Fixed 1. Leadership is an EXPIRING LEASE ROW in \`x_scheduler_leader\` (dev-roles.ts,
|
|
217
|
+
# driver-pg-ddl.ts), NOT an advisory lock: that grant belongs to the session, not to the
|
|
218
|
+
# process — it outlives every transaction and no pooled node can renew it or prove it still
|
|
219
|
+
# holds one. A second instance is harmless but idle.
|
|
220
|
+
healthcheck: *metrics-probe
|
|
221
|
+
deploy: { replicas: 1 }
|
|
196
222
|
|
|
197
223
|
volumes:
|
|
198
224
|
pgdata:
|
|
@@ -208,7 +234,7 @@ nothing to rebuild between staging and production.
|
|
|
208
234
|
| \`web\` | HTTP: pages, actions, assets | \`$PORT\` (default 3000) |
|
|
209
235
|
| \`sync\` | websockets for live queries | \`$PORT + 1\` |
|
|
210
236
|
| \`worker\` | the job queue | — |
|
|
211
|
-
| \`scheduler\` | cron tasks; leadership is
|
|
237
|
+
| \`scheduler\` | cron tasks; leadership is an expiring lease row in \`x_scheduler_leader\` | — |
|
|
212
238
|
| \`replicator\` | the logical replication slot, exactly one per database | — |
|
|
213
239
|
| \`migrate\` | applies pending migrations and **exits** | — |
|
|
214
240
|
|