@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
package/src/templates/entity.ts
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
import type { GeneratedFile, NameSet } from './naming';
|
|
6
6
|
import { names } from './naming';
|
|
7
|
+
import { wrapImport, wrapList } from './wrap';
|
|
8
|
+
|
|
9
|
+
/** The columns that leave the server. One list, read by the declaration and by its test. */
|
|
10
|
+
const VIEW_KEYS: readonly string[] = ["'id'", "'title'", "'price'", "'createdAt'"];
|
|
7
11
|
|
|
8
12
|
export interface FeatureTarget {
|
|
9
13
|
/** `apps/web/app` or `apps/web/site` — the surface the feature lives in. */
|
|
@@ -28,16 +32,18 @@ export const ${name.camel} = entity('${table}', {
|
|
|
28
32
|
id: uuid().primaryKey(),
|
|
29
33
|
orgId: uuid(),
|
|
30
34
|
title: text({ max: 200 }),
|
|
31
|
-
// One property,
|
|
32
|
-
// Money is integer minor units plus an ISO code, never a
|
|
35
|
+
// One property, three physical columns: price_minor bigint + price_currency char(3) +
|
|
36
|
+
// the nullable price_scale integer. Money is integer minor units plus an ISO code, never a
|
|
37
|
+
// float; the scale is what lets an amount name a sub-cent value, and NULL is not 0.
|
|
33
38
|
price: money(),
|
|
34
39
|
// Always timestamptz. Stored UTC; formatted at the edge with an explicit IANA time zone.
|
|
35
40
|
createdAt: timestamp().defaultNow(),
|
|
36
41
|
},
|
|
37
42
|
// Each rule runs in the app on write AND as a Postgres CHECK — one declaration, both sides.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
invariant('${snake}
|
|
43
|
+
// \`c\` is typed from the columns above: \`c.titel\` is a compile error that names \`title\`.
|
|
44
|
+
invariants: (c) => [
|
|
45
|
+
invariant('${snake}_title_not_blank', c.title.trimmed().minLength(1)),
|
|
46
|
+
invariant('${snake}_price_non_negative', c.price.minor.atLeast(0)),
|
|
41
47
|
],
|
|
42
48
|
indexes: [{ on: ['orgId', 'createdAt'] }],
|
|
43
49
|
});
|
|
@@ -47,14 +53,31 @@ export type ${name.pascal} = typeof ${name.camel}.$row;
|
|
|
47
53
|
// What leaves the server: an action writes \`output: ${name.pascal}View\` and the shape is the
|
|
48
54
|
// columns', never a second declaration to keep in sync. The tenant column is not in it — an org
|
|
49
55
|
// id is the caller's context, not the client's data.
|
|
50
|
-
export const ${name.pascal}View = ${name.camel}.$view([
|
|
56
|
+
${wrapList('', `export const ${name.pascal}View = ${name.camel}.$view([`, VIEW_KEYS, ']);')}
|
|
51
57
|
export type ${name.pascal}View = typeof ${name.pascal}View.$row;
|
|
52
58
|
`;
|
|
53
59
|
|
|
54
|
-
const repoSource = (
|
|
55
|
-
name
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
const repoSource = (name: NameSet, table: string): string => {
|
|
61
|
+
const row = name.pascal;
|
|
62
|
+
const byIdCall = wrapList(
|
|
63
|
+
' ',
|
|
64
|
+
`const row = await db().one<${row}>(`,
|
|
65
|
+
[`sql\`select * from ${table} where id = \${id}\``],
|
|
66
|
+
');',
|
|
67
|
+
);
|
|
68
|
+
const listSignature = wrapList(
|
|
69
|
+
'',
|
|
70
|
+
'export async function listByOrg(',
|
|
71
|
+
['orgId: string', 'limit = 50'],
|
|
72
|
+
`): Promise<readonly ${row}[]> {`,
|
|
73
|
+
);
|
|
74
|
+
const insertSignature = wrapList(
|
|
75
|
+
'',
|
|
76
|
+
'export async function insert(',
|
|
77
|
+
[`row: Omit<${row}, 'id' | 'createdAt'>`],
|
|
78
|
+
`): Promise<${row}> {`,
|
|
79
|
+
);
|
|
80
|
+
return `// The only module allowed to query the ${name.pluralKebab} table. Routes call actions and
|
|
58
81
|
// queries; actions call services; services call this.
|
|
59
82
|
// \`db()\` is the ambient handle: inside a transaction it IS the transaction, so these functions
|
|
60
83
|
// join the caller's transaction without knowing one is open.
|
|
@@ -64,43 +87,53 @@ import { dbDrift, newId } from '@ultimat3/entity';
|
|
|
64
87
|
import type { ${name.pascal} } from './entity';
|
|
65
88
|
|
|
66
89
|
export async function byId(id: string): Promise<${name.pascal} | undefined> {
|
|
67
|
-
|
|
90
|
+
${byIdCall}
|
|
68
91
|
return row ?? undefined;
|
|
69
92
|
}
|
|
70
93
|
|
|
71
|
-
|
|
94
|
+
${listSignature}
|
|
72
95
|
// Ordered and bounded: an unordered page is a different page on every request.
|
|
73
96
|
return db().query<${name.pascal}>(
|
|
74
97
|
sql\`select * from ${table} where org_id = \${orgId} order by created_at desc limit \${limit}\`,
|
|
75
98
|
);
|
|
76
99
|
}
|
|
77
100
|
|
|
78
|
-
|
|
79
|
-
// Money is
|
|
101
|
+
${insertSignature}
|
|
102
|
+
// Money is three physical columns — integer minor units, the ISO code, and the scale, never a
|
|
103
|
+
// float. \`scale ?? null\`: an amount at the currency's own minor unit carries no scale at all,
|
|
104
|
+
// and writing \`0\` for it would claim whole units — a 100x reinterpretation of the price.
|
|
80
105
|
const created = await db().one<${name.pascal}>(sql\`
|
|
81
|
-
insert into ${table} (id, org_id, title, price_minor, price_currency)
|
|
82
|
-
values (\${newId()}, \${row.orgId}, \${row.title}, \${row.price.minor}, \${row.price.currency}
|
|
106
|
+
insert into ${table} (id, org_id, title, price_minor, price_currency, price_scale)
|
|
107
|
+
values (\${newId()}, \${row.orgId}, \${row.title}, \${row.price.minor}, \${row.price.currency},
|
|
108
|
+
\${row.price.scale ?? null})
|
|
83
109
|
returning *\`);
|
|
84
110
|
if (created === null) throw dbDrift('${table}', 'id');
|
|
85
111
|
return created;
|
|
86
112
|
}
|
|
87
113
|
`;
|
|
114
|
+
};
|
|
88
115
|
|
|
89
116
|
const entityTest = (
|
|
90
117
|
name: NameSet,
|
|
91
118
|
snake: string,
|
|
92
119
|
table: string,
|
|
93
|
-
): string =>
|
|
120
|
+
): string => `// The ${name.kebab} entity's declaration: the table it maps to and the invariants it names. Both
|
|
121
|
+
// are what the migration generator and every query read, so both are worth pinning.
|
|
122
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
94
123
|
import type { ${name.pascal} } from './entity';
|
|
95
|
-
|
|
124
|
+
${wrapImport([`${name.pascal}View`, name.camel], './entity')}
|
|
125
|
+
|
|
126
|
+
type Over = Partial<${name.pascal}>;
|
|
96
127
|
|
|
97
|
-
const row = (over:
|
|
128
|
+
const row = (over: Over = {}): ${name.pascal} => ({
|
|
98
129
|
id: '00000000-0000-4000-8000-000000000001',
|
|
99
130
|
orgId: '00000000-0000-4000-8000-000000000002',
|
|
100
131
|
title: 'valid title',
|
|
101
|
-
// \`money()\` puts \`MoneyValue\` on the row
|
|
102
|
-
//
|
|
103
|
-
|
|
132
|
+
// \`money()\` puts \`MoneyValue\` on the row — the same type \`@ultimat3/money\`'s \`Money\` is,
|
|
133
|
+
// so this value goes straight to \`add()\`, \`formatMoney()\` and \`<Money>\` with no conversion.
|
|
134
|
+
// Integer minor units, never a float; the column is a Postgres bigint and a stored value past
|
|
135
|
+
// ±2^53 is refused when it is read rather than rounded into the row.
|
|
136
|
+
price: { minor: 1000, currency: 'USD' },
|
|
104
137
|
createdAt: new Date(0),
|
|
105
138
|
...over,
|
|
106
139
|
});
|
|
@@ -117,24 +150,32 @@ unitTest('${name.camel} describes itself for the manifest', () => {
|
|
|
117
150
|
// a column added below reaches every one of them without a second declaration.
|
|
118
151
|
const described = ${name.camel}.$describe();
|
|
119
152
|
expect(described.orgScoped).toBe(true);
|
|
120
|
-
// One \`price\` property,
|
|
121
|
-
// description is where that shows, and it is what fails here if money ever
|
|
153
|
+
// One \`price\` property, three physical columns: integer minor units, the ISO code, and the
|
|
154
|
+
// nullable scale. The description is where that shows, and it is what fails here if money ever
|
|
155
|
+
// becomes a float — or if a migration forgets the scale column and unscales every sub-cent row.
|
|
122
156
|
expect(described.columns.map((column) => column.column)).toContain('price_minor');
|
|
123
157
|
expect(described.columns.map((column) => column.column)).toContain('price_currency');
|
|
124
|
-
expect(described.
|
|
158
|
+
expect(described.columns.map((column) => column.column)).toContain('price_scale');
|
|
159
|
+
// Named first: the assertion line carries the entity's own name and stays under the app's
|
|
160
|
+
// formatter width whatever that name is.
|
|
161
|
+
const rules = described.invariants.map((rule) => rule.name);
|
|
162
|
+
expect(rules).toContain('${snake}_price_non_negative');
|
|
125
163
|
});
|
|
126
164
|
|
|
127
|
-
unitTest('${name.pascal}View projects the row an action returns
|
|
128
|
-
|
|
165
|
+
unitTest('${name.pascal}View projects the row an action returns', () => {
|
|
166
|
+
const keys = ${name.pascal}View.$keys;
|
|
167
|
+
expect(keys).toEqual([${VIEW_KEYS.join(', ')}]);
|
|
129
168
|
// The org id is the caller's context, never the client's data: a view that leaked it would
|
|
130
169
|
// let a response carry a tenant boundary the policy already decided.
|
|
131
|
-
expect(
|
|
170
|
+
expect(keys).not.toContain('orgId');
|
|
132
171
|
});
|
|
133
172
|
|
|
134
|
-
unitTest('${name.camel} invariants reject
|
|
173
|
+
unitTest('${name.camel} invariants reject blank and negative', () => {
|
|
174
|
+
const blank = row({ title: ' ' });
|
|
175
|
+
const negative = row({ price: { minor: -1, currency: 'USD' } });
|
|
135
176
|
expect(() => ${name.camel}.$assert(row())).not.toThrow();
|
|
136
|
-
expect(() => ${name.camel}.$assert(
|
|
137
|
-
expect(() => ${name.camel}.$assert(
|
|
177
|
+
expect(() => ${name.camel}.$assert(blank)).toThrow();
|
|
178
|
+
expect(() => ${name.camel}.$assert(negative)).toThrow();
|
|
138
179
|
});
|
|
139
180
|
|
|
140
181
|
unitTest('${name.camel} parses a row through its own columns', () => {
|
|
@@ -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
|