@ultimat3/cli 1.1.0 → 2.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 +724 -0
- package/README.md +41 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +87 -17
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +13 -7
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +186 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +205 -140
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +87 -14
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +73 -0
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +202 -18
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The app's HTTP authenticator, seen as the `sync` node's. Until this file `sync-node.ts` was
|
|
2
|
+
// handed no `authenticate` by any host, so every socket the framework ever opened carried
|
|
3
|
+
// `actorId: null` — and the channel guard, the live-query gate, the presence entry and the
|
|
4
|
+
// per-tenant subscription cap all decided against an anonymous actor. Realtime was single-tenant
|
|
5
|
+
// by wiring, not by design.
|
|
6
|
+
|
|
7
|
+
import type { Actor } from '@ultimat3/core';
|
|
8
|
+
import type { HttpConfig } from '@ultimat3/http';
|
|
9
|
+
import {
|
|
10
|
+
configuredAuthenticator,
|
|
11
|
+
createRequestContext,
|
|
12
|
+
defineHttpConfig,
|
|
13
|
+
UltimateRequest,
|
|
14
|
+
} from '@ultimat3/http';
|
|
15
|
+
import type { SyncAuthenticator, SyncGrant } from '@ultimat3/realtime';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The upgrade request, dressed as the request an `Authenticator` reads.
|
|
19
|
+
*
|
|
20
|
+
* A websocket upgrade IS an HTTP request — same cookies, same `Authorization` header — so the
|
|
21
|
+
* app's one resolver answers it, and an app does not write a second identity for its sockets.
|
|
22
|
+
* The limiter is off because nothing in this config path serves a request: it exists so
|
|
23
|
+
* `ctx.config` is a real `HttpConfig`, and a rate limit resolved here would be a second, unread
|
|
24
|
+
* declaration of the app's own numbers.
|
|
25
|
+
*/
|
|
26
|
+
function upgradeConfig(buildId: string): HttpConfig {
|
|
27
|
+
return defineHttpConfig({ buildId, rateLimit: { enabled: false, scope: 'process' } });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What the sync node is given when the app configured an authenticator, and `undefined` when it
|
|
32
|
+
* did not — which keeps `x dev` anonymous and makes the node log that it is, exactly as
|
|
33
|
+
* `createSyncNode` documents. A stub that answered `{ actor: anonymous }` would look configured.
|
|
34
|
+
*
|
|
35
|
+
* The grant carries **no `expiresAt` and no `refresh`**, and that is the honest limit of this
|
|
36
|
+
* adapter rather than an omission: `configureAuthenticator()` resolves an `Actor` and says nothing
|
|
37
|
+
* about how long it stays true, so inventing a window here would either close live sockets that
|
|
38
|
+
* are still authorized or claim a lifetime the app never promised. A deployment whose credential
|
|
39
|
+
* has a real expiry passes `runtime.syncAuthenticate` and gets re-authorization; the timer for it
|
|
40
|
+
* already lives in `createSyncNode.start()`.
|
|
41
|
+
*/
|
|
42
|
+
export function syncAuthenticator(buildId: string): SyncAuthenticator | undefined {
|
|
43
|
+
const authenticate = configuredAuthenticator();
|
|
44
|
+
if (authenticate === undefined) return undefined;
|
|
45
|
+
// Once per node, not once per upgrade: resolving a config is pure and a 50k-socket node pays
|
|
46
|
+
// this per connection otherwise.
|
|
47
|
+
const config = upgradeConfig(buildId);
|
|
48
|
+
return async (request: Request): Promise<SyncGrant | null> => {
|
|
49
|
+
const ctx = createRequestContext({
|
|
50
|
+
url: new URL(request.url),
|
|
51
|
+
method: request.method,
|
|
52
|
+
role: 'sync',
|
|
53
|
+
config,
|
|
54
|
+
requestHeaders: request.headers,
|
|
55
|
+
});
|
|
56
|
+
const actor: Actor | null = await authenticate(new UltimateRequest(request, ctx), ctx);
|
|
57
|
+
return actor === null ? null : { actor };
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/templates/action.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import type { FeatureTarget } from './entity';
|
|
6
6
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
7
|
import { names } from './naming';
|
|
8
|
+
import { sliceFoundation } from './slice-foundation';
|
|
9
|
+
import { wrapImport } from './wrap';
|
|
8
10
|
|
|
9
11
|
const actionSource = (
|
|
10
12
|
name: NameSet,
|
|
@@ -18,7 +20,7 @@ import { action, t } from '@ultimat3/action';
|
|
|
18
20
|
// slice's own files and are shared by every action in it.
|
|
19
21
|
|
|
20
22
|
import { ${feature.pascal}NotFoundError } from '../errors';
|
|
21
|
-
|
|
23
|
+
${wrapImport([`can${feature.pascal}Write`, `${feature.camel}Tag`], '../policy')}
|
|
22
24
|
import * as repo from '../repo';
|
|
23
25
|
|
|
24
26
|
export const ${name.camel} = action({
|
|
@@ -28,7 +30,7 @@ export const ${name.camel} = action({
|
|
|
28
30
|
output: t.object({ id: t.uuid, title: t.string }),
|
|
29
31
|
policy: can${feature.pascal}Write,
|
|
30
32
|
cache: { invalidates: [${feature.camel}Tag] },
|
|
31
|
-
mcp: { expose: true, description: '${name.raw} —
|
|
33
|
+
mcp: { expose: true, description: '${name.raw} — edit this description' },
|
|
32
34
|
async handle({ input }) {
|
|
33
35
|
const row = await repo.byId(input.id);
|
|
34
36
|
if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id });
|
|
@@ -58,7 +60,7 @@ export const ${name.camel} = mutator({
|
|
|
58
60
|
input: t.object({ id: t.uuid, orgId: t.uuid, title: t.string }),
|
|
59
61
|
output: t.object({ id: t.uuid, title: t.string }),
|
|
60
62
|
policy: can${feature.pascal}Write,
|
|
61
|
-
mcp: { expose: true, description: '${name.raw} —
|
|
63
|
+
mcp: { expose: true, description: '${name.raw} — edit this description' },
|
|
62
64
|
// tx.table(name) rather than tx.${feature.plural}: the typed accessor exists only once the app
|
|
63
65
|
// augments LocalTables, and generated code cannot assume that has happened yet. The name is the
|
|
64
66
|
// entity's snake_case table, so the local twin and the server row live under one key.
|
|
@@ -77,25 +79,6 @@ export const ${name.camel} = mutator({
|
|
|
77
79
|
});
|
|
78
80
|
`;
|
|
79
81
|
|
|
80
|
-
const errorsSource = (
|
|
81
|
-
feature: NameSet,
|
|
82
|
-
): string => `// The ${feature.kebab} feature's X_* codes. Never throw a bare Error: an agent reading the failure
|
|
83
|
-
// needs the code, the cause and the exact command that fixes it.
|
|
84
|
-
|
|
85
|
-
import { UltimateError } from '@ultimat3/core';
|
|
86
|
-
|
|
87
|
-
export class ${feature.pascal}NotFoundError extends UltimateError {
|
|
88
|
-
constructor(input: { id: string }) {
|
|
89
|
-
super({
|
|
90
|
-
code: 'X_${feature.kebab.toUpperCase().split('-').join('_')}_NOT_FOUND',
|
|
91
|
-
cause: \`no ${feature.kebab} with id \${input.id}\`,
|
|
92
|
-
fix: 'x db studio to confirm the row exists, or pass an id from the list query',
|
|
93
|
-
docs: 'https://ultimate.dev/errors/X_NOT_FOUND',
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
`;
|
|
98
|
-
|
|
99
82
|
const ID = '00000000-0000-4000-8000-000000000001';
|
|
100
83
|
const ORG = '00000000-0000-4000-8000-000000000002';
|
|
101
84
|
const OTHER_ORG = '00000000-0000-4000-8000-000000000009';
|
|
@@ -121,7 +104,9 @@ const actionTest = (
|
|
|
121
104
|
name: NameSet,
|
|
122
105
|
feature: NameSet,
|
|
123
106
|
isMutator: boolean,
|
|
124
|
-
): string =>
|
|
107
|
+
): string => `// ${name.camel}: its declared shape, the input it refuses, the contract every action owes, and the
|
|
108
|
+
// foreign-org actor it denies before the handler runs. One declaration, every surface.
|
|
109
|
+
import { testActor } from '@ultimat3/policy';
|
|
125
110
|
import { contractTest, expect, unitTest } from '@ultimat3/testing';
|
|
126
111
|
import { ${name.camel} } from './${name.kebab}';
|
|
127
112
|
|
|
@@ -148,21 +133,21 @@ unitTest('${name.camel} rejects input that is not a uuid', async () => {
|
|
|
148
133
|
await expect(target.input).toAcceptInput(input);
|
|
149
134
|
});
|
|
150
135
|
|
|
151
|
-
contractTest('${name.camel} passes the
|
|
136
|
+
contractTest('${name.camel} passes the action contract', async () => {
|
|
152
137
|
// Three assertions the framework makes for any action, without knowing what this one does:
|
|
153
138
|
// garbage input is rejected, an anonymous actor is denied, and the operation reaches the
|
|
154
139
|
// OpenAPI document. \`.contract()\` is the projection; this loop just runs it.
|
|
155
140
|
for (const contract of target.contract()) await contract.run();
|
|
156
141
|
});
|
|
157
142
|
|
|
158
|
-
contractTest('${name.camel} denies a foreign org
|
|
143
|
+
contractTest('${name.camel} denies a foreign org', async () => {
|
|
159
144
|
// \`.as()\` is the one execution path with the actor swapped, so this denial is the same one
|
|
160
145
|
// HTTP, MCP and the job surface would produce — and no repo call happened to produce it.
|
|
161
146
|
const denied = await target.as(outsider, input).catch((error: unknown) => error);
|
|
162
147
|
expect(denied).toBeUltimateError('X_FORBIDDEN');
|
|
163
148
|
});
|
|
164
149
|
|
|
165
|
-
contractTest('${name.camel} projects one
|
|
150
|
+
contractTest('${name.camel} projects one tool and one operation', () => {
|
|
166
151
|
// Same policy object on both surfaces — an MCP call cannot reach a different authz path.
|
|
167
152
|
expect(target.tool().policy).toBe(target.policy);
|
|
168
153
|
expect(target.tool().description).not.toBe('');
|
|
@@ -180,14 +165,14 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
|
|
|
180
165
|
const dir = `${target.surfaceDir}/${target.feature}/actions`;
|
|
181
166
|
const isMutator = target.mutator === true;
|
|
182
167
|
return [
|
|
168
|
+
// The three slice modules this action's source imports — `../errors`, `../policy`, `../repo`
|
|
169
|
+
// (which comes with `../entity`, its row type). Composed rather than assumed: `x g action`
|
|
170
|
+
// into a slice no `x g resource` had created emitted all three imports and wrote none of them.
|
|
171
|
+
...sliceFoundation(target, ['entity', 'policy', 'errors']),
|
|
183
172
|
{
|
|
184
173
|
path: `${dir}/${name.kebab}.ts`,
|
|
185
174
|
contents: isMutator ? mutatorSource(name, feature) : actionSource(name, feature),
|
|
186
175
|
},
|
|
187
176
|
{ path: `${dir}/${name.kebab}.test.ts`, contents: actionTest(name, feature, isMutator) },
|
|
188
|
-
{
|
|
189
|
-
path: `${target.surfaceDir}/${target.feature}/errors.ts`,
|
|
190
|
-
contents: errorsSource(feature),
|
|
191
|
-
},
|
|
192
177
|
];
|
|
193
178
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// `x g admin:page <name>` — a screen the admin derives from nothing: a reconciliation fixer, a
|
|
2
|
+
// proxy health board, a deploy button. What the template has to get right is what it does NOT
|
|
3
|
+
// emit: no `defineRoute`, because `pages:` is the one thing that puts a page in the admin's route
|
|
4
|
+
// table and `guardedPage()` is the one thing that decides it. A scaffold that wrote a route
|
|
5
|
+
// declaration here would hand back the unguarded second way in that seam exists to close.
|
|
6
|
+
|
|
7
|
+
import { catalogJson } from './catalog-json';
|
|
8
|
+
import { catalogPath, resolveLocales } from './locales';
|
|
9
|
+
import type { GeneratedFile } from './naming';
|
|
10
|
+
import { camel, kebab, pascal } from './naming';
|
|
11
|
+
|
|
12
|
+
/** Where an admin lives when the caller does not say. `x new` scaffolds this layout. */
|
|
13
|
+
export const DEFAULT_ADMIN_PAGE_DIR = 'apps/admin/src/pages';
|
|
14
|
+
|
|
15
|
+
export interface AdminPageOptions {
|
|
16
|
+
/** The permission the page's own work needs. `admin:read` is composed in front of it. */
|
|
17
|
+
readonly permission: string;
|
|
18
|
+
/**
|
|
19
|
+
* Directory the page lands in, app-root-relative and POSIX — the same `--at` `x g island` takes,
|
|
20
|
+
* and for the same reason: an app's admin is wherever its `defineAdmin` is, which no generator
|
|
21
|
+
* can derive. `apps/admin/app/admin/` is as real a layout as the scaffold's, and a hardcoded
|
|
22
|
+
* destination means every such app moves the two files by hand after every run.
|
|
23
|
+
*/
|
|
24
|
+
readonly dir?: string;
|
|
25
|
+
readonly locales?: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const titleKeyFor = (name: string): string => `admin.${name}.title`;
|
|
29
|
+
|
|
30
|
+
const pageSource = (name: string, permission: string, dir: string): string => {
|
|
31
|
+
const Name = pascal(name);
|
|
32
|
+
const declaration = camel(name);
|
|
33
|
+
return `// Admin page: /${name}. An ORDINARY component — there is no \`defineRoute\` here, deliberately:
|
|
34
|
+
// \`pages:\` is what puts this in the admin's route table and \`guardedPage()\` is what decides it,
|
|
35
|
+
// so a route declaration in this file would be a second, unguarded way in.
|
|
36
|
+
//
|
|
37
|
+
// Wire it in once, and add \`navGroup\` to link it in the sidebar — this file is ${dir}/${name}.tsx,
|
|
38
|
+
// so the specifier is relative to wherever \`defineAdmin\` lives:
|
|
39
|
+
// import { ${declaration}Page } from './${name}';
|
|
40
|
+
// defineAdmin({ …, pages: […, ${declaration}Page] })
|
|
41
|
+
|
|
42
|
+
import type { AdminCustomPage, AdminPageProps } from '@ultimat3/admin';
|
|
43
|
+
import { t } from '@ultimat3/i18n';
|
|
44
|
+
|
|
45
|
+
export function ${Name}Page(props: AdminPageProps) {
|
|
46
|
+
return (
|
|
47
|
+
<section>
|
|
48
|
+
<h1>{t('${titleKeyFor(name)}')}</h1>
|
|
49
|
+
<p>{props.url}</p>
|
|
50
|
+
</section>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const ${declaration}Page: AdminCustomPage = {
|
|
55
|
+
path: '/${name}',
|
|
56
|
+
titleKey: '${titleKeyFor(name)}',
|
|
57
|
+
// At least one, never empty: an empty list is X_ADMIN_PAGE_UNGUARDED at declaration time, which
|
|
58
|
+
// is the whole reason the permission is a required field and not an optional one.
|
|
59
|
+
permissions: ['${permission}'],
|
|
60
|
+
component: ${Name}Page,
|
|
61
|
+
};
|
|
62
|
+
`;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const pageTest = (name: string, permission: string): string => {
|
|
66
|
+
const declaration = camel(name);
|
|
67
|
+
return `// The ${name} admin page is guarded and owns no route of its own — the two facts that separate an
|
|
68
|
+
// admin screen from a page, and the two an edit here is most likely to break.
|
|
69
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
70
|
+
import { ${declaration}Page } from './${name}';
|
|
71
|
+
|
|
72
|
+
// Both facts the frame reads off the declaration, so both are decidable without a request: a path
|
|
73
|
+
// that is not rooted is X_ADMIN_PAGE_PATH_INVALID, and no permission at all is
|
|
74
|
+
// X_ADMIN_PAGE_UNGUARDED — a page that would render for anyone who can open the admin.
|
|
75
|
+
unitTest('the ${name} admin page is rooted and guarded', () => {
|
|
76
|
+
expect(${declaration}Page.path.startsWith('/')).toBe(true);
|
|
77
|
+
expect(${declaration}Page.permissions).toContain('${permission}');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
unitTest('the ${name} admin page declares no route of its own', () => {
|
|
81
|
+
// \`pages:\` is the only way in. A \`config\` export here would be a route the frame never guards.
|
|
82
|
+
expect('config' in ${declaration}Page).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
`;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export function adminPageFiles(
|
|
88
|
+
rawName: string,
|
|
89
|
+
options: AdminPageOptions,
|
|
90
|
+
): readonly GeneratedFile[] {
|
|
91
|
+
const name = kebab(rawName);
|
|
92
|
+
// Trailing slashes trimmed exactly as `islandFiles` does — one `--at`, one normalization.
|
|
93
|
+
const dir = (options.dir ?? DEFAULT_ADMIN_PAGE_DIR).replace(/\/+$/, '');
|
|
94
|
+
return [
|
|
95
|
+
{ path: `${dir}/${name}.tsx`, contents: pageSource(name, options.permission, dir) },
|
|
96
|
+
{ path: `${dir}/${name}.test.ts`, contents: pageTest(name, options.permission) },
|
|
97
|
+
...resolveLocales(options.locales).map((locale) => ({
|
|
98
|
+
path: catalogPath(locale),
|
|
99
|
+
contents: catalogJson({ [titleKeyFor(name)]: pascal(name) }),
|
|
100
|
+
merge: 'json' as const,
|
|
101
|
+
})),
|
|
102
|
+
];
|
|
103
|
+
}
|
package/src/templates/admin.ts
CHANGED
|
@@ -11,8 +11,8 @@ import { names } from './naming';
|
|
|
11
11
|
const resourceSource = (
|
|
12
12
|
feature: NameSet,
|
|
13
13
|
): string => `// Admin override for ${feature.pluralKebab}. Everything not set here — fields, operations,
|
|
14
|
-
// detail layout — is derived from the entity. Wire
|
|
15
|
-
//
|
|
14
|
+
// detail layout — is derived from the entity. Wire it in once, importing ${feature.camel}AdminResource
|
|
15
|
+
// through this app's own tsconfig path alias for apps/web/app/${feature.kebab}/admin/resource:
|
|
16
16
|
// defineAdmin({ entities: [..., ${feature.camel}], resources: { ${feature.table}: ${feature.camel}AdminResource } })
|
|
17
17
|
|
|
18
18
|
import type { AdminResourceOptions, AdminRow } from '@ultimat3/admin';
|
|
@@ -26,13 +26,17 @@ export const ${feature.camel}AdminResource: AdminResourceOptions<AdminRow> = {
|
|
|
26
26
|
|
|
27
27
|
const resourceTest = (
|
|
28
28
|
feature: NameSet,
|
|
29
|
-
): string =>
|
|
29
|
+
): string => `// The ${feature.kebab} admin override says what the entity cannot derive: a title key, the list
|
|
30
|
+
// columns, a bounded page size. Everything unset here is derived, and needs no test.
|
|
31
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
30
32
|
import { ${feature.camel}AdminResource } from './resource';
|
|
31
33
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
expect(
|
|
34
|
+
const resource = ${feature.camel}AdminResource;
|
|
35
|
+
|
|
36
|
+
unitTest('${feature.camel}AdminResource sets a title key and list fields', () => {
|
|
37
|
+
expect(resource.titleKey).toBe('admin.${feature.kebab}.title');
|
|
38
|
+
expect(resource.listFields?.length).toBeGreaterThan(0);
|
|
39
|
+
expect(resource.pageSize).toBeGreaterThan(0);
|
|
36
40
|
});
|
|
37
41
|
`;
|
|
38
42
|
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// `x g backfill` — a one-pass table sweep. The backfill is a factory over job(), not a ninth
|
|
2
|
+
// primitive, so it inherits .enqueue(), the retry policy, the cancellation and the manifest row.
|
|
3
|
+
// One live run per name: a second enqueue while the pass is going is the same pass.
|
|
4
|
+
|
|
5
|
+
import type { FeatureTarget } from './entity';
|
|
6
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
import { names } from './naming';
|
|
8
|
+
import { sliceFoundation } from './slice-foundation';
|
|
9
|
+
import { wrapImport } from './wrap';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the entity's value export is called inside the generated file. `x g backfill invoice
|
|
13
|
+
* --feature invoice` is a legal invocation and it emitted `import { invoice } from '../entity'`
|
|
14
|
+
* beside `export const invoice = backfill(...)` — one name, two declarations, which is
|
|
15
|
+
* `lint/suspicious/noRedeclare` in the app's own gate and a genuinely ambiguous reference in TS.
|
|
16
|
+
* Aliased only when it would collide, because every other backfill reads better without one.
|
|
17
|
+
*/
|
|
18
|
+
const entityRef = (name: NameSet, feature: NameSet): string =>
|
|
19
|
+
name.camel === feature.camel ? `${feature.camel}Entity` : feature.camel;
|
|
20
|
+
|
|
21
|
+
const entityImport = (name: NameSet, feature: NameSet): string => {
|
|
22
|
+
const local = entityRef(name, feature);
|
|
23
|
+
return local === feature.camel ? feature.camel : `${feature.camel} as ${local}`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The chain accessor, wrapped the way Biome would wrap it. Emitted pre-formatted rather than
|
|
28
|
+
* always-wrapped because the formatter joins an arrow body back onto one line when it fits — so a
|
|
29
|
+
* fixed shape is wrong for one name length or the other. Same reason `policy.ts` measures its
|
|
30
|
+
* `definePermissions` line.
|
|
31
|
+
*/
|
|
32
|
+
const tableLine = (name: NameSet, feature: NameSet): string => {
|
|
33
|
+
const ref = entityRef(name, feature);
|
|
34
|
+
const head = `const ${feature.camel}Table = () =>`;
|
|
35
|
+
const body = `tableFor(${ref}, postgresRepo(${ref}));`;
|
|
36
|
+
return `${head} ${body}`.length <= 100 ? `${head} ${body}` : `${head}\n ${body}`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Working source, never a stub: a generated `throw new Error(…)` carries no `X_*` code and a
|
|
41
|
+
* generated no-op handler checkpoints a page it never wrote, which reports swept rows nobody
|
|
42
|
+
* touched. The row projection is the one line an author replaces, and it is exported so the
|
|
43
|
+
* generated test asserts the WORK rather than only the declaration around it.
|
|
44
|
+
*/
|
|
45
|
+
const backfillSource = (
|
|
46
|
+
name: NameSet,
|
|
47
|
+
feature: NameSet,
|
|
48
|
+
): string => `// ${name.camel}: one pass over a chain of rows. The backfill is a job factory, not a ninth
|
|
49
|
+
// primitive, so it inherits .enqueue(), retry, cancellation and the manifest row.
|
|
50
|
+
// \`BackfillBatch\` comes from @ultimat3/jobs, not @ultimat3/schema: a backfill file imports one package.
|
|
51
|
+
|
|
52
|
+
import type { Ctx } from '@ultimat3/core';
|
|
53
|
+
import { assert, hasScope } from '@ultimat3/core';
|
|
54
|
+
import type { ReadBuilder } from '@ultimat3/entity';
|
|
55
|
+
import { CROSS_TENANT_SCOPE, postgresRepo, tableFor } from '@ultimat3/entity';
|
|
56
|
+
import type { BackfillBatch } from '@ultimat3/jobs';
|
|
57
|
+
import { backfill } from '@ultimat3/jobs';
|
|
58
|
+
import type { ${feature.pascal} } from '../entity';
|
|
59
|
+
import { ${entityImport(name, feature)} } from '../entity';
|
|
60
|
+
|
|
61
|
+
/** The row this sweep visits, aliased once: every signature below then reads at one width. */
|
|
62
|
+
type Row = ${feature.pascal};
|
|
63
|
+
|
|
64
|
+
/** The table as a chain — the seam \`database()\` hands an app, so this sweep reads what a query reads. */
|
|
65
|
+
${tableLine(name, feature)}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The rows this pass visits. A one-pass sweep has no single org, so it declares \`tenant: 'none'\`
|
|
69
|
+
* below — which STRIPS the org from the run rather than inheriting the worker's. That makes
|
|
70
|
+
* spanning tenants a capability instead of an accident: the actor this worker runs as has to carry
|
|
71
|
+
* \`tenancy:cross\`, and this is where that is said, before a page is read rather than inside the
|
|
72
|
+
* plan builder. A single-org sweep is the other shape — declare \`tenant: () => '<org id>'\` and put
|
|
73
|
+
* \`.where({ orgId: ctx.actor.orgId })\` back.
|
|
74
|
+
*/
|
|
75
|
+
const ${name.camel}Scope = (ctx: Ctx): ReadBuilder<Row> => {
|
|
76
|
+
assert(
|
|
77
|
+
hasScope(ctx.actor, CROSS_TENANT_SCOPE),
|
|
78
|
+
'${name.kebab}: this pass spans every tenant and its actor holds no tenancy:cross',
|
|
79
|
+
// A generated \`fix:\` is copied and run verbatim, so it names a command this build SHIPS.
|
|
80
|
+
'x db backfill ${name.kebab} --write --json',
|
|
81
|
+
);
|
|
82
|
+
return ${feature.camel}Table();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What the sweep writes for one row. Replace the projection with the change this pass exists to
|
|
87
|
+
* make, and keep it IDEMPOTENT: a page replays whole when an attempt is cancelled between the last
|
|
88
|
+
* row and its checkpoint, so the second run of this function must produce the first run's row.
|
|
89
|
+
*/
|
|
90
|
+
export const ${name.camel}Row = (row: Row): Row => ({
|
|
91
|
+
...row,
|
|
92
|
+
title: row.title.trim(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const ${name.camel} = backfill({
|
|
96
|
+
name: '${name.kebab}',
|
|
97
|
+
// A sweep over a table belongs to no one org, so it declares none — and \`'none'\` STRIPS the org
|
|
98
|
+
// rather than inheriting the worker's, so a tenant-scoped read inside the pass fails closed
|
|
99
|
+
// (X_TENANCY_ACTOR_ORG_REQUIRED) instead of reading somebody's rows by accident. A sweep that
|
|
100
|
+
// genuinely spans tenants says so out loud: its work runs inside \`crossTenant(reason, fn)\`, and
|
|
101
|
+
// the reason IS the mechanism. A per-org sweep declares its org instead: \`tenant: () => orgId\`,
|
|
102
|
+
// one enqueue per org.
|
|
103
|
+
tenant: 'none',
|
|
104
|
+
source: ({ ctx }): ReadBuilder<Row> => ${name.camel}Scope(ctx),
|
|
105
|
+
handle: async ({ rows, signal }: BackfillBatch<Row>) => {
|
|
106
|
+
// One page, in its own durable step, at least once. Write through upsertAll, updateWhere or an
|
|
107
|
+
// idempotent statement; never count + 1. The signal is the run cancellation composed with this
|
|
108
|
+
// batch's ceiling, so a cancelled pass stops here instead of writing past its lease.
|
|
109
|
+
signal.throwIfAborted();
|
|
110
|
+
const next = rows.map(${name.camel}Row);
|
|
111
|
+
await ${feature.camel}Table().upsertAll(next, { onConflict: ['id'] });
|
|
112
|
+
},
|
|
113
|
+
// How many rows still NEED the change — never how many the sweep visits. Declare it once
|
|
114
|
+
// \`source\` narrows to the rows that are actually behind (\`.andWhere('publishedAt', 'is', null)\`
|
|
115
|
+
// and the like): then a dry run cannot lie, and a pass that exhausts its source while this still
|
|
116
|
+
// answers above zero fails as X_BACKFILL_STALLED instead of writing a completed row nobody can
|
|
117
|
+
// trust. Left out here because this scaffold re-normalises every row it visits, so a count of
|
|
118
|
+
// the same chain would never reach zero.
|
|
119
|
+
// count: ({ ctx }) => ${name.camel}Scope(ctx).andWhere('publishedAt', 'is', null).count(),
|
|
120
|
+
// batch: 1_000, // rows per step, default. Adjust to balance statement size and retry scope.
|
|
121
|
+
// rate: 5, // batches per second, default. Raise to sweep faster; there is no unthrottled mode.
|
|
122
|
+
// retry: { attempts: 5, backoff: 'exponential' },
|
|
123
|
+
// requires: '20260814120000_add_publish_at', // the migration x db backfill checks first
|
|
124
|
+
// environments: ['staging', 'production'], // omit for every environment — never implied
|
|
125
|
+
});
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
const backfillTest = (
|
|
129
|
+
name: NameSet,
|
|
130
|
+
feature: NameSet,
|
|
131
|
+
): string => `// ${name.camel} sweeps rows a user never asked for, so the two facts worth failing on are its
|
|
132
|
+
// durable identity — one live run per name, retried under the same key — and that the row
|
|
133
|
+
// projection it applies is idempotent, because a cancelled attempt replays its page whole.
|
|
134
|
+
|
|
135
|
+
import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
|
|
136
|
+
import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
|
|
137
|
+
import type { ${feature.pascal} } from '../entity';
|
|
138
|
+
${wrapImport([name.camel, `${name.camel}Row`], `./${name.kebab}`)}
|
|
139
|
+
|
|
140
|
+
// The driver is process-global, so it is installed and released around this file rather than
|
|
141
|
+
// left behind for whichever test happens to run next.
|
|
142
|
+
beforeAll(() => {
|
|
143
|
+
setJobDriver(createMemoryDriver());
|
|
144
|
+
});
|
|
145
|
+
afterAll(resetJobDriver);
|
|
146
|
+
|
|
147
|
+
// The durable name this sweep runs under, spelled once — so the assertion below carries the
|
|
148
|
+
// backfill's own name and still fits the formatter width the app's \`lint\` step enforces.
|
|
149
|
+
const expectedKey = '${name.kebab}';
|
|
150
|
+
|
|
151
|
+
const row = (over: Partial<${feature.pascal}> = {}): ${feature.pascal} => ({
|
|
152
|
+
id: '00000000-0000-4000-8000-000000000001',
|
|
153
|
+
orgId: '00000000-0000-4000-8000-000000000002',
|
|
154
|
+
title: ' needs normalising ',
|
|
155
|
+
price: { minor: 1000, currency: 'USD' },
|
|
156
|
+
createdAt: new Date(0),
|
|
157
|
+
...over,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
jobTest('${name.camel} declares a durable name and retry policy', () => {
|
|
161
|
+
expect(${name.camel}.kind).toBe('job');
|
|
162
|
+
expect(${name.camel}.idempotencyKeyFor({})).toBe(expectedKey);
|
|
163
|
+
expect(${name.camel}.retry.attempts).toBeGreaterThan(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
jobTest('${name.camel} uses one key across attempts', () => {
|
|
167
|
+
const key = ${name.camel}.idempotencyKeyFor({});
|
|
168
|
+
expect(${name.camel}.idempotencyKeyFor({})).toBe(key);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
jobTest('${name.camel} projects itself into the manifest', () => {
|
|
172
|
+
const described = ${name.camel}.describe();
|
|
173
|
+
expect(described.queue).toBe('default');
|
|
174
|
+
expect(described.retry.attempts).toBeGreaterThan(0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
jobTest('${name.camel} actually rewrites the row it is handed', () => {
|
|
178
|
+
// The declaration alone cannot fail this: a handler that returned without writing would still
|
|
179
|
+
// enqueue, still checkpoint and still report the page as swept.
|
|
180
|
+
expect(${name.camel}Row(row()).title).toBe('needs normalising');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
jobTest('${name.camel} replays a page idempotently', () => {
|
|
184
|
+
// At least once is the contract: an attempt cancelled between the last row and its checkpoint
|
|
185
|
+
// hands this page to the next attempt. Twice through must equal once through.
|
|
186
|
+
const once = ${name.camel}Row(row());
|
|
187
|
+
expect(${name.camel}Row(once)).toEqual(once);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
|
|
191
|
+
// One live run per name, forced or not: a second enqueue while the pass is going is the same pass.
|
|
192
|
+
// \`.enqueue()\` is the backfill path — the declared job, queued with no scheduler involved.
|
|
193
|
+
const first = await ${name.camel}.enqueue({});
|
|
194
|
+
expect(first.deduped).toBe(false);
|
|
195
|
+
const again = await ${name.camel}.enqueue({});
|
|
196
|
+
expect(again.deduped).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
`;
|
|
199
|
+
|
|
200
|
+
export function backfillFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] {
|
|
201
|
+
const name = names(rawName);
|
|
202
|
+
const feature = names(target.feature);
|
|
203
|
+
const dir = `${target.surfaceDir}/${target.feature}/backfills`;
|
|
204
|
+
return [
|
|
205
|
+
// A sweep is a chain over the entity's own table (`tableFor(entity, postgresRepo(entity))`), so
|
|
206
|
+
// the entity is what it reads and what its generated test builds rows of. No repo call, but
|
|
207
|
+
// `repo.ts` rides along with `entity.ts`: it is that file's only reader.
|
|
208
|
+
...sliceFoundation(target, ['entity']),
|
|
209
|
+
{ path: `${dir}/${name.kebab}.ts`, contents: backfillSource(name, feature) },
|
|
210
|
+
{ path: `${dir}/${name.kebab}.test.ts`, contents: backfillTest(name, feature) },
|
|
211
|
+
];
|
|
212
|
+
}
|