@ultimat3/cli 1.2.0 → 3.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 +761 -0
- package/README.md +42 -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 +134 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +219 -0
- package/src/cmd-db.ts +458 -153
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +92 -18
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +74 -10
- 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 +14 -8
- 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 +29 -24
- package/src/cmd-verify.ts +197 -25
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +269 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +144 -0
- package/src/db-seed.ts +294 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +108 -23
- 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 +167 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +247 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +37 -7
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +78 -10
- package/src/error-catalog.ts +8 -18
- package/src/error-codes.ts +192 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +67 -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 +92 -15
- 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 +128 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +93 -2
- package/src/metrics-endpoint.ts +64 -16
- 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 +185 -13
- package/src/shell-quote.ts +15 -0
- 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 +20 -11
- package/src/test-workers.ts +50 -0
- package/src/ts-scan.ts +284 -15
- package/src/tsconfig-references.ts +103 -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,143 @@
|
|
|
1
|
+
// `x g guard <name>` — the app's own convention, scaffolded as a build error. Not a primitive and
|
|
2
|
+
// not a check the framework owns: what the emitted file has to get right is the directory (it is
|
|
3
|
+
// the registration), the exported `guard` the gate looks for, and a rule that fires on something
|
|
4
|
+
// real, so an author replacing the example knows what a working one looks like.
|
|
5
|
+
|
|
6
|
+
import type { GeneratedFile } from './naming';
|
|
7
|
+
import { kebab } from './naming';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The code the emitted guard raises, derived from its name — the same shape `x g action` derives
|
|
11
|
+
* `X_<FEATURE>_NOT_FOUND` in. Derived and never written as a literal here for the same reason: an
|
|
12
|
+
* `X_*` literal in framework source is a framework code, and it would have to be registered and
|
|
13
|
+
* documented in `wiki/Error-Codes.md`. The app owns the codes its own conventions raise.
|
|
14
|
+
*/
|
|
15
|
+
export const guardCode = (name: string): string =>
|
|
16
|
+
`X_${kebab(name).toUpperCase().split('-').join('_')}`;
|
|
17
|
+
|
|
18
|
+
const guardSource = (
|
|
19
|
+
name: string,
|
|
20
|
+
): string => `// ${name}: one convention this app enforces, as a build error. \`x verify\` discovers every file in
|
|
21
|
+
// \`guards/\` and runs its \`guard\` inside the \`boundaries\` step — nothing registers this file, so
|
|
22
|
+
// nothing can forget to. Replace the rule below with the one this app needs; the shape is the
|
|
23
|
+
// contract, and the findings it returns are what \`--json\` and the exit code are made of.
|
|
24
|
+
|
|
25
|
+
import type { Finding, Guard } from '@ultimat3/cli';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The example rule, and the class of failure a guard exists for: a migration that adds a NOT NULL
|
|
29
|
+
* column with no DEFAULT applies cleanly to an empty local database and fails on the first
|
|
30
|
+
* production table that already holds rows. Nothing else in the gate can see it — \`x verify\`'s
|
|
31
|
+
* \`drift\` step reads these same files and asks a different question (is every destructive
|
|
32
|
+
* statement declared?), and a test suite runs against a database this statement has never met.
|
|
33
|
+
*
|
|
34
|
+
* The code is this guard's own name: the line an agent reads has to say WHICH convention broke, so
|
|
35
|
+
* a guard is named after its convention and the two are renamed together.
|
|
36
|
+
*/
|
|
37
|
+
const CODE = '${guardCode(name)}';
|
|
38
|
+
|
|
39
|
+
/** A column definition begins at \`add column\` and ends at the comma or semicolon after it. */
|
|
40
|
+
const ADD_COLUMN = /add\\s+column\\s+(?:if\\s+not\\s+exists\\s+)?"?([\\w]+)"?([^,;]*)/gi;
|
|
41
|
+
|
|
42
|
+
export interface MigrationFile {
|
|
43
|
+
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
44
|
+
readonly path: string;
|
|
45
|
+
readonly sql: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pure — the caller does the I/O — so the rule is testable without a filesystem, which is the same
|
|
50
|
+
* split the framework's own checks use. A guard returns findings and nothing else: it never
|
|
51
|
+
* prints, never throws for a normal result and never decides the exit code.
|
|
52
|
+
*/
|
|
53
|
+
export function unsafeAdditions(files: readonly MigrationFile[]): readonly Finding[] {
|
|
54
|
+
const findings: Finding[] = [];
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
// Comments first, block before line: a statement inside \`/* … */\` is a note, and reading one
|
|
57
|
+
// as real is a finding an author cannot act on — it blocks \`x verify\` over nothing.
|
|
58
|
+
const sql = file.sql.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, ' ').replaceAll(/--[^\\n]*/g, ' ');
|
|
59
|
+
for (const statement of sql.split(';')) {
|
|
60
|
+
if (!/\\balter\\s+table\\b/i.test(statement)) continue;
|
|
61
|
+
for (const match of statement.matchAll(ADD_COLUMN)) {
|
|
62
|
+
const definition = match[2] ?? '';
|
|
63
|
+
if (!/\\bnot\\s+null\\b/i.test(definition)) continue;
|
|
64
|
+
// \`DEFAULT NULL\` is a default in syntax and none in effect: every existing row still
|
|
65
|
+
// takes NULL and still violates NOT NULL, which is this rule's whole subject.
|
|
66
|
+
const nullDefault = /\\bdefault\\s+\\(?\\s*null\\b/i.test(definition);
|
|
67
|
+
if (/\\bdefault\\b/i.test(definition) && !nullDefault) continue;
|
|
68
|
+
const column = match[1] ?? 'the column';
|
|
69
|
+
findings.push({
|
|
70
|
+
code: CODE,
|
|
71
|
+
cause: \`\${file.path} adds \${column} NOT NULL with no usable DEFAULT — every row already in the table takes NULL and violates it the moment this runs against data\`,
|
|
72
|
+
fix: \`give \${column} a non-null DEFAULT in \${file.path}, then: x db migrate\`,
|
|
73
|
+
at: file.path,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return findings;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const guard: Guard = {
|
|
82
|
+
summary: 'a migration never adds a NOT NULL column without a DEFAULT',
|
|
83
|
+
async check(root) {
|
|
84
|
+
const files: MigrationFile[] = [];
|
|
85
|
+
for await (const path of new Bun.Glob('packages/db/migrations/*.sql').scan({
|
|
86
|
+
cwd: root,
|
|
87
|
+
absolute: false,
|
|
88
|
+
})) {
|
|
89
|
+
files.push({ path, sql: await Bun.file(\`\${root}/\${path}\`).text() });
|
|
90
|
+
}
|
|
91
|
+
return unsafeAdditions(files);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
const guardTest = (
|
|
97
|
+
name: string,
|
|
98
|
+
): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
|
|
99
|
+
// a green gate over the convention it was written to enforce.
|
|
100
|
+
|
|
101
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
102
|
+
import { unsafeAdditions } from './${name}';
|
|
103
|
+
|
|
104
|
+
const migration = (sql: string) => [{ path: 'packages/db/migrations/0002_probe.sql', sql }];
|
|
105
|
+
|
|
106
|
+
unitTest('a NOT NULL column added with no DEFAULT is refused', () => {
|
|
107
|
+
const findings = unsafeAdditions(migration('ALTER TABLE posts ADD COLUMN slug text NOT NULL;'));
|
|
108
|
+
expect(findings).toHaveLength(1);
|
|
109
|
+
expect(findings[0]?.at).toBe('packages/db/migrations/0002_probe.sql');
|
|
110
|
+
expect(findings[0]?.cause).toContain('slug');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
unitTest('a DEFAULT makes the same addition safe', () => {
|
|
114
|
+
const sql = "ALTER TABLE posts ADD COLUMN slug text NOT NULL DEFAULT '';";
|
|
115
|
+
expect(unsafeAdditions(migration(sql))).toHaveLength(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
unitTest('DEFAULT NULL is a default in syntax and none in effect', () => {
|
|
119
|
+
const sql = 'ALTER TABLE posts ADD COLUMN slug text NOT NULL DEFAULT NULL;';
|
|
120
|
+
expect(unsafeAdditions(migration(sql))).toHaveLength(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
unitTest('a nullable column was always safe, and a new table is not an addition', () => {
|
|
124
|
+
expect(unsafeAdditions(migration('ALTER TABLE posts ADD COLUMN slug text;'))).toHaveLength(0);
|
|
125
|
+
expect(unsafeAdditions(migration('CREATE TABLE posts (slug text NOT NULL);'))).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
unitTest('a commented-out statement is not a statement', () => {
|
|
129
|
+
const block = '/* ALTER TABLE posts ADD COLUMN slug text NOT NULL; */';
|
|
130
|
+
expect(unsafeAdditions(migration(block))).toHaveLength(0);
|
|
131
|
+
const line = '-- ALTER TABLE posts ADD COLUMN slug text NOT NULL;';
|
|
132
|
+
expect(unsafeAdditions(migration(line))).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
/** `guards/<name>.ts` and its test. No index, no registry, no manifest row — the directory is it. */
|
|
137
|
+
export function guardFiles(rawName: string): readonly GeneratedFile[] {
|
|
138
|
+
const name = kebab(rawName);
|
|
139
|
+
return [
|
|
140
|
+
{ path: `guards/${name}.ts`, contents: guardSource(name) },
|
|
141
|
+
{ path: `guards/${name}.test.ts`, contents: guardTest(name) },
|
|
142
|
+
];
|
|
143
|
+
}
|
package/src/templates/index.ts
CHANGED
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
export type { ActionOptions } from './action';
|
|
5
5
|
export { actionFiles } from './action';
|
|
6
6
|
export { adminFiles } from './admin';
|
|
7
|
+
export type { AdminPageOptions } from './admin-page';
|
|
8
|
+
export { adminPageFiles } from './admin-page';
|
|
9
|
+
export { backfillFiles } from './backfill';
|
|
7
10
|
export type { FeatureTarget } from './entity';
|
|
8
11
|
export { entityFiles } from './entity';
|
|
12
|
+
export { guardCode, guardFiles } from './guard';
|
|
13
|
+
export type { IslandOptions } from './island';
|
|
14
|
+
export { islandFiles } from './island';
|
|
9
15
|
export { jobFiles, taskFiles } from './job';
|
|
10
16
|
export { CATALOG_ROOT, catalogPath, DEFAULT_LOCALES, resolveLocales } from './locales';
|
|
11
|
-
export type { GeneratedFile, NameSet } from './naming';
|
|
17
|
+
export type { GeneratedFile, GeneratedFoundationFile, NameSet } from './naming';
|
|
12
18
|
export { camel, kebab, names, pascal, plural, titleKey } from './naming';
|
|
13
19
|
export { policyFiles } from './policy';
|
|
14
20
|
export type { QueryOptions } from './query';
|
|
@@ -18,7 +24,12 @@ export { resourceFiles } from './resource';
|
|
|
18
24
|
export type { RouteOptions, Surface } from './route';
|
|
19
25
|
export { routeFiles } from './route';
|
|
20
26
|
export { appFiles } from './scaffold-app';
|
|
27
|
+
export { claudeFiles } from './scaffold-claude';
|
|
28
|
+
export { claudeAgentFiles } from './scaffold-claude-agents';
|
|
29
|
+
export { claudeCommandFiles } from './scaffold-claude-commands';
|
|
21
30
|
export { containerFiles } from './scaffold-container';
|
|
22
31
|
export { docsFiles, EXECUTABLE_FILES } from './scaffold-docs';
|
|
23
32
|
export { i18nIndex } from './scaffold-i18n';
|
|
24
33
|
export { repoFiles } from './scaffold-repo';
|
|
34
|
+
export type { SliceModule } from './slice-foundation';
|
|
35
|
+
export { sliceFoundation } from './slice-foundation';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// `x g island <name>` — the one file on a route that ships JavaScript. Not a ninth primitive and
|
|
2
|
+
// not a component generator: an island is a client ENTRY POINT, so what the scaffold has to get
|
|
3
|
+
// right is the filename (the bundler discovers by it) and the `mount` export (the hydration
|
|
4
|
+
// runtime calls it by name). Both are pinned by the emitted test.
|
|
5
|
+
|
|
6
|
+
import type { GeneratedFile } from './naming';
|
|
7
|
+
import { kebab, pascal } from './naming';
|
|
8
|
+
|
|
9
|
+
export interface IslandOptions {
|
|
10
|
+
/** Directory the entry lands in, app-root-relative and POSIX — normally a route's own folder. */
|
|
11
|
+
readonly dir: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const islandSource = (name: string): string => {
|
|
15
|
+
const Name = pascal(name);
|
|
16
|
+
return `// ${Name}: the interactive half of an otherwise static page, and the only module on this
|
|
17
|
+
// route the browser downloads.
|
|
18
|
+
//
|
|
19
|
+
// The page names this file by SPECIFIER, never by import:
|
|
20
|
+
// const ${Name} = island({ src: './${name}.island.tsx', props: ['label'] });
|
|
21
|
+
// A string has no import edge, so nothing follows one into this file and the page's bundle graph
|
|
22
|
+
// stays the page's (axiom 6). WHEN it wakes is the route's \`hydrate\`, never a declaration here.
|
|
23
|
+
|
|
24
|
+
/** What the server sends. Declared here AND in the page's \`island({ props })\` — both, or neither. */
|
|
25
|
+
export interface ${Name}Props {
|
|
26
|
+
/** Already translated: this runs in the browser, where \`t()\`'s catalog is not. */
|
|
27
|
+
readonly label: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The one export the hydration runtime calls — \`import(entry).then((m) => m.mount(el, props))\`.
|
|
32
|
+
* \`el\` is the wrapper the page rendered, with the server's own markup already inside it, so a
|
|
33
|
+
* mount that replaces the markup instead of taking it over is a visible flash on every load.
|
|
34
|
+
*/
|
|
35
|
+
export function mount(el: HTMLElement, props: ${Name}Props): void {
|
|
36
|
+
el.textContent = props.label;
|
|
37
|
+
el.addEventListener('click', () => {
|
|
38
|
+
// \`dataset.open\`, not \`dataset['open']\`: the bracket form is lint/complexity/useLiteralKeys,
|
|
39
|
+
// which the app's own \`biome check\` fails on — twice, in the one file every island copies.
|
|
40
|
+
el.dataset.open = el.dataset.open === 'true' ? 'false' : 'true';
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
`;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const islandTest = (
|
|
47
|
+
name: string,
|
|
48
|
+
): string => `// The runtime boots an island by calling \`mount\` on whatever the module exports. A renamed or
|
|
49
|
+
// deleted export is a page that renders, serves, passes every other gate and does nothing when
|
|
50
|
+
// clicked — which is exactly the failure nothing else in the build can see.
|
|
51
|
+
|
|
52
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
53
|
+
import * as entry from './${name}.island';
|
|
54
|
+
|
|
55
|
+
unitTest('${name}.island exports the mount the hydration runtime calls', () => {
|
|
56
|
+
expect(typeof entry.mount).toBe('function');
|
|
57
|
+
});
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
export function islandFiles(rawName: string, options: IslandOptions): readonly GeneratedFile[] {
|
|
61
|
+
const name = kebab(rawName);
|
|
62
|
+
const dir = options.dir.replace(/\/+$/, '');
|
|
63
|
+
return [
|
|
64
|
+
{ path: `${dir}/${name}.island.tsx`, contents: islandSource(name) },
|
|
65
|
+
{ path: `${dir}/${name}.island.test.ts`, contents: islandTest(name) },
|
|
66
|
+
];
|
|
67
|
+
}
|
package/src/templates/job.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// `x g job` / `x g task` — durable background work and the cron trigger that enqueues it. The
|
|
2
|
-
// idempotency key
|
|
3
|
-
// pins
|
|
2
|
+
// idempotency key and the tenant are both required by the type, so the generator always emits
|
|
3
|
+
// both; the generated test pins them through a real driver, because a key that is not stable is a
|
|
4
|
+
// job that runs twice and a tenant that is not declared is a job that reads the wrong org's rows.
|
|
4
5
|
|
|
5
6
|
import type { FeatureTarget } from './entity';
|
|
6
7
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
8
|
import { names } from './naming';
|
|
9
|
+
import { sliceFoundation } from './slice-foundation';
|
|
8
10
|
|
|
9
11
|
const jobSource = (
|
|
10
12
|
name: NameSet,
|
|
@@ -16,7 +18,14 @@ import { job, t } from '@ultimat3/jobs';
|
|
|
16
18
|
import * as repo from '../repo';
|
|
17
19
|
|
|
18
20
|
export const ${name.camel} = job({
|
|
19
|
-
input: t.object({ id: t.uuid }),
|
|
21
|
+
input: t.object({ id: t.uuid, orgId: t.uuid }),
|
|
22
|
+
// The org this run's body acts as, derived from the job's OWN input — never from whoever
|
|
23
|
+
// enqueued it, who may have changed orgs by the time a retried job settles. \`orgId\` is in the
|
|
24
|
+
// input for this and no other reason: \`x g entity\` scaffolds \`tenant: 'orgId'\`, so every read
|
|
25
|
+
// below is tenant-scoped. \`tenant: 'none'\` is the other spelling and it STRIPS the org, which
|
|
26
|
+
// makes a tenant-scoped read fail closed with X_TENANCY_ACTOR_ORG_REQUIRED — use it only for a
|
|
27
|
+
// job that touches no tenanted table.
|
|
28
|
+
tenant: (input) => input.orgId,
|
|
20
29
|
idempotencyKey: ({ id }) => \`${name.kebab}:\${id}\`,
|
|
21
30
|
retry: { attempts: 5, backoff: 'exponential' },
|
|
22
31
|
async run({ input, step }) {
|
|
@@ -42,17 +51,34 @@ import { ${jobName.camel} } from '../jobs/${jobName.kebab}';
|
|
|
42
51
|
export const ${name.camel} = task({
|
|
43
52
|
cron: '0 3 * * *',
|
|
44
53
|
tz: 'UTC',
|
|
45
|
-
|
|
54
|
+
// The org rides in the payload because the job DECLARES its tenant from its own input: a task
|
|
55
|
+
// has no request behind it, so there is no caller whose org could be read instead.
|
|
56
|
+
enqueue: () => [
|
|
57
|
+
[
|
|
58
|
+
${jobName.camel},
|
|
59
|
+
{
|
|
60
|
+
id: '00000000-0000-4000-8000-000000000001',
|
|
61
|
+
orgId: '00000000-0000-4000-8000-000000000002',
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
],
|
|
46
65
|
});
|
|
47
66
|
`;
|
|
48
67
|
|
|
49
68
|
const jobTest = (
|
|
50
69
|
name: NameSet,
|
|
51
|
-
): string =>
|
|
70
|
+
): string => `// ${name.camel} against a real driver: enqueue, drain, assert. Retries and the dead-letter path
|
|
71
|
+
// are the framework's, so what this pins is that THIS job's steps run and are idempotent.
|
|
72
|
+
import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
|
|
52
73
|
import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
|
|
53
74
|
import { ${name.camel} } from './${name.kebab}';
|
|
54
75
|
|
|
55
76
|
const id = '00000000-0000-4000-8000-000000000001';
|
|
77
|
+
const orgId = '00000000-0000-4000-8000-000000000002';
|
|
78
|
+
const input = { id, orgId };
|
|
79
|
+
// The key this job owes, spelled once. Named rather than inlined so the assertion below carries
|
|
80
|
+
// the job's own name and still fits the formatter width the app's \`lint\` step enforces.
|
|
81
|
+
const expectedKey = \`${name.kebab}:\${id}\`;
|
|
56
82
|
|
|
57
83
|
// The driver is process-global, so it is installed and released around this file rather than
|
|
58
84
|
// left behind for whichever test happens to run next.
|
|
@@ -61,14 +87,22 @@ beforeAll(() => {
|
|
|
61
87
|
});
|
|
62
88
|
afterAll(resetJobDriver);
|
|
63
89
|
|
|
64
|
-
jobTest('${name.camel} declares
|
|
90
|
+
jobTest('${name.camel} declares a key and a retry policy', () => {
|
|
65
91
|
expect(${name.camel}.kind).toBe('job');
|
|
66
|
-
expect(${name.camel}.idempotencyKeyFor(
|
|
92
|
+
expect(${name.camel}.idempotencyKeyFor(input)).toBe(expectedKey);
|
|
67
93
|
expect(${name.camel}.retry.attempts).toBeGreaterThan(1);
|
|
68
94
|
});
|
|
69
95
|
|
|
70
96
|
jobTest('${name.camel} derives the same key for the same input', () => {
|
|
71
|
-
|
|
97
|
+
const key = ${name.camel}.idempotencyKeyFor(input);
|
|
98
|
+
expect(${name.camel}.idempotencyKeyFor(input)).toBe(key);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
jobTest('${name.camel} runs as the org its own input names', () => {
|
|
102
|
+
// Not a formality: \`tenant: 'none'\` compiles just as well and strips the org, and every read in
|
|
103
|
+
// this job is tenant-scoped — so this declaration is the whole of what stands between the body
|
|
104
|
+
// and X_TENANCY_ACTOR_ORG_REQUIRED, or worse, another org's rows.
|
|
105
|
+
expect(${name.camel}.tenantFor(input)).toBe(orgId);
|
|
72
106
|
});
|
|
73
107
|
|
|
74
108
|
jobTest('${name.camel} projects itself into the manifest', () => {
|
|
@@ -80,9 +114,9 @@ jobTest('${name.camel} projects itself into the manifest', () => {
|
|
|
80
114
|
jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
|
|
81
115
|
// The whole point of the key: an at-least-once caller may enqueue twice and the work still
|
|
82
116
|
// happens once. \`.enqueue()\` is the one queue path — a job is never run inline.
|
|
83
|
-
const first = await ${name.camel}.enqueue(
|
|
117
|
+
const first = await ${name.camel}.enqueue(input);
|
|
84
118
|
expect(first.deduped).toBe(false);
|
|
85
|
-
const again = await ${name.camel}.enqueue(
|
|
119
|
+
const again = await ${name.camel}.enqueue(input);
|
|
86
120
|
expect(again.deduped).toBe(true);
|
|
87
121
|
});
|
|
88
122
|
`;
|
|
@@ -90,7 +124,9 @@ jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
|
|
|
90
124
|
const taskTest = (
|
|
91
125
|
name: NameSet,
|
|
92
126
|
jobName: NameSet,
|
|
93
|
-
): string =>
|
|
127
|
+
): string => `// ${name.camel}: the schedule it declares, the timezone it declares it in, and the job it
|
|
128
|
+
// enqueues. A cron with no explicit IANA zone fires at a different hour twice a year.
|
|
129
|
+
import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
|
|
94
130
|
import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
|
|
95
131
|
import { ${jobName.camel} } from '../jobs/${jobName.kebab}';
|
|
96
132
|
import { ${name.camel} } from './${name.kebab}';
|
|
@@ -106,7 +142,7 @@ jobTest('${name.camel} declares a cron with an explicit time zone', () => {
|
|
|
106
142
|
expect(${name.camel}.tz).toBe('UTC');
|
|
107
143
|
});
|
|
108
144
|
|
|
109
|
-
jobTest('${name.camel} enqueues
|
|
145
|
+
jobTest('${name.camel} enqueues exactly one job', () => {
|
|
110
146
|
const pairs = ${name.camel}.entries();
|
|
111
147
|
expect(pairs).toHaveLength(1);
|
|
112
148
|
expect(pairs[0]?.[0]).toBe(${jobName.camel});
|
|
@@ -118,7 +154,7 @@ jobTest('${name.camel} describes its schedule and its jobs', () => {
|
|
|
118
154
|
expect(described.jobs).toHaveLength(1);
|
|
119
155
|
});
|
|
120
156
|
|
|
121
|
-
jobTest('${name.camel} fires its entries
|
|
157
|
+
jobTest('${name.camel} fires its declared entries', async () => {
|
|
122
158
|
// \`.enqueue()\` is the backfill path: the declared entries, through the facade a job handle
|
|
123
159
|
// uses, with no scheduler and no leader involved.
|
|
124
160
|
const results = await ${name.camel}.enqueue();
|
|
@@ -131,6 +167,10 @@ export function jobFiles(rawName: string, target: FeatureTarget): readonly Gener
|
|
|
131
167
|
const name = names(rawName);
|
|
132
168
|
const dir = `${target.surfaceDir}/${target.feature}/jobs`;
|
|
133
169
|
return [
|
|
170
|
+
// The job's steps read through `../repo`, which carries `../entity` for its row type. No
|
|
171
|
+
// policy: a job has no request behind it and evaluates none, so a generated one would be a
|
|
172
|
+
// file nobody asked for. `x g task` inherits this by composing `jobFiles` below.
|
|
173
|
+
...sliceFoundation(target, ['entity']),
|
|
134
174
|
{ path: `${dir}/${name.kebab}.ts`, contents: jobSource(name) },
|
|
135
175
|
{ path: `${dir}/${name.kebab}.test.ts`, contents: jobTest(name) },
|
|
136
176
|
];
|
package/src/templates/naming.ts
CHANGED
|
@@ -27,13 +27,29 @@ export interface GeneratedSourceFile {
|
|
|
27
27
|
readonly merge?: undefined;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* A module the feature slice owns and no single generator does — `entity.ts`, `repo.ts`,
|
|
32
|
+
* `policy.ts`, `errors.ts`. Several generators import one and none of them wrote it, so `x g job`
|
|
33
|
+
* into a slice that has no `repo.ts` emitted an import against nothing. Emitting it as an ordinary
|
|
34
|
+
* file is the opposite failure: the copy on disk is the author's, and rewriting it (or reporting
|
|
35
|
+
* `X_GENERATE_CONFLICT` and abandoning the whole set) is how `x g action` into an existing slice
|
|
36
|
+
* stops working. `'if-absent'` is the third answer — written when the slice lacks it, left exactly
|
|
37
|
+
* as it is when the slice has it, `--force` included, and never a conflict either way.
|
|
38
|
+
*/
|
|
39
|
+
export interface GeneratedFoundationFile {
|
|
40
|
+
/** POSIX path relative to the app root. */
|
|
41
|
+
readonly path: string;
|
|
42
|
+
readonly contents: string;
|
|
43
|
+
readonly merge: 'if-absent';
|
|
44
|
+
}
|
|
45
|
+
|
|
30
46
|
/**
|
|
31
47
|
* Split on `merge` rather than widening one shape's `contents` in place, so the split is
|
|
32
48
|
* load-bearing, not cosmetic: a `merge: 'json'` file's `contents` stays a plain `string` at the
|
|
33
49
|
* type level, which is what stops a byte-carrying file from ever reaching
|
|
34
50
|
* `cmd-generate.ts`'s JSON parser — the compiler refuses the call before the code can run.
|
|
35
51
|
*/
|
|
36
|
-
export type GeneratedFile = GeneratedJsonFile | GeneratedSourceFile;
|
|
52
|
+
export type GeneratedFile = GeneratedJsonFile | GeneratedSourceFile | GeneratedFoundationFile;
|
|
37
53
|
|
|
38
54
|
const words = (input: string): readonly string[] =>
|
|
39
55
|
input
|
package/src/templates/policy.ts
CHANGED
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
import type { GeneratedFile, NameSet } from './naming';
|
|
6
6
|
import { names } from './naming';
|
|
7
|
+
import { wrapList } from './wrap';
|
|
7
8
|
|
|
8
9
|
/** Biome would rewrap this itself, so the generator emits the already-formatted form. */
|
|
9
|
-
const permissionSet = (feature: NameSet): string =>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
};
|
|
10
|
+
const permissionSet = (feature: NameSet): string =>
|
|
11
|
+
wrapList(
|
|
12
|
+
'',
|
|
13
|
+
`export const ${feature.camel}Permissions = definePermissions([`,
|
|
14
|
+
[`'${feature.kebab}:read'`, `'${feature.kebab}:write'`],
|
|
15
|
+
']);',
|
|
16
|
+
);
|
|
17
17
|
|
|
18
18
|
const policySource = (
|
|
19
19
|
feature: NameSet,
|
|
@@ -67,38 +67,45 @@ export const can${feature.pascal}Write = can<${feature.pascal}Scope>(
|
|
|
67
67
|
);
|
|
68
68
|
`;
|
|
69
69
|
|
|
70
|
-
const policyTest = (
|
|
70
|
+
const policyTest = (
|
|
71
|
+
feature: NameSet,
|
|
72
|
+
): string => `// The ${feature.kebab} rules, from the DENIAL side: anonymous, cross-org, and the actor holding
|
|
73
|
+
// only read. A policy whose tests all pass is a policy nobody has tried to get past.
|
|
74
|
+
import { testActor } from '@ultimat3/policy';
|
|
71
75
|
import { expect, unitTest } from '@ultimat3/testing';
|
|
72
76
|
import { can${feature.pascal}Read, can${feature.pascal}Write } from './policy';
|
|
73
77
|
|
|
74
78
|
const org = '00000000-0000-4000-8000-000000000002';
|
|
75
79
|
const otherOrg = '00000000-0000-4000-8000-000000000009';
|
|
76
80
|
|
|
77
|
-
// Direct grants rather than roles:
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
// Direct grants rather than roles: defineRoles() MERGES into one app-global map that outlives this
|
|
82
|
+
// file, so a role installed here would still be granting permissions to every later test in the
|
|
83
|
+
// process — and a second test declaring the same name differently is X_ROLE_REDEFINED rather than
|
|
84
|
+
// an override. The app's roles live in apps/web/shared/roles.ts, once. \`permissions\` is the same
|
|
85
|
+
// check one layer down.
|
|
86
|
+
// The two grants and the org, each named once. Not decoration: every assertion below then carries
|
|
87
|
+
// the feature's own name and still fits the formatter's width, whatever that name is — a generated
|
|
88
|
+
// file the app's \`lint\` step rewrites is a red gate over code nobody typed.
|
|
89
|
+
const read = '${feature.kebab}:read';
|
|
90
|
+
const write = '${feature.kebab}:write';
|
|
91
|
+
const input = { orgId: org };
|
|
92
|
+
|
|
93
|
+
const reader = testActor('reader', { orgId: org, permissions: [read] }).actor;
|
|
94
|
+
const writer = testActor('writer', { orgId: org, permissions: [read, write] }).actor;
|
|
95
|
+
const outsider = testActor('outsider', { orgId: otherOrg, permissions: [read, write] }).actor;
|
|
89
96
|
|
|
90
97
|
unitTest('${feature.camel} read denies anonymous and cross-org actors', async () => {
|
|
91
|
-
await expect(can${feature.pascal}Read).toDenyPolicy({ actor: null, input
|
|
92
|
-
await expect(can${feature.pascal}Read).toDenyPolicy({ actor: outsider, input
|
|
93
|
-
await expect(can${feature.pascal}Read).not.toDenyPolicy({ actor: reader, input
|
|
98
|
+
await expect(can${feature.pascal}Read).toDenyPolicy({ actor: null, input });
|
|
99
|
+
await expect(can${feature.pascal}Read).toDenyPolicy({ actor: outsider, input });
|
|
100
|
+
await expect(can${feature.pascal}Read).not.toDenyPolicy({ actor: reader, input });
|
|
94
101
|
});
|
|
95
102
|
|
|
96
|
-
unitTest('${feature.camel} write denies
|
|
103
|
+
unitTest('${feature.camel} write denies the read-only actor', async () => {
|
|
97
104
|
// The outsider holds the grant and is still denied: the predicate is a second, independent
|
|
98
105
|
// gate, and this is the assertion that fails if someone deletes it.
|
|
99
|
-
await expect(can${feature.pascal}Write).toDenyPolicy({ actor: reader, input
|
|
100
|
-
await expect(can${feature.pascal}Write).toDenyPolicy({ actor: outsider, input
|
|
101
|
-
await expect(can${feature.pascal}Write).not.toDenyPolicy({ actor: writer, input
|
|
106
|
+
await expect(can${feature.pascal}Write).toDenyPolicy({ actor: reader, input });
|
|
107
|
+
await expect(can${feature.pascal}Write).toDenyPolicy({ actor: outsider, input });
|
|
108
|
+
await expect(can${feature.pascal}Write).not.toDenyPolicy({ actor: writer, input });
|
|
102
109
|
});
|
|
103
110
|
|
|
104
111
|
unitTest('${feature.camel} rules name the permission they require', () => {
|
package/src/templates/query.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
|
/** A live read always fans out fresh, so a TTL on it would only ever be dead configuration. */
|
|
10
12
|
const cacheLine = (feature: NameSet, live: boolean): string =>
|
|
@@ -20,7 +22,10 @@ const querySource = (
|
|
|
20
22
|
|
|
21
23
|
import { from, query, t } from '@ultimat3/query';
|
|
22
24
|
import type { ${feature.pascal} } from '../entity';
|
|
23
|
-
|
|
25
|
+
${wrapImport(
|
|
26
|
+
live ? [`can${feature.pascal}Read`] : [`can${feature.pascal}Read`, `${feature.camel}Tag`],
|
|
27
|
+
'../policy',
|
|
28
|
+
)}
|
|
24
29
|
import * as repo from '../repo';
|
|
25
30
|
|
|
26
31
|
export const ${name.camel} = query({
|
|
@@ -28,7 +33,7 @@ export const ${name.camel} = query({
|
|
|
28
33
|
policy: can${feature.pascal}Read,
|
|
29
34
|
live: ${String(live)},${cacheLine(feature, live)}
|
|
30
35
|
// Opt-in, unlike an action's tool: a read hands rows to an agent, so silence exposes nothing.
|
|
31
|
-
mcp: { expose: true, description: '${name.raw} —
|
|
36
|
+
mcp: { expose: true, description: '${name.raw} — edit this description' },
|
|
32
37
|
sql: ({ orgId, limit }) =>
|
|
33
38
|
// \`feature.table\`, not the kebab plural: \`from()\` quotes the identifier into the SQL text,
|
|
34
39
|
// and the entity created the table as snake_case.
|
|
@@ -45,7 +50,9 @@ export const ${name.camel} = query({
|
|
|
45
50
|
|
|
46
51
|
const queryTest = (name: NameSet, feature: NameSet, live: boolean): string => {
|
|
47
52
|
const wrapper = live ? 'liveTest' : 'unitTest';
|
|
48
|
-
return
|
|
53
|
+
return `// ${name.camel}: the shape it reads, the policy it asserts, and the actor it refuses. The read is
|
|
54
|
+
// declarative, so what a test can get wrong is the authz, and that is what this pins.
|
|
55
|
+
import { testActor } from '@ultimat3/policy';
|
|
49
56
|
import { sourceFor } from '@ultimat3/query';
|
|
50
57
|
import { expect, ${wrapper} } from '@ultimat3/testing';
|
|
51
58
|
import { ${name.camel} } from './${name.kebab}';
|
|
@@ -74,8 +81,16 @@ ${wrapper}('${name.camel} is bounded and TOTALLY ordered', async () => {
|
|
|
74
81
|
// The SQL text is the contract an agent reads to self-correct, so assert on it, not on a
|
|
75
82
|
// shape. \`sourceFor\` is the one read path — it parses the input and builds the source exactly
|
|
76
83
|
// as a request does. \`actor: null\` gives the call a context of its own rather than borrowing
|
|
77
|
-
// an ambient one, and \`
|
|
78
|
-
|
|
84
|
+
// an ambient one, and \`unenforced\` states WHY the policy is skipped: the escape hatch takes a
|
|
85
|
+
// written reason, never a boolean, because a boolean reads exactly like forgetting the policy.
|
|
86
|
+
const source = await sourceFor(
|
|
87
|
+
target,
|
|
88
|
+
{ orgId, limit: 50 },
|
|
89
|
+
{
|
|
90
|
+
actor: null,
|
|
91
|
+
unenforced: 'a scaffolded test asserts the SQL text; the policy is asserted separately',
|
|
92
|
+
},
|
|
93
|
+
);
|
|
79
94
|
const { sql } = source.toSQL();
|
|
80
95
|
const text = sql.toLowerCase();
|
|
81
96
|
expect(text).toContain('order by');
|
|
@@ -110,6 +125,10 @@ export function queryFiles(rawName: string, target: QueryOptions): readonly Gene
|
|
|
110
125
|
const live = target.live === true;
|
|
111
126
|
const dir = `${target.surfaceDir}/${target.feature}/${live ? 'live' : 'queries'}`;
|
|
112
127
|
return [
|
|
128
|
+
// A read declares the row type it returns (`../entity`), the rule that admits it (`../policy`)
|
|
129
|
+
// and the one module allowed to query the table (`../repo`) — all three are the slice's, and
|
|
130
|
+
// `--live` changes only which directory this file lands in.
|
|
131
|
+
...sliceFoundation(target, ['entity', 'policy']),
|
|
113
132
|
{ path: `${dir}/${name.kebab}.ts`, contents: querySource(name, feature, live) },
|
|
114
133
|
{ path: `${dir}/${name.kebab}.test.ts`, contents: queryTest(name, feature, live) },
|
|
115
134
|
];
|
|
@@ -27,11 +27,16 @@ import * as repo from './repo';
|
|
|
27
27
|
/** Derived from the row, never restated: a new column reaches this input without an edit here. */
|
|
28
28
|
export type Create${feature.pascal}Input = Omit<${feature.pascal}, 'id' | 'createdAt'>;
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
/** The row, aliased once, so every signature below reads at one width whatever the feature is
|
|
31
|
+
* called — a generated file the app's own formatter rewrites is a red \`lint\` over code nobody
|
|
32
|
+
* typed. */
|
|
33
|
+
type Row = ${feature.pascal};
|
|
34
|
+
|
|
35
|
+
export async function create(input: Create${feature.pascal}Input): Promise<Row> {
|
|
31
36
|
return repo.insert(input);
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
export async function require${feature.pascal}(id: string): Promise
|
|
39
|
+
export async function require${feature.pascal}(id: string): Promise<Row> {
|
|
35
40
|
const row = await repo.byId(id);
|
|
36
41
|
if (row === undefined) throw new ${feature.pascal}NotFoundError({ id });
|
|
37
42
|
return row;
|
|
@@ -40,7 +45,9 @@ export async function require${feature.pascal}(id: string): Promise<${feature.pa
|
|
|
40
45
|
|
|
41
46
|
const serviceTest = (
|
|
42
47
|
feature: NameSet,
|
|
43
|
-
): string =>
|
|
48
|
+
): string => `// The ${feature.kebab} feature's failure, pinned: a code an agent can match on, a cause naming
|
|
49
|
+
// the row, and a fix that is an instruction. A bare Error would satisfy none of the three.
|
|
50
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
44
51
|
import { ${feature.pascal}NotFoundError } from './errors';
|
|
45
52
|
|
|
46
53
|
unitTest('${feature.pascal}NotFoundError carries a code, a cause and a fix', () => {
|
|
@@ -69,9 +76,10 @@ export function ${feature.pascal}List(props: ${feature.pascal}ListProps) {
|
|
|
69
76
|
return (
|
|
70
77
|
<ul class={styles.list}>
|
|
71
78
|
<For each={props.rows} fallback={<li>{t('app.${feature.kebab}.empty')}</li>}>
|
|
72
|
-
{/*
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
{/* <For> is keyed by value identity, so the item arrives directly and a row is only
|
|
80
|
+
re-created when its value changes. <Index> is the accessor-shaped one — reach for it
|
|
81
|
+
when the list is a fixed set of slots whose contents mutate. */}
|
|
82
|
+
{(row) => <li class={styles.item}>{row.title}</li>}
|
|
75
83
|
</For>
|
|
76
84
|
</ul>
|
|
77
85
|
);
|
|
@@ -82,14 +90,14 @@ const uiStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
|
82
90
|
|
|
83
91
|
.list {
|
|
84
92
|
display: grid;
|
|
85
|
-
gap: tokens
|
|
93
|
+
gap: tokens.space(2);
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
.item {
|
|
89
|
-
padding: tokens
|
|
90
|
-
border-radius: tokens
|
|
91
|
-
background: tokens
|
|
92
|
-
color: tokens
|
|
97
|
+
padding: tokens.space(2);
|
|
98
|
+
border-radius: tokens.radius('sm');
|
|
99
|
+
background: tokens.role('surface-raised');
|
|
100
|
+
color: tokens.role('fg');
|
|
93
101
|
}
|
|
94
102
|
`;
|
|
95
103
|
|