diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts index 203b59975..00ea97303 100644 --- a/packages/cli/src/errors.ts +++ b/packages/cli/src/errors.ts @@ -12,6 +12,7 @@ export const CLI_ERROR_CODES = [ 'X_NOT_IMPLEMENTED', 'X_TEST_NO_FILES', 'X_TEST_SHARD_FAILED', + 'X_SCAFFOLD_PATH_ESCAPE', ] as const; export type CliErrorCode = (typeof CLI_ERROR_CODES)[number]; @@ -91,6 +92,22 @@ export class NoTestFilesError extends UltimateError { } } +/** + * A generated file's path resolves outside the sandbox the scaffold gate writes into. `..` in a + * `GeneratedFile.path` would put template output on the developer's real disk, so it fails here + * rather than after the write. + */ +export class ScaffoldPathEscapeError extends UltimateError { + constructor(input: { path: string; dir: string }) { + super({ + code: 'X_SCAFFOLD_PATH_ESCAPE', + cause: `generated path "${input.path}" resolves outside the sandbox ${input.dir}`, + fix: `make the path relative to the app root with no ".." segment, then re-run: bun test packages/cli/src/scaffold-typecheck.contract.test.ts`, + docs: docsFor('X_SCAFFOLD_PATH_ESCAPE'), + }); + } +} + /** An interface-complete command path whose remote/native half is not written yet. */ export class CliNotImplementedError extends UltimateError { constructor(input: { feature: string; fix: string }) { diff --git a/packages/cli/src/scaffold-typecheck.contract.test.ts b/packages/cli/src/scaffold-typecheck.contract.test.ts new file mode 100644 index 000000000..1bfe3151c --- /dev/null +++ b/packages/cli/src/scaffold-typecheck.contract.test.ts @@ -0,0 +1,31 @@ +// The scaffold drift gate: `x new` plus every `x g` generator, written to a sandbox and compiled +// by the real `tsc` against the real workspace packages. A template is a string, and a string +// that parses has said nothing about whether it compiles — this is where that gets decided. + +import { describe, expect, test } from 'bun:test'; +import { + formatDiagnostics, + staleGapsIn, + typecheckScaffold, + unexpectedIn, +} from './scaffold-typecheck'; + +/** One compile for the file: tsc over the fixture is the cost, not the assertions. */ +const report = await typecheckScaffold(); + +describe('contract · generated code compiles', () => { + test('the scaffolded app has no diagnostic outside KNOWN_GAPS', () => { + // The formatted form first: it names file, line, code and message, so a red gate is a bug + // report rather than a count. + expect(formatDiagnostics(unexpectedIn(report.diagnostics))).toBe(''); + }); + + test('every pinned gap still reproduces', () => { + expect(staleGapsIn(report.diagnostics).map((gap) => gap.owner)).toEqual([]); + }); + + test('the sandbox is removed once the gate has run', () => { + expect(report.fileCount).toBeGreaterThan(100); + expect(Bun.file(`${report.dir}/app.config.ts`).size).toBe(0); + }); +}); diff --git a/packages/cli/src/scaffold-typecheck.test.ts b/packages/cli/src/scaffold-typecheck.test.ts new file mode 100644 index 000000000..745b5433e --- /dev/null +++ b/packages/cli/src/scaffold-typecheck.test.ts @@ -0,0 +1,117 @@ +// The harness itself: diagnostic parsing, the fixture's shape, and the known-gap bookkeeping. +// Whether the scaffolded app actually compiles is the contract test next to this file. + +import { describe, expect, test } from 'bun:test'; +import { ScaffoldPathEscapeError } from './errors'; +import { + FIXTURE_GENERATORS, + formatDiagnostics, + KNOWN_GAPS, + parseDiagnostics, + sandboxPath, + scaffoldFixture, + staleGapsIn, + unexpectedIn, +} from './scaffold-typecheck'; + +const gap = { file: 'apps/web/app/post/entity.ts', line: 20, code: 'TS18048' } as const; +const pinned = { ...gap, message: "'c.title' is possibly 'undefined'." }; +/** Every pin satisfied at once, so a test can assert on the surplus alone. */ +const allPinned = KNOWN_GAPS.map((entry) => ({ + file: entry.file, + line: 20, + code: entry.code, + message: entry.message, +})); + +describe('unit · scaffold typecheck harness', () => { + test('parses both diagnostic shapes tsc emits', () => { + const parsed = parseDiagnostics( + ["src/a.ts(3,7): error TS2307: Cannot find module './b'.", 'error TS5083: bad config'].join( + '\n', + ), + ); + expect(parsed).toEqual([ + { file: 'src/a.ts', line: 3, code: 'TS2307', message: "Cannot find module './b'." }, + { file: '', line: 0, code: 'TS5083', message: 'bad config' }, + ]); + }); + + test('CRLF output parses to the same diagnostics, so a pinned gap still matches', () => { + const crlf = [ + "apps/web/app/post/entity.ts(20,1): error TS18048: 'c.title' is possibly 'undefined'.", + 'error TS5083: bad config', + '', + ].join('\r\n'); + expect(parseDiagnostics(crlf)).toEqual([ + pinned, + { file: '', line: 0, code: 'TS5083', message: 'bad config' }, + ]); + // The point of the split: a trailing \r inside the message would defeat every pin. + expect(unexpectedIn(parseDiagnostics(crlf).slice(0, 1))).toEqual([]); + }); + + test('a config error carries no file and still counts — a silent harness is a green lie', () => { + expect(unexpectedIn(parseDiagnostics('error TS6053: File not found'))).toHaveLength(1); + }); + + test('a pinned gap is accepted and everything else is not', () => { + expect(unexpectedIn([pinned])).toEqual([]); + expect(unexpectedIn([{ ...pinned, code: 'TS2322' }])).toHaveLength(1); + expect(unexpectedIn([{ ...pinned, file: 'apps/web/app/post/repo.ts' }])).toHaveLength(1); + expect(unexpectedIn([{ ...pinned, message: "'row' is possibly 'undefined'." }])).toHaveLength( + 1, + ); + }); + + test('a pin is spent by one match, so a second identical diagnostic still fails the gate', () => { + expect(unexpectedIn(allPinned)).toEqual([]); + // Same file, same code, same message, one occurrence too many: a new regression hiding + // behind an old bug is exactly what an unpinned count would wave through. + expect(unexpectedIn([...allPinned, pinned])).toEqual([pinned]); + }); + + test('a gap that stops reproducing is reported, so a pin cannot outlive its bug', () => { + expect(staleGapsIn(allPinned)).toEqual([]); + expect(staleGapsIn([])).toEqual(KNOWN_GAPS); + // One of the two pins on this file is satisfied; the other is not, and only it is stale. + expect(staleGapsIn([pinned]).map((entry) => `${entry.file} ${entry.message}`)).not.toContain( + `${pinned.file} ${pinned.message}`, + ); + }); + + test('every pinned gap names who fixes it', () => { + for (const entry of KNOWN_GAPS) expect(entry.owner.length).toBeGreaterThan(20); + }); + + test('a failed gate reads as a runnable bug report', () => { + expect(formatDiagnostics([pinned])).toBe( + "apps/web/app/post/entity.ts:20 TS18048: 'c.title' is possibly 'undefined'.", + ); + expect(formatDiagnostics([{ file: '', line: 0, code: 'TS5083', message: 'bad' }])).toBe( + 'error TS5083: bad', + ); + }); + + test('a generated path may not escape the sandbox', () => { + expect(sandboxPath('/tmp/x-scaffold-1', 'apps/web/site/page.tsx')).toBe( + '/tmp/x-scaffold-1/apps/web/site/page.tsx', + ); + for (const outside of ['../../target', '/etc/passwd', 'apps/../../outside.ts']) { + expect(() => sandboxPath('/tmp/x-scaffold-1', outside)).toThrow(ScaffoldPathEscapeError); + } + // A sibling directory sharing the prefix is outside too — string prefixes are not paths. + expect(() => sandboxPath('/tmp/x-scaffold-1', '../x-scaffold-12/a.ts')).toThrow( + ScaffoldPathEscapeError, + ); + }); + + test('the fixture runs every generator on top of a whole scaffolded app', () => { + const paths = scaffoldFixture().map((file) => file.path); + expect(FIXTURE_GENERATORS.length).toBeGreaterThanOrEqual(9); + expect(paths).toContain('app.config.ts'); + expect(paths).toContain('apps/web/app/invoice/actions/send-invoice.ts'); + // First write wins, exactly as `x g` resolves a file two generators both produce. + expect(new Set(paths).size).toBe(paths.length); + }); +}); diff --git a/packages/cli/src/scaffold-typecheck.ts b/packages/cli/src/scaffold-typecheck.ts new file mode 100644 index 000000000..77203a7aa --- /dev/null +++ b/packages/cli/src/scaffold-typecheck.ts @@ -0,0 +1,252 @@ +// Typechecks what `x new` and `x g` write, the way the user's own `tsc` will: real files on disk, +// the real workspace packages, the real compiler. A template that merely parses has not been +// checked — it has moved its failure from this gate to the first command the user runs. + +// `node:` and not Bun: Bun has no API for a temporary directory (`mkdtempSync` + `tmpdir`), for a +// symlink (`symlinkSync`, how the sandbox borrows the workspace's node_modules), or for a +// recursive delete (`rmSync`). `node:path` comes with them — `Bun.write` takes the joined path, +// but only `resolve`/`sep` can prove that path stayed inside the sandbox. +import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep } from 'node:path'; +import type { GenerateOptions } from './cmd-generate'; +import { generate } from './cmd-generate'; +import { planNewApp } from './cmd-new'; +import { ScaffoldPathEscapeError } from './errors'; +import type { Runner } from './exec'; +import { exec } from './exec'; +import type { GeneratedFile } from './templates'; + +/** Derived from this file, never from cwd, so the harness works from any working directory. */ +export const workspaceRoot = (): string => resolve(import.meta.dir, '..', '..', '..'); + +/** The app the fixture scaffolds. Kebab, multi-word: single-word names hide casing bugs. */ +export const FIXTURE_APP = 'ledger-demo'; + +/** + * One realistic invocation of every generator, on top of `x new --example`. Names differ from + * their feature on purpose: `x g query invoice --feature invoice` would collide with the entity + * import, and a fixture that trips over its own naming stops testing the templates. + */ +export const FIXTURE_GENERATORS: readonly GenerateOptions[] = [ + { kind: 'resource', name: 'invoice' }, + { kind: 'entity', name: 'credit-note', feature: 'credit-note' }, + { kind: 'policy', name: 'credit-note', feature: 'credit-note' }, + { kind: 'action', name: 'send-invoice', feature: 'invoice' }, + { kind: 'mutator', name: 'rename-invoice', feature: 'invoice' }, + { kind: 'query', name: 'invoice-search', feature: 'invoice' }, + { kind: 'query', name: 'invoice-feed', feature: 'invoice', live: true }, + { kind: 'job', name: 'sweep-invoices', feature: 'invoice' }, + { kind: 'task', name: 'nightly-sweep', feature: 'invoice' }, + { kind: 'route', name: 'pricing', surface: 'site' }, + { kind: 'route', name: 'billing', surface: 'app' }, +]; + +/** First write wins, exactly as `x g` and `x new` resolve a shared file such as `errors.ts`. */ +const dedupe = (files: readonly GeneratedFile[]): readonly GeneratedFile[] => { + const seen = new Map(); + for (const file of files) if (!seen.has(file.path)) seen.set(file.path, file); + return [...seen.values()]; +}; + +/** The whole scaffolded surface: a new app, then every generator run inside it. */ +export function scaffoldFixture(): readonly GeneratedFile[] { + return dedupe([ + ...planNewApp({ name: FIXTURE_APP, example: true }), + ...FIXTURE_GENERATORS.flatMap((options) => generate(options)), + ]); +} + +export interface TypeDiagnostic { + /** Sandbox-relative path, or '' for a diagnostic the compiler raised about the project itself. */ + readonly file: string; + readonly line: number; + readonly code: string; + readonly message: string; +} + +const AT_FILE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/; +const PROJECT_WIDE = /^error (TS\d+): (.*)$/; + +/** + * Config errors carry no file, so both shapes are collected: a silent harness is a green lie. + * Split on `\r?\n`, because a trailing `\r` lands inside the message capture and no `KNOWN_GAPS` + * entry would match it — a CRLF `tsc` would turn every pinned gap into an unexplained failure. + */ +export function parseDiagnostics(output: string): readonly TypeDiagnostic[] { + const found: TypeDiagnostic[] = []; + for (const line of output.split(/\r?\n/)) { + const at = AT_FILE.exec(line); + if (at !== null) { + found.push({ + file: at[1] ?? '', + line: Number.parseInt(at[2] ?? '0', 10), + code: at[4] ?? '', + message: at[5] ?? '', + }); + continue; + } + const wide = PROJECT_WIDE.exec(line.trim()); + if (wide !== null) + found.push({ file: '', line: 0, code: wide[1] ?? '', message: wide[2] ?? '' }); + } + return found; +} + +export interface KnownGap { + readonly code: string; + /** Exact sandbox-relative path. A pattern would absolve the same bug in a file nobody pinned. */ + readonly file: string; + /** Exact `tsc` message. Matched literally so a near-miss regression is not absorbed. */ + readonly message: string; + /** Who fixes it, and why the template cannot. */ + readonly owner: string; +} + +// `InvariantColumns` is an index-signature type, so `c.title` is `ColumnExpr | undefined` under +// `noUncheckedIndexedAccess`. Every hand-written entity in `examples/dummy` reproduces it +// identically: the fix is a column proxy typed from the entity's own columns, in @ultimat3/entity +// — a different template cannot avoid it without dropping to `satisfies()`, which would silently +// stop emitting the Postgres CHECK. +const INVARIANT_PROXY = + '@ultimat3/entity — type the invariant column proxy from the declared columns'; + +/** Every entity the fixture generates. Each one declares the same two invariants. */ +const FIXTURE_ENTITIES = [ + 'apps/web/app/credit-note/entity.ts', + 'apps/web/app/invoice/entity.ts', + 'apps/web/app/post/entity.ts', +] as const; + +/** + * Diagnostics a template cannot fix, pinned one occurrence at a time. Pinned, never ignored: + * `unexpectedIn` fails on anything not listed here — including a second copy of a listed + * diagnostic, because each entry is consumed by exactly one match — and `staleGapsIn` fails when + * a listed entry stops reproducing, so an entry cannot outlive the bug it describes. + */ +export const KNOWN_GAPS: readonly KnownGap[] = FIXTURE_ENTITIES.flatMap((file) => + ["'c.title' is possibly 'undefined'.", "'c.price' is possibly 'undefined'."].map( + (message): KnownGap => ({ code: 'TS18048', file, message, owner: INVARIANT_PROXY }), + ), +); + +const matches = (diagnostic: TypeDiagnostic, gap: KnownGap): boolean => + diagnostic.code === gap.code && + diagnostic.file === gap.file && + diagnostic.message === gap.message; + +export interface GapPartition { + /** Diagnostics no unconsumed `KNOWN_GAPS` entry accounts for. */ + readonly unexpected: readonly TypeDiagnostic[]; + /** Entries that found no match — the bug is fixed and the pin has to go. */ + readonly stale: readonly KnownGap[]; +} + +/** + * One pass, first-fit, each pin spent once. Counting matters: two occurrences of a diagnostic + * pinned once means a new regression is hiding behind an old bug, so the surplus is unexpected. + */ +export function partitionDiagnostics(diagnostics: readonly TypeDiagnostic[]): GapPartition { + const budget = KNOWN_GAPS.map((gap) => ({ gap, spent: false })); + const unexpected: TypeDiagnostic[] = []; + for (const entry of diagnostics) { + const slot = budget.find((pin) => !pin.spent && matches(entry, pin.gap)); + if (slot === undefined) unexpected.push(entry); + else slot.spent = true; + } + return { unexpected, stale: budget.filter((pin) => !pin.spent).map((pin) => pin.gap) }; +} + +/** Everything the gate refuses: a diagnostic no unconsumed `KNOWN_GAPS` entry accounts for. */ +export const unexpectedIn = (diagnostics: readonly TypeDiagnostic[]): readonly TypeDiagnostic[] => + partitionDiagnostics(diagnostics).unexpected; + +/** Entries that no longer reproduce — the bug is fixed and the pin has to go. */ +export const staleGapsIn = (diagnostics: readonly TypeDiagnostic[]): readonly KnownGap[] => + partitionDiagnostics(diagnostics).stale; + +/** Written beside the app's own tsconfig so the gate inherits every flag the app ships with. */ +const OVERLAY = 'tsconfig.scaffold-check.json'; + +/** + * The one thing the sandbox may change about the generated project: where its imports resolve. + * `@ultimat3/*` points at workspace source instead of a published tarball, and the app's own + * workspace packages resolve without a `bun install` that a sealed test could never run. + */ +const overlay = (root: string, app: string): string => + `${JSON.stringify( + { + extends: './tsconfig.json', + compilerOptions: { + noEmit: true, + paths: { + '@ultimat3/*': [`${root}/packages/*/src`], + [`@${app}/web/*`]: ['./apps/web/*'], + [`@${app}/admin/*`]: ['./apps/admin/*'], + [`@${app}/*`]: ['./packages/*/src'], + }, + }, + }, + null, + 2, + )}\n`; + +export interface TypecheckOptions { + readonly files?: readonly GeneratedFile[]; + readonly app?: string; + readonly runner?: Runner; + /** Leave the sandbox on disk. For debugging a red gate by hand, never for the gate itself. */ + readonly keep?: boolean; +} + +export interface TypecheckReport { + readonly dir: string; + readonly fileCount: number; + readonly diagnostics: readonly TypeDiagnostic[]; + readonly output: string; +} + +/** + * `GeneratedFile.path` is documented as relative-POSIX, not enforced as it. A `..` segment would + * put template output on the developer's real disk, so the sandbox proves containment before it + * writes rather than after. + */ +export function sandboxPath(dir: string, path: string): string { + const target = resolve(dir, path); + if (target !== dir && !target.startsWith(`${dir}${sep}`)) + throw new ScaffoldPathEscapeError({ path, dir }); + return target; +} + +export async function typecheckScaffold(options: TypecheckOptions = {}): Promise { + const root = workspaceRoot(); + const app = options.app ?? FIXTURE_APP; + const files = options.files ?? scaffoldFixture(); + const dir = mkdtempSync(join(tmpdir(), 'x-scaffold-')); + try { + for (const file of files) await Bun.write(sandboxPath(dir, file.path), file.contents); + // The sandbox borrows the workspace's installed dependencies. The gate is about the + // templates; whether a registry install succeeds is a different question, and a sealed + // test cannot ask it. + symlinkSync(join(root, 'node_modules'), join(dir, 'node_modules'), 'dir'); + await Bun.write(join(dir, OVERLAY), overlay(root, app)); + const result = await (options.runner ?? exec)( + [join(root, 'node_modules', '.bin', 'tsc'), '--noEmit', '--pretty', 'false', '-p', OVERLAY], + { cwd: dir }, + ); + const output = [result.stdout, result.stderr].filter((part) => part.length > 0).join('\n'); + return { dir, fileCount: files.length, diagnostics: parseDiagnostics(output), output }; + } finally { + if (options.keep !== true) rmSync(dir, { recursive: true, force: true }); + } +} + +/** One diagnostic per line, in the shape `tsc` prints — a failed gate is a runnable bug report. */ +export const formatDiagnostics = (diagnostics: readonly TypeDiagnostic[]): string => + diagnostics + .map((entry) => + entry.file === '' + ? `error ${entry.code}: ${entry.message}` + : `${entry.file}:${entry.line} ${entry.code}: ${entry.message}`, + ) + .join('\n'); diff --git a/packages/cli/src/templates/action.ts b/packages/cli/src/templates/action.ts index db192cd5a..c1ac5e500 100644 --- a/packages/cli/src/templates/action.ts +++ b/packages/cli/src/templates/action.ts @@ -11,18 +11,20 @@ const actionSource = ( feature: NameSet, ): string => `// ${name.camel}: one mutation, server-authoritative. Input is validated before the handler runs // and the policy is the same object the MCP tool and the HTTP route evaluate. -import { action, t } from '@ultimat3/action'; -import { can } from '@ultimat3/policy'; -import { ${feature.pascal}NotFoundError } from './errors'; -import { ${feature.camel}Tag } from './policy'; -import * as repo from './repo'; +import { action } from '@ultimat3/action'; +import { t } from '@ultimat3/schema'; +// One directory up: actions live in \`actions/\`, the feature's errors, policy and repo are the +// slice's own files and are shared by every action in it. +import { ${feature.pascal}NotFoundError } from '../errors'; +import { can${feature.pascal}Write, ${feature.camel}Tag } from '../policy'; +import * as repo from '../repo'; export const ${name.camel} = action({ - input: t.object({ id: t.uuid }), + // orgId is part of the input because the policy decides on it — authz reads the declaration, + // never the database. + input: t.object({ id: t.uuid, orgId: t.uuid }), output: t.object({ id: t.uuid, title: t.string }), - policy: can('${feature.kebab}:write', ({ actor, input }) => - actor !== null && repo.byId(input.id).then((row) => row?.orgId === actor.orgId), - ), + policy: can${feature.pascal}Write, cache: { invalidates: [${feature.camel}Tag] }, mcp: { expose: true, description: '${name.raw} — generated, edit the description' }, async handle({ input }) { @@ -39,17 +41,34 @@ const mutatorSource = ( ): string => `// ${name.camel}: an action with an optimistic local twin. The local half runs against the client // store immediately; the server half is authoritative and reconciles on conflict. import { mutator } from '@ultimat3/action'; -import { ${feature.pascal}NotFoundError } from './errors'; -import * as repo from './repo'; +import { t } from '@ultimat3/schema'; +import { ${feature.pascal}NotFoundError } from '../errors'; +import { can${feature.pascal}Write } from '../policy'; +import * as repo from '../repo'; + +interface Local${feature.pascal} { + readonly id: string; + readonly title: string; + readonly pending: boolean; +} export const ${name.camel} = mutator({ - local(tx, { id }: { id: string }) { - tx.${feature.plural}.update(id, (row) => ({ ...row, pending: true })); + input: t.object({ id: t.uuid, orgId: t.uuid, title: t.string }), + output: t.object({ id: t.uuid, title: t.string }), + policy: can${feature.pascal}Write, + // tx.table(name) rather than tx.${feature.plural}: the typed accessor exists only once the app + // augments LocalTables, and generated code cannot assume that has happened yet. The name is the + // entity's snake_case table, so the local twin and the server row live under one key. + local(tx, input) { + tx.table('${feature.table}').update(input.id, { + title: input.title, + pending: true, + }); }, - async server(_ctx, { id }: { id: string }) { - const row = await repo.byId(id); - if (row === undefined) throw new ${feature.pascal}NotFoundError({ id }); - return { id: row.id, title: row.title }; + async server(_ctx, input) { + const row = await repo.byId(input.id); + if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id }); + return { id: row.id, title: input.title }; }, conflict: 'server-wins', }); @@ -73,41 +92,49 @@ export class ${feature.pascal}NotFoundError extends UltimateError { } `; +const ID = '00000000-0000-0000-0000-000000000001'; +const ORG = '00000000-0000-0000-0000-000000000002'; + const actionTest = ( name: NameSet, isMutator: boolean, -): string => `import { expect } from 'bun:test'; -import { contractTest, unitTest } from '@ultimat3/testing'; +): string => `import { contractTest, expect, unitTest } from '@ultimat3/testing'; import { ${name.camel} } from './${name.kebab}'; -unitTest('${name.camel} is a declared ${isMutator ? 'mutator' : 'action'}', () => { - expect(${name.camel}.kind).toBe('${isMutator ? 'mutator' : 'action'}'); -}); +const id = '${ID}'; +const orgId = '${ORG}'; +unitTest('${name.camel} is a declared ${isMutator ? 'mutator' : 'action'}', () => { ${ isMutator - ? `unitTest('${name.camel} declares a conflict strategy and both halves', () => { - expect(${name.camel}.conflict).toBe('server-wins'); - expect(typeof ${name.camel}.local).toBe('function'); - expect(typeof ${name.camel}.server).toBe('function'); -});` - : `unitTest('${name.camel} rejects input that is not a uuid', async () => { - await expect(${name.camel}.input).toRejectInput({ id: 'not-a-uuid' }); - await expect(${name.camel}.input).toAcceptInput({ - id: '00000000-0000-0000-0000-000000000001', - }); + ? ` expect(${name.camel}.describeMutator().kind).toBe('mutator'); + expect(${name.camel}.isMutator).toBe(true);` + : ` expect(${name.camel}.kind).toBe('action');` +} +}); + +unitTest('${name.camel} rejects input that is not a uuid', async () => { + await expect(${name.camel}.def.input).toRejectInput({ id: 'not-a-uuid', orgId${ + isMutator ? ", title: 'a title'" : '' + } }); + await expect(${name.camel}.def.input).toAcceptInput({ id, orgId${ + isMutator ? ", title: 'a title'" : '' + } }); }); unitTest('${name.camel} denies an anonymous actor', async () => { - await expect(${name.camel}.policy).toDenyPolicy({ - actor: null, - input: { id: '00000000-0000-0000-0000-000000000001' }, - }); + await expect(${name.camel}.def.policy).toDenyPolicy({ actor: null, input: { orgId } }); }); -contractTest('${name.camel} is exposed as an MCP tool with a description', () => { - expect(${name.camel}.mcp?.expose).toBe(true); - expect(${name.camel}.mcp?.description.length).toBeGreaterThan(0); +${ + isMutator + ? `unitTest('${name.camel} declares a conflict strategy and an optimistic local half', () => { + expect(${name.camel}.conflict).toBe('server-wins'); + expect(typeof ${name.camel}.applyLocal).toBe('function'); +});` + : `contractTest('${name.camel} is exposed as an MCP tool with a description', () => { + expect(${name.camel}.def.mcp?.expose).toBe(true); + expect(${name.camel}.def.mcp?.description ?? '').not.toBe(''); });` } `; diff --git a/packages/cli/src/templates/entity.ts b/packages/cli/src/templates/entity.ts index e24dd739f..89473e6b4 100644 --- a/packages/cli/src/templates/entity.ts +++ b/packages/cli/src/templates/entity.ts @@ -13,58 +13,75 @@ export interface FeatureTarget { const entitySource = ( name: NameSet, + snake: string, + table: string, ): string => `// The ${name.camel} table, its domain type and its invariants. No I/O beyond the column // definitions: repo.ts owns every query that touches this table. -import { entity, t } from '@ultimat3/entity'; +import { entity, invariant, money, text, timestamp, uuid } from '@ultimat3/entity'; -export const ${name.camel} = entity({ - table: '${name.pluralKebab}', +export const ${name.camel} = entity('${table}', { columns: { - id: t.uuid.primary(), - orgId: t.uuid.references('orgs.id'), - title: t.string.max(200), - // Money is integer minor units + an ISO code, never a float. - priceMinor: t.integer.default(0), - priceCurrency: t.string.length(3).default('USD'), - // Stored UTC; formatted at the edge with an explicit IANA time zone. - createdAt: t.timestamp.defaultNow(), - }, - invariants: { - 'title must not be blank': (row) => row.title.trim().length > 0, - 'price must not be negative': (row) => row.priceMinor >= 0, + id: uuid().primaryKey(), + // Declaring the tenant column is what turns tenancy on: a read without an org predicate + // then fails with X_TENANCY_UNSCOPED instead of leaking another org's rows. + orgId: uuid().tenant(), + title: text({ max: 200 }), + // One property, two physical columns: price_minor bigint + price_currency char(3). + // Money is integer minor units plus an ISO code, never a float. + price: money(), + // Always timestamptz. Stored UTC; formatted at the edge with an explicit IANA time zone. + createdAt: timestamp().defaultNow(), }, + // Each rule runs in the app on write AND as a Postgres CHECK — one declaration, both sides. + invariants: [ + invariant('${snake}_title_not_blank', (c) => c.title.trimmed().minLength(1)), + invariant('${snake}_price_non_negative', (c) => c.price.minor.atLeast(0)), + ], indexes: [{ on: ['orgId', 'createdAt'] }], }); -export type ${name.pascal} = typeof ${name.camel}.$type; +export type ${name.pascal} = typeof ${name.camel}.$row; `; const repoSource = ( name: NameSet, + table: string, ): string => `// The only module allowed to query the ${name.pluralKebab} table. Routes call actions and // queries; actions call services; services call this. -import { db } from '@ultimat3/db'; -import { ${name.camel} } from './entity'; +// \`db()\` is the ambient handle: inside a transaction it IS the transaction, so these functions +// join the caller's transaction without knowing one is open. +import { db, sql } from '@ultimat3/db'; +import { dbDrift, newId } from '@ultimat3/entity'; import type { ${name.pascal} } from './entity'; export async function byId(id: string): Promise<${name.pascal} | undefined> { - const rows = await db.select().from(${name.camel}).where({ id }).limit(1); - return rows[0]; + const row = await db().one<${name.pascal}>(sql\`select * from ${table} where id = \${id}\`); + return row ?? undefined; } export async function listByOrg(orgId: string, limit = 50): Promise { - return db.select().from(${name.camel}).where({ orgId }).orderBy('createdAt').limit(limit); + // Ordered and bounded: an unordered page is a different page on every request. + return db().query<${name.pascal}>( + sql\`select * from ${table} where org_id = \${orgId} order by created_at desc limit \${limit}\`, + ); } export async function insert(row: Omit<${name.pascal}, 'id' | 'createdAt'>): Promise<${name.pascal}> { - const [created] = await db.insert(${name.camel}).values(row).returning(); - if (created === undefined) throw new Error('insert returned no row'); + // Money is two physical columns — integer minor units plus the ISO code, never a float. + const created = await db().one<${name.pascal}>(sql\` + insert into ${table} (id, org_id, title, price_minor, price_currency) + values (\${newId()}, \${row.orgId}, \${row.title}, \${row.price.minor}, \${row.price.currency}) + returning *\`); + if (created === null) throw dbDrift('${table}', 'id'); return created; } `; -const entityTest = (name: NameSet): string => `import { expect } from 'bun:test'; -import { unitTest } from '@ultimat3/testing'; +const entityTest = ( + name: NameSet, + snake: string, + table: string, +): string => `import { expect, unitTest } from '@ultimat3/testing'; import { ${name.camel} } from './entity'; import type { ${name.pascal} } from './entity'; @@ -72,23 +89,24 @@ const row = (over: Partial<${name.pascal}> = {}): ${name.pascal} => ({ id: '00000000-0000-0000-0000-000000000001', orgId: '00000000-0000-0000-0000-000000000002', title: 'valid title', - priceMinor: 1000, - priceCurrency: 'USD', + // \`money()\` puts \`MoneyValue\` on the row, whose minor units are bigint — the column is a + // Postgres bigint, and a JS number would silently lose precision above 2^53 minor units. + price: { minor: 1000n, currency: 'USD' }, createdAt: new Date(0), ...over, }); unitTest('${name.camel} declares a table with invariants', () => { - expect(${name.camel}.kind).toBe('entity'); - expect(${name.camel}.table).toBe('${name.pluralKebab}'); - expect(Object.keys(${name.camel}.invariants)).toContain('title must not be blank'); + expect(${name.camel}.$name).toBe('${table}'); + expect(${name.camel}.$tenantColumn).toBe('orgId'); + const named = ${name.camel}.$invariants.map((rule) => rule.name); + expect(named).toContain('${snake}_title_not_blank'); }); unitTest('${name.camel} invariants reject a blank title and a negative price', () => { - const { invariants } = ${name.camel}; - expect(invariants['title must not be blank'](row())).toBe(true); - expect(invariants['title must not be blank'](row({ title: ' ' }))).toBe(false); - expect(invariants['price must not be negative'](row({ priceMinor: -1 }))).toBe(false); + expect(() => ${name.camel}.$assert(row())).not.toThrow(); + expect(() => ${name.camel}.$assert(row({ title: ' ' }))).toThrow(); + expect(() => ${name.camel}.$assert(row({ price: { minor: -1n, currency: 'USD' } }))).toThrow(); }); `; @@ -96,8 +114,8 @@ export function entityFiles(rawName: string, target: FeatureTarget): readonly Ge const name = names(rawName); const dir = `${target.surfaceDir}/${target.feature}`; return [ - { path: `${dir}/entity.ts`, contents: entitySource(name) }, - { path: `${dir}/entity.test.ts`, contents: entityTest(name) }, - { path: `${dir}/repo.ts`, contents: repoSource(name) }, + { path: `${dir}/entity.ts`, contents: entitySource(name, name.snake, name.table) }, + { path: `${dir}/entity.test.ts`, contents: entityTest(name, name.snake, name.table) }, + { path: `${dir}/repo.ts`, contents: repoSource(name, name.table) }, ]; } diff --git a/packages/cli/src/templates/job.ts b/packages/cli/src/templates/job.ts index 1bec3600e..d5472e7c0 100644 --- a/packages/cli/src/templates/job.ts +++ b/packages/cli/src/templates/job.ts @@ -10,7 +10,8 @@ const jobSource = ( name: NameSet, ): string => `// ${name.camel}: multi-step durable work. Each step is retried independently and its result is // stored under its name — step names are stable identifiers, not labels. -import { job, t } from '@ultimat3/jobs'; +import { job } from '@ultimat3/jobs'; +import { t } from '@ultimat3/schema'; import * as repo from '../repo'; export const ${name.camel} = job({ @@ -43,20 +44,19 @@ export const ${name.camel} = task({ }); `; -const jobTest = (name: NameSet): string => `import { expect } from 'bun:test'; -import { jobTest } from '@ultimat3/testing'; +const jobTest = (name: NameSet): string => `import { expect, jobTest } from '@ultimat3/testing'; import { ${name.camel} } from './${name.kebab}'; +const id = '00000000-0000-0000-0000-000000000001'; + jobTest('${name.camel} declares an idempotency key and a retry policy', () => { expect(${name.camel}.kind).toBe('job'); - expect(${name.camel}.idempotencyKey({ id: 'abc' })).toBe('${name.kebab}:abc'); + expect(${name.camel}.idempotencyKeyFor({ id })).toBe(\`${name.kebab}:\${id}\`); expect(${name.camel}.retry.attempts).toBeGreaterThan(1); }); jobTest('${name.camel} is idempotent for the same input', () => { - const first = ${name.camel}.idempotencyKey({ id: 'abc' }); - const second = ${name.camel}.idempotencyKey({ id: 'abc' }); - expect(first).toBe(second); + expect(${name.camel}.idempotencyKeyFor({ id })).toBe(${name.camel}.idempotencyKeyFor({ id })); }); jobTest('${name.camel} runs its steps in order', async () => { @@ -64,10 +64,12 @@ jobTest('${name.camel} runs its steps in order', async () => { }); `; -const taskTest = (name: NameSet, jobName: NameSet): string => `import { expect } from 'bun:test'; -import { jobTest } from '@ultimat3/testing'; -import { ${name.camel} } from './${name.kebab}'; +const taskTest = ( + name: NameSet, + jobName: NameSet, +): string => `import { expect, jobTest } from '@ultimat3/testing'; import { ${jobName.camel} } from '../jobs/${jobName.kebab}'; +import { ${name.camel} } from './${name.kebab}'; jobTest('${name.camel} declares a cron with an explicit time zone', () => { expect(${name.camel}.kind).toBe('task'); @@ -76,7 +78,7 @@ jobTest('${name.camel} declares a cron with an explicit time zone', () => { }); jobTest('${name.camel} enqueues ${jobName.camel} and nothing else', () => { - const pairs = ${name.camel}.enqueue(); + const pairs = ${name.camel}.entries(); expect(pairs).toHaveLength(1); expect(pairs[0]?.[0]).toBe(${jobName.camel}); }); diff --git a/packages/cli/src/templates/naming.ts b/packages/cli/src/templates/naming.ts index ad2d65642..32007e6e5 100644 --- a/packages/cli/src/templates/naming.ts +++ b/packages/cli/src/templates/naming.ts @@ -42,16 +42,28 @@ export interface NameSet { readonly pascal: string; readonly plural: string; readonly pluralKebab: string; + /** Singular snake_case. Constraint and index names. */ + readonly snake: string; + /** + * The table identifier: plural snake_case. Derived here rather than at each call site because + * the entity, the repo SQL, the query source and the mutator's local table all name the same + * table — and Postgres lowercases every unquoted identifier, so a hyphen would have to be + * quoted forever. + */ + readonly table: string; } export function names(input: string): NameSet { const base = camel(input); + const pluralKebab = kebab(plural(base)); return { raw: input, kebab: kebab(input), camel: base, pascal: pascal(input), plural: plural(base), - pluralKebab: kebab(plural(base)), + pluralKebab, + snake: kebab(input).split('-').join('_'), + table: pluralKebab.split('-').join('_'), }; } diff --git a/packages/cli/src/templates/policy.ts b/packages/cli/src/templates/policy.ts index cb9f996f7..9f71f3048 100644 --- a/packages/cli/src/templates/policy.ts +++ b/packages/cli/src/templates/policy.ts @@ -8,32 +8,51 @@ import { names } from './naming'; const policySource = ( feature: NameSet, ): string => `// Authz for the ${feature.kebab} feature. Every branch here is reachable from every surface. -import { can, tag } from '@ultimat3/policy'; +// Predicates are synchronous on purpose: a live query re-evaluates one per subscriber per patch, +// so an await here would be a database round trip per row per connected client. +import { tag } from '@ultimat3/cache'; +import { can } from '@ultimat3/policy'; export const ${feature.camel}Tag = tag('${feature.kebab}'); +/** What every ${feature.kebab} rule needs to decide. Actions and queries both accept it. */ +export interface ${feature.pascal}Scope { + readonly orgId: string; +} + /** Read is org-scoped: an actor sees rows in their own org and nothing else. */ -export const can${feature.pascal}Read = can('${feature.kebab}:read', ({ actor, input }) => { - if (actor === null) return false; - return input.orgId === actor.orgId; -}); +export const can${feature.pascal}Read = can<${feature.pascal}Scope>( + '${feature.kebab}:read', + ({ actor, input }) => actor !== null && actor.orgId === input.orgId, +); /** Write additionally requires the member role — viewers are read-only. */ -export const can${feature.pascal}Write = can('${feature.kebab}:write', ({ actor, input }) => { - if (actor === null) return false; - if (input.orgId !== actor.orgId) return false; - return actor.roles.includes('member') || actor.roles.includes('owner'); -}); +export const can${feature.pascal}Write = can<${feature.pascal}Scope>( + '${feature.kebab}:write', + ({ actor, input }) => { + if (actor === null || actor.orgId !== input.orgId) return false; + const roles = actor.roles ?? []; + return roles.includes('member') || roles.includes('owner'); + }, +); `; -const policyTest = (feature: NameSet): string => `import { expect } from 'bun:test'; -import { unitTest } from '@ultimat3/testing'; +const policyTest = (feature: NameSet): string => `import type { Actor } from '@ultimat3/policy'; +import { expect, unitTest } from '@ultimat3/testing'; import { can${feature.pascal}Read, can${feature.pascal}Write } from './policy'; const org = '00000000-0000-0000-0000-000000000002'; -const viewer = { id: 'a', orgId: org, roles: ['viewer'] }; -const member = { id: 'b', orgId: org, roles: ['member'] }; -const outsider = { id: 'c', orgId: '00000000-0000-0000-0000-000000000009', roles: ['owner'] }; +const actor = (id: string, orgId: string, roles: readonly string[]): Actor => ({ + kind: 'user', + id, + orgId, + roles, + scopes: [], +}); + +const viewer = actor('a', org, ['viewer']); +const member = actor('b', org, ['member']); +const outsider = actor('c', '00000000-0000-0000-0000-000000000009', ['owner']); unitTest('${feature.camel} read denies anonymous and cross-org actors', async () => { await expect(can${feature.pascal}Read).toDenyPolicy({ actor: null, input: { orgId: org } }); diff --git a/packages/cli/src/templates/query.ts b/packages/cli/src/templates/query.ts index 93ba3d777..6510e5491 100644 --- a/packages/cli/src/templates/query.ts +++ b/packages/cli/src/templates/query.ts @@ -12,42 +12,51 @@ const querySource = ( live: boolean, ): string => `// ${name.camel}: a ${live ? 'live (subscribable)' : 'one-shot'} read over ${feature.pluralKebab}. // Bounded and ordered — required for${live ? ' live queries' : ' predictable pagination'}. -import { db } from '@ultimat3/db'; -import { can } from '@ultimat3/policy'; -import { query, t } from '@ultimat3/query'; -import { ${feature.camel} } from '../entity'; +import { from, query } from '@ultimat3/query'; +import { t } from '@ultimat3/schema'; +import type { ${feature.pascal} } from '../entity'; +import { can${feature.pascal}Read } from '../policy'; +import * as repo from '../repo'; export const ${name.camel} = query({ input: t.object({ orgId: t.uuid, limit: t.number.default(50) }), - policy: can('${feature.kebab}:read'), + policy: can${feature.pascal}Read, live: ${String(live)}, sql: ({ orgId, limit }) => - db.select().from(${feature.camel}).where({ orgId }).orderBy('createdAt').limit(limit), + // \`feature.table\`, not the kebab plural: \`from()\` quotes the identifier into the SQL text, + // and the entity created the table as snake_case. + from<${feature.pascal}>('${feature.table}', () => repo.listByOrg(orgId, limit)) + .where({ orgId }) + .orderBy('createdAt') + .limit(limit), }); `; -const queryTest = (name: NameSet, live: boolean): string => `import { expect } from 'bun:test'; -import { ${live ? 'liveTest' : 'unitTest'} } from '@ultimat3/testing'; +const queryTest = (name: NameSet, live: boolean): string => { + const wrapper = live ? 'liveTest' : 'unitTest'; + return `import { anonymousCtx } from '@ultimat3/action'; +import { expect, ${wrapper} } from '@ultimat3/testing'; import { ${name.camel} } from './${name.kebab}'; -${live ? 'liveTest' : 'unitTest'}('${name.camel} is a declared query', () => { +const orgId = '00000000-0000-0000-0000-000000000002'; + +${wrapper}('${name.camel} is a declared query', () => { expect(${name.camel}.kind).toBe('query'); expect(${name.camel}.live).toBe(${String(live)}); }); -${live ? 'liveTest' : 'unitTest'}('${name.camel} is bounded and ordered', () => { - const sql = String(${name.camel}.sql({ orgId: '00000000-0000-0000-0000-000000000002', limit: 50 })); +${wrapper}('${name.camel} is bounded and ordered', () => { + // The SQL text is the contract an agent reads to self-correct, so assert on it, not on a shape. + const { sql } = ${name.camel}.def.sql({ orgId, limit: 50 }, anonymousCtx()).toSQL(); expect(sql.toLowerCase()).toContain('order by'); expect(sql.toLowerCase()).toContain('limit'); }); -${live ? 'liveTest' : 'unitTest'}('${name.camel} requires an actor with read permission', async () => { - await expect(${name.camel}.policy).toDenyPolicy({ - actor: null, - input: { orgId: '00000000-0000-0000-0000-000000000002', limit: 50 }, - }); +${wrapper}('${name.camel} requires an actor with read permission', async () => { + await expect(${name.camel}.def.policy).toDenyPolicy({ actor: null, input: { orgId } }); }); `; +}; export interface QueryOptions extends FeatureTarget { readonly live?: boolean; diff --git a/packages/cli/src/templates/resource.ts b/packages/cli/src/templates/resource.ts index 20070f46e..2f2963c5e 100644 --- a/packages/cli/src/templates/resource.ts +++ b/packages/cli/src/templates/resource.ts @@ -20,12 +20,8 @@ import { ${feature.pascal}NotFoundError } from './errors'; import * as repo from './repo'; import type { ${feature.pascal} } from './entity'; -export interface Create${feature.pascal}Input { - readonly orgId: string; - readonly title: string; - readonly priceMinor: number; - readonly priceCurrency: string; -} +/** Derived from the row, never restated: a new column reaches this input without an edit here. */ +export type Create${feature.pascal}Input = Omit<${feature.pascal}, 'id' | 'createdAt'>; export async function create(input: Create${feature.pascal}Input): Promise<${feature.pascal}> { return repo.insert(input); @@ -38,8 +34,9 @@ export async function require${feature.pascal}(id: string): Promise<${feature.pa } `; -const serviceTest = (feature: NameSet): string => `import { expect } from 'bun:test'; -import { unitTest } from '@ultimat3/testing'; +const serviceTest = ( + feature: NameSet, +): string => `import { expect, unitTest } from '@ultimat3/testing'; import { ${feature.pascal}NotFoundError } from './errors'; unitTest('${feature.pascal}NotFoundError carries a code, a cause and a fix', () => { @@ -67,7 +64,9 @@ export function ${feature.pascal}List(props: ${feature.pascal}ListProps) { return (
    {t('app.${feature.kebab}.empty')}}> - {(row) =>
  • {row.title}
  • } + {/* The item arrives as an accessor: reading it inside the row is what keeps the update + surgical instead of re-rendering the list. */} + {(row) =>
  • {row().title}
  • }
); diff --git a/packages/cli/src/templates/route.ts b/packages/cli/src/templates/route.ts index e5838aede..7b01c6fed 100644 --- a/packages/cli/src/templates/route.ts +++ b/packages/cli/src/templates/route.ts @@ -10,10 +10,15 @@ export type Surface = 'site' | 'app'; const RENDER: Record = { site: 'isr', app: 'stream' }; const HYDRATE: Record = { site: 'never', app: 'visible' }; const OFFLINE: Record = { site: 'precache', app: 'runtime' }; -const BUDGET: Record = { - site: "{ js: '0kb', lcp: 1800 }", - app: "{ js: '60kb', lcp: 2500 }", +/** Structured, not a literal string: the route and the test that pins it read the same fact. */ +const BUDGET: Record = { + site: { js: '0kb', lcp: 1800 }, + app: { js: '60kb', lcp: 2500 }, }; +const budgetLiteral = (surface: Surface): string => + `{ js: '${BUDGET[surface].js}', lcp: ${BUDGET[surface].lcp} }`; +/** `isr` without a trigger is `static` wearing a costume — @ultimat3/render rejects it at boot. */ +const REVALIDATE: Record = { site: "\n revalidate: { ttl: '1h' },", app: '' }; const routeDir = (surface: Surface, path: string): string => `apps/web/${surface}/${path @@ -36,11 +41,10 @@ import { t } from '@ultimat3/i18n'; import styles from './page.module.scss'; export const config = defineRoute({ - kind: 'route', - render: '${RENDER[surface]}', + render: '${RENDER[surface]}',${REVALIDATE[surface]} hydrate: '${HYDRATE[surface]}', offline: '${OFFLINE[surface]}', - budget: ${BUDGET[surface]}, + budget: ${budgetLiteral(surface)}, meta: () => ({ title: t('${titleKey(path)}'), description: t('${titleKey(path).replace('.title', '.description')}'), @@ -68,14 +72,17 @@ const styleSource = } `; -const routeTest = (surface: Surface, path: string): string => `import { expect } from 'bun:test'; -import { e2eTest, unitTest } from '@ultimat3/testing'; +const routeTest = ( + surface: Surface, + path: string, +): string => `import { e2eTest, expect, unitTest } from '@ultimat3/testing'; import { config } from './page'; -unitTest('/${path} declares metadata', () => { - const meta = config.meta(); - expect(meta.title.length).toBeGreaterThan(0); - expect(meta.description.length).toBeGreaterThan(0); +unitTest('/${path} declares metadata', async () => { + // meta() takes the route's data and may be async, so a caller always awaits it. + const meta = await config.meta({}); + expect(meta.title ?? '').not.toBe(''); + expect(meta.description ?? '').not.toBe(''); }); unitTest('/${path} declares a render mode, an offline strategy and a budget', () => { @@ -85,7 +92,7 @@ unitTest('/${path} declares a render mode, an offline strategy and a budget', () }); unitTest('/${path} stays inside its byte budget declaration', () => { - expect(config.budget.js).toBe('${surface === 'site' ? '0kb' : '60kb'}'); + expect(config.budget?.js).toBe('${BUDGET[surface].js}'); }); e2eTest('/${path} renders offline from its fallback', async ({ page, offline }) => { diff --git a/packages/cli/src/templates/scaffold-app.ts b/packages/cli/src/templates/scaffold-app.ts index ec0741967..7aaaa4d76 100644 --- a/packages/cli/src/templates/scaffold-app.ts +++ b/packages/cli/src/templates/scaffold-app.ts @@ -14,9 +14,12 @@ const webPackage = (app: NameSet): string => `{ } `; +// The ambient \`*.module.scss\` declaration is not reachable through an import, so a program that +// only sees this app's files would report TS2307 on every stylesheet. Naming it in \`include\` +// is what makes \`tsc -p apps/web\` agree with \`tsc -p .\`. const tsconfig = (): string => `{ "extends": "../../tsconfig.json", - "include": ["**/*.ts", "**/*.tsx"] + "include": ["**/*.ts", "**/*.tsx", "../../types/scss.d.ts"] } `; @@ -28,7 +31,6 @@ import { t } from '@ultimat3/i18n'; import styles from './page.module.scss'; export const config = defineRoute({ - kind: 'route', render: 'static', hydrate: 'never', offline: 'precache', @@ -73,15 +75,15 @@ const siteStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens; } `; -const sitePageTest = (): string => `import { expect } from 'bun:test'; -import { unitTest } from '@ultimat3/testing'; +const sitePageTest = (): string => `import { expect, unitTest } from '@ultimat3/testing'; import { config } from './page'; -unitTest('the landing page ships zero JS and declares metadata', () => { +unitTest('the landing page ships zero JS and declares metadata', async () => { expect(config.render).toBe('static'); expect(config.hydrate).toBe('never'); - expect(config.budget.js).toBe('0kb'); - expect(config.meta().title.length).toBeGreaterThan(0); + expect(config.budget?.js).toBe('0kb'); + const meta = await config.meta({}); + expect(meta.title ?? '').not.toBe(''); }); `; @@ -93,11 +95,11 @@ import { t } from '@ultimat3/i18n'; import styles from './page.module.scss'; export const config = defineRoute({ - kind: 'route', render: 'stream', hydrate: 'visible', offline: 'runtime', - auth: 'required', + // Auth is a policy, never a route-local flag: one authz system, evaluated everywhere. + policy: { permission: 'dashboard:read' }, budget: { js: '60kb', lcp: 2500 }, meta: () => ({ title: t('app.dashboard.title'), description: t('app.dashboard.description') }), }); @@ -120,13 +122,12 @@ const dashboardStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens; } `; -const dashboardTest = (): string => `import { expect } from 'bun:test'; -import { unitTest } from '@ultimat3/testing'; +const dashboardTest = (): string => `import { expect, unitTest } from '@ultimat3/testing'; import { config } from './page'; -unitTest('the dashboard streams, requires auth and has an offline strategy', () => { +unitTest('the dashboard streams, requires a permission and has an offline strategy', () => { expect(config.render).toBe('stream'); - expect(config.auth).toBe('required'); + expect(config.policy?.permission).toBe('dashboard:read'); expect(config.offline).toBe('runtime'); }); `; @@ -161,13 +162,16 @@ const offlineStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens; const apiAction = (): string => `// api/ holds actions only: no rendering, no components. This one is the readiness probe every // role exposes, declared as an action so it appears in OpenAPI and MCP like everything else. -import { action, t } from '@ultimat3/action'; -import { can } from '@ultimat3/policy'; +import { action } from '@ultimat3/action'; +import { allow } from '@ultimat3/policy'; +import { t } from '@ultimat3/schema'; export const health = action({ input: t.object({}), output: t.object({ ok: t.boolean, role: t.string }), - policy: can('public'), + // Public, said out loud. \`can('x:y')\` is the other branch; a missing policy is a build error, + // so "anyone may call this" has to be a declaration too. + policy: allow('public'), mcp: { expose: true, description: 'Readiness of this process' }, async handle({ ctx }) { return { ok: true, role: ctx.role }; @@ -175,13 +179,12 @@ export const health = action({ }); `; -const apiTest = (): string => `import { expect } from 'bun:test'; -import { contractTest } from '@ultimat3/testing'; +const apiTest = (): string => `import { contractTest, expect } from '@ultimat3/testing'; import { health } from './health'; contractTest('health is an action exposed over MCP', () => { expect(health.kind).toBe('action'); - expect(health.mcp?.expose).toBe(true); + expect(health.def.mcp?.expose).toBe(true); }); `; @@ -239,11 +242,11 @@ import { defineRoute } from '@ultimat3/render'; import { t } from '@ultimat3/i18n'; export const config = defineRoute({ - kind: 'route', render: 'spa', hydrate: 'idle', offline: 'network-only', - auth: 'required', + // A spa renders no data, so the shell itself must be gated — @ultimat3/render requires it. + policy: { permission: 'admin:read' }, budget: { js: '120kb', lcp: 3000 }, meta: () => ({ title: t('admin.home.title'), description: t('admin.home.description') }), }); diff --git a/packages/cli/src/templates/scaffold-repo.ts b/packages/cli/src/templates/scaffold-repo.ts index bf017537e..20cc709a0 100644 --- a/packages/cli/src/templates/scaffold-repo.ts +++ b/packages/cli/src/templates/scaffold-repo.ts @@ -29,6 +29,7 @@ const rootPackage = (app: NameSet): string => `{ }, "dependencies": { "@ultimat3/action": "^0.0.1", + "@ultimat3/cache": "^0.0.1", "@ultimat3/cli": "^0.0.1", "@ultimat3/core": "^0.0.1", "@ultimat3/db": "^0.0.1", @@ -40,8 +41,8 @@ const rootPackage = (app: NameSet): string => `{ "@ultimat3/pwa": "^0.0.1", "@ultimat3/query": "^0.0.1", "@ultimat3/render": "^0.0.1", + "@ultimat3/schema": "^0.0.1", "@ultimat3/ui": "^0.0.1", - "drizzle-orm": "^0.44.0", "solid-js": "^2.0.0" }, "engines": { "bun": ">=1.3.0" } @@ -76,20 +77,23 @@ const appConfig = ( app: NameSet, ): string => `// The one config file. Everything the app needs to boot is here, typed and validated at startup — // a missing value fails the boot with the exact command that fixes it, never at the first request. -import { defineApp } from '@ultimat3/core'; +// A named export, never a default: the CLI and the runtime both import \`config\` by name. +import { defineConfig } from '@ultimat3/core'; -export default defineApp({ +export const config = defineConfig({ name: '${app.kebab}', - locales: { default: 'en', supported: ['en'] }, - timeZone: 'UTC', - currency: 'USD', - db: { url: process.env['DATABASE_URL'] ?? 'embedded' }, - auth: { providers: ['password'], sessionDays: 30 }, - jobs: { driver: 'postgres', queues: { default: { concurrency: 4 } } }, - realtime: { transport: process.env['NATS_URL'] === undefined ? 'in-process' : 'nats' }, - storage: { driver: process.env['S3_ENDPOINT'] === undefined ? 'local-dir' : 's3' }, - budgets: { site: { js: '0kb' }, app: { js: '60kb' } }, - observability: { otel: true, serviceName: '${app.kebab}' }, + locales: ['en'], + defaultLocale: 'en', + defaultTimeZone: 'UTC', + defaultCurrency: 'USD', + // Env KEYS, never the value: the same image deploys to every environment. + database: { urlEnv: 'DATABASE_URL', poolSize: 10 }, + cache: { driver: 'memory', tiers: ['memo', 'lru'] }, + jobs: { driver: 'postgres', queues: ['${app.kebab}-default'], concurrency: 4 }, + // In-process transport by default; set urlEnv and transport: 'nats' to scale past one node. + realtime: { enabled: true, tier: 'live-queries', transport: 'memory' }, + pwa: { enabled: true, offline: 'runtime', installPrompt: true }, + ai: { mcp: { expose: true, path: '/mcp' } }, }); `; @@ -115,6 +119,16 @@ root = "." preload = ["@ultimat3/testing/preload"] `; +const scssTypes = + (): string => `// SCSS modules resolve to a class-name map at build time. Ambient because an import cannot +// reach a declaration file — every surface names this file in its tsconfig "include". + +declare module '*.module.scss' { + const classes: Readonly>; + export default classes; +} +`; + const gitignore = (): string => `node_modules/ .x/ dist/ @@ -182,28 +196,12 @@ unitTest('money refuses to add across currencies', () => { }); `; -const dbIndex = ( - app: NameSet, -): string => `// The Drizzle client. Schema and migrations only — no business logic lives in this package. -export { db } from './client'; +const dbIndex = + (): string => `// Schema and migrations only — no business logic lives in this package. The client itself is +// @ultimat3/db's: one connection pool, sized by ROLE, shared by every package in the app. +export type { DbClient, SqlFragment } from '@ultimat3/db'; +export { db, sql, withTransaction } from '@ultimat3/db'; export * as schema from './schema'; -export type { ${app.pascal}Database } from './client'; -`; - -const dbClient = ( - app: NameSet, -): string => `// One database handle per process. The URL comes from app.config.ts, which validated it at boot. -import { SQL } from 'bun'; -import { drizzle } from 'drizzle-orm/bun-sql'; -import * as schema from './schema'; - -const url = process.env['DATABASE_URL']; - -export const db = drizzle(new SQL(url === undefined || url === '' ? 'postgres://localhost/${app.kebab}' : url), { - schema, -}); - -export type ${app.pascal}Database = typeof db; `; const dbSchema = ( @@ -216,17 +214,22 @@ export { post } from '@${app.kebab}/web/app/post/entity'; const dbSeed = ( app: NameSet, ): string => `// Deterministic seed: same rows every time, so a test and a demo see the same database. -import { db } from './client'; -import { post } from './schema'; +import { db, sql } from '@ultimat3/db'; const ORG = '00000000-0000-0000-0000-000000000002'; export async function seed(): Promise { const rows = [ - { id: '00000000-0000-0000-0000-000000000101', orgId: ORG, title: 'Hello ${app.pascal}', priceMinor: 0, priceCurrency: 'USD' }, - { id: '00000000-0000-0000-0000-000000000102', orgId: ORG, title: 'Second post', priceMinor: 1900, priceCurrency: 'USD' }, + { id: '00000000-0000-0000-0000-000000000101', title: 'Hello ${app.pascal}', minor: 0 }, + { id: '00000000-0000-0000-0000-000000000102', title: 'Second post', minor: 1900 }, ]; - await db.insert(post).values(rows).onConflictDoNothing(); + for (const row of rows) { + // Idempotent by primary key, so re-seeding a branch database is a no-op rather than a crash. + await db().execute(sql\` + insert into posts (id, org_id, title, price_minor, price_currency) + values (\${row.id}, \${ORG}, \${row.title}, \${row.minor}, 'USD') + on conflict (id) do nothing\`); + } return rows.length; } @@ -345,22 +348,29 @@ const mcpIndex = ( app: NameSet, ): string => `// The app's own MCP tools. Every action with mcp.expose is already a tool; add app-specific // read-only helpers here. Authorization is the action's policy, unchanged. -import { defineTools } from '@ultimat3/mcp'; -import { health } from '@${app.kebab}/web/api/health'; +import { registerActions } from '@ultimat3/action'; +import { defineAppMcp } from '@ultimat3/mcp'; +import * as api from '@${app.kebab}/web/api/health'; + +// Names come from export names, so the registry agrees with the module the app already wrote. +registerActions(api); -export const tools = defineTools({ +// \`include: 'exposed'\` projects straight from the registry. Re-listing the actions here would +// copy \`mcp: { expose: true }\` into a second place, and the copy goes stale in silence. +export const mcp = defineAppMcp({ name: '${app.kebab}', - actions: [health], + include: 'exposed', }); `; -const mcpTest = (app: NameSet): string => `import { expect } from 'bun:test'; -import { unitTest } from '@ultimat3/testing'; -import { tools } from './index'; +const mcpTest = (): string => `import { expect, unitTest } from '@ultimat3/testing'; +import { mcp } from './index'; unitTest('the app exposes its actions as MCP tools', () => { - expect(tools.name).toBe('${app.kebab}'); - expect(tools.actions.length).toBeGreaterThan(0); + expect(mcp.tools.length).toBeGreaterThan(0); + // Every projected tool must describe itself: an agent picks a tool by its description. Assert + // on the value, not its length — a failure then prints the empty description, not "0 > 0". + for (const tool of mcp.tools) expect(tool.description).not.toBe(''); }); `; @@ -372,6 +382,7 @@ export function repoFiles(app: NameSet): readonly GeneratedFile[] { { path: 'biome.json', contents: biome() }, { path: 'bunfig.toml', contents: bunfig() }, { path: 'app.config.ts', contents: appConfig(app) }, + { path: 'types/scss.d.ts', contents: scssTypes() }, { path: '.gitignore', contents: gitignore() }, { path: '.env.development', contents: envDevelopment() }, { @@ -384,8 +395,7 @@ export function repoFiles(app: NameSet): readonly GeneratedFile[] { path: 'packages/db/package.json', contents: domainPackage(app, 'db', 'Drizzle schema and migrations, no business logic'), }, - { path: 'packages/db/src/index.ts', contents: dbIndex(app) }, - { path: 'packages/db/src/client.ts', contents: dbClient(app) }, + { path: 'packages/db/src/index.ts', contents: dbIndex() }, { path: 'packages/db/src/schema.ts', contents: dbSchema(app) }, { path: 'packages/db/src/seed.ts', contents: dbSeed(app) }, { path: 'packages/db/migrations/0000_initial.sql', contents: migration() }, @@ -408,6 +418,6 @@ export function repoFiles(app: NameSet): readonly GeneratedFile[] { contents: domainPackage(app, 'mcp', "The app's own MCP tools"), }, { path: 'packages/mcp/src/index.ts', contents: mcpIndex(app) }, - { path: 'packages/mcp/src/index.test.ts', contents: mcpTest(app) }, + { path: 'packages/mcp/src/index.test.ts', contents: mcpTest() }, ]; } diff --git a/wiki/Error-Codes.md b/wiki/Error-Codes.md index 27777c57f..2af3d5c80 100644 --- a/wiki/Error-Codes.md +++ b/wiki/Error-Codes.md @@ -270,6 +270,7 @@ X_DB_DRIFT: schema differs from migrations | `X_BUILD_FAILED` | `x build` failed | a static check or the bundler | read `cause`; the failing step is named | | `X_DEPLOY_FAILED` | a deploy step failed | the compose/helm command exited non-zero | run the printed command directly for full output | | `X_GENERATE_CONFLICT` | a generator would overwrite a file | the name is taken | `x g … --force`, or choose another name | +| `X_SCAFFOLD_PATH_ESCAPE` | a generated path resolves outside the scaffold sandbox | a `..` segment or an absolute path in a template's `GeneratedFile.path` | make the path relative to the app root with no `..`, then `bun test packages/cli/src/scaffold-typecheck.contract.test.ts` | ## Names used in the design docs