@ultimat3/cli 22.14.0 → 23.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 +25 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +35 -3
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-dev.ts +2 -2
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +30 -6
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/document-options.ts +64 -0
- package/src/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/page-speculation.ts +124 -0
- package/src/permission-grants.ts +41 -9
- package/src/prerender.ts +9 -5
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +36 -60
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -147
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/static-document.ts +108 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +142 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
|
@@ -170,9 +170,9 @@ export function mount(el: HTMLElement, props: ${feature.pascal}FormProps): void
|
|
|
170
170
|
const formIslandTest = (
|
|
171
171
|
feature: NameSet,
|
|
172
172
|
dir: string,
|
|
173
|
-
): string => `// The form the browser actually runs
|
|
174
|
-
// \`buildIslands\` that \`x build\` and \`x dev\` use,
|
|
175
|
-
// runtime
|
|
173
|
+
): string => `// The form the browser actually runs, in every state its manifest declares: built once for this
|
|
174
|
+
// file with the same \`buildIslands\` that \`x build\` and \`x dev\` use, imported the way the
|
|
175
|
+
// hydration runtime imports it, and driven against a DOM small enough to read.
|
|
176
176
|
//
|
|
177
177
|
// It is the test that keeps this file a CLIENT entry. A generated form that only typechecks is
|
|
178
178
|
// what shipped before: server-rendered, every \`on*\` prop dropped, every signal read once.
|
|
@@ -180,76 +180,46 @@ const formIslandTest = (
|
|
|
180
180
|
import { join } from 'node:path';
|
|
181
181
|
import { buildIslands } from '@ultimat3/cli';
|
|
182
182
|
import {
|
|
183
|
-
|
|
184
|
-
beforeAll,
|
|
185
|
-
describe,
|
|
183
|
+
describeIslandState,
|
|
186
184
|
expect,
|
|
187
185
|
type FakeElement,
|
|
188
186
|
type MountedIsland,
|
|
189
|
-
mountIsland,
|
|
190
187
|
test,
|
|
191
188
|
} from '@ultimat3/testing';
|
|
192
|
-
|
|
193
|
-
const APP_ROOT = join(import.meta.dir, ${upToAppRoot(dir)});
|
|
194
|
-
const ISLAND = '${dir}/${feature.kebab}-form.island.tsx';
|
|
195
|
-
const ENDPOINT = '/api/${feature.kebab}/create-${feature.kebab}';
|
|
196
|
-
|
|
197
|
-
const LABELS = {
|
|
198
|
-
title: 'Title',
|
|
199
|
-
price: 'Price',
|
|
200
|
-
submit: 'Save',
|
|
201
|
-
saved: 'Saved',
|
|
202
|
-
retry: 'Try again',
|
|
203
|
-
};
|
|
189
|
+
import { ${feature.camel}FormStates } from './${feature.kebab}-form.island.states';
|
|
204
190
|
|
|
205
191
|
const calls: { url: string; body: Record<string, unknown> }[] = [];
|
|
206
192
|
|
|
207
|
-
/** One stub, both outcomes:
|
|
193
|
+
/** One stub, both outcomes: a block shares its mount, so the network's answer is a switch. */
|
|
208
194
|
let networkFails = false;
|
|
209
195
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
// why the form has to catch it.
|
|
229
|
-
return networkFails
|
|
230
|
-
? Promise.reject(new TypeError('Failed to fetch'))
|
|
231
|
-
: Promise.resolve(Response.json({ id: 'created' }));
|
|
232
|
-
},
|
|
196
|
+
// One block per declared state: \`describeIslandState\` mounts it before the block's first test
|
|
197
|
+
// and disposes it after the last. The props are the manifest's own — what
|
|
198
|
+
// \`x shot --island ${feature.kebab}-form\` photographs — so the picture and the test are of one
|
|
199
|
+
// component.
|
|
200
|
+
const island = {
|
|
201
|
+
build: buildIslands,
|
|
202
|
+
root: join(import.meta.dir, ${upToAppRoot(dir)}),
|
|
203
|
+
// What the server rendered inside the island's wrapper. \`mount\` replaces it.
|
|
204
|
+
shell: '<p>Loading</p>',
|
|
205
|
+
globals: {
|
|
206
|
+
// The form sends through \`clientTransport\`, which calls \`globalThis.fetch\` — this stub.
|
|
207
|
+
fetch: (url: string, init: { body: string }): Promise<Response> => {
|
|
208
|
+
calls.push({ url, body: JSON.parse(init.body) as Record<string, unknown> });
|
|
209
|
+
// What a browser rejects with when there is no network. Not a response, which is exactly
|
|
210
|
+
// why the form has to catch it.
|
|
211
|
+
return networkFails
|
|
212
|
+
? Promise.reject(new TypeError('Failed to fetch'))
|
|
213
|
+
: Promise.resolve(Response.json({ id: 'created' }));
|
|
233
214
|
},
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// The fake \`document\` is process-global: left installed it reaches every LATER FILE in the run.
|
|
238
|
-
//
|
|
239
|
-
// \`?.\` on a binding the type says is always set: TypeScript's definite-assignment analysis does not
|
|
240
|
-
// cross the \`beforeAll\` closure, so a setup that REJECTED leaves this undefined at run time — and
|
|
241
|
-
// bun runs \`afterAll\` regardless. Unguarded, the build failure is followed by a \`TypeError:
|
|
242
|
-
// undefined is not an object\` that says nothing, and that second line is the one a tail reads.
|
|
243
|
-
// Nothing is skipped by the guard: \`mountIsland\` restores the process itself when a mount throws.
|
|
244
|
-
afterAll(() => {
|
|
245
|
-
mounted?.[Symbol.dispose]();
|
|
246
|
-
});
|
|
215
|
+
},
|
|
216
|
+
};
|
|
247
217
|
|
|
248
218
|
/**
|
|
249
219
|
* Until the status line changes, a bounded number of macrotasks: the send is several awaits deep
|
|
250
220
|
* inside \`clientTransport\`, so counting microtasks would pin the transport, not the form.
|
|
251
221
|
*/
|
|
252
|
-
async function statusSettled(): Promise<void> {
|
|
222
|
+
async function statusSettled(mounted: MountedIsland): Promise<void> {
|
|
253
223
|
const before = mounted.text('[data-role="status"]');
|
|
254
224
|
for (let tick = 0; tick < 50; tick += 1) {
|
|
255
225
|
if (mounted.text('[data-role="status"]') !== before) return;
|
|
@@ -257,53 +227,61 @@ async function statusSettled(): Promise<void> {
|
|
|
257
227
|
}
|
|
258
228
|
}
|
|
259
229
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
* case sets up anyway. What each one asserts is independent.
|
|
264
|
-
*/
|
|
265
|
-
describe('the ${feature.kebab} form island', () => {
|
|
230
|
+
// Each case sets the network's answer itself and asserts on what its own submit produced, so
|
|
231
|
+
// none depends on the one before it.
|
|
232
|
+
describeIslandState(${feature.camel}FormStates, 'idle', island, (mounted) => {
|
|
266
233
|
test('mount replaces the server shell with the editor', () => {
|
|
267
|
-
expect(mounted.find('p')?.getAttribute('data-role')).toBe('status');
|
|
234
|
+
expect(mounted().find('p')?.getAttribute('data-role')).toBe('status');
|
|
235
|
+
expect(mounted().text('button')).toBe('Save');
|
|
268
236
|
// Solid compiles to real DOM calls; a chunk that fell back to the classic React factory names
|
|
269
237
|
// a global that is not in it, and \`Bun.build\` answers \`success: true\` over that all the same.
|
|
270
|
-
expect(mounted.code).not.toMatch(/\\bReact\\b/);
|
|
238
|
+
expect(mounted().code).not.toMatch(/\\bReact\\b/);
|
|
271
239
|
});
|
|
272
240
|
|
|
273
|
-
test('the fields track,
|
|
274
|
-
|
|
275
|
-
const
|
|
241
|
+
test('the fields track, submit posts the create input, and the status answers', async () => {
|
|
242
|
+
networkFails = false;
|
|
243
|
+
const title: FakeElement | null = mounted().find('input[aria-label="Title"]');
|
|
244
|
+
const price: FakeElement | null = mounted().find('input[aria-label="Price"]');
|
|
276
245
|
expect(title).not.toBeNull();
|
|
277
246
|
expect(price).not.toBeNull();
|
|
278
247
|
if (title !== null) title.value = 'First ${feature.camel}';
|
|
279
248
|
if (price !== null) price.value = '12.50';
|
|
280
249
|
// \`false\` means no handler ran — an island whose onInput never reached the DOM looks
|
|
281
250
|
// identical to a selector typo otherwise.
|
|
282
|
-
expect(mounted.fire(title, 'input')).toBe(true);
|
|
283
|
-
expect(mounted.fire(price, 'input')).toBe(true);
|
|
284
|
-
expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
|
|
285
|
-
await statusSettled();
|
|
251
|
+
expect(mounted().fire(title, 'input')).toBe(true);
|
|
252
|
+
expect(mounted().fire(price, 'input')).toBe(true);
|
|
253
|
+
expect(mounted().fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
|
|
254
|
+
await statusSettled(mounted());
|
|
286
255
|
|
|
287
256
|
const body = {
|
|
288
257
|
title: 'First ${feature.camel}',
|
|
289
258
|
price: { minor: 1250, currency: 'USD' },
|
|
290
259
|
};
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
test('the status line answers the response', () => {
|
|
260
|
+
// The endpoint is the state's own: the form posts where the server told it to.
|
|
261
|
+
expect(calls.at(-1)).toEqual({ url: '/api/${feature.pluralKebab}/create', body });
|
|
295
262
|
// The signal reached the DOM: an eager JSX factory renders '' here and never runs again.
|
|
296
|
-
expect(mounted.text('[data-role="status"]')).toBe(
|
|
263
|
+
expect(mounted().text('[data-role="status"]')).toBe('Saved');
|
|
297
264
|
});
|
|
298
265
|
|
|
299
266
|
test('a request that never got a response still reaches retry', async () => {
|
|
300
267
|
// The outcome \`retry\` is FOR. A \`fetch\` that rejects produces no answer, so without the
|
|
301
268
|
// catch in \`send\` the status line stays on its last value and the rejection escapes.
|
|
302
269
|
networkFails = true;
|
|
303
|
-
expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
|
|
304
|
-
await statusSettled();
|
|
270
|
+
expect(mounted().fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
|
|
271
|
+
await statusSettled(mounted());
|
|
272
|
+
|
|
273
|
+
expect(mounted().text('[data-role="status"]')).toBe('That did not save. Try again.');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
305
276
|
|
|
306
|
-
|
|
277
|
+
describeIslandState(${feature.camel}FormStates, 'long-labels', island, (mounted) => {
|
|
278
|
+
test('every label is the state’s own, in the locale it names', () => {
|
|
279
|
+
// Whether they FIT is a picture's question — \`x shot --island ${feature.kebab}-form\`. What a
|
|
280
|
+
// test can say is that the island was handed this state's text, and none of the idle one's.
|
|
281
|
+
expect(mounted().find('input[aria-label="Bezeichnung des Beitrags"]')).not.toBeNull();
|
|
282
|
+
expect(mounted().find('input[aria-label="Title"]')).toBeNull();
|
|
283
|
+
expect(mounted().text('button')).toBe('Änderungen speichern');
|
|
284
|
+
expect(mounted().documentElement.getAttribute('lang')).toBe('de');
|
|
307
285
|
});
|
|
308
286
|
});
|
|
309
287
|
`;
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// `x g resource <name>`'s page: the plural route a slice is reached at. It READS — `load` calls the
|
|
2
|
+
// slice's own query as the request's actor — renders the rows through the slice's list component
|
|
3
|
+
// inside `AsyncRegion` (ready, empty, failed), and mounts the slice's form island. `x g route`
|
|
4
|
+
// writes a page with a heading and nothing to read; a resource has rows and a form, and a page
|
|
5
|
+
// that showed neither was a slice nobody could reach without wiring it by hand.
|
|
6
|
+
|
|
7
|
+
import { sortedImports } from './imports';
|
|
8
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
9
|
+
import { titleKey } from './naming';
|
|
10
|
+
import { formIslandSpecifier } from './resource-form-island';
|
|
11
|
+
import { LINE_WIDTH } from './wrap';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The page's JS budget — MEASURED, never a ceiling picked to pass. `x build --target static` on a
|
|
15
|
+
* fresh `x new` app with `x g resource widget`, Bun 1.4.0 (CI's pin), 2026-10-01: 58,522 B — the
|
|
16
|
+
* form island 57,667 + the `visible` hydration runtime 855. 64kb is 65,536: the headroom is Bun's
|
|
17
|
+
* own movement on one source — a patch release that keeps core's declared modules measured +3.0 kB
|
|
18
|
+
* on the scaffold's theme island, and the tree-shaker flaps by up to 1.1 kB between runs. An app
|
|
19
|
+
* that adds per-document scripts (`navigation: { client: [...] }` charges its router, ~18 kB, to
|
|
20
|
+
* every `app/` page) is over it on its first build, and `nextSteps` sends the author there.
|
|
21
|
+
*/
|
|
22
|
+
export const RESOURCE_PAGE_BUDGET = '64kb';
|
|
23
|
+
export const RESOURCE_PAGE_MEASURED = '58,522 B';
|
|
24
|
+
|
|
25
|
+
/** How the page reaches a string — the same rule `route.ts` states, for the same issue (#249). */
|
|
26
|
+
const catalogImport = (module: string | undefined): string =>
|
|
27
|
+
module === undefined
|
|
28
|
+
? "import { t } from '@ultimat3/i18n';"
|
|
29
|
+
: `import { useT } from '${module}';`;
|
|
30
|
+
|
|
31
|
+
/** `currentLocale` rides on the framework import when the app has no catalog module. */
|
|
32
|
+
const i18nImports = (module: string | undefined): readonly string[] =>
|
|
33
|
+
module === undefined
|
|
34
|
+
? ["import { currentLocale, t } from '@ultimat3/i18n';"]
|
|
35
|
+
: [catalogImport(module), "import { currentLocale } from '@ultimat3/i18n';"];
|
|
36
|
+
|
|
37
|
+
/** `const load = … => rowsState(() => <read>(input()));`, as Biome prints it at any name length. */
|
|
38
|
+
const loadLine = (read: string): string => {
|
|
39
|
+
const head = 'const load = (): Promise<PageData> =>';
|
|
40
|
+
const body = `rowsState(() => ${read}(input()));`;
|
|
41
|
+
return `${head} ${body}`.length <= LINE_WIDTH ? `${head} ${body}` : `${head}\n ${body}`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface ResourcePageOptions {
|
|
45
|
+
readonly feature: NameSet;
|
|
46
|
+
/** The slice: `apps/web/app/<feature>`. */
|
|
47
|
+
readonly dir: string;
|
|
48
|
+
/** Where the page lands: `apps/web/app/<features>`. */
|
|
49
|
+
readonly pageDir: string;
|
|
50
|
+
readonly catalogModule?: string;
|
|
51
|
+
/** The app has `apps/web/shared/shell.tsx` — every `x new` app does — so the page sits in it. */
|
|
52
|
+
readonly shell: boolean;
|
|
53
|
+
readonly dbModule: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const pageSource = (options: ResourcePageOptions): string => {
|
|
57
|
+
const { feature } = options;
|
|
58
|
+
const path = feature.pluralKebab;
|
|
59
|
+
const list = `${feature.camel}List`;
|
|
60
|
+
const open = options.shell
|
|
61
|
+
? '<Shell>\n <div class={styles.page}>'
|
|
62
|
+
: '<main class={styles.page}>';
|
|
63
|
+
const close = options.shell ? ' </div>\n </Shell>' : ' </main>';
|
|
64
|
+
const indent = options.shell ? ' ' : ' ';
|
|
65
|
+
const inner = (lines: string): string =>
|
|
66
|
+
lines
|
|
67
|
+
.split('\n')
|
|
68
|
+
.map((line) => (line === '' ? line : `${indent}${line}`))
|
|
69
|
+
.join('\n');
|
|
70
|
+
return `// /${path}: the org's ${feature.pluralKebab}, newest first, and the form that adds one. \`load\`
|
|
71
|
+
// reads through the slice's own query, so the policy, the tenancy and the order are the query's;
|
|
72
|
+
// the form is the slice's island, the one module of this page a browser downloads.
|
|
73
|
+
|
|
74
|
+
${sortedImports([
|
|
75
|
+
...i18nImports(options.catalogModule),
|
|
76
|
+
"import { derivePath } from '@ultimat3/action';",
|
|
77
|
+
"import type { AsyncState } from '@ultimat3/core';",
|
|
78
|
+
"import type { KnownPermission } from '@ultimat3/policy';",
|
|
79
|
+
"import { defineRoute, island } from '@ultimat3/render';",
|
|
80
|
+
"import { AsyncRegion, EmptyState, PageHeader, Section, Skeleton } from '@ultimat3/ui';",
|
|
81
|
+
])}
|
|
82
|
+
${options.shell ? "import { Shell } from '../../shared/shell';\n" : ''}import type { ${feature.pascal} } from '../${feature.kebab}/entity';
|
|
83
|
+
import { ${list} } from '../${feature.kebab}/live/${feature.kebab}-list';
|
|
84
|
+
import { ${feature.pascal}List } from '../${feature.kebab}/ui';
|
|
85
|
+
import styles from './page.module.scss';
|
|
86
|
+
|
|
87
|
+
/** One page of rows: the read's own bound. Past it, page with a cursor (\`${list}.page\`). */
|
|
88
|
+
const PAGE_ROWS = 50;
|
|
89
|
+
|
|
90
|
+
/** The currency a price is typed in; the form sends integer minor units of it. */
|
|
91
|
+
const CURRENCY = 'USD';
|
|
92
|
+
|
|
93
|
+
type Row = ${feature.pascal};
|
|
94
|
+
|
|
95
|
+
/** What \`load\` answers: the read's outcome as DATA, so the page renders each of its states. */
|
|
96
|
+
export type PageData = AsyncState<readonly Row[]>;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The read, as a state. A refusal or a failed read is the region's error branch — code, cause and
|
|
100
|
+
* fix on screen, the form still there — never a 500 for the whole document.
|
|
101
|
+
*/
|
|
102
|
+
export async function rowsState(read: () => Promise<readonly Row[]>): Promise<PageData> {
|
|
103
|
+
try {
|
|
104
|
+
return { status: 'ready', data: await read() };
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return { status: 'failed', error };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** No org: the query reads the ACTOR's, so a read can only ever be for the caller's own org. */
|
|
111
|
+
const input = () => ({ limit: PAGE_ROWS });
|
|
112
|
+
|
|
113
|
+
${loadLine(list)}
|
|
114
|
+
|
|
115
|
+
// Declared ABOVE \`defineRoute\`: the route drains the islands declared before it. Named by
|
|
116
|
+
// SPECIFIER, never imported — a string has no import edge, so the page's bundle graph stays the
|
|
117
|
+
// page's (axiom 6). \`props\` are exactly the form's \`${feature.pascal}FormProps\` keys.
|
|
118
|
+
const ${feature.pascal}Form = island({
|
|
119
|
+
src: '${formIslandSpecifier(feature, options.dir, options.pageDir)}',
|
|
120
|
+
props: ['endpoint', 'locale', 'currency', 'labels'],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export const config = defineRoute({
|
|
124
|
+
render: 'ssr',
|
|
125
|
+
hydrate: 'visible',
|
|
126
|
+
offline: 'runtime',
|
|
127
|
+
// An org's rows are not public: a route with no policy registers \`auth: 'public'\` and its
|
|
128
|
+
// response carries no \`vary: cookie\`. Which ORG is the query's decision, on every read.
|
|
129
|
+
policy: { permission: '${feature.kebab}:read' satisfies KnownPermission },
|
|
130
|
+
// measured: ${RESOURCE_PAGE_MEASURED} (\`x build --target static\`, Bun 1.4.0) — the form island and the
|
|
131
|
+
// \`visible\` runtime. why: the form is Solid, @ultimat3/ui's Form/Input/Button and the transport.
|
|
132
|
+
// A raise states its own measured bytes and why here, in the same diff (\`X_BUDGET_EXCEEDED\`).
|
|
133
|
+
budget: { js: '${RESOURCE_PAGE_BUDGET}' },
|
|
134
|
+
load,
|
|
135
|
+
meta: ({ t }) => ({
|
|
136
|
+
title: t('${titleKey(path)}'),
|
|
137
|
+
description: t('${titleKey(path).replace('.title', '.description')}'),
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export interface PageProps {
|
|
142
|
+
readonly data: PageData;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function Page(props: PageProps) {${options.catalogModule === undefined ? '' : '\n const t = useT();\n'}
|
|
146
|
+
return (
|
|
147
|
+
${open}
|
|
148
|
+
${inner(`<PageHeader title={t('${titleKey(path)}')} />
|
|
149
|
+
<Section title={t('app.${feature.kebab}.new')}>
|
|
150
|
+
<${feature.pascal}Form
|
|
151
|
+
endpoint={derivePath('create${feature.pascal}').path}
|
|
152
|
+
locale={currentLocale()}
|
|
153
|
+
currency={CURRENCY}
|
|
154
|
+
labels={{
|
|
155
|
+
title: t('app.${feature.kebab}.titleLabel'),
|
|
156
|
+
price: t('app.${feature.kebab}.priceLabel'),
|
|
157
|
+
submit: t('app.${feature.kebab}.submit'),
|
|
158
|
+
saved: t('app.${feature.kebab}.saved'),
|
|
159
|
+
retry: t('app.${feature.kebab}.retry'),
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
<Skeleton lines={3} />
|
|
163
|
+
</${feature.pascal}Form>
|
|
164
|
+
</Section>
|
|
165
|
+
<AsyncRegion
|
|
166
|
+
state={props.data}
|
|
167
|
+
reserve={{ lines: 3 }}
|
|
168
|
+
empty={() => <EmptyState title={t('app.${feature.kebab}.empty')} />}
|
|
169
|
+
ready={(rows) => <${feature.pascal}List rows={rows} />}
|
|
170
|
+
/>`)}
|
|
171
|
+
${close}
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
`;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const ORG = '00000000-0000-4000-8000-000000000002';
|
|
178
|
+
const OTHER_ORG = '00000000-0000-4000-8000-000000000009';
|
|
179
|
+
|
|
180
|
+
const pageTest = (options: ResourcePageOptions): string => {
|
|
181
|
+
const { feature } = options;
|
|
182
|
+
const t = options.catalogModule === undefined ? '' : '\n const t = useT();';
|
|
183
|
+
return `// /${feature.pluralKebab}, rendered as a request renders it: \`load\` reads the actor's org through the
|
|
184
|
+
// slice's query — the in-memory driver under test — and the page shows the rows, the empty state
|
|
185
|
+
// or the refusal, with the form island beside them in every one.
|
|
186
|
+
${sortedImports([
|
|
187
|
+
catalogImport(options.catalogModule),
|
|
188
|
+
`import { driver } from '${options.dbModule}';`,
|
|
189
|
+
"import { createContext, frozenClock, runWithContext } from '@ultimat3/core';",
|
|
190
|
+
"import { testActor } from '@ultimat3/policy';",
|
|
191
|
+
"import { afterEach, expect, renderRoute, unitTest } from '@ultimat3/testing';",
|
|
192
|
+
])}
|
|
193
|
+
// The app's API, as boot loads it: \`defineApi\` is what names the query the page reads, and a
|
|
194
|
+
// query with no name is refused (X_QUERY_UNREGISTERED) before it reads a row.
|
|
195
|
+
import '../../api';
|
|
196
|
+
import * as repo from '../${feature.kebab}/repo';
|
|
197
|
+
import * as page from './page';
|
|
198
|
+
|
|
199
|
+
const url = 'https://example.test/${feature.pluralKebab}';
|
|
200
|
+
const orgId = '${ORG}';
|
|
201
|
+
const otherOrg = '${OTHER_ORG}';
|
|
202
|
+
// The read grant, named once so every line below fits the formatter's width at any feature name.
|
|
203
|
+
const read = '${feature.kebab}:read';
|
|
204
|
+
// Holds the read grant in the org: what a member of it is.
|
|
205
|
+
const member = testActor('member', { orgId, permissions: [read] }).actor;
|
|
206
|
+
|
|
207
|
+
// The request clock, so \`createdAt\` is an instant this file chose and "newest" is decidable.
|
|
208
|
+
const clock = frozenClock('2026-01-01T00:00:00.000Z');
|
|
209
|
+
|
|
210
|
+
const store = (title: string) => {
|
|
211
|
+
const draft = { orgId, title, price: { minor: 1200, currency: 'USD' } };
|
|
212
|
+
return runWithContext(createContext({ actor: member, clock }), () => repo.insert(draft));
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
216
|
+
afterEach(() => {
|
|
217
|
+
driver.reset?.();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
unitTest('the page lists the org’s rows, newest first, beside the form', async () => {
|
|
221
|
+
await store('older');
|
|
222
|
+
clock.advance(1000);
|
|
223
|
+
await store('newer');
|
|
224
|
+
const view = await renderRoute(page, { url, actor: member });
|
|
225
|
+
expect(view.data.status).toBe('ready');
|
|
226
|
+
expect(view.text.indexOf('newer')).toBeLessThan(view.text.indexOf('older'));
|
|
227
|
+
// The form is the one island, and it wakes when it is visible.
|
|
228
|
+
expect(view.islands.map((island) => island.strategy)).toEqual(['visible']);
|
|
229
|
+
expect(view.islands[0]?.moduleId).toContain('${feature.kebab}-form');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
unitTest('an org with no rows reads the empty state, not an empty list', async () => {${t}
|
|
233
|
+
const view = await renderRoute(page, { url, actor: member });
|
|
234
|
+
expect(view.data).toEqual({ status: 'ready', data: [] });
|
|
235
|
+
expect(view.text).toContain(t('app.${feature.kebab}.empty'));
|
|
236
|
+
expect(view.meta.title).toBe(t('${titleKey(feature.pluralKebab)}'));
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
unitTest('a member of another org reads none of this org’s rows', async () => {
|
|
240
|
+
await store('ours');
|
|
241
|
+
// The same page, the same URL: which org it reads is the actor's, never a parameter.
|
|
242
|
+
const outsider = testActor('outsider', { orgId: otherOrg, permissions: [read] }).actor;
|
|
243
|
+
const view = await renderRoute(page, { url, actor: outsider });
|
|
244
|
+
expect(view.data).toEqual({ status: 'ready', data: [] });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
unitTest('a caller the read refuses sees the refusal, and the page around it', async () => {
|
|
248
|
+
// In the org, without the grant: the query's policy refuses before any row is read.
|
|
249
|
+
const stranger = testActor('stranger', { orgId }).actor;
|
|
250
|
+
const view = await renderRoute(page, { url, actor: stranger });
|
|
251
|
+
expect(view.data.status).toBe('failed');
|
|
252
|
+
expect(view.html).toContain('X_FORBIDDEN');
|
|
253
|
+
expect(view.islands).toHaveLength(1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
unitTest('it is gated, rendered per request, and budgeted', () => {
|
|
257
|
+
expect(page.config.render).toBe('ssr');
|
|
258
|
+
expect(page.config.policy?.permission).toBe(read);
|
|
259
|
+
expect(page.config.offline).toBe('runtime');
|
|
260
|
+
expect(page.config.budget.js).toBe('${RESOURCE_PAGE_BUDGET}');
|
|
261
|
+
});
|
|
262
|
+
`;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** The resource's `page.tsx` and its unit test. The stylesheet, e2e test and catalog are `x g route`'s. */
|
|
266
|
+
export const resourcePageFiles = (options: ResourcePageOptions): readonly GeneratedFile[] => [
|
|
267
|
+
{ path: `${options.pageDir}/page.tsx`, contents: pageSource(options) },
|
|
268
|
+
{ path: `${options.pageDir}/page.test.ts`, contents: pageTest(options) },
|
|
269
|
+
];
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// `x g resource`'s service module and its test: business logic over the slice's repo, covered
|
|
2
|
+
// against the in-memory driver. Split from `resource.ts`, which composes the slice — this file
|
|
3
|
+
// owns the one module in it that holds a rule.
|
|
4
|
+
|
|
5
|
+
import { sortedImports } from './imports';
|
|
6
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
|
|
8
|
+
const serviceSource = (
|
|
9
|
+
feature: NameSet,
|
|
10
|
+
): string => `// Business logic for ${feature.pluralKebab}. Knows nothing about HTTP or requests, so a job and an
|
|
11
|
+
// action can both call it. Takes values, not a request.
|
|
12
|
+
|
|
13
|
+
import type { ${feature.pascal} } from './entity';
|
|
14
|
+
import { ${feature.pascal}NotFoundError } from './errors';
|
|
15
|
+
import * as repo from './repo';
|
|
16
|
+
|
|
17
|
+
/** Derived from the row, never restated: a new column reaches this input without an edit here. */
|
|
18
|
+
export type Create${feature.pascal}Input = Omit<${feature.pascal}, 'id' | 'createdAt'>;
|
|
19
|
+
|
|
20
|
+
/** The row, aliased once, so every signature below reads at one width whatever the feature is
|
|
21
|
+
* called — a generated file the app's own formatter rewrites is a red \`lint\` over code nobody
|
|
22
|
+
* typed. */
|
|
23
|
+
type Row = ${feature.pascal};
|
|
24
|
+
|
|
25
|
+
export async function create(input: Create${feature.pascal}Input): Promise<Row> {
|
|
26
|
+
return repo.insert(input);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function require${feature.pascal}(id: string): Promise<Row> {
|
|
30
|
+
const row = await repo.byId(id);
|
|
31
|
+
if (row === undefined) throw new ${feature.pascal}NotFoundError({ id });
|
|
32
|
+
return row;
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
/** The feature's own code, spelled once: the class throws it and the test matches on it. */
|
|
37
|
+
const notFoundCode = (feature: NameSet): string =>
|
|
38
|
+
`X_${feature.kebab.toUpperCase().split('-').join('_')}_NOT_FOUND`;
|
|
39
|
+
|
|
40
|
+
const serviceTest = (
|
|
41
|
+
feature: NameSet,
|
|
42
|
+
dbModule: string,
|
|
43
|
+
): string => `// The ${feature.kebab} service against the in-memory driver, and its failure, pinned: a code an
|
|
44
|
+
// agent can match on, a cause naming the row, and a fix that is an instruction.
|
|
45
|
+
${sortedImports([
|
|
46
|
+
"import { createContext, runWithContext } from '@ultimat3/core';",
|
|
47
|
+
"import { testActor } from '@ultimat3/policy';",
|
|
48
|
+
"import { afterEach, expect, unitTest } from '@ultimat3/testing';",
|
|
49
|
+
`import { driver } from '${dbModule}';`,
|
|
50
|
+
])}
|
|
51
|
+
import { ${feature.pascal}NotFoundError } from './errors';
|
|
52
|
+
import { create, require${feature.pascal} } from './service';
|
|
53
|
+
|
|
54
|
+
const orgId = '00000000-0000-4000-8000-000000000002';
|
|
55
|
+
const absent = '00000000-0000-4000-8000-0000000000ff';
|
|
56
|
+
const draft = { orgId, title: 'First', price: { minor: 1200, currency: 'USD' } };
|
|
57
|
+
|
|
58
|
+
/** What a request is to the repo underneath: an actor, whose org every statement runs under. */
|
|
59
|
+
const inOrg = <T>(run: () => Promise<T>): Promise<T> =>
|
|
60
|
+
runWithContext(createContext({ actor: testActor('member', { orgId }).actor }), run);
|
|
61
|
+
|
|
62
|
+
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
driver.reset?.();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
unitTest('${feature.pascal}NotFoundError carries a code, a cause and a fix', () => {
|
|
68
|
+
const error = new ${feature.pascal}NotFoundError({ id: 'missing' });
|
|
69
|
+
expect(error).toBeUltimateError('${notFoundCode(feature)}');
|
|
70
|
+
expect(error.cause).toContain('missing');
|
|
71
|
+
expect(error.fix.length).toBeGreaterThan(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
unitTest('create stores the row and the lookup answers it', async () => {
|
|
75
|
+
const created = await inOrg(() => create(draft));
|
|
76
|
+
expect(created.title).toBe('First');
|
|
77
|
+
// \`toEqualRow\`, never \`toEqual\`: a \`.sealed()\` column is a non-enumerable property of a row.
|
|
78
|
+
expect(await inOrg(() => require${feature.pascal}(created.id))).toEqualRow(created);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
unitTest('the lookup refuses an id nothing holds', async () => {
|
|
82
|
+
const lookup = inOrg(() => require${feature.pascal}(absent));
|
|
83
|
+
const refused = await lookup.catch((error: unknown) => error);
|
|
84
|
+
expect(refused).toBeUltimateError('${notFoundCode(feature)}');
|
|
85
|
+
});
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
/** `service.ts` and the test that covers it. `dbModule` is where the test resets the driver. */
|
|
89
|
+
export const serviceFiles = (
|
|
90
|
+
feature: NameSet,
|
|
91
|
+
dir: string,
|
|
92
|
+
dbModule: string,
|
|
93
|
+
): readonly GeneratedFile[] => [
|
|
94
|
+
{ path: `${dir}/service.ts`, contents: serviceSource(feature) },
|
|
95
|
+
{ path: `${dir}/service.test.ts`, contents: serviceTest(feature, dbModule) },
|
|
96
|
+
];
|