@ultimat3/cli 20.2.1 → 22.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +84 -1582
- package/README.md +40 -2
- package/package.json +31 -31
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- package/src/app-env.ts +2 -2
- package/src/app-load.ts +37 -5
- package/src/app-openapi.ts +27 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-root.ts +1 -1
- package/src/async-pages.ts +32 -0
- package/src/bin.ts +7 -1
- package/src/boundary-findings.ts +36 -0
- package/src/browser-launcher-fake-html.ts +80 -0
- package/src/browser-launcher-fake.ts +165 -0
- package/src/browser-launcher-port.ts +159 -0
- package/src/browser-launcher.ts +26 -133
- package/src/budgets.ts +56 -13
- package/src/build-errors.ts +54 -0
- package/src/cdp-shot-a11y.ts +77 -0
- package/src/cdp-shot-clock.ts +14 -0
- package/src/cdp-shot-driver.ts +150 -0
- package/src/cdp-shot-element.ts +147 -0
- package/src/cdp-shot-errors.ts +62 -0
- package/src/cdp-shot-keys.ts +152 -0
- package/src/cdp-shot-page.ts +230 -0
- package/src/cdp-shot-watch.ts +241 -0
- package/src/cmd-affected-spec.ts +24 -0
- package/src/cmd-affected.ts +3 -19
- package/src/cmd-build-spec.ts +16 -0
- package/src/cmd-build.ts +46 -20
- package/src/cmd-ci-spec.ts +24 -0
- package/src/cmd-ci.ts +17 -21
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db-spec.ts +99 -0
- package/src/cmd-db.ts +15 -94
- package/src/cmd-deploy-helm.ts +136 -0
- package/src/cmd-deploy-spec.ts +40 -0
- package/src/cmd-deploy.ts +117 -42
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +78 -122
- package/src/cmd-docs-spec.ts +16 -0
- package/src/cmd-docs.ts +2 -11
- package/src/cmd-doctor-spec.ts +21 -0
- package/src/cmd-doctor.ts +4 -18
- package/src/cmd-env-spec.ts +18 -0
- package/src/cmd-env.ts +4 -13
- package/src/cmd-errors-spec.ts +23 -0
- package/src/cmd-errors.ts +3 -17
- package/src/cmd-fix-spec.ts +18 -0
- package/src/cmd-fix.ts +4 -13
- package/src/cmd-generate-spec.ts +31 -0
- package/src/cmd-generate.ts +46 -42
- package/src/cmd-i18n-spec.ts +16 -0
- package/src/cmd-i18n.ts +7 -14
- package/src/cmd-jobs-spec.ts +56 -0
- package/src/cmd-jobs.ts +3 -50
- package/src/cmd-manifest-spec.ts +15 -0
- package/src/cmd-manifest.ts +19 -20
- package/src/cmd-mcp-spec.ts +21 -0
- package/src/cmd-mcp.ts +2 -15
- package/src/cmd-new-spec.ts +33 -0
- package/src/cmd-new.ts +38 -33
- package/src/cmd-policy-spec.ts +13 -0
- package/src/cmd-policy.ts +2 -9
- package/src/cmd-pr-spec.ts +39 -0
- package/src/cmd-pr.ts +5 -65
- package/src/cmd-registries-spec.ts +32 -0
- package/src/cmd-registries.ts +4 -25
- package/src/cmd-routes-spec.ts +12 -0
- package/src/cmd-routes.ts +2 -7
- package/src/cmd-secrets-spec.ts +19 -0
- package/src/cmd-secrets.ts +22 -34
- package/src/cmd-shot-island.ts +12 -13
- package/src/cmd-shot-spec.ts +53 -0
- package/src/cmd-shot.ts +11 -60
- package/src/cmd-tasks-spec.ts +21 -0
- package/src/cmd-tasks.ts +2 -16
- package/src/cmd-test-spec.ts +54 -0
- package/src/cmd-test.ts +4 -49
- package/src/cmd-verify-spec.ts +28 -0
- package/src/cmd-verify.ts +3 -23
- package/src/db-seed.ts +4 -2
- package/src/dev-dashboard.ts +4 -4
- package/src/dev-lock.ts +18 -2
- package/src/dev-port.ts +21 -0
- package/src/dev-route-table.ts +119 -0
- package/src/dispatch.ts +12 -5
- package/src/document-styles.ts +1 -1
- package/src/drift.ts +4 -18
- package/src/error-catalog.ts +16 -9
- package/src/error-codes.ts +33 -28
- package/src/error-pages.ts +19 -7
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +22 -29
- package/src/favicon.ts +2 -2
- package/src/fix-imports.ts +1 -1
- package/src/fix-scan.ts +2 -9
- package/src/flag-reads.ts +1 -2
- package/src/foreign-text.ts +36 -0
- package/src/framework-schema.ts +5 -6
- package/src/generate-feature.ts +42 -0
- package/src/generate-files.ts +7 -3
- package/src/generate-grants.ts +83 -0
- package/src/generate-kinds.ts +56 -4
- package/src/i18n-index.ts +59 -10
- package/src/icon-assets.ts +1 -1
- package/src/image-prepare.ts +14 -0
- package/src/import-scan.ts +63 -0
- package/src/index.ts +67 -334
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +38 -11
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +98 -0
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +2 -2
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/live-routes.ts +82 -42
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +30 -26
- package/src/mcp-host.ts +27 -12
- package/src/mcp-ui-diff.ts +27 -0
- package/src/mcp-ui-inspect.ts +4 -4
- package/src/mcp-ui-interact.ts +21 -15
- package/src/mcp-ui.ts +19 -15
- package/src/measure-database.ts +73 -0
- package/src/measure-paths.ts +74 -0
- package/src/measure-scope.ts +74 -0
- package/src/messages.ts +1 -3
- package/src/metrics-endpoint.ts +1 -1
- package/src/otlp-export.ts +1 -1
- package/src/output.ts +6 -0
- package/src/page-sync.ts +54 -0
- package/src/permission-grants.ts +86 -0
- package/src/prerender-out.ts +25 -0
- package/src/prerender.ts +121 -102
- package/src/pwa-artifacts.ts +3 -3
- package/src/realtime-browser-probe-fixture.ts +2 -2
- package/src/reexport-manifest.ts +2 -1
- package/src/registry.ts +80 -56
- package/src/role-realtime.ts +36 -0
- package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
- package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
- package/src/role-start-types.ts +112 -0
- package/src/{dev-roles.ts → role-start.ts} +42 -115
- package/src/{dev-sync.ts → role-sync.ts} +23 -11
- package/src/root-env.ts +67 -0
- package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
- package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
- package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
- package/src/runtime-jobs.ts +87 -0
- package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
- package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
- package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
- package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
- package/src/runtime-realtime.ts +55 -0
- package/src/{dev-render.ts → runtime-render.ts} +119 -20
- package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
- package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
- package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
- package/src/scaffold-fixture.ts +28 -6
- package/src/scaffold-typecheck.ts +6 -3
- package/src/schema-drift.ts +7 -1
- package/src/script-csp.ts +5 -2
- package/src/secrets-rotation.ts +59 -0
- package/src/serve-boot.ts +192 -0
- package/src/serve-drain.ts +24 -0
- package/src/serve-entry.ts +6 -0
- package/src/serve-env.ts +116 -0
- package/src/serve-types.ts +55 -0
- package/src/serve.ts +44 -338
- package/src/shot-server.ts +2 -2
- package/src/shot-settle.ts +10 -1
- package/src/shot-theme.ts +3 -3
- package/src/shot-verdict.ts +16 -7
- package/src/signal-shred.ts +27 -0
- package/src/solid-loader.ts +26 -2
- package/src/static-report.ts +8 -1
- package/src/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- package/src/templates/action.ts +30 -16
- package/src/templates/entity.ts +12 -7
- package/src/templates/index.ts +1 -1
- package/src/templates/job.ts +10 -7
- package/src/templates/policy.ts +20 -2
- package/src/templates/resource-create.ts +127 -0
- package/src/templates/resource-form-island.ts +76 -30
- package/src/templates/resource.ts +10 -4
- package/src/templates/route.ts +3 -0
- package/src/templates/scaffold-app.ts +4 -1
- package/src/templates/scaffold-auth.ts +3 -1
- package/src/templates/scaffold-container-compose.ts +184 -0
- package/src/templates/scaffold-container.ts +25 -145
- package/src/templates/scaffold-dashboard-example.ts +2 -2
- package/src/templates/scaffold-db-package.ts +16 -3
- package/src/templates/scaffold-demo-org.ts +41 -0
- package/src/templates/scaffold-entries.ts +1 -1
- package/src/templates/scaffold-env.ts +6 -0
- package/src/templates/scaffold-helm-templates.ts +66 -7
- package/src/templates/scaffold-helm.ts +27 -5
- package/src/templates/scaffold-i18n.ts +15 -10
- package/src/templates/scaffold-repo.ts +13 -10
- package/src/templates/scaffold-roles.ts +38 -10
- package/src/templates/slice-foundation.ts +1 -1
- package/src/templates/wrap.ts +4 -1
- package/src/test-passes.ts +2 -1
- package/src/test-workers.ts +26 -0
- package/src/ts-scan.ts +3 -6
- package/src/tsconfig-references.ts +1 -2
- package/src/verify-checks.ts +41 -39
- package/src/verify-e2e.ts +41 -0
- package/src/verify-run.ts +115 -50
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +22 -30
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/worker-bundle.ts +192 -0
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -100
- package/src/cdp-connection.ts +0 -211
- package/src/cdp-e2e-page.ts +0 -209
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -139
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -97
- package/src/e2e-errors.ts +0 -103
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -150
- package/src/e2e-selection.ts +0 -182
- package/src/measurement-actor.ts +0 -26
- /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
package/src/sw-artifacts.ts
CHANGED
|
@@ -57,6 +57,12 @@ export interface ServiceWorkerInput {
|
|
|
57
57
|
* this map does not name.
|
|
58
58
|
*/
|
|
59
59
|
readonly documents?: ReadonlyMap<string, RenderedDocument>;
|
|
60
|
+
/**
|
|
61
|
+
* The page's framework scripts — realtime's page boot and sync worker (`pageSync(…).scripts`).
|
|
62
|
+
* Precached beside the island chunks for their reason: source-addressed and `immutable`, and an
|
|
63
|
+
* offline reload that cannot load the boot restores no record and shows the old count.
|
|
64
|
+
*/
|
|
65
|
+
readonly scripts?: readonly { readonly url: string; readonly bytes: number }[];
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
/**
|
|
@@ -117,8 +123,12 @@ const pwaRoutes = (
|
|
|
117
123
|
* Sorted by url, because `buildPrecacheManifest` sorts its own entries but the ASSET list is what
|
|
118
124
|
* decides which of two equal urls wins, and `sw.js` must be byte-identical for identical input.
|
|
119
125
|
*/
|
|
120
|
-
const staticAssets = (
|
|
121
|
-
|
|
126
|
+
const staticAssets = (
|
|
127
|
+
islands: IslandBundle,
|
|
128
|
+
styles: StyleBundle,
|
|
129
|
+
scripts: readonly { readonly url: string; readonly bytes: number }[],
|
|
130
|
+
): readonly PrecacheAsset[] =>
|
|
131
|
+
[...islands.chunks, ...styles.chunks, ...scripts]
|
|
122
132
|
.map((chunk) => ({ url: chunk.url, revision: chunk.url, bytes: chunk.bytes }))
|
|
123
133
|
.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
124
134
|
|
|
@@ -200,7 +210,7 @@ export function serviceWorkerArtifacts(
|
|
|
200
210
|
neverCache: pwa.offline.neverCache,
|
|
201
211
|
},
|
|
202
212
|
capabilities: { backgroundSync: pwa.backgroundSync, push: pwa.push },
|
|
203
|
-
assets: staticAssets(input.islands, input.styles),
|
|
213
|
+
assets: staticAssets(input.islands, input.styles, input.scripts ?? []),
|
|
204
214
|
},
|
|
205
215
|
input.buildId,
|
|
206
216
|
);
|
package/src/sync-url.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Where a page's one socket dials — the framework's answer, so no app owns a `sync-url.ts`. Read
|
|
2
|
+
// once at boot from the deployment's env and handed to every document as `ultimate-sync`.
|
|
3
|
+
|
|
4
|
+
import { ConfigInvalidError } from '@ultimat3/core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The sync node's own path (`createSyncNode`'s default in `@ultimat3/realtime`). Same origin by
|
|
8
|
+
* default because every rung already serves it there: `x dev` and a combined-role container mount
|
|
9
|
+
* the node on the web port, and `docker/helm`'s ingress routes `/_x/sync` to the `sync` service.
|
|
10
|
+
*/
|
|
11
|
+
export const SYNC_PATH = '/_x/sync';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `SYNC_URL` verbatim when the deployment states one — the Compose rung publishes `sync` on its
|
|
15
|
+
* own port with no proxy in front, so only the deployment knows that URL — else `SYNC_PATH`,
|
|
16
|
+
* resolved by the browser against its own origin. Never derived from a port: behind any ingress a
|
|
17
|
+
* neighbouring port is a URL nothing publishes.
|
|
18
|
+
*/
|
|
19
|
+
export function syncUrlFrom(env: Readonly<Record<string, string | undefined>>): string {
|
|
20
|
+
const declared = env['SYNC_URL']?.trim() ?? '';
|
|
21
|
+
if (declared === '') return SYNC_PATH;
|
|
22
|
+
const parsed = URL.parse(declared);
|
|
23
|
+
if (parsed === null || (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:')) {
|
|
24
|
+
throw new ConfigInvalidError({
|
|
25
|
+
cause: 'SYNC_URL is set but is not a ws:// or wss:// URL, so no browser could dial it',
|
|
26
|
+
fix: 'export SYNC_URL="wss://sync.example.com/_x/sync" # or unset it to dial /_x/sync on the page origin',
|
|
27
|
+
meta: { key: 'SYNC_URL' },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return declared;
|
|
31
|
+
}
|
package/src/templates/action.ts
CHANGED
|
@@ -15,15 +15,19 @@ import { wrapImport } from './wrap';
|
|
|
15
15
|
* (ai-maxxing's `fleet`, with `HostNotFoundError` and `SessionNotFoundError`) declares the errors
|
|
16
16
|
* it has and not this one. Importing it anyway is a file that fails at import.
|
|
17
17
|
*/
|
|
18
|
-
const missingLookup = (feature: NameSet): string =>
|
|
19
|
-
|
|
18
|
+
const missingLookup = (feature: NameSet, entity: boolean): string =>
|
|
19
|
+
entity
|
|
20
|
+
? ` // No lookup by id: ../errors declares no ${feature.pascal}NotFoundError, and a row that is not
|
|
20
21
|
// there needs one to be thrown for it. Declare it there — the shape \`x g resource\` writes —
|
|
21
|
-
// then read the row through ../repo and throw it when the read answers nothing
|
|
22
|
+
// then read the row through ../repo and throw it when the read answers nothing.`
|
|
23
|
+
: ` // No lookup by id: this feature has no entity, and a generator writing into a slice never
|
|
24
|
+
// invents one. \`x g entity ${feature.kebab}\` declares the table; this body is yours until then.`;
|
|
22
25
|
|
|
23
26
|
const actionSource = (
|
|
24
27
|
name: NameSet,
|
|
25
28
|
feature: NameSet,
|
|
26
29
|
lookup: boolean,
|
|
30
|
+
entity: boolean,
|
|
27
31
|
): string => `// ${name.camel}: one mutation, server-authoritative. Input is validated before the handler runs
|
|
28
32
|
// and the policy is the same object the MCP tool and the HTTP route evaluate.
|
|
29
33
|
// \`t\` comes from @ultimat3/action, not @ultimat3/schema: an action file imports one package.
|
|
@@ -40,14 +44,15 @@ export const ${name.camel} = action({
|
|
|
40
44
|
output: t.object({ id: t.uuid${lookup ? ', title: t.string' : ''} }),
|
|
41
45
|
policy: can${feature.pascal}Write,
|
|
42
46
|
cache: { invalidates: [${feature.camel}Tag] },
|
|
43
|
-
mcp
|
|
47
|
+
// No \`mcp\`: a tool's description is what an agent reads to decide to call it, and a placeholder
|
|
48
|
+
// there is worse than no tool. Write one, then \`mcp: { expose: true, description: '…' }\`.
|
|
44
49
|
async handle({ input }) {
|
|
45
50
|
${
|
|
46
51
|
lookup
|
|
47
52
|
? ` const row = await repo.byId(input.id);
|
|
48
53
|
if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id });
|
|
49
54
|
return { id: row.id, title: row.title };`
|
|
50
|
-
: `${missingLookup(feature)}
|
|
55
|
+
: `${missingLookup(feature, entity)}
|
|
51
56
|
return { id: input.id };`
|
|
52
57
|
}
|
|
53
58
|
},
|
|
@@ -58,6 +63,7 @@ const mutatorSource = (
|
|
|
58
63
|
name: NameSet,
|
|
59
64
|
feature: NameSet,
|
|
60
65
|
lookup: boolean,
|
|
66
|
+
entity: boolean,
|
|
61
67
|
): string => `// ${name.camel}: an action with an optimistic local twin. The local half runs against the client
|
|
62
68
|
// store immediately; the server half is authoritative and reconciles on conflict.
|
|
63
69
|
|
|
@@ -74,7 +80,7 @@ export const ${name.camel} = mutator({
|
|
|
74
80
|
input: t.object({ id: t.uuid, orgId: t.uuid, title: t.string }),
|
|
75
81
|
output: t.object({ id: t.uuid, title: t.string }),
|
|
76
82
|
policy: can${feature.pascal}Write,
|
|
77
|
-
mcp
|
|
83
|
+
// No \`mcp\` until a real description is written: see \`x g action\`.
|
|
78
84
|
// tx.table(name) rather than tx.${feature.plural}: the typed accessor exists only once the app
|
|
79
85
|
// augments LocalTables, and generated code cannot assume that has happened yet. The name is the
|
|
80
86
|
// entity's snake_case table, so the local twin and the server row live under one key.
|
|
@@ -90,7 +96,7 @@ ${
|
|
|
90
96
|
? ` const row = await repo.byId(input.id);
|
|
91
97
|
if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id });
|
|
92
98
|
return { id: row.id, title: input.title };`
|
|
93
|
-
: `${missingLookup(feature)}
|
|
99
|
+
: `${missingLookup(feature, entity)}
|
|
94
100
|
return { id: input.id, title: input.title };`
|
|
95
101
|
}
|
|
96
102
|
},
|
|
@@ -190,11 +196,10 @@ contractTest('${name.camel} denies a foreign org', async () => {
|
|
|
190
196
|
expect(denied).toBeUltimateError('X_FORBIDDEN');
|
|
191
197
|
});
|
|
192
198
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
expect(target.tool().policy).toBe(target.policy);
|
|
196
|
-
expect(target.tool().description).not.toBe('');
|
|
199
|
+
// No MCP tool until it is described: a placeholder description misleads the agent reading it.
|
|
200
|
+
contractTest('${name.camel} projects one operation and no tool', () => {
|
|
197
201
|
expect(target.openapi().operationId).toBe('${name.camel}');
|
|
202
|
+
expect(target.mcp?.expose ?? false).toBe(false);
|
|
198
203
|
});
|
|
199
204
|
`;
|
|
200
205
|
|
|
@@ -207,6 +212,12 @@ export interface ActionOptions extends FeatureTarget {
|
|
|
207
212
|
* `sliceExports` finds it there.
|
|
208
213
|
*/
|
|
209
214
|
readonly sliceErrors?: string;
|
|
215
|
+
/**
|
|
216
|
+
* The slice's `entity.ts`, or absent when the feature has none. Absent, the action reads no row
|
|
217
|
+
* and the generator writes no entity — only `x g entity` and `x g resource` create a feature's
|
|
218
|
+
* data. `x g resource` passes the entity it is writing in the same run.
|
|
219
|
+
*/
|
|
220
|
+
readonly sliceEntity?: string;
|
|
210
221
|
}
|
|
211
222
|
|
|
212
223
|
export function actionFiles(rawName: string, target: ActionOptions): readonly GeneratedFile[] {
|
|
@@ -214,19 +225,22 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
|
|
|
214
225
|
const feature = names(target.feature);
|
|
215
226
|
const dir = `${target.surfaceDir}/${target.feature}/actions`;
|
|
216
227
|
const isMutator = target.mutator === true;
|
|
228
|
+
const entity = target.sliceEntity !== undefined;
|
|
217
229
|
const lookup =
|
|
218
|
-
|
|
219
|
-
|
|
230
|
+
entity &&
|
|
231
|
+
(target.sliceErrors === undefined ||
|
|
232
|
+
sliceExports(target.sliceErrors, `${feature.pascal}NotFoundError`));
|
|
220
233
|
return [
|
|
221
234
|
// The three slice modules this action's source imports — `../errors`, `../policy`, `../repo`
|
|
222
235
|
// (which comes with `../entity`, its row type). Composed rather than assumed: `x g action`
|
|
223
236
|
// into a slice no `x g resource` had created emitted all three imports and wrote none of them.
|
|
224
|
-
|
|
237
|
+
// With no entity, only the policy: a lookup-free body imports neither ../errors nor ../repo.
|
|
238
|
+
...sliceFoundation(target, entity ? ['entity', 'policy', 'errors'] : ['policy']),
|
|
225
239
|
{
|
|
226
240
|
path: `${dir}/${name.kebab}.ts`,
|
|
227
241
|
contents: isMutator
|
|
228
|
-
? mutatorSource(name, feature, lookup)
|
|
229
|
-
: actionSource(name, feature, lookup),
|
|
242
|
+
? mutatorSource(name, feature, lookup, entity)
|
|
243
|
+
: actionSource(name, feature, lookup, entity),
|
|
230
244
|
},
|
|
231
245
|
// TWO test files, because the gate types a test by its FILENAME and this declaration owes two
|
|
232
246
|
// suites: the input parse is a `unit` assertion and the three projections are `contract` ones.
|
package/src/templates/entity.ts
CHANGED
|
@@ -68,7 +68,7 @@ const repoSource = (name: NameSet, table: string): string => {
|
|
|
68
68
|
const row = name.pascal;
|
|
69
69
|
const byIdCall = wrapList(
|
|
70
70
|
' ',
|
|
71
|
-
|
|
71
|
+
'const row = await db().one<Physical>(',
|
|
72
72
|
[`sql\`select * from ${table} where id = \${id}\``],
|
|
73
73
|
');',
|
|
74
74
|
);
|
|
@@ -90,32 +90,37 @@ const repoSource = (name: NameSet, table: string): string => {
|
|
|
90
90
|
// join the caller's transaction without knowing one is open.
|
|
91
91
|
|
|
92
92
|
import { db, sql } from '@ultimat3/db';
|
|
93
|
-
import { dbDrift, newId } from '@ultimat3/entity';
|
|
94
|
-
|
|
93
|
+
import { dbDrift, decodeRow, newId } from '@ultimat3/entity';
|
|
94
|
+
${wrapImport([`type ${name.pascal}`, name.camel], './entity')}
|
|
95
|
+
|
|
96
|
+
// What \`select *\` answers: snake_case columns, and money as three of them. Never cast to the row
|
|
97
|
+
// type — \`decodeRow\` is the entity's own reading of it, so \`price\` is a Money again.
|
|
98
|
+
type Physical = Readonly<Record<string, unknown>>;
|
|
95
99
|
|
|
96
100
|
export async function byId(id: string): Promise<${name.pascal} | undefined> {
|
|
97
101
|
${byIdCall}
|
|
98
|
-
return row
|
|
102
|
+
return row === null ? undefined : decodeRow(${name.camel}, row);
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
${listSignature}
|
|
102
106
|
// Ordered and bounded: an unordered page is a different page on every request.
|
|
103
|
-
|
|
107
|
+
const rows = await db().query<Physical>(
|
|
104
108
|
sql\`select * from ${table} where org_id = \${orgId} order by created_at desc limit \${limit}\`,
|
|
105
109
|
);
|
|
110
|
+
return rows.map((row) => decodeRow(${name.camel}, row));
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
${insertSignature}
|
|
109
114
|
// Money is three physical columns — integer minor units, the ISO code, and the scale, never a
|
|
110
115
|
// float. \`scale ?? null\`: an amount at the currency's own minor unit carries no scale at all,
|
|
111
116
|
// and writing \`0\` for it would claim whole units — a 100x reinterpretation of the price.
|
|
112
|
-
const created = await db().one
|
|
117
|
+
const created = await db().one<Physical>(sql\`
|
|
113
118
|
insert into ${table} (id, org_id, title, price_minor, price_currency, price_scale)
|
|
114
119
|
values (\${newId()}, \${row.orgId}, \${row.title}, \${row.price.minor}, \${row.price.currency},
|
|
115
120
|
\${row.price.scale ?? null})
|
|
116
121
|
returning *\`);
|
|
117
122
|
if (created === null) throw dbDrift('${table}', 'id');
|
|
118
|
-
return created;
|
|
123
|
+
return decodeRow(${name.camel}, created);
|
|
119
124
|
}
|
|
120
125
|
`;
|
|
121
126
|
};
|
package/src/templates/index.ts
CHANGED
|
@@ -45,7 +45,7 @@ export { entryFiles } from './scaffold-entries';
|
|
|
45
45
|
export { errorPageFiles, PWA_COLORS } from './scaffold-errors';
|
|
46
46
|
// The nine guards `x new` ships, distinct from `guardFiles` above, which is `x g guard <name>`.
|
|
47
47
|
export { scaffoldGuardFiles } from './scaffold-guards';
|
|
48
|
-
export { i18nIndex } from './scaffold-i18n';
|
|
48
|
+
export { i18nIndex, localeEntry, localeImport } from './scaffold-i18n';
|
|
49
49
|
export { repoFiles } from './scaffold-repo';
|
|
50
50
|
export { shellFiles } from './scaffold-shell';
|
|
51
51
|
export { siteFiles } from './scaffold-site';
|
package/src/templates/job.ts
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// both; the generated test pins them through a real driver, because a key that is not stable is a
|
|
4
4
|
// job that runs twice and a tenant that is not declared is a job that reads the wrong org's rows.
|
|
5
5
|
|
|
6
|
-
import { stripComments } from '
|
|
6
|
+
import { stripComments } from '@ultimat3/core';
|
|
7
7
|
import type { FeatureTarget } from './entity';
|
|
8
8
|
import type { GeneratedFile, NameSet } from './naming';
|
|
9
9
|
import { names } from './naming';
|
|
10
10
|
import { sliceExports, sliceFoundation } from './slice-foundation';
|
|
11
|
+
import { wrapList } from './wrap';
|
|
11
12
|
|
|
12
13
|
const jobSource = (
|
|
13
14
|
name: NameSet,
|
|
@@ -69,7 +70,8 @@ export const ${name.camel} = job({
|
|
|
69
70
|
retry: { attempts: 5, backoff: 'exponential' },
|
|
70
71
|
async run({ step }) {
|
|
71
72
|
await step.run('process', async () => {
|
|
72
|
-
//
|
|
73
|
+
// This job's own work. No table is read here: this feature has no entity, and a generator
|
|
74
|
+
// writing into a slice never invents one — \`x g entity\` declares it.
|
|
73
75
|
});
|
|
74
76
|
return { processed: true };
|
|
75
77
|
},
|
|
@@ -117,7 +119,7 @@ export const ${name.camel} = task({
|
|
|
117
119
|
tz: 'UTC',
|
|
118
120
|
// No org in the payload: the job this enqueues declares \`tenant: 'none'\`, because this
|
|
119
121
|
// feature's entity names no tenant column (or has none yet).
|
|
120
|
-
enqueue: () => [[${jobName.camel}, { id: '00000000-0000-4000-8000-000000000001' }]],
|
|
122
|
+
${wrapList(' ', 'enqueue: () => [', [`[${jobName.camel}, { id: '00000000-0000-4000-8000-000000000001' }]`], '],')}
|
|
121
123
|
});
|
|
122
124
|
`;
|
|
123
125
|
|
|
@@ -291,9 +293,10 @@ export interface JobOptions extends FeatureTarget {
|
|
|
291
293
|
}
|
|
292
294
|
|
|
293
295
|
/**
|
|
294
|
-
* Whether `x g job`/`x g task` may assume the tenant-scoped shape
|
|
295
|
-
*
|
|
296
|
-
*
|
|
296
|
+
* Whether `x g job`/`x g task` may assume the tenant-scoped shape. A feature with no `entity.ts` is
|
|
297
|
+
* NOT: only `x g entity` and `x g resource` create a feature's data, and counting absent as scoped
|
|
298
|
+
* made `x g task nightly` lay down an `entity('nightlies', { title, price })` that the `drift` step
|
|
299
|
+
* then demanded a migration for. One that exists is trusted: it declares
|
|
297
300
|
* a real, non-`'none'` `tenant`, AND its `repo.ts` actually exports the `byId`/`listByOrg` pair the
|
|
298
301
|
* tenant-scoped body calls. Both have to hold — an entity that still names `tenant: 'orgId'` after
|
|
299
302
|
* an author trimmed `listByOrg` out of `repo.ts` (or never generated one) is not a slice this job
|
|
@@ -303,7 +306,7 @@ export function isTenantScopedSlice(
|
|
|
303
306
|
sliceEntity: string | undefined,
|
|
304
307
|
sliceRepo: string | undefined,
|
|
305
308
|
): boolean {
|
|
306
|
-
if (sliceEntity === undefined) return
|
|
309
|
+
if (sliceEntity === undefined) return false;
|
|
307
310
|
const declaresTenant = /\btenant\s*:\s*'(?!none')[^']+'/.test(stripComments(sliceEntity));
|
|
308
311
|
if (!declaresTenant) return false;
|
|
309
312
|
if (sliceRepo === undefined) return true;
|
package/src/templates/policy.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { GeneratedFile, NameSet } from './naming';
|
|
6
6
|
import { names } from './naming';
|
|
7
|
-
import { wrapList } from './wrap';
|
|
7
|
+
import { wrapImport, wrapList } from './wrap';
|
|
8
8
|
|
|
9
9
|
/** Biome would rewrap this itself, so the generator emits the already-formatted form. */
|
|
10
10
|
const permissionSet = (feature: NameSet): string =>
|
|
@@ -65,6 +65,15 @@ export const can${feature.pascal}Write = can<${feature.pascal}Scope>(
|
|
|
65
65
|
'${feature.kebab}:write',
|
|
66
66
|
({ actor, input }) => actor !== null && actor.orgId === input.orgId,
|
|
67
67
|
);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create takes no org from its input — the row is written under the actor's own — so the tenancy
|
|
71
|
+
* rule is that the actor HAS one. Same grant as write.
|
|
72
|
+
*/
|
|
73
|
+
export const can${feature.pascal}Create = can(
|
|
74
|
+
'${feature.kebab}:write',
|
|
75
|
+
({ actor }) => actor !== null && typeof actor.orgId === 'string' && actor.orgId !== '',
|
|
76
|
+
);
|
|
68
77
|
`;
|
|
69
78
|
|
|
70
79
|
const policyTest = (
|
|
@@ -73,7 +82,7 @@ const policyTest = (
|
|
|
73
82
|
// only read. A policy whose tests all pass is a policy nobody has tried to get past.
|
|
74
83
|
import { testActor } from '@ultimat3/policy';
|
|
75
84
|
import { expect, unitTest } from '@ultimat3/testing';
|
|
76
|
-
|
|
85
|
+
${wrapImport([`can${feature.pascal}Create`, `can${feature.pascal}Read`, `can${feature.pascal}Write`], './policy')}
|
|
77
86
|
|
|
78
87
|
const org = '00000000-0000-4000-8000-000000000002';
|
|
79
88
|
const otherOrg = '00000000-0000-4000-8000-000000000009';
|
|
@@ -108,9 +117,18 @@ unitTest('${feature.camel} write denies the read-only actor', async () => {
|
|
|
108
117
|
await expect(can${feature.pascal}Write).not.toDenyPolicy({ actor: writer, input });
|
|
109
118
|
});
|
|
110
119
|
|
|
120
|
+
unitTest('${feature.camel} create denies the read-only and the orgless actor', async () => {
|
|
121
|
+
const orgless = testActor('orgless', { permissions: [read, write] }).actor;
|
|
122
|
+
await expect(can${feature.pascal}Create).toDenyPolicy({ actor: null, input: {} });
|
|
123
|
+
await expect(can${feature.pascal}Create).toDenyPolicy({ actor: reader, input: {} });
|
|
124
|
+
await expect(can${feature.pascal}Create).toDenyPolicy({ actor: orgless, input: {} });
|
|
125
|
+
await expect(can${feature.pascal}Create).not.toDenyPolicy({ actor: writer, input: {} });
|
|
126
|
+
});
|
|
127
|
+
|
|
111
128
|
unitTest('${feature.camel} rules name the permission they require', () => {
|
|
112
129
|
expect(can${feature.pascal}Read.permissions).toEqual(['${feature.kebab}:read']);
|
|
113
130
|
expect(can${feature.pascal}Write.permissions).toEqual(['${feature.kebab}:write']);
|
|
131
|
+
expect(can${feature.pascal}Create.permissions).toEqual(['${feature.kebab}:write']);
|
|
114
132
|
});
|
|
115
133
|
`;
|
|
116
134
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// `x g resource`'s create action: a real insert. It used to be `x g action`'s generic body — read a
|
|
2
|
+
// row by id, throw NotFound — so the route the resource named `create` could never create, and its
|
|
3
|
+
// input was `{ id, orgId }` while the form posted `{ title }`. The input is the entity's own view of
|
|
4
|
+
// the columns a caller supplies; the org comes from the actor, never from the body.
|
|
5
|
+
|
|
6
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
import { names } from './naming';
|
|
8
|
+
import { wrapImport, wrapList } from './wrap';
|
|
9
|
+
|
|
10
|
+
/** The columns a caller supplies: the resource entity's, minus `id`, `orgId` and `createdAt`. */
|
|
11
|
+
export const CREATE_COLUMNS = ['title', 'price'] as const;
|
|
12
|
+
|
|
13
|
+
const createSource = (
|
|
14
|
+
feature: NameSet,
|
|
15
|
+
): string => `// create${feature.pascal}: inserts one ${feature.kebab} in the caller's org and returns it.
|
|
16
|
+
// The input is a view of the entity's own columns, so a column added there is one edit here —
|
|
17
|
+
// the list below — and never a second schema to keep in step.
|
|
18
|
+
|
|
19
|
+
import { action } from '@ultimat3/action';
|
|
20
|
+
import { tenancyActorOrgRequired } from '@ultimat3/entity';
|
|
21
|
+
${wrapImport([`${feature.pascal}View`, feature.camel], '../entity')}
|
|
22
|
+
${wrapImport([`can${feature.pascal}Create`, `${feature.camel}Tag`], '../policy')}
|
|
23
|
+
import * as service from '../service';
|
|
24
|
+
|
|
25
|
+
/** What a caller supplies. \`id\`, \`orgId\` and \`createdAt\` are the server's to write. */
|
|
26
|
+
${wrapList(
|
|
27
|
+
'',
|
|
28
|
+
`export const Create${feature.pascal}Input = ${feature.camel}.$view([`,
|
|
29
|
+
CREATE_COLUMNS.map((column) => `'${column}'`),
|
|
30
|
+
']);',
|
|
31
|
+
)}
|
|
32
|
+
|
|
33
|
+
export const create${feature.pascal} = action({
|
|
34
|
+
input: Create${feature.pascal}Input,
|
|
35
|
+
output: ${feature.pascal}View,
|
|
36
|
+
policy: can${feature.pascal}Create,
|
|
37
|
+
cache: { invalidates: [${feature.camel}Tag] },
|
|
38
|
+
// No \`mcp\`: an MCP tool's description IS what an agent reads to decide to call it, and a
|
|
39
|
+
// placeholder there is worse than no tool. Write one, then \`mcp: { expose: true, description }\`.
|
|
40
|
+
async handle({ input, ctx }) {
|
|
41
|
+
// The tenant is the actor's. The policy already refused an actor with none; this is the same
|
|
42
|
+
// fact for the type checker, and the framework's own refusal if a caller skips the policy.
|
|
43
|
+
const orgId = ctx.actor.orgId;
|
|
44
|
+
if (typeof orgId !== 'string' || orgId === '') {
|
|
45
|
+
throw tenancyActorOrgRequired({
|
|
46
|
+
entityName: '${feature.camel}',
|
|
47
|
+
operation: 'create',
|
|
48
|
+
actorId: ctx.actor.id,
|
|
49
|
+
actorKind: ctx.actor.kind,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return service.create({ ...input, orgId });
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
const ORG = '00000000-0000-4000-8000-000000000002';
|
|
58
|
+
|
|
59
|
+
const createUnitTest = (
|
|
60
|
+
feature: NameSet,
|
|
61
|
+
): string => `// create${feature.pascal}: its declared shape and the input it refuses — answered by the
|
|
62
|
+
// declaration alone, so they belong to the \`unit\` step.
|
|
63
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
64
|
+
import { create${feature.pascal} } from './create-${feature.kebab}';
|
|
65
|
+
|
|
66
|
+
const target = create${feature.pascal}.named('create${feature.pascal}');
|
|
67
|
+
const input = { title: 'A ${feature.kebab}', price: { minor: 1200, currency: 'USD' } };
|
|
68
|
+
|
|
69
|
+
unitTest('create${feature.pascal} is a declared action', () => {
|
|
70
|
+
expect(target.kind).toBe('action');
|
|
71
|
+
expect(target.describe().name).toBe('create${feature.pascal}');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
unitTest('create${feature.pascal} takes the columns a caller supplies', async () => {
|
|
75
|
+
await expect(target.input).toAcceptInput(input);
|
|
76
|
+
await expect(target.input).toRejectInput({ price: input.price });
|
|
77
|
+
});
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
const createContractTest = (
|
|
81
|
+
feature: NameSet,
|
|
82
|
+
): string => `// create${feature.pascal}: the contract every action owes, and the orgless actor it denies before
|
|
83
|
+
// the handler runs — the org a row is written under is the actor's, so an actor with none is refused.
|
|
84
|
+
import { testActor } from '@ultimat3/policy';
|
|
85
|
+
import { contractTest, expect } from '@ultimat3/testing';
|
|
86
|
+
import { create${feature.pascal} } from './create-${feature.kebab}';
|
|
87
|
+
|
|
88
|
+
const target = create${feature.pascal}.named('create${feature.pascal}');
|
|
89
|
+
const input = { title: 'A ${feature.kebab}', price: { minor: 1200, currency: 'USD' } };
|
|
90
|
+
|
|
91
|
+
// Holds the grant and belongs to no org: the denial is the predicate deciding, not the grant.
|
|
92
|
+
const orgless = testActor('orgless', { permissions: ['${feature.kebab}:write'] }).actor;
|
|
93
|
+
// And one that holds both, so the refusal above is not simply "every actor is refused".
|
|
94
|
+
const writer = testActor('writer', {
|
|
95
|
+
orgId: '${ORG}',
|
|
96
|
+
permissions: ['${feature.kebab}:write'],
|
|
97
|
+
}).actor;
|
|
98
|
+
|
|
99
|
+
contractTest('create${feature.pascal} passes the action contract', async () => {
|
|
100
|
+
for (const contract of target.contract()) await contract.run();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
contractTest('create${feature.pascal} needs an actor with an org', async () => {
|
|
104
|
+
expect(await target.as(orgless, input).catch((error: unknown) => error)).toBeUltimateError(
|
|
105
|
+
'X_FORBIDDEN',
|
|
106
|
+
);
|
|
107
|
+
await expect(target.policy).not.toDenyPolicy({ actor: writer, input });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
contractTest('create${feature.pascal} projects one operation', () => {
|
|
111
|
+
expect(target.openapi().operationId).toBe('create${feature.pascal}');
|
|
112
|
+
});
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
/** The create action and its two suites, under `<slice>/actions/create-<feature>`. */
|
|
116
|
+
export function resourceCreateFiles(rawName: string, sliceDir: string): readonly GeneratedFile[] {
|
|
117
|
+
const feature = names(rawName);
|
|
118
|
+
const dir = `${sliceDir}/actions`;
|
|
119
|
+
return [
|
|
120
|
+
{ path: `${dir}/create-${feature.kebab}.ts`, contents: createSource(feature) },
|
|
121
|
+
{ path: `${dir}/create-${feature.kebab}.test.ts`, contents: createUnitTest(feature) },
|
|
122
|
+
{
|
|
123
|
+
path: `${dir}/create-${feature.kebab}.contract.test.ts`,
|
|
124
|
+
contents: createContractTest(feature),
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
}
|