Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major

### Added

Tier 0 — core. Tier 1 — i18n. Tier 3 — jobs, query. Tier 4 — ui. Tier 5 — testing.
Tier 0 — core. Tier 1 — i18n. Tier 3 — jobs, query. Tier 4 — ui. Tier 5 — cli, testing. Both tracked apps.

- **`redisJobDriver()` — a Redis queue on `Bun.redis` (#710, owner decision 5).** `import { redisJobDriver } from '@ultimat3/jobs/redis'` — its own entry, so the barrel every role boots carries none of it — then `setJobDriver(redisJobDriver())`, or `ServeOptions.runtime.jobs`; options `client`, `prefix` (default `x:jobs`), `clock`, `doneTtlMs` (default a week; `0` keeps finished rows). Every operation is one Lua script, every key under one `{prefix}` hash tag, so it runs on Redis Cluster and Dragonfly. It passes `@ultimat3/testing`'s `jobDriverConformance` — the suite memory and pg pass — against the CI Redis, and a second live suite runs one script of operations on it and on the memory driver under one frozen clock and compares every answer. It carries no `introspect` (`x jobs show`/`retry`/`cancel`, queue pauses), no `backfills` ledger and no `leases`, so a job declaring `concurrency` still refuses `jobWorker().start()` on it.
- **A read's `Date` reaches the typed client as a `Date` (#710, owner decision 19).** A query declares no output schema, so a column typed `Date` arrived as the ISO string `JSON.stringify` wrote, and every app converted at its `load` (the reference app's `shared/wire.ts`). The query route now names every `Date` of its answer in `x-ultimate-dates` — by path, one entry per column of a list, row by row only where the same column also holds text — and `clientTransport` revives exactly those (`wire-dates.ts`: `DATES_HEADER`, `wireDatePaths`, `reviveWireDates`). The body and the OpenAPI document are unchanged; a client that ignores the header reads what it read before. Measured: `clientTransport` +702 B minified, `@ultimat3/query/client` +763 B.
- **A catalog slice crosses into an island (#710, owner decision 19).** `catalogSubset(t, keys)` (`@ultimat3/i18n`) resolves the named templates, plural variants included, into JSON; `subsetTranslator(subset)` (`@ultimat3/i18n/subset`, an entry that installs no framework catalog) is the island's full translator over it. `uiCatalog(t)` (`@ultimat3/ui`) is the design system's own slice: every `UI_KEYS` template, so `<UiProvider t={subsetTranslator(props.ui)}>` renders no `⟦ui.*⟧`. The reference app's `shared/ui-strings.ts`, `shared/ui-strings-server.ts` and `shared/wire.ts` are deleted.
- **The generator's directory form is the one layout (#710, owner decision 10).** One primitive per file, in its kind's directory — `<slice>/actions/<name>.ts`, `live/`, `queries/`, `jobs/`, `tasks/` — what `x g` writes. `x verify`'s `boundaries` step now refuses a primitive module beside its directory in an app's surfaces — `actions.ts` with `actions/`, or `live`, `queries`, `jobs`, `tasks` (`X_LAYOUT_SIBLING_MODULE`); a component module beside its folder (`ui.tsx` + `ui/`, what `x g resource` writes) is not one: `./actions` resolves to the file and never the directory, so the two homes disagree silently. Both tracked apps migrated: `examples/dummy`'s 56 primitives and the demo's 11 each in their own file (`api/tasks.ts`'s two tasks moved into their slices' `tasks/`), registered module by module in `api/index.ts`. `docs/architecture/12-generated-app.md`, `wiki/Project-Layout.md` and `wiki/Actions.md` say so. An app that keeps `actions.ts` with no `actions/` beside it is not refused.

## 27.3.0 - 2026-10-09

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ An agent's scarcest resource is context, and most of it goes on infrastructure t
|---|---|
| everything the author owns, 218 files | **9,712** code lines |
| production only, tests excluded | **7,230** |
| a fully-projected endpoint — input schema, output schema, policy, OpenAPI operation, MCP tool, typed client | **~16 code lines**, from 4 actions in [`app/friends/actions.ts`](dummy/social-media-clone/apps/web/app/friends/actions.ts) |
| a fully-projected endpoint — input schema, output schema, policy, OpenAPI operation, MCP tool, typed client | **~16 code lines**, from 4 actions in [`app/friends/actions/`](dummy/social-media-clone/apps/web/app/friends/actions/) |
| one action, 51 code lines | **179 lines** of committed generated interface — OpenAPI operation, two JSON Schemas, the manifest row — plus five handles it never declares |

Re-derive it — `node_modules`, `.x/`, `dist/` and `coverage/` are gitignored, so `git ls-files` never enters them:
Expand All @@ -353,7 +353,7 @@ The 1,767 generated Lucide glyph files are excluded on purpose — leaving them
| that the demo passes its own gate | it is **pinned red on 2 steps**, `As of 2026-09-23` — `boundaries` (`X_BOUNDARY_SITE_TO_APP` ×3, the static feed importing the authed post service) and `budgets` (`X_BUDGET_UNMEASURED`, no `.x/build-stats.json` has ever existed there). It was 3: `drift` came off when the migration reconciling its foreign keys landed. [`examples/dummy`](examples/dummy/README.md) is pinned on 1 step — `budgets`. The pins and their reasons are [`scripts/lib/gated-apps.ts`](scripts/lib/gated-apps.ts); `bun run scripts/reference-app-gate.ts` re-derives them |
| that low lines means high leverage | partly it means **few features**. Roughly half of [`DOMAIN.md`](dummy/social-media-clone/DOMAIN.md) is a plan, not a build: `likes` and `comments` are entities with migrations and no write path |
| that the framework wrote the auth | it did not. 13 non-test files hand-write argon2id parameters, `__Host-` cookie prefixes, session token hashing and a captcha, and **`@ultimat3/auth` is imported nowhere in that app**. `@ultimat3/storage` likewise, despite a media feature. The largest thing the framework could have projected and did not |
| that live messaging works | the thread's live query ([`app/messages/live.ts`](dummy/social-media-clone/apps/web/app/messages/live.ts)) and its channel ([`app/messages/topics.ts`](dummy/social-media-clone/apps/web/app/messages/topics.ts)) are declared and tested against a real hub, and **no island subscribes to either** — there is no `useChannel` or `useQuery` in the app — and the deployed stack runs no `sync` role, `As of 2026-09-23` |
| that live messaging works | the thread's live query ([`app/messages/live/`](dummy/social-media-clone/apps/web/app/messages/live/)) and its channel ([`app/messages/topics.ts`](dummy/social-media-clone/apps/web/app/messages/topics.ts)) are declared and tested against a real hub, and **no island subscribes to either** — there is no `useChannel` or `useQuery` in the app — and the deployed stack runs no `sync` role, `As of 2026-09-23` |
| that the typed client is proven | it is projected and unused. There is **no `.client()` call in either tracked app**; the demo's forms post HTML |

**The larger win is not the lines** — it is that a bug is found once, here, where the fix reaches every app at once. The sweeps in [`CHANGELOG.md`](CHANGELOG.md) closed defects of exactly that kind:
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture/12-generated-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ apps/web/app/<features>/page.tsx the route, at the plural URL
| `ui.tsx`, `ui/` | Solid components | fetching, business logic, its own authz |
| `admin/resource.ts` | list columns, title key, page size | a second authz path |

`x g` writes one primitive per file: `actions/<verb>-post.ts`, `live/<name>.ts`, `queries/<name>.ts`, `jobs/<verb>-post.ts`, `tasks/<name>.ts`. A primitive is registered by `defineApi()` and found by the module scan, never by its filename, so a feature may equally keep several declarations in one file — `examples/dummy` keeps one `actions.ts` per feature — and nothing enforces either layout ([Project layout](../../wiki/Project-Layout.md#feature-slicing-inside-a-surface) names the filenames that ARE enforced). The flat files are the ones there is exactly one of per feature.
`x g` writes one primitive per file: `actions/<verb>-post.ts`, `live/<name>.ts`, `queries/<name>.ts`, `jobs/<verb>-post.ts`, `tasks/<name>.ts`, each named for its export in kebab case. **That directory form is the one layout** (`As of 2026-10`, owner decision 10 of #648): both tracked apps use it, and a primitive module beside its directory (`actions.ts` with `actions/`, or `live`, `queries`, `jobs`, `tasks`) is refused — `X_LAYOUT_SIBLING_MODULE`, in the `boundaries` step — because `./actions` resolves to the file and never the directory. A factory's kind decides its directory: a mutator, a `transition()` and an `llm()`/`agent()`/`hive()` call are actions; a `backfill()` keeps `backfills/`, and every other job factory is a job. A primitive is registered by `defineApi()` and found by the module scan, never by its filename ([Project layout](../../wiki/Project-Layout.md#feature-slicing-inside-a-surface) names the filenames that ARE enforced). The flat files are the ones there is exactly one of per feature.

The folder is the name passed to `x g resource`; the route is its plural, because a collection URL is plural — `x g resource post` writes the slice to `apps/web/app/post/` and the page to `apps/web/app/posts/page.tsx`.

Expand Down Expand Up @@ -152,7 +152,7 @@ this file a route?" mechanically decidable from the filename alone.

**`api/` holds the action and query projections, and no route file.** An action, mutator, query,
job or task reaches HTTP through `defineApi()`, not through a filename: those modules keep whatever
name their feature gives them (`app/posts/actions.ts`, `app/digest/jobs.ts`, …) and
name their feature gives them (`app/posts/actions/create-post.ts`, `app/digest/jobs/send-digest.ts`, …) and
`apps/web/api/index.ts` collects them into one `defineApi()` call, which projects the HTTP routes,
`openapi.json`, the typed client and the job handles.

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/15-adding-a-feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ x g resource post --admin

Generates schema, entity, repo, service, policy, actions, live query, job stub, components, the form island, the `posts` page, i18n keys, admin screens, MCP entries, and the tests beside each. The page is reachable as written: its `load` reads the list through the slice's query as the request's actor, `AsyncRegion` renders ready, empty and refused, and the form island posts to `createPost`. `x g` ends by printing what the new table owes (`x db gen`, `x db migrate`) and the build that weighs the page against its budget. Everything below is *editing* generated files — not creating them.

`x g` writes one primitive per file: `actions/<verb>-post.ts`, `live/<name>.ts`, `queries/<name>.ts`, `jobs/<verb>-post.ts`, `tasks/<name>.ts`. A primitive is registered by `defineApi()` and found by the module scan, never by its filename, so a feature may equally keep several declarations in one file — `examples/dummy` keeps one `actions.ts` per feature — and nothing enforces either layout ([Project layout](../../wiki/Project-Layout.md#feature-slicing-inside-a-surface) names the filenames that ARE enforced). `x g resource <name>` takes no `--feature`: the name is the slice (`X_CLI_BAD_FLAG`).
`x g` writes one primitive per file: `actions/<verb>-post.ts`, `live/<name>.ts`, `queries/<name>.ts`, `jobs/<verb>-post.ts`, `tasks/<name>.ts`. That directory form is the one layout: a second primitive is a second file, and a primitive module beside its directory (`actions.ts` with `actions/`) is `X_LAYOUT_SIBLING_MODULE` ([Project layout](../../wiki/Project-Layout.md#feature-slicing-inside-a-surface) names the filenames that ARE enforced). `x g resource <name>` takes no `--feature`: the name is the slice (`X_CLI_BAD_FLAG`).

## 2. Entity + invariants

Expand Down
2 changes: 1 addition & 1 deletion dummy/social-media-clone/.claude/agents/web-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ tools: Read, Edit, Write, Bash, Grep, Glob

You own `apps/web/site/**`, `apps/web/app/**/page.tsx`, `apps/web/app/**/ui/**`, `*.module.scss`,
`apps/web/shared/tokens.scss` and `packages/i18n/catalogs/**`. You do **not** own `policy.ts`,
`actions.ts`, `live.ts`, `jobs.ts`, `repo.ts` or `packages/db` — stop and report if you need them.
`actions/`, `live/`, `queries/`, `jobs/`, `tasks/`, `repo.ts` or `packages/db` — stop and report if you need them.

**The surface decides the rules.**

Expand Down
2 changes: 1 addition & 1 deletion dummy/social-media-clone/.claude/commands/planx.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ One or two sentences: what, and why.

## Context
- Only the facts the executor needs.
- Pattern to follow: `apps/web/app/posts/actions.ts:33` — copy this shape.
- Pattern to follow: `apps/web/app/friends/actions/request-friend.ts` — copy this shape.

## Slices (execute in order)
1. [`01-<aspect>.md`](01-<aspect>.md) — one line, and the exclusive file set it owns.
Expand Down
5 changes: 3 additions & 2 deletions dummy/social-media-clone/apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ applies migrations and exits.
**The directory is the URL.** `app/posts/[id]/page.tsx` is `/posts/:id`. The filename never
contributes — `page.tsx` on `site/`/`app/`, `route.ts` on `api/`, and nothing else is a route.

A feature slice is one directory holding `entity.ts`, `policy.ts`, `actions.ts`, `mutator.ts`,
`live.ts`, `jobs.ts`, `service.ts`, `repo.ts`, `errors.ts` and `ui/`. Each file has one job, and
A feature slice is one directory holding `entity.ts`, `policy.ts`, `service.ts`, `repo.ts`,
`errors.ts`, `ui/`, and one primitive per file under `actions/`, `live/`, `queries/`, `jobs/` and
`tasks/` — the layout `x g` writes, and the only one (`X_LAYOUT_SIBLING_MODULE`). Each file has one job, and
only `repo.ts` writes SQL. There is no `lib/`, `utils/` or `helpers/` — those names mean the code
has no owner.

Expand Down
11 changes: 8 additions & 3 deletions dummy/social-media-clone/apps/web/api/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
// `defineApi`'s own ordering, because a task descriptor lists the jobs it enqueues by name.

import { defineApi } from '@ultimat3/action';
import * as taskJobs from '../app/tasks/jobs';
import * as taskSchedule from '../app/tasks/schedule';
import * as resetDemo from '../app/tasks/jobs/reset-demo';
import * as sweepOrphanMedia from '../app/tasks/jobs/sweep-orphan-media';
import * as hourlyDemoReset from '../app/tasks/tasks/hourly-demo-reset';
import * as hourlyMediaSweep from '../app/tasks/tasks/hourly-media-sweep';

export const scheduledApi = defineApi({ jobs: taskJobs, tasks: taskSchedule });
export const scheduledApi = defineApi({
jobs: [sweepOrphanMedia, resetDemo],
tasks: [hourlyMediaSweep, hourlyDemoReset],
});
102 changes: 0 additions & 102 deletions dummy/social-media-clone/apps/web/app/friends/actions.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// `blockUser`, the friends slice's action — one primitive per file, the layout `x g` writes.
//
// The social graph's four commands. Declarations only — every body delegates to `./service`, so the
// same logic runs whether the caller is HTTP, the typed client, a job, an MCP tool or admin.
//
// `t` comes from @ultimat3/action, not @ultimat3/schema: an action file imports one package. Each
// `row:` loader is the async half authz is not allowed to have — it runs once per invocation, and
// the predicate that reads its result stays synchronous.

import { action, t } from '@ultimat3/action';
import { blockCreate } from '../policy';
import { blockPerson } from '../service';
import { BlockView, personRow } from '../views';

export const blockUser = action({
input: t.object({ userId: t.uuid }),
output: BlockView,
policy: blockCreate,
row: personRow,
idempotent: true,
mcp: { expose: true, description: 'Block a user and decline any friendship between them' },
handle: ({ input, ctx }) => blockPerson(ctx.actor.id, input.userId, ctx.now()),
});
Loading
Loading