diff --git a/.claude/commands/feature.md b/.claude/commands/feature.md index 8808a2028..9595c8370 100644 --- a/.claude/commands/feature.md +++ b/.claude/commands/feature.md @@ -8,7 +8,7 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Task, SendMessage, Ta You are a **senior engineer on Ultimate** — a Bun-only, opinionated full-stack framework whose *primary user is an AI agent*. Read [`CLAUDE.md`](../../CLAUDE.md) and [`docs/architecture/15-adding-a-feature.md`](../../docs/architecture/15-adding-a-feature.md) before designing anything. The seven design axioms override any instinct that conflicts with them. -**Done means merged and green — nothing less counts.** understand → falsify the spec → explore → slice → build → **`bun run verify` green** → PR → **merged** → **CI green on `main`** → docs, wiki and `examples/dummy/` left true → **npm published**, when a release was in scope. This repo is pre-alpha and deploys nowhere: there is no production, so the arc genuinely ends at merged (or at a published `@ultimat3/*` version). A green local gate is not done; an open PR is not done; a version bump that never published is not done. Report what you actually verified, not what you assume happened. +**Done means merged and green — nothing less counts.** understand → falsify the spec → explore → slice → build → **`bun run verify` green** → PR → **merged** → **CI green on `main`** → docs, wiki and `examples/dummy/` left true → **npm published**, when a release was in scope. This repo deploys nowhere itself: there is no production, so the arc genuinely ends at merged (or at a published `@ultimat3/*` version). A green local gate is not done; an open PR is not done; a version bump that never published is not done. Report what you actually verified, not what you assume happened. ## Request $ARGUMENTS @@ -30,7 +30,7 @@ When you do hive, a big task is not one agent doing more; it is a **team sharing - **Agents are long-lived teammates.** New work in a package someone holds goes to them via `SendMessage`, keeping their context and their file lock. A second agent on the same paths means two writers and a lost fix. - **Work in waves; each wave re-tasks the next.** Wave 1's findings decide wave 2's slices. Don't plan wave 3 before wave 1 reports — it will be wrong. - **Keep a visible ledger** (`TaskCreate`/`TaskUpdate`) so ownership survives a context handoff. -- **Expect the hive to contradict you.** A good agent reports "premise H1 is false, here is the line." Drop it. In a pre-alpha repo where the spec runs ahead of the code, this happens constantly and is the hive working correctly. +- **Expect the hive to contradict you.** A good agent reports "premise H1 is false, here is the line." Drop it. Where the spec runs ahead of the code, this happens constantly and is the hive working correctly. ### Who runs which checks @@ -58,7 +58,7 @@ There is no `test:changed`-style command here, and there should not be: a comman 1. **Understand.** Restate the goal in a line. Name **which of the eight primitives** it is (`entity · policy · action · mutator · query · job · route · task`) and **which tier** it lives in. If it fits none of the eight, it does not ship — do not invent a ninth; if it fits no tier, the design is wrong, so fix the design rather than widening the table. URLs in the ask → `WebFetch` the *mechanism*, then translate it onto this stack (Bun-only, Postgres, SolidJS, containers). -2. **Distrust the paperwork.** This repo is **pre-alpha**: `docs/idea/` is the design spec — what is intended, not what exists — and `docs/architecture/` documents internals that may have moved. Before planning work off either, check them against the code and `git log`; `docs/idea/14-roadmap.md` claims milestone state that the packages may contradict in both directions. Merged commit subjects are the cheapest ground truth. State plainly which claims you falsified, and correct the doc in the same PR. +2. **Distrust the paperwork.** `docs/idea/` is the design spec — what is intended, not what exists — and `docs/architecture/` documents internals that may have moved. Before planning work off either, check them against the code and `git log`; `docs/idea/14-roadmap.md` claims milestone state that the packages may contradict in both directions. Merged commit subjects are the cheapest ground truth. State plainly which claims you falsified, and correct the doc in the same PR. 3. **Prove it against the reference app.** There is no production to query, so `examples/dummy/` is the proving ground: it exercises every primitive once, idiomatically, and a framework change that does not show up there is unverified. Reach for `bun run x -- doctor --json` and `bun run x -- --json` — every command and every error is `--json` for exactly this reason, and every framework error carries an executable `fix:`. **Run the `fix:` before improvising.** A finding backed by a real CLI trace or a failing dummy-app run outranks one derived from reading alone. diff --git a/AGENTS.md b/AGENTS.md index ecfe53b67..bc0110ea0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,11 @@ Hand-written, deliberately short. The *facts* about this codebase are generated ## What this repo is -The **Ultimate** web framework. Bun-only, Postgres + Drizzle, SolidJS, SCSS tokens. A monorepo of `@ultimat3/*` packages plus the `x` CLI. Pre-alpha. +The **Ultimate** web framework. Bun-only, Postgres, SolidJS, SCSS tokens. A monorepo of 27 `@ultimat3/*` packages — the `x` CLI among them — plus the unscoped `create-ultimate`. All 28 publish to npm at **1.0.0** in lockstep, `As of 2026-08`. + +No ORM dependency, `As of 2026-08`: `entity()` is the one table declaration, and `postgresDriver()` (`packages/entity/src/pg-driver.ts`, `pg-sql.ts`) is hand-written parameterised SQL. Generated apps declare their tables the same way — `packages/db/` holds those declarations plus plain-SQL migrations. + +Semver applies from 1.0.0: breaking the eight primitive shapes, the `x` CLI surface, the tier table, or an `X_*` code needs a major. ## Before you start diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aa3a219a..c4c37e24b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,29 +2,58 @@ All notable changes to Ultimate. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -Framework packages version in **lockstep** — a release bumps every `@ultimat3/*` package to the same version. See [PUBLISHING.md](PUBLISHING.md). +Framework packages version in **lockstep** — a release bumps every package to the same version, in one commit, under one tag. Pin `@ultimat3/*` exactly; a mixed-version install is a combination nobody tested. See [PUBLISHING.md](PUBLISHING.md). + +Semver applies from 1.0.0. A breaking change to a documented API needs a major — [Upgrading](https://github.com/developerz-ai/ultimate/wiki/Upgrading) says what "documented API" covers. ## [Unreleased] +Nothing yet. + +## [1.0.0] - 2026-08-10 + +First release. 27 `@ultimat3/*` packages plus the unscoped `create-ultimate` — 28 in all — publish at 1.0.0 to npm, in tier order. + +1.0.0 itself is the **manual bootstrap**: a trusted publisher can only be attached to a package that already exists, so this one version is published by hand by an npm org member. Every release after it goes through the workflow over OIDC trusted publishing, no `NPM_TOKEN` — see [PUBLISHING.md](PUBLISHING.md). + ### Added -- Repository foundation: monorepo layout, tier-enforced package boundaries, Biome + strict TypeScript, free-runner CI, npm OIDC trusted publishing. -- The eight primitives as typed package skeletons: `entity`, `policy`, `action`, `mutator`, `query`, `job`, `route`, `task`. -- `@ultimat3/core` — `UltimateError` and the error contract, ALS request context, typed env, roles, clock, structured logging, OpenTelemetry spans, graceful drain. -- `@ultimat3/schema` — Standard Schema interface with a built-in default provider and JSON Schema projection. -- `@ultimat3/http` — the owned `Bun.serve` lifecycle with an explicit, ordered request pipeline. -- `@ultimat3/action` — one declaration projecting to HTTP route, OpenAPI entry, typed client, MCP tool, job handle, and contract tests. -- `@ultimat3/jobs` — Postgres queue driver, durable steps with memoized replay, transactional outbox on by default, cron tasks with a required timezone. -- `@ultimat3/realtime` — tier 1 channels and presence, tier 2 live queries with per-subscriber policy, tier 3 offline queue and rebase. -- `@ultimat3/i18n` · `@ultimat3/money` · `@ultimat3/time` — the cross-cutting concerns, enforced rather than documented. -- `@ultimat3/ui` — SCSS-module design system with semantic tokens for both colour schemes. -- `@ultimat3/render` · `@ultimat3/pwa` · `@ultimat3/seo` — five render modes, generated service worker, build-error SEO gates. -- `@ultimat3/mcp` · `@ultimat3/ai` · `@ultimat3/manifest` — the AI-first layer, including the built-in MCP dev server. -- `@ultimat3/admin` — the `/_x` dev dashboard and `defineAdmin()` for generated apps. -- `@ultimat3/cli` — the `x` binary, and `create-ultimate` for `bunx create-ultimate myapp`. -- `examples/dummy` — the reference app exercising every primitive and every cross-cutting concern. -- Documentation: `docs/idea/` (design), `docs/architecture/` (internals), `wiki/` (reference), `site/` (GitHub Pages), `llms.txt`. +- **The eight primitives**, shapes frozen under semver: `entity`, `policy`, `action`, `mutator`, `query`, `job`, `route`, `task`. There is no ninth — a new capability arrives as a factory over an existing primitive, which is why `llm()` returns an `action`. +- **One authz object across every surface.** A `policy` decides the HTTP call, the typed client call, the job run, the MCP tool call and the live-query subscription. No trusted-tool mode, no second permission table. +- **`@ultimat3/core`** — `UltimateError` and the error contract, ALS request context, `defineEnv`, roles, clock, structured logging, OpenTelemetry spans, graceful drain, signed cursors, `defineService`. +- **`@ultimat3/schema`** — Standard Schema over a built-in default provider, JSON Schema projection, and one `formatIssues` shared by every package that reports a validation failure. +- **`@ultimat3/entity` + `@ultimat3/db`** — a Postgres driver (`postgresDriver()`) and an in-memory one over one shared plan/cursor layer, so the two cannot drift; PGlite and database branching, so `x dev` needs no Docker. +- **`@ultimat3/action` + `@ultimat3/query`** — one declaration projecting to an HTTP route, an OpenAPI operation, a typed client method, a job handle, an MCP tool and contract tests, all through a single `invoke` path. +- **`@ultimat3/http`** — the owned `Bun.serve` lifecycle with an explicit, ordered request pipeline. +- **`@ultimat3/jobs`** — Postgres queue driver, durable steps with memoized replay, transactional outbox on by default, cron `task`s with a required IANA timezone and leader election. +- **`@ultimat3/realtime`** — tiers 1–2: channels, presence, live queries with per-subscriber policy, an incremental matcher, a Postgres logical-replication change feed (`pgoutput` over `Bun.connect`), and a NATS bus for fanout. +- **`@ultimat3/render` · `pwa` · `seo`** — five render modes with `stream` the default, the `site/` → `app/` surface boundary as a build error, a generated service worker, and SEO gates that fail the build rather than the audit. +- **`@ultimat3/cache`** — four tiers and one tag invalidation graph; an untagged cached query fails the gate. +- **`@ultimat3/mcp` · `ai` · `manifest`** — the AI-first surface: an MCP dev server whose tool catalog is per-connection and fail-closed, a read-only SQL guard with four independent defenses, `x.manifest.json`, `llm()` with token-and-money budgets and a scope-partitioned semantic cache, `PgVectorStore` fusing pgvector cosine and Postgres FTS through RRF, and evals that gate on score delta from a committed baseline. +- **`@ultimat3/auth` · `mail` · `storage`** — OAuth authorization-code exchange with id-token verification, ESMTP and Resend transports, S3 storage. +- **`@ultimat3/i18n` · `money` · `time`** — enforced, not documented: no hardcoded user-facing string, no float money, no date without an explicit IANA `timeZone`. +- **`@ultimat3/ui` · `admin`** — an SCSS-module design system on semantic tokens for both colour schemes, and the `/_x` dashboard. +- **`@ultimat3/cli`** — the `x` binary. `x dev` boots the real app in any role, and every fact it reports comes from a framework package rather than a second implementation inside the CLI. +- **`create-ultimate`** — `bunx create-ultimate myapp` scaffolds a monorepo whose unmodified generated code passes `x verify`. +- **`x verify`, 17 steps**, with no way to run fewer: typecheck, lint, boundaries, filesize, package-shape, errors, unit, contract, live, job, e2e, eval, drift, contract-diff, budgets, manifest, roadmap. +- **The error contract, as gate steps.** Every failure carries a stable `X_*` code, a cause, a runnable `fix:` and a `--json` form. `x verify` fails a `fix:` that names no command, and an `X_*` code with no documented row. + +### Fixed + +- A lockstep release now rewrites sibling `@ultimat3/*` pins, not only each package's own version. Moving versions alone would have published `@ultimat3/jobs@1.0.0` naming `@ultimat3/core@0.0.1` — a version that is not on the registry, so every install of the release would fail. +- Version skew is a `package-shape` finding (`X_RELEASE_VERSION_SKEW`), so it fails the gate instead of reaching npm. +- A changelog entry inserts under `[Unreleased]` instead of appending, which keeps the file newest-first past the second release. ### Notes -Pre-alpha. Nothing here is production-ready. Milestones 0–5 are the path to usable — see [docs/idea/14-roadmap.md](docs/idea/14-roadmap.md). Remote drivers (Redis, NATS, real S3, Postgres logical replication) are interface-complete and throw `X_NOT_IMPLEMENTED` with a fix line rather than pretending to work. +Not claimed at 1.0.0, named here rather than left to be discovered: + +| Open | Where it stands | +|---|---| +| Realtime capacity | no published benchmark. The 50k-socket forced-restart number is unmeasured; documented capacity figures are targets, not results | +| Two-platform deploy proof | `x build --target docker\|binary\|static`, both compose files and the Helm chart ship. The demo app on Compose **and** K8s from one image, with an invisible rolling restart, is [milestone 11](docs/idea/14-roadmap.md) and is not yet demonstrated | +| Deferred to v2 | realtime tier 3 local-first (`persist: true`), the plugin API, multi-region replication, and the Redis/NATS **job** drivers — each behind an interface that ships today, throwing `X_NOT_IMPLEMENTED` with a runnable `fix:` rather than pretending to work | + +## [0.0.1] - 2026-07-26 + +Repository bootstrap: monorepo layout, tier-enforced package boundaries, Biome and strict TypeScript, free-runner CI, npm OIDC trusted publishing, and the design docs. Never published to npm. diff --git a/CLAUDE.md b/CLAUDE.md index 27bd27e55..23e02ed6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,16 @@ This repo is the framework itself: a monorepo of `@ultimat3/*` packages, the `x` CLI binary: `x`. npm scope: `@ultimat3`. Import paths: `@ultimat3/`. -**Status:** pre-alpha. Architecture + docs + package skeletons landed; milestones 0–5 are the path to usable. See [`docs/idea/14-roadmap.md`](docs/idea/14-roadmap.md). +**Status:** 1.0.0, `As of 2026-08`. 27 `@ultimat3/*` packages plus the unscoped `create-ultimate` — +28 in all — publish to npm at 1.0.0 in lockstep: one version, one commit, one tag, through OIDC +trusted publishing. Semver applies from here — a breaking change to a documented API needs a major, +and the eight primitive shapes, the `x` CLI surface and the tier table are now as stable as the +`X_*` codes already were. Two things stay open: the realtime capacity benchmark (the 50k-socket +forced-restart number is unmeasured — every capacity figure in the docs is a target, not a result), +and roadmap milestone 11's two-platform deploy proof (`x build --target docker|binary|static`, the +dev/prod compose files and the Helm chart all ship; the demo app running on Compose **and** K8s +from one image with an invisible rolling restart is not yet demonstrated). See +[`docs/idea/14-roadmap.md`](docs/idea/14-roadmap.md) for the milestone detail. ## Design axioms (override any instinct that conflicts) @@ -29,7 +38,7 @@ CLI binary: `x`. npm scope: `@ultimat3`. Import paths: `@ultimat3/`. | Task | Command | |---|---| | install | `bun install` | -| **the gate** | `bun run verify` — `x verify` at the repo root: typecheck, lint, boundaries, sizes, shape, every test type, drift, contracts, budgets, manifest. Green = shippable. | +| **the gate** | `bun run verify` — `x verify` at the repo root, 17 steps: typecheck, lint, boundaries, filesize, package-shape, errors, unit, contract, live, job, e2e, eval, drift, contract-diff, budgets, manifest, roadmap. Green = shippable. | | typecheck | `bun run typecheck` | | lint | `bun run lint` · fix: `bun run lint:fix` | | test (all) | `bun run test` — every framework suite, opt-in ones included. The reference app is gated separately: `cd examples/dummy && bun run ../../packages/cli/src/bin.ts verify` | diff --git a/PUBLISHING.md b/PUBLISHING.md index deffb70fe..d61ec2068 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -14,18 +14,35 @@ automatically. other by tier, so a mixed-version install is a combination nobody tested. There is no independent package version, no changeset-per-package, and no "just bump the one that changed". +The rule is enforced, not documented: `x verify`'s **package-shape** step reports +`X_RELEASE_VERSION_SKEW` for a published package off the lockstep version, and for a sibling +`@ultimat3/*` pin left behind. Skew fails the gate rather than reaching the registry, where it +would surface as a failed install of a version that cannot be unpublished. + ```sh -bun run scripts/release.ts --bump minor # or --version 0.4.0 -git add -A && git commit -m "release: 0.4.0" -git tag v0.4.0 && git push --follow-tags +bun run scripts/release.ts --bump minor # or --version 1.1.0 +bun install # regenerate bun.lock, by hand — never Dependabot +bun run verify # the gate, including the lockstep check +git add -A && git commit -m "release: 1.1.0" +git tag v1.1.0 && git push --follow-tags ``` Then publish a GitHub Release for the tag — that is what triggers the workflow. +The release script rewrites **every** workspace manifest, not just the ones that publish: the +reference app under `examples/` is private and never reaches npm, but it resolves `@ultimat3/*` +out of the same lockfile, so one pin left at the old version sends `bun install --frozen-lockfile` +to the registry for a version that is not there. + +`bun.lock` is regenerated by hand and committed with the release. **Dependabot cannot write it** — +a dependency PR that touches versions arrives with a lockfile that has to be regenerated locally, +and installing over a conflicted lock produces a resolution nobody reviewed. + ## One-time bootstrap (manual, requires npm auth) -A trusted publisher can only be attached to a package that **already exists**. The first version -of each package must be published by hand by a member of the `ultimate` npm org, in tier order: +A trusted publisher can only be attached to a package that **already exists**. The first version — +**1.0.0** — must be published by hand by a member of the `ultimate` npm org, in tier order. Every +release after it goes through the workflow: ```sh npm login # as an @ultimat3 org member diff --git a/README.md b/README.md index 93c2bea80..bdd860c96 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,19 @@ [![CI](https://github.com/developerz-ai/ultimate/actions/workflows/ci.yml/badge.svg)](https://github.com/developerz-ai/ultimate/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Bun](https://img.shields.io/badge/bun-%E2%89%A5%201.3-black.svg?logo=bun)](https://bun.sh) -[![Status](https://img.shields.io/badge/status-pre--alpha-orange.svg)](docs/idea/14-roadmap.md) +[![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)](CHANGELOG.md) -> **Status: pre-alpha.** The architecture, the docs, and the package skeletons are in place. Milestones 0–5 are the path to usable — see the [roadmap](docs/idea/14-roadmap.md). Nothing here is production-ready yet, and this README will say so until it isn't. +> **Status: 1.0.0**, `As of 2026-08`. 27 `@ultimat3/*` packages plus the unscoped `create-ultimate` — 28 in all — publish to npm in lockstep: one version, one commit, one tag. Semver applies from here — a breaking change to a documented API needs a major. That is what 1.0.0 means: a stable API under semver, not a promise about your infrastructure. + +**Not claimed at 1.0.0:** + +| Open | Where it stands | +|---|---| +| **Realtime capacity** | no published benchmark. The 50k-socket forced-restart number is unmeasured; the capacity figures in the docs are targets, not results | +| **Two-platform deploy proof** | `x build --target docker\|binary\|static`, the dev/prod compose files and the Helm chart all ship. The demo app on Compose **and** K8s from one image, with an invisible rolling restart, is [milestone 11](docs/idea/14-roadmap.md) and is not yet demonstrated | +| **Deferred to v2** | realtime tier 3 local-first (`persist: true`), the plugin API, multi-region replication, the Redis/NATS **job** drivers — each behind the interface that ships today. The job drivers throw `X_NOT_IMPLEMENTED` with a runnable `fix:` rather than pretending to work | --- @@ -45,6 +53,8 @@ bunx create-ultimate myapp && cd myapp && x dev No Docker. No env scavenger hunt. Embedded Postgres, in-process NATS, S3 → a local directory. What you get is a running app with auth, a seeded database, a working example route, and a dev dashboard at `/_x`. +Every `@ultimat3/*` dependency it writes is pinned to one exact version. They move together — never mix versions across the scope. + ## One `action`, six artifacts This is the load-bearing idea. You write one declaration: @@ -112,13 +122,13 @@ Not "supported". Not "documented". **Enforced, and impossible to get wrong.** ## Realtime — a ladder, not a cliff -Three tiers, the same mutator shape at every rung. Tier 2 → tier 3 is a config flag, not a rewrite. +Three tiers, the same mutator shape at every rung. Tier 2 → tier 3 is a config flag, not a rewrite. Tiers 1–2 ship in 1.0.0; tier 3 lands in v2, behind the interfaces that are already here. | Tier | What | Covers | |---|---|---| | 1 · **Channels** | `ctx.publish(topic, msg)` over Bun's native WS pub/sub | presence, cursors, notifications | | 2 · **Live queries** | declare server-side with a policy, receive a Solid signal | **90% of "realtime app"** | -| 3 · **Local-first** | optimistic mutators, OPFS SQLite, offline queue, rebase | offline writes that reconcile | +| 3 · **Local-first** *(v2)* | optimistic mutators, OPFS SQLite, offline queue, rebase | offline writes that reconcile | → [Realtime design and its honest limits](docs/idea/03-realtime.md) @@ -128,15 +138,15 @@ Three tiers, the same mutator shape at every rung. Tier 2 → tier 3 is a config |---|---|---| | Runtime | **Bun ≥ 1.3, only** | native SQL / Redis / S3 / WS / test / bundler / image — kills ~15 deps | | HTTP | thin layer over `Bun.serve` | we own the lifecycle, so context/tracing/authz can't be skipped | -| DB | **Postgres + Drizzle** | SQL-transparent, so an agent reads the generated SQL and self-corrects | -| Validation | Standard Schema, **ArkType** default | swappable interface, one blessed default | +| DB | **Postgres**, no ORM | `entity()` is the one table declaration; `postgresDriver()` emits hand-written parameterised SQL, so an agent reads the statement and self-corrects | +| Validation | **Standard Schema**, builtin provider default | dependency-free and shipped; ArkType/Zod/Valibot swap in behind `configureSchemaProvider()` with a ~40-line adapter you write | | Auth | **Better Auth**, wrapped | MIT, self-hosted, with our policy layer on top | | Frontend | **SolidJS 2** + our own router | fine-grained reactivity; we vendor the router rather than track an alpha | | Styling | **SCSS modules + design tokens** | no Tailwind (diff noise), no CSS-in-JS (runtime cost) | -| Jobs | Postgres queue default, Redis/NATS drivers | zero-infra start, a real scale path | +| Jobs | Postgres queue default; Redis/NATS drivers in v2 | zero-infra start, a real scale path behind one interface | | Observability | **OpenTelemetry, always on** | one trace across HTTP → job → live query | -**Excluded on purpose:** GraphQL · multi-runtime · multi-ORM · a second CSS solution · React Server Components · a plugin API before v1 · vendor edge/KV primitives. +**Excluded on purpose:** GraphQL · multi-runtime · multi-ORM · a second CSS solution · React Server Components · a plugin API in 1.x · vendor edge/KV primitives. ## Steal explicitly @@ -177,7 +187,7 @@ myapp/ desktop/ Tauri, later packages/ domain/ pure types + constants, no I/O - db/ Drizzle schema + migrations, no business logic + db/ entity declarations + SQL migrations, no business logic core/ your business services — shared by web, admin, worker i18n/ your catalogs ui/ your components, on top of @ultimat3/ui @@ -218,14 +228,14 @@ Every command takes `--json`. → [CLI reference](wiki/CLI-Reference.md) ```sh bun install -bun run verify # typecheck + lint + boundaries + tests. Green = shippable. +bun run verify # the 17-step gate: typecheck → lint → boundaries → tests → drift → budgets → manifest → roadmap. Green = shippable. ``` Read [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/architecture/00-conventions.md](docs/architecture/00-conventions.md) first. The tier boundaries in that second file are enforced by `bun run boundaries` — a sideways import fails the build, by design. ## Roadmap -Twelve milestones, each ending in a working demo app and a green `x verify`. **The sequencing rule: ship 0–5 before touching realtime.** A framework with great DX, jobs, and SEO is already shippable; a half-built sync engine is worthless. +Twelve milestones, each ending in a working demo app and a green `x verify`. **Milestones 0–10 are shipped.** Milestone 11 — deploy, docs, 1.0 — is open on one thing: the demo app proven on Compose **and** K8s from a single image, rolling restart invisible. The status markers in that table are enforced by `x verify`'s `roadmap` step, so they cannot quietly rot. → [The full roadmap](docs/idea/14-roadmap.md) · [The risks, stated plainly](docs/idea/15-risks.md) diff --git a/SECURITY.md b/SECURITY.md index b076a1410..73f44041c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,14 @@ ## Supported versions -Pre-alpha. No version is supported for production use yet, and no security backports exist. Track [the roadmap](docs/idea/14-roadmap.md). +Framework packages move in lockstep, so "supported version" is one number covering all 28 — the 27 `@ultimat3/*` packages and the unscoped `create-ultimate`. + +| Version | Supported | +|---|---| +| `1.0.x` | ✅ security fixes | +| `< 1.0.0` | ❌ pre-release, never supported — upgrade | + +A fix ships as a patch to every package in one release, on the [same lockstep rule as any other release](PUBLISHING.md). There are no per-package security branches and no backports below 1.0.0. Report against the latest `1.0.x`. ## Reporting a vulnerability @@ -26,8 +33,10 @@ Design decisions that carry security weight, so you know what to audit: | **CSP** | Locked defaults. The theme inline script ships with its sha256 hash so no `unsafe-inline` is needed. | | **Egress in tests** | Sealed by default. Any unmocked outbound request fails the test. | -## Known gaps (pre-alpha) +## Known gaps + +Open at 1.0.0, `As of 2026-08`. None of these is closed by the release; audit accordingly. -- Better Auth integration is wrapped but not yet hardened or audited. -- Rate limiting ships an in-memory default; the distributed store is interface-only. -- The sync protocol has not had an adversarial review. Do not expose tier-3 local-first sync to untrusted clients yet. +- **No third-party security audit.** The auth stack has had no external review. Better Auth binds through `AuthAdapter` rather than being a dependency, so the seam is narrow and swappable — but neither the built-in adapter nor a Better Auth binding has been audited by anyone outside this repo. +- **Rate limiting is per-replica.** `RateLimitStore` is an interface with one shipped implementation, `memoryRateLimitStore()`; the Redis/Postgres store is still interface-only. N replicas therefore enforce N × the configured bucket. Terminate rate limiting at a shared proxy if the limit has to hold across the fleet. The credential-path throttle in `@ultimat3/auth` (per-IP and per-account lockout) has the same per-process scope. +- **The sync protocol has had no adversarial review.** Tiers 1–2 ship; tier 3 local-first (`persist: true`) is deferred to v2, and its OPFS store throws until the browser entry ships — so the untrusted-client exposure is closed by absence, not by review. When tier 3 arrives, the protocol still needs that review first. diff --git a/bun.lock b/bun.lock index 3481d7663..ac4cc688a 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ "dependencies": { "@postly/db": "0.0.1", "@postly/web": "0.0.1", - "@ultimat3/admin": "0.0.1", + "@ultimat3/admin": "1.0.0", }, }, "examples/dummy/apps/web": { @@ -31,20 +31,20 @@ "@postly/domain": "0.0.1", "@postly/i18n": "0.0.1", "@postly/ui": "0.0.1", - "@ultimat3/action": "0.0.1", - "@ultimat3/ai": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/jobs": "0.0.1", - "@ultimat3/mail": "0.0.1", - "@ultimat3/policy": "0.0.1", - "@ultimat3/query": "0.0.1", - "@ultimat3/realtime": "0.0.1", - "@ultimat3/render": "0.0.1", - "@ultimat3/schema": "0.0.1", - "@ultimat3/seo": "0.0.1", - "@ultimat3/testing": "0.0.1", - "@ultimat3/time": "0.0.1", - "@ultimat3/ui": "0.0.1", + "@ultimat3/action": "1.0.0", + "@ultimat3/ai": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/jobs": "1.0.0", + "@ultimat3/mail": "1.0.0", + "@ultimat3/policy": "1.0.0", + "@ultimat3/query": "1.0.0", + "@ultimat3/realtime": "1.0.0", + "@ultimat3/render": "1.0.0", + "@ultimat3/schema": "1.0.0", + "@ultimat3/seo": "1.0.0", + "@ultimat3/testing": "1.0.0", + "@ultimat3/time": "1.0.0", + "@ultimat3/ui": "1.0.0", "solid-js": "2.0.0-experimental.16", }, }, @@ -53,9 +53,9 @@ "version": "0.0.1", "dependencies": { "@postly/domain": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/money": "0.0.1", - "@ultimat3/time": "0.0.1", + "@ultimat3/core": "1.0.0", + "@ultimat3/money": "1.0.0", + "@ultimat3/time": "1.0.0", }, }, "examples/dummy/packages/db": { @@ -63,24 +63,24 @@ "version": "0.0.1", "dependencies": { "@postly/domain": "0.0.1", - "@ultimat3/cache": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/entity": "0.0.1", + "@ultimat3/cache": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/entity": "1.0.0", }, }, "examples/dummy/packages/domain": { "name": "@postly/domain", "version": "0.0.1", "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/money": "0.0.1", + "@ultimat3/core": "1.0.0", + "@ultimat3/money": "1.0.0", }, }, "examples/dummy/packages/i18n": { "name": "@postly/i18n", "version": "0.0.1", "dependencies": { - "@ultimat3/i18n": "0.0.1", + "@ultimat3/i18n": "1.0.0", }, }, "examples/dummy/packages/mcp": { @@ -89,11 +89,11 @@ "dependencies": { "@postly/core": "0.0.1", "@postly/domain": "0.0.1", - "@ultimat3/action": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/mcp": "0.0.1", - "@ultimat3/schema": "0.0.1", - "@ultimat3/testing": "0.0.1", + "@ultimat3/action": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/mcp": "1.0.0", + "@ultimat3/schema": "1.0.0", + "@ultimat3/testing": "1.0.0", }, }, "examples/dummy/packages/ui": { @@ -102,105 +102,105 @@ "dependencies": { "@postly/domain": "0.0.1", "@postly/i18n": "0.0.1", - "@ultimat3/money": "0.0.1", - "@ultimat3/ui": "0.0.1", + "@ultimat3/money": "1.0.0", + "@ultimat3/ui": "1.0.0", "solid-js": "2.0.0-experimental.16", }, }, "packages/action": { "name": "@ultimat3/action", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/cache": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/http": "0.0.1", - "@ultimat3/policy": "0.0.1", - "@ultimat3/schema": "0.0.1", + "@ultimat3/cache": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/http": "1.0.0", + "@ultimat3/policy": "1.0.0", + "@ultimat3/schema": "1.0.0", }, }, "packages/admin": { "name": "@ultimat3/admin", - "version": "0.0.1", - "dependencies": { - "@ultimat3/action": "0.0.1", - "@ultimat3/ai": "0.0.1", - "@ultimat3/cache": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/entity": "0.0.1", - "@ultimat3/i18n": "0.0.1", - "@ultimat3/jobs": "0.0.1", - "@ultimat3/mcp": "0.0.1", - "@ultimat3/money": "0.0.1", - "@ultimat3/policy": "0.0.1", - "@ultimat3/query": "0.0.1", - "@ultimat3/render": "0.0.1", - "@ultimat3/ui": "0.0.1", + "version": "1.0.0", + "dependencies": { + "@ultimat3/action": "1.0.0", + "@ultimat3/ai": "1.0.0", + "@ultimat3/cache": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/entity": "1.0.0", + "@ultimat3/i18n": "1.0.0", + "@ultimat3/jobs": "1.0.0", + "@ultimat3/mcp": "1.0.0", + "@ultimat3/money": "1.0.0", + "@ultimat3/policy": "1.0.0", + "@ultimat3/query": "1.0.0", + "@ultimat3/render": "1.0.0", + "@ultimat3/ui": "1.0.0", }, }, "packages/ai": { "name": "@ultimat3/ai", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/action": "0.0.1", - "@ultimat3/cache": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/db": "0.0.1", - "@ultimat3/money": "0.0.1", - "@ultimat3/policy": "0.0.1", - "@ultimat3/schema": "0.0.1", - "@ultimat3/time": "0.0.1", + "@ultimat3/action": "1.0.0", + "@ultimat3/cache": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/db": "1.0.0", + "@ultimat3/money": "1.0.0", + "@ultimat3/policy": "1.0.0", + "@ultimat3/schema": "1.0.0", + "@ultimat3/time": "1.0.0", }, }, "packages/auth": { "name": "@ultimat3/auth", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/db": "0.0.1", - "@ultimat3/schema": "0.0.1", + "@ultimat3/core": "1.0.0", + "@ultimat3/db": "1.0.0", + "@ultimat3/schema": "1.0.0", }, }, "packages/cache": { "name": "@ultimat3/cache", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", + "@ultimat3/core": "1.0.0", }, }, "packages/cli": { "name": "@ultimat3/cli", - "version": "0.0.1", + "version": "1.0.0", "bin": { "x": "./src/bin.ts", }, "dependencies": { - "@ultimat3/action": "0.0.1", - "@ultimat3/admin": "0.0.1", - "@ultimat3/ai": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/db": "0.0.1", - "@ultimat3/entity": "0.0.1", - "@ultimat3/http": "0.0.1", - "@ultimat3/i18n": "0.0.1", - "@ultimat3/jobs": "0.0.1", - "@ultimat3/mail": "0.0.1", - "@ultimat3/manifest": "0.0.1", - "@ultimat3/mcp": "0.0.1", - "@ultimat3/policy": "0.0.1", - "@ultimat3/query": "0.0.1", - "@ultimat3/realtime": "0.0.1", - "@ultimat3/render": "0.0.1", - "@ultimat3/storage": "0.0.1", - "@ultimat3/testing": "0.0.1", + "@ultimat3/action": "1.0.0", + "@ultimat3/admin": "1.0.0", + "@ultimat3/ai": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/db": "1.0.0", + "@ultimat3/entity": "1.0.0", + "@ultimat3/http": "1.0.0", + "@ultimat3/i18n": "1.0.0", + "@ultimat3/jobs": "1.0.0", + "@ultimat3/mail": "1.0.0", + "@ultimat3/manifest": "1.0.0", + "@ultimat3/mcp": "1.0.0", + "@ultimat3/policy": "1.0.0", + "@ultimat3/query": "1.0.0", + "@ultimat3/realtime": "1.0.0", + "@ultimat3/render": "1.0.0", + "@ultimat3/storage": "1.0.0", + "@ultimat3/testing": "1.0.0", }, }, "packages/core": { "name": "@ultimat3/core", - "version": "0.0.1", + "version": "1.0.0", }, "packages/create-ultimate": { "name": "create-ultimate", - "version": "0.0.1", + "version": "1.0.0", "bin": { "create-ultimate": "./src/bin.ts", }, @@ -210,9 +210,9 @@ }, "packages/db": { "name": "@ultimat3/db", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", + "@ultimat3/core": "1.0.0", }, "peerDependencies": { "@electric-sql/pglite": ">=0.5.0", @@ -223,165 +223,165 @@ }, "packages/entity": { "name": "@ultimat3/entity", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/db": "0.0.1", - "@ultimat3/schema": "0.0.1", + "@ultimat3/core": "1.0.0", + "@ultimat3/db": "1.0.0", + "@ultimat3/schema": "1.0.0", }, }, "packages/http": { "name": "@ultimat3/http", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/schema": "0.0.1", + "@ultimat3/core": "1.0.0", + "@ultimat3/schema": "1.0.0", }, }, "packages/i18n": { "name": "@ultimat3/i18n", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", + "@ultimat3/core": "1.0.0", }, }, "packages/jobs": { "name": "@ultimat3/jobs", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/entity": "0.0.1", - "@ultimat3/schema": "0.0.1", - "@ultimat3/time": "0.0.1", + "@ultimat3/core": "1.0.0", + "@ultimat3/entity": "1.0.0", + "@ultimat3/schema": "1.0.0", + "@ultimat3/time": "1.0.0", }, }, "packages/mail": { "name": "@ultimat3/mail", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/i18n": "0.0.1", - "@ultimat3/jobs": "0.0.1", - "@ultimat3/schema": "0.0.1", - "@ultimat3/time": "0.0.1", + "@ultimat3/core": "1.0.0", + "@ultimat3/i18n": "1.0.0", + "@ultimat3/jobs": "1.0.0", + "@ultimat3/schema": "1.0.0", + "@ultimat3/time": "1.0.0", }, }, "packages/manifest": { "name": "@ultimat3/manifest", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/action": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/entity": "0.0.1", - "@ultimat3/jobs": "0.0.1", - "@ultimat3/query": "0.0.1", + "@ultimat3/action": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/entity": "1.0.0", + "@ultimat3/jobs": "1.0.0", + "@ultimat3/query": "1.0.0", }, }, "packages/mcp": { "name": "@ultimat3/mcp", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/action": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/entity": "0.0.1", - "@ultimat3/jobs": "0.0.1", - "@ultimat3/policy": "0.0.1", - "@ultimat3/query": "0.0.1", - "@ultimat3/schema": "0.0.1", + "@ultimat3/action": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/entity": "1.0.0", + "@ultimat3/jobs": "1.0.0", + "@ultimat3/policy": "1.0.0", + "@ultimat3/query": "1.0.0", + "@ultimat3/schema": "1.0.0", }, }, "packages/money": { "name": "@ultimat3/money", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", + "@ultimat3/core": "1.0.0", }, }, "packages/policy": { "name": "@ultimat3/policy", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", + "@ultimat3/core": "1.0.0", }, }, "packages/pwa": { "name": "@ultimat3/pwa", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", + "@ultimat3/core": "1.0.0", }, }, "packages/query": { "name": "@ultimat3/query", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/cache": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/policy": "0.0.1", - "@ultimat3/schema": "0.0.1", + "@ultimat3/cache": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/policy": "1.0.0", + "@ultimat3/schema": "1.0.0", }, }, "packages/realtime": { "name": "@ultimat3/realtime", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/query": "0.0.1", + "@ultimat3/core": "1.0.0", + "@ultimat3/query": "1.0.0", }, }, "packages/render": { "name": "@ultimat3/render", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/cache": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/seo": "0.0.1", + "@ultimat3/cache": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/seo": "1.0.0", }, }, "packages/schema": { "name": "@ultimat3/schema", - "version": "0.0.1", + "version": "1.0.0", }, "packages/seo": { "name": "@ultimat3/seo", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", + "@ultimat3/core": "1.0.0", }, }, "packages/storage": { "name": "@ultimat3/storage", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", + "@ultimat3/core": "1.0.0", }, }, "packages/testing": { "name": "@ultimat3/testing", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/db": "0.0.1", - "@ultimat3/jobs": "0.0.1", - "@ultimat3/mail": "0.0.1", - "@ultimat3/time": "0.0.1", + "@ultimat3/core": "1.0.0", + "@ultimat3/db": "1.0.0", + "@ultimat3/jobs": "1.0.0", + "@ultimat3/mail": "1.0.0", + "@ultimat3/time": "1.0.0", }, }, "packages/time": { "name": "@ultimat3/time", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", + "@ultimat3/core": "1.0.0", }, }, "packages/ui": { "name": "@ultimat3/ui", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/i18n": "0.0.1", - "@ultimat3/money": "0.0.1", - "@ultimat3/time": "0.0.1", + "@ultimat3/core": "1.0.0", + "@ultimat3/i18n": "1.0.0", + "@ultimat3/money": "1.0.0", + "@ultimat3/time": "1.0.0", }, "peerDependencies": { "solid-js": "^2.0.0", diff --git a/docs/architecture/01-package-map.md b/docs/architecture/01-package-map.md index 7719657e5..273dbdf51 100644 --- a/docs/architecture/01-package-map.md +++ b/docs/architecture/01-package-map.md @@ -44,7 +44,7 @@ Decided **2026-08**, when the Postgres entity driver needed a home. `db` imports | Package | Tier | Responsibility (one line) | Owns | Must never | |---|---|---|---|---| | `core` | 0 | `UltimateError`, ALS request context, ids, build ID, typed env, the image pipeline | the error base + code registry, `ctx` shape, cross-tier interface types, the logger, the one decode/resize/encode path | import any `@ultimat3/*`; do I/O beyond `process.env` and stdout | -| `schema` | 0 | Standard Schema façade; ArkType exposed as `t`; JSON Schema emit | `t`, `parse`, `toJsonSchema`, the env schema helper | know about HTTP, DB, or locales | +| `schema` | 0 | Standard Schema façade; the dependency-free builtin provider exposed as `t`; JSON Schema emit | `t`, `parse`, `toJsonSchema`, `configureSchemaProvider()` — the swap point a third-party adapter plugs into | know about HTTP, DB, or locales; ship an adapter for ArkType, Zod or Valibot | | `i18n` | 1 | translator, catalog flattening, locale negotiation, loud misses | `t()`, catalog format, `⟦key⟧` rendering, plural selection via CLDR | read a request object; format money | | `money` | 1 | integer minor units with an attached currency | `Money`, arithmetic, `allocate`, ISO exponent table, `Intl` formatting | floats; cross-currency arithmetic; a bare number as a total | | `time` | 1 | UTC instants, zone math, cron, durations | `Instant`, `ZonedFormat`, cron parse/next, duration parse (`'3d'`) | format without an explicit IANA `timeZone` | @@ -70,6 +70,7 @@ Decided **2026-08**, when the Postgres entity driver needed a home. `db` imports | `admin` | 5 | generated admin dashboard, itself an Ultimate app with MCP on | admin screens derived from entities, its MCP surface | bypass `policy`; ship in the app bundle graph | | `testing` | 5 | the six test runners, template DB, frozen clock, sealed network | fixture shapes, DB cloning, seeded RNG, egress trap | appear in a production bundle | | `cli` | 5 | the `x` binary: generators, dev server, `verify` orchestration | command surface, `--json` output, generator templates, composition wiring | contain framework logic — it delegates | +| `create-ultimate` | unlisted (6) | the published `bunx create-ultimate` shim | the `create-ultimate` bin, argument forwarding into `x new` | reimplement a template `cli` already owns | ## Dependency graph @@ -77,6 +78,7 @@ Arrow = imports. Only representative edges are drawn; the tier rule is the compl ```mermaid graph TD + create-ultimate["create-ultimate (unlisted)"] subgraph T5["tier 5"] cli; testing; admin; ui end @@ -96,6 +98,8 @@ graph TD core; schema end + create-ultimate --> cli + cli --> manifest cli --> render cli --> pwa diff --git a/docs/architecture/02-boundaries.md b/docs/architecture/02-boundaries.md index 717a30f9f..8b149c122 100644 --- a/docs/architecture/02-boundaries.md +++ b/docs/architecture/02-boundaries.md @@ -23,7 +23,7 @@ One error code for the whole family: `X_BOUNDARY_VIOLATION`, plus a `rule` field | `site-imports-app` | `site/` → `app/`, transitively | the marketing page that ships a charting library three hops away from any reviewed file ([`../idea/06-surfaces.md`](../idea/06-surfaces.md)) | | `shared-imports-surface` | `shared/` → `site/` or `app/` | `shared/` stops being a leaf; the graph becomes bidirectional and unbudgetable | | `app-imports-api-runtime` | `app/` → `api/` as a value import | bundling server handlers into the client. `import type` is allowed; call the typed client instead | -| `route-touches-db` | a route module importing `db` / a Drizzle table / `Bun.sql` | N+1 queries in a `` computation, SQL that no policy guards, and a route that cannot be unit-tested | +| `route-touches-db` | a route module importing `db` / `drizzle-orm` / `Bun.sql` | N+1 queries in a `` computation, SQL that no policy guards, and a route that cannot be unit-tested | | `component-holds-logic` | `ui/**` importing `repo.ts` / `service.ts` / `db` | business rules duplicated per component, and a rule that changes in one screen but not the other | | `service-imports-http` | `service.ts` importing `Request`/`Response`/`@ultimat3/http` | a service that only works inside a request — so the identical logic gets re-implemented in a job | | `cross-feature-repo` | `feature-a/**` → `feature-b/repo.ts` | "just add a join" turning a feature slice into a distributed monolith | diff --git a/docs/architecture/05-type-chain.md b/docs/architecture/05-type-chain.md index d4c104c22..7d3b8857c 100644 --- a/docs/architecture/05-type-chain.md +++ b/docs/architecture/05-type-chain.md @@ -12,10 +12,10 @@ Every hop is inference, not generation. A generated artifact can be stale; an in | # | Hop | Mechanism | Stale-able? | |---|---|---|---| -| 1 | column → row type | Drizzle `$inferSelect` / `$inferInsert` | no | -| 2 | row type → entity domain type | `entity()` wraps the table; invariants narrow the type (branded ids, non-empty strings) | no | +| 1 | column → row type | `RowOf`, derived from the `columns` object — there is no ORM and no second table declaration | no | +| 2 | column set → entity domain type | `entity(name, init)` binds the tenant column and the invariants; `$parse` is the write boundary | no | | 3 | entity → repo signatures | repo factory is generic over the entity | no | -| 4 | entity → view schema | `view(posts, ['id','title','excerpt'])` — a compile error if a field does not exist | no | +| 4 | entity → view schema | `posts.$view(['id','title','excerpt'])` — a compile error if a field does not exist | no | | 5 | view → action `output` | assignment; the handler's return type must satisfy it | no | | 6 | `input`/`output` → JSON Schema | Standard Schema → `toJsonSchema()` at build | **generated** — drift is `X_MANIFEST_STALE` | | 7 | action declaration → typed client | `typeof publishPost` projected to `(input) => Promise` | no | @@ -27,25 +27,35 @@ Only hops 6 and 9 emit files, and both have a drift check in `x verify`. Everyth ## Worked example ```ts -// packages/db/src/schema/posts.ts ← hop 1 -export const posts = pgTable('posts', { - id: uuid('id').primaryKey().defaultRandom(), - orgId: uuid('org_id').notNull(), - title: text('title').notNull(), - excerpt: text('excerpt').notNull(), - cover: text('cover'), - publishedAt: timestamp('published_at', { withTimezone: true }), +// packages/db/src/schema/posts.ts ← hops 1, 2 +import { entity, integer, invariant, text, timestamp, url, uuid } from '@ultimat3/entity'; + +export const posts = entity('posts', { + columns: { + id: uuid().primaryKey(), + orgId: uuid().references(() => orgs.id, { onDelete: 'cascade' }).tenant(), + title: text({ max: TITLE_MAX }), + excerpt: text({ max: EXCERPT_MAX }), + cover: url().nullable(), + likeCount: integer().default(0), + publishedAt: timestamp().nullable(), + }, + invariants: [invariant('post_title_present', (c) => c.title.trimmed().minLength(1))], }); + +export type Post = typeof posts.$row; ``` +`entity(name, init)` is name-first, and `init` is `{ columns, tenant?, primaryKey?, invariants?, +indexes?, tags? }`. `tenant: 'orgId'` in `init` is the said-out-loud form of the `.tenant()` marker +above; `init` wins when both appear, and with neither, a column named `orgId` is still inferred — +silence never means unscoped. + ```ts -// apps/web/app/posts/entity.ts ← hops 2, 4 -export const Post = entity(posts, { - tenant: 'orgId', - invariants: [inv('title-not-blank', (p) => p.title.trim().length > 0)], -}); +// apps/web/app/posts/entity.ts ← hop 4 +export const PostView = posts.$view(['id', 'title', 'excerpt', 'cover', 'publishedAt']); -export const PostView = view(Post, ['id', 'title', 'excerpt', 'cover', 'publishedAt']); +export type PostView = typeof PostView.$row; ``` ```ts @@ -83,7 +93,7 @@ Rename `excerpt` → `summary` in `packages/db/src/schema/posts.ts` and change n | Where it breaks | Error | Message shape | |---|---|---| -| `apps/web/app/posts/entity.ts` | `tsc` | `view(Post, [...])` — `'excerpt'` is not assignable to `keyof Post` | +| `apps/web/app/posts/entity.ts` | `tsc` | `posts.$view([...])` — `'excerpt'` is not assignable to `keyof Post` | | `apps/web/api/posts.ts` | `tsc` | handler return type missing `summary`, only after `PostView` is fixed | | `apps/web/app/posts/ui/post-card.tsx` | `tsc` | `Property 'excerpt' does not exist on type 'PostView'` | | `apps/web/site/blog/[slug]/page.tsx` | `tsc` | `meta: ({ post }) => ({ description: post.excerpt })` — same error, in the SEO callback | @@ -121,7 +131,7 @@ Be honest about the seams. Each one is an explicit, greppable parse — never a | Seam | Risk | Required handling | |---|---|---| -| `jsonb` columns | Drizzle infers `unknown` | declare `$type()` **and** parse with a schema on read | +| a JSON payload column | no `jsonb()` builder ships at 1.0.0 — `ColumnKind` reserves the kind, nothing derives a type for it | parse with a schema on read; never a cast | | raw SQL (`sql\`...\``) | result shape is asserted, not inferred | wrap in a repo function whose return value is schema-parsed | | external HTTP / webhooks | `any` | `t` parse at the boundary; failure is `X_INPUT_INVALID` | | `process.env` | `string \| undefined` | the env schema below | @@ -132,26 +142,42 @@ Be honest about the seams. Each one is an explicit, greppable parse — never a ## Typed env, validated at boot +`defineEnv` runs at **module scope inside `app.config.ts`** — the one config file, and the one +root marker the CLI walks up to find ([`app-root.ts`](../../packages/cli/src/app-root.ts)). There +is no `env.ts` convention and no separate discovery mechanism: importing the config is what parses +the environment, so a bad env fails before the first listener binds. + ```ts // app.config.ts -export const env = envSchema({ - DATABASE_URL: t.string.url, - REDIS_URL: t.string.url.optional(), - ROLE: t('"web"|"sync"|"worker"|"scheduler"|"migrate"|"replicator"'), - DRAIN_TIMEOUT_MS: t.number.integer.default(30_000), - DEFAULT_LOCALE: t.string.default('en-US'), - DEFAULT_TZ: t.string.default('UTC'), - VAPID_PUBLIC: t.string.optional(), +import { defineConfig, defineEnv } from '@ultimat3/core'; + +const env = defineEnv({ + DATABASE_URL: { type: 'url', secret: true }, + REDIS_URL: { type: 'url', required: false }, + NATS_URL: { type: 'url', role: 'sync' }, // required for ROLE=sync only + DRAIN_TIMEOUT_MS: { type: 'integer', default: 30_000 }, + VAPID_PUBLIC: { type: 'string', required: false }, +}); + +export const config = defineConfig({ + name: 'postly', + database: { urlEnv: 'DATABASE_URL', poolSize: 12 }, + realtime: { enabled: true, tier: 'live-queries', transport: 'nats', urlEnv: 'NATS_URL' }, }); ``` | Property | Behavior | |---|---| | When | at boot, before the first listener binds — a bad env fails in ~40ms, not on the first request | -| Failure | `X_CONFIG_INVALID`, listing **every** missing/invalid key at once, with the expected type per key | -| Access | `env.DATABASE_URL` is `string`; reading `process.env` directly outside `app.config.ts` is a boundary violation | +| Failure | `X_ENV_MISSING`, listing **every** missing/invalid key at once, with the expected type per key | +| Access | `env.DATABASE_URL` is `string`, `env.DRAIN_TIMEOUT_MS` is `number` — the declaration is the only place a key's type is written | | Defaults | in the schema, so there is one place to look — not scattered `?? 30000` | -| Roles | `ROLE` is a union, so a role switch is exhaustively checked in `cli` ([`13-topology-runtime.md`](./13-topology-runtime.md)) | -| Secrets | never logged; the boot report prints key names and `set`/`unset`, never values | - -Same schema library as actions and entities, so the whole system has one parse mechanism and one failure shape. +| Roles | `role: 'sync'` makes a key required for that role only, so a `worker` does not fail on a key it never reads. `ROLE` itself is the framework's (`resolveRole()`), not a key you declare | +| Secrets | `secret: true` is never logged; the boot report prints key names and `set`/`unset`, never values | + +`defineEnv` is a purpose-built declarative record, not the `@ultimat3/schema` `t` used by actions and +entities — env vars are always strings on the wire and need coercion (`number`/`port`/`boolean`/`enum`), +a `role` gate, and `secret` redaction a generic object schema has no vocabulary for. `X_ENV_MISSING` is +the one code for this gate; `X_CONFIG_INVALID` is the unrelated failure of `app.config.ts` itself +failing its own schema (bad `defaultLocale`, `db.pool < 1`, a non-IANA `timeZone`) — see +[Configuration](../../wiki/Configuration.md). diff --git a/docs/architecture/06-data-layer.md b/docs/architecture/06-data-layer.md index a07c956a5..4f699da82 100644 --- a/docs/architecture/06-data-layer.md +++ b/docs/architecture/06-data-layer.md @@ -1,29 +1,40 @@ # Data layer -Postgres + Drizzle. SQL stays legible so an agent can read the generated statement and self-correct ([`../idea/01-stack.md`](../idea/01-stack.md)). `@ultimat3/entity` owns the schema→type→repo chain; nothing else touches SQL. +Postgres, no ORM. `postgresDriver()` (`packages/entity/src/pg-driver.ts`, `pg-sql.ts`) emits hand-written parameterised SQL, and it stays legible so an agent can read the statement and self-correct ([`../idea/01-stack.md`](../idea/01-stack.md)). `@ultimat3/entity` owns the schema→type→repo chain; nothing else touches SQL. ## Entity A table + its domain type + invariants **the database also enforces**. ```ts -export const Post = entity(posts, { +export const posts = entity('posts', { + columns: { + id: uuid().primaryKey(), + orgId: uuid().references(() => orgs.id, { onDelete: 'cascade' }), + title: text({ max: TITLE_MAX }), + status: enumerated(POST_STATUSES).default('draft'), + publishedAt: timestamp().nullable(), + createdAt: timestamp().defaultNow(), + deletedAt: timestamp().nullable(), // presence alone makes the entity soft-deletable + }, tenant: 'orgId', - softDelete: 'deletedAt', invariants: [ - inv('title-not-blank', (p) => p.title.trim().length > 0, { db: "length(btrim(title)) > 0" }), - inv('published-after-created', (p) => !p.publishedAt || p.publishedAt >= p.createdAt, - { db: 'published_at IS NULL OR published_at >= created_at' }), + invariant('post_title_present', (c) => c.title.trimmed().minLength(1)), + invariant('post_publish_coherent', (c) => + c.satisfies(hasCoherentPublishState, ['status', 'publishedAt'])), ], + indexes: [{ on: ['orgId', 'publishedAt'], order: 'desc' }], }); ``` | Aspect | Rule | |---|---| -| Projects to | Drizzle table, domain type, migration, repo, admin screen, seed factory, cache tag | -| `tenant` | required on any multi-tenant entity; names the column, not a value | -| `invariants` | checked in TS on write **and** emitted as a `CHECK` constraint when `db` is given | -| A TS-only invariant | allowed, but `x verify` warns: a rule the DB does not know is a rule a migration script can violate | +| Signature | `entity(name, init)` — name first, `init` is `{ columns, tenant?, primaryKey?, invariants?, indexes?, tags? }` | +| Projects to | SQL DDL, domain type (`typeof posts.$row`), migration, repo, admin screen, seed factory, cache tag | +| `tenant` | required on any multi-tenant entity; names the column, not a value. `.tenant()` on the column says the same thing, `init` wins when both appear, and with neither a column named `orgId` is inferred — silence never means unscoped | +| `invariants` | plural, and each one is `invariant(name, build)` written in the expression language, so one declaration yields the TS check **and** the `CHECK`/`UNIQUE` the migration emits | +| A JS-predicate invariant | `c.satisfies(fn, [...columns])` and `c.matches(fn)` cannot be translated, so they report `kind: 'assert'` with `sql: null` — a rule the DB does not know is a rule a migration script can violate, and it is never faked as a CHECK | +| Soft delete | the presence of a `deletedAt` column, not a flag — there is no `softDelete:` option | | Never | business logic, I/O, HTTP awareness, policy decisions | Two enforcement points, one declaration. The TS check gives a typed error with a field path; the DB constraint means a bulk `UPDATE` from a migration, a psql session, or another service cannot write a row the app considers impossible. @@ -182,7 +193,7 @@ X_DB_DRIFT: schema differs from migrations fix: x db gen "add publish_at" ``` -Drift detection compares three things — the Drizzle schema, the migration ledger, and the live catalog — so it catches both "you edited the schema and forgot to generate" and "someone ran DDL by hand". +Drift detection compares three things — the declared entities, the migration ledger, and the live catalog — so it catches both "you edited an entity and forgot to generate" and "someone ran DDL by hand". ## Template-DB parallel testing diff --git a/docs/architecture/11-ai-surface.md b/docs/architecture/11-ai-surface.md index 151f8d1d2..fab496187 100644 --- a/docs/architecture/11-ai-surface.md +++ b/docs/architecture/11-ai-surface.md @@ -29,7 +29,7 @@ Neither adapter may parse, authorize, or handle on its own. Both go through `inv | MCP requirement | Source | Notes | |---|---|---| | tool name | action name, kebab-cased | `publish-post` | -| input JSON Schema | the ArkType `input` via Standard Schema → JSON Schema | the same schema HTTP parses | +| input JSON Schema | the action's `input` via Standard Schema → JSON Schema | the same schema HTTP parses | | output schema | `output` | same | | description | `mcp.description` | required when `expose: true` | | **authorization** | the action's `policy` — unchanged, unwrapped, identical | one authz system | diff --git a/docs/architecture/12-generated-app.md b/docs/architecture/12-generated-app.md index 9ace23eee..5776c94d7 100644 --- a/docs/architecture/12-generated-app.md +++ b/docs/architecture/12-generated-app.md @@ -17,7 +17,7 @@ myapp/ desktop/ # placeholder + README (Tauri/Electron later) packages/ domain/ # pure types + constants, no I/O - db/ # Drizzle schema + migrations, no business logic + db/ # entity re-exports + plain-SQL migrations, no business logic i18n/ # app catalogs (en, es, ...) ui/ # app-specific Solid components on top of @ultimat3/ui mcp/ # the app's own MCP tools (its dashboards are AI-first too) @@ -57,7 +57,7 @@ The rule that keeps a growing app scalable: **a package is defined by what it ma | Package | Owns | Must never | Importable by | |---|---|---|---| | `domain` | types, constants, enums, pure predicates, branded ids | any I/O, any framework import beyond `@ultimat3/schema` | everything, including a future native client | -| `db` | Drizzle schema, migrations, entity declarations | business logic, HTTP, policy | `core`, jobs, admin | +| `db` | the schema registry, plain-SQL migrations, entity re-exports | business logic, HTTP, policy | `core`, jobs, admin | | `core` | business services composed from repos | HTTP, rendering, direct SQL outside a repo | actions, jobs, admin, MCP tools | | `ui` | Solid components + app tokens | fetching, business logic, its own authz | `apps/*` surfaces | | `mcp` | the app's MCP tool declarations | a second authz path | the MCP role | @@ -136,7 +136,7 @@ x g resource post --admin --locales en,es ``` The entity is the one declaration — `entity()` from `@ultimat3/entity` owns the table, the tenant -column and the invariants together, so there is no second Drizzle table definition to keep in sync +column and the invariants together, so there is no ORM table definition to keep in sync with it. MCP exposure is the same story: an action that sets `mcp: { expose: true }` already reaches the app's MCP server through `defineAppMcp({ include: 'exposed' })`, so the generator does not write a second, parallel tool declaration — that would be the two-authz-paths mistake the framework diff --git a/docs/architecture/15-adding-a-feature.md b/docs/architecture/15-adding-a-feature.md index 5e57cdea0..0949d6c31 100644 --- a/docs/architecture/15-adding-a-feature.md +++ b/docs/architecture/15-adding-a-feature.md @@ -44,22 +44,35 @@ Generates schema, entity, repo, service, policy, actions, live query, job stub, ## 2. Entity + invariants ```ts -// apps/web/app/posts/entity.ts -export const Post = entity(posts, { - tenant: 'orgId', +// packages/db/src/schema/posts.ts +export const posts = entity('posts', { + columns: { + id: uuid().primaryKey(), + orgId: uuid().references(() => orgs.id, { onDelete: 'cascade' }).tenant(), + slug: text({ max: SLUG_MAX }), + title: text({ max: TITLE_MAX }), + excerpt: text({ max: EXCERPT_MAX }), + cover: url().nullable(), + publishedAt: timestamp().nullable(), + createdAt: timestamp().defaultNow(), + }, invariants: [ - inv('title-not-blank', (p) => p.title.trim().length > 0, { db: 'length(btrim(title)) > 0' }), - inv('published-after-created', (p) => !p.publishedAt || p.publishedAt >= p.createdAt, - { db: 'published_at IS NULL OR published_at >= created_at' }), + invariant('post_title_present', (c) => c.title.trimmed().minLength(1)), + invariant('post_slug_unique', (c) => c.unique(['slug'])), ], + indexes: [{ on: ['orgId', 'publishedAt'], order: 'desc' }], }); +``` -export const PostView = view(Post, ['id', 'title', 'excerpt', 'cover', 'publishedAt']); +```ts +// apps/web/app/posts/entity.ts +export const PostView = posts.$view(['id', 'title', 'excerpt', 'cover', 'publishedAt']); ``` -- `tenant` is required on a multi-tenant entity; the repo injects the filter, you never write it. -- `db:` on an invariant emits a `CHECK` — a rule the DB does not know is a rule a migration can violate. -- Money → `money('price')`; dates → `timestamptz`/`Instant`, wall-clock dates → `PlainDate` ([`10-cross-cutting.md`](./10-cross-cutting.md)). +- `entity(name, init)` is name-first; `init` is `{ columns, tenant?, primaryKey?, invariants?, indexes?, tags? }`. `invariants` is plural. +- Tenancy is `.tenant()` on the column or `tenant: 'orgId'` in `init`; with neither, a column named `orgId` is inferred. The repo injects the filter, you never write it. +- Each invariant emits its own `CHECK`/`UNIQUE` automatically; only `c.satisfies(fn, [...])` and `c.matches(fn)` stay TS-only, and a rule the DB does not know is a rule a migration can violate. +- Money → `money()`; dates → `timestamp()` (always `timestamptz`) ([`10-cross-cutting.md`](./10-cross-cutting.md)). ## 3–4. Migration diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 49cdcf61a..ae4348168 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -5,7 +5,7 @@ How Ultimate is built. [`../idea/`](../idea/README.md) answers *what and why*; t | Doc | Hook | |---|---| | [`00-conventions.md`](./00-conventions.md) | File layout, naming, export surface, LOC ceilings — the rules Biome and `x verify` enforce. | -| [`01-package-map.md`](./01-package-map.md) | 22 packages, 6 tiers, one reason to change each. What every package owns and must never do. | +| [`01-package-map.md`](./01-package-map.md) | 28 packages, 6 tiers, one reason to change each. What every package owns and must never do. | | [`02-boundaries.md`](./02-boundaries.md) | Tier violations are build errors. `scripts/boundaries.ts` resolves the transitive chain and prints it. | | [`03-request-lifecycle.md`](./03-request-lifecycle.md) | 17 ordered stages and why each sits where it does. ALS context, so no layer threads `actor`. | | [`04-error-contract.md`](./04-error-contract.md) | `UltimateError`: one object, three renderings. Every error carries an executable `fix:`. | diff --git a/docs/idea/00-thesis.md b/docs/idea/00-thesis.md index 8727f90eb..4e7922116 100644 --- a/docs/idea/00-thesis.md +++ b/docs/idea/00-thesis.md @@ -53,7 +53,7 @@ Each one is a permanent no, not a "later". |---|---| | **GraphQL** | a second schema language, a second authz surface, and a resolver-shaped N+1 factory. Typed actions + live queries cover the need. | | **Multi-runtime** (Node, Deno, workerd) | portability costs a lowest-common-denominator API. Bun-only buys `Bun.sql`, `Bun.redis`, `Bun.s3`, the bundler, and the test runner as *language features*. | -| **Multi-ORM** | two query builders means two migration stories and two sets of generated SQL for an agent to learn. Drizzle, because its SQL is legible enough to self-correct against. | +| **Multi-ORM** | two query builders means two migration stories and two sets of generated SQL for an agent to learn. No ORM at all, in fact: `entity()` is the one table declaration and `postgresDriver()` emits hand-written parameterised SQL, legible enough to self-correct against. | | **Multiple CSS solutions** | Tailwind + modules + CSS-in-JS in one repo is three token systems. SCSS modules + design tokens, one way to theme. | | **React Server Components** | wrong runtime, and the mental model taxes the exact audience we optimize for. Solid streaming with `` gets the same payoff with no new component dialect. | | **A plugin API before v1** | plugins freeze internals. Ship the blessed path first; extension points earn their existence from real forks. | diff --git a/docs/idea/01-stack.md b/docs/idea/01-stack.md index 4eaf1c17f..8f7f9bbd1 100644 --- a/docs/idea/01-stack.md +++ b/docs/idea/01-stack.md @@ -8,8 +8,8 @@ One choice per layer. No alternatives, no adapters, no `driver:` config for thin |---|---|---| | Runtime | Bun >= 1.3 (target 2.0), **only**. No Node APIs unless via `node:` and unavoidable. | natives replace whole dependency trees; one runtime means one perf profile to reason about | | HTTP | thin layer over `Bun.serve` routes; own the lifecycle for ALS context / tracing / authz | policy must run on every surface — impossible if a third-party router owns the request | -| DB | Postgres + Drizzle | Postgres does queue, pubsub, vectors, logical replication. Drizzle's SQL is legible, so an agent reads the generated statement and self-corrects | -| Validation | Standard Schema interface; **ArkType** blessed, exposed as `t` | one schema drives runtime parse + TS type + OpenAPI + MCP tool schema | +| DB | **Postgres**, no ORM — the framework owns its SQL | Postgres does queue, pubsub, vectors, logical replication. `entity()` is the one table declaration and `postgresDriver()` emits hand-written parameterised SQL, so an agent reads the statement and self-corrects | +| Validation | Standard Schema interface; **our dependency-free builtin validators** blessed, exposed as `t` | one schema drives runtime parse + TS type + OpenAPI + MCP tool schema; Standard Schema is the seam, so ArkType/Zod/Valibot swap in behind `configureSchemaProvider()` with a ~40-line adapter you write — none ships | | Auth | Better Auth, wrapped, with our `policy` layer on top | sessions/OAuth/passkeys are solved; authorization is ours because it must be identical in HTTP, WS, jobs, and MCP | | Frontend | SolidJS 2 + our own minimal router | fine-grained reactivity → streaming shells cost ~0 hydration; the router must own render mode + offline strategy, so it can't be a dependency | | Styling | **SCSS modules + design tokens** | build-time only, zero runtime, dark theme is a token flip. No Tailwind, no CSS-in-JS | @@ -54,7 +54,7 @@ Costs, stated plainly: no native-addon packages, and long-running-process maturi |---|---| | GraphQL | typed `action` + `query`; OpenAPI is generated | | Multi-runtime (Node/Deno/workerd) | Bun only | -| Multi-ORM | Drizzle only | +| Multi-ORM | no ORM at all — one hand-written Postgres driver | | Tailwind / CSS-in-JS / a second CSS system | SCSS modules + tokens | | React Server Components | Solid `stream` render mode + `` | | A plugin API before v1 | fork the blessed path; extension points earn their way in | @@ -64,4 +64,4 @@ Costs, stated plainly: no native-addon packages, and long-running-process maturi ## Versions -`As of 2026-07`: Bun 1.3 is the floor, Bun 2.0 the target. SolidJS 2 is in beta — the router and UI kit are ours precisely because the ecosystem around Solid 2 is thin. ArkType and Drizzle are both pre-1.0-stable in places; pin exactly and treat their upgrades as framework work, not app work. +`As of 2026-07`: Bun 1.3 is the floor, Bun 2.0 the target. SolidJS 2 is in beta — the router and UI kit are ours precisely because the ecosystem around Solid 2 is thin. There is no ORM and no schema library to pin: the SQL driver (`postgresDriver()`) and the validators behind `t` are both ours, so a change to either is framework work by definition, never app work. diff --git a/docs/idea/02-primitives.md b/docs/idea/02-primitives.md index 2b932ca5e..4821b3019 100644 --- a/docs/idea/02-primitives.md +++ b/docs/idea/02-primitives.md @@ -76,11 +76,36 @@ Plus cache invalidation: `cache.invalidates` propagates to every tier in one hop ## `entity` -A table + its domain type + invariants. The single source of the DB schema, the TS type, and the parse boundary. +A table + its domain type + invariants. The single source of the DB schema, the TS type, and the parse boundary. The row type is **derived** from the columns — there is no second declaration of the same shape to keep in sync. + +```ts +export const posts = entity('posts', { + columns: { + id: uuid().primaryKey(), + orgId: uuid().references(() => orgs.id, { onDelete: 'cascade' }).tenant(), + title: text({ max: TITLE_MAX }), + status: enumerated(POST_STATUSES).default('draft'), + likeCount: integer().default(0), + deletedAt: timestamp().nullable(), // presence alone makes the entity soft-deletable + createdAt: timestamp().defaultNow(), + updatedAt: timestamp().defaultNow().onUpdateNow(), + }, + tenant: 'orgId', // said out loud; inferred from `.tenant()` or an `orgId` column if omitted + invariants: [ + invariant('post_title_present', (c) => c.title.trimmed().minLength(1)), + invariant('post_like_count_non_negative', (c) => c.likeCount.atLeast(0)), + ], + indexes: [{ on: ['orgId', 'status'] }], +}); +``` + +`invariants` is plural, and each entry is a named `invariant(name, build)` — the name becomes the +constraint name (`posts_post_title_present_check`), which is what makes a violation point at a rule +instead of at a column. | Aspect | Rule | |---|---| -| Projects to | Drizzle table, domain type, migration, repo type, admin screen, seed factory | +| Projects to | SQL DDL, domain type (`typeof posts.$row`), migration, repo type, admin screen, seed factory | | Owns | column types, defaults, invariants, tenant column | | Never | business logic, I/O, HTTP awareness, policy decisions | diff --git a/docs/idea/05-caching.md b/docs/idea/05-caching.md index 0b891bf42..3c49e1808 100644 --- a/docs/idea/05-caching.md +++ b/docs/idea/05-caching.md @@ -22,7 +22,7 @@ A tag is a typed handle derived from an `entity`. There is no string-keyed inval ```ts export const tag = tags({ post: entityTag(posts), // tag.post, tag.post.id(x) - feed: derivedTag('feed', [tags.post]), // invalidating post cascades to feed + feed: derivedTag('feed', [tag.post]), // invalidating post cascades to feed }); ``` diff --git a/docs/idea/06-surfaces.md b/docs/idea/06-surfaces.md index 98c476d41..a7f98d7b8 100644 --- a/docs/idea/06-surfaces.md +++ b/docs/idea/06-surfaces.md @@ -112,7 +112,7 @@ myapp/ | Package | Rule | |---|---| | `packages/domain` | pure types + constants, **no I/O** | -| `packages/db` | Drizzle schema + migrations, **no business logic** | +| `packages/db` | `entity()` declarations + plain-SQL migrations, **no business logic** | | `packages/i18n` | flat catalogs; a missing key renders `⟦key⟧` and fails `x verify` | | `packages/ui` | app components on `@ultimat3/ui`; subject to the same byte budgets as `shared/` | | `packages/mcp` | the app's own MCP tools ([`09-ai-first.md`](./09-ai-first.md)) | diff --git a/docs/idea/09-ai-first.md b/docs/idea/09-ai-first.md index a9beaa34f..ae2a0b969 100644 --- a/docs/idea/09-ai-first.md +++ b/docs/idea/09-ai-first.md @@ -46,7 +46,7 @@ That line is the entire integration. From the existing declaration: | MCP requirement | Source | |---|---| | tool name | action name | -| JSON Schema for input | the ArkType `input` (Standard Schema → JSON Schema) | +| JSON Schema for input | the action's `input` (Standard Schema → JSON Schema) | | output schema | `output` | | description | `mcp.description` | | **authorization** | the action's `policy` — unchanged, unwrapped, identical | diff --git a/docs/idea/13-dx.md b/docs/idea/13-dx.md index 20764a958..b2983d472 100644 --- a/docs/idea/13-dx.md +++ b/docs/idea/13-dx.md @@ -91,17 +91,17 @@ Everything in `/_x` is also an MCP tool and a `--json` CLI command — same data | `x new ` | scaffold the monorepo ([`06-surfaces.md`](./06-surfaces.md)) | | `x dev` | all roles, one process, HMR, `/_x`, MCP server | | `x verify` | the gate: typecheck, lint, boundaries, tests, drift, contract diff, budgets ([`10-testing.md`](./10-testing.md)) | -| `x gen entity\|action\|mutator\|query\|job\|route\|task\|feature ` | generators, wired end-to-end with test scaffolds | -| `x db gen ""` / `x db apply` / `x db drift` / `x db studio` | migrations; drift is a `x verify` failure | +| `x g resource\|action\|mutator\|job\|route\|policy\|entity\|query\|task ` (alias `x generate`) | generators, wired end-to-end with test scaffolds | +| `x db gen ""` / `x db migrate` / `x db drift` / `x db studio` | migrations; drift is a `x verify` failure | | `x test [unit\|contract\|live\|job\|e2e\|eval]` | one type or all | | `x jobs ls\|show\|retry\|drain` | queue operations | | `x cache graph\|clear` | inspect the tag graph, clear (dev only) | | `x branch ` / `x branch rm ` | copy-on-write DB + preview URL + scoped SW | | `x build --target docker\|binary\|static` | artifacts ([`12-build-deploy.md`](./12-build-deploy.md)) | -| `x deploy compose\|static` | apply generated compose; push static output | +| `x deploy --method compose\|static` | apply generated compose; push static output | | `x status` | build-ID distribution, role health, queue depth | -| `x mcp` | run the MCP server standalone (CI, remote agents) | -| `x explain ` | cause, fix, docs for any error code | +| `x mcp serve` | run the MCP server standalone (CI, remote agents) | +| `x errors explain ` | cause, fix, docs for any error code | | `x fix boundary\|meta\|budget ` | apply the mechanical fix a failure suggested | | `x upgrade` | framework version bump with codemods | @@ -136,10 +136,10 @@ X_DB_DRIFT: schema differs from migrations Write the schema once. Nothing downstream is hand-typed and nothing needs a codegen step you can forget. -``` -Drizzle table packages/db/schema.ts - ↓ inferred -entity type + invariants entity({ table: posts }) +```text +entity + invariants entity('posts', { columns, invariants }) packages/db/src/schema/posts.ts + ↓ derived, no ORM +row type type Post = typeof posts.$row ↓ inferred action input/output action({ input: t.object({...}), output: PostView }) ↓ inferred @@ -159,27 +159,44 @@ One rename, N errors, all pointing at real work. No runtime surprises and no "re ## Typed env, validated at boot +`defineEnv` (from `@ultimat3/core`) takes a plain `EnvSchema` — a record of declarations, not a +`@ultimat3/schema` object — because env vars are always strings on the wire and need type +coercion (`number`/`port`/`boolean`/`enum`), a `role` gate, and `secret` redaction that a generic +object schema has no vocabulary for: + ```ts -// app.config.ts -env: t.object({ - DATABASE_URL: t.string.url, - NATS_URL: t.string.url.optional(), - S3_BUCKET: t.string, - VAPID: t.string.optional(), - STRIPE_KEY: t.string.matching(/^sk_/), -}), +// app.config.ts — module scope, so importing the config is what parses the environment +import { defineEnv } from '@ultimat3/core'; + +const env = defineEnv({ + DATABASE_URL: { type: 'url' }, + NATS_URL: { type: 'url', role: 'worker' }, + S3_BUCKET: { type: 'string' }, + VAPID: { type: 'string', required: false }, + STRIPE_KEY: { type: 'string', pattern: /^sk_/, secret: true }, +}); ``` -``` -X_ENV_INVALID: environment does not satisfy the schema - cause: STRIPE_KEY missing; NATS_URL is not a URL ("nats:4222") - fix: x env check --fix (writes the missing keys to .env with placeholders) +```text +X_ENV_MISSING: required environment variables are missing or invalid + cause: STRIPE_KEY is missing (expected a string matching /^sk_/); NATS_URL="nats:4222" is not a url + fix: add STRIPE_KEY NATS_URL to .env (copy .env.example), then run: x env check ``` | Property | Value | |---|---| | When | first thing at boot, before any listener binds | | Cost of failure | ~40ms and exit 1 — **not a 3am `undefined` in a payment handler** | -| Access | `env.STRIPE_KEY` is typed; `process.env` reads outside the schema are a lint error | -| Per-role | a role only requires the keys it uses, so `worker` does not fail on a missing `VAPID` | -| CI | `x env check` runs in `x verify` against the declared schema, not against a checked-in example file | +| Access | `env.STRIPE_KEY` is typed, and the declaration is the only place its type is written | +| Per-role | `role: 'worker'` requires the key for that role only, so a `web` replica does not fail on `NATS_URL` | +| Where | `app.config.ts`, at module scope. There is no `env.ts` convention — one config file is also one root marker | + +`x env check` is **planned**, `As of 2026-08`: it exits `X_NOT_IMPLEMENTED` with `fix: x doctor --json`, +and `x verify` has no environment step. The gate that exists today is the boot parse above, which is +why it runs before the first listener binds rather than in CI. + +`X_ENV_MISSING` is the one code for the whole env gate — every offending key (missing or +malformed) is reported in one throw, not one exception per key. `X_CONFIG_INVALID` is a +different failure entirely: it is `app.config.ts` itself failing its own schema (bad +`defaultLocale`, `db.pool < 1`, a non-IANA `timeZone`), not an environment variable — see +[Configuration](../../wiki/Configuration.md). diff --git a/docs/idea/14-roadmap.md b/docs/idea/14-roadmap.md index b52d1a6af..53f3059f6 100644 --- a/docs/idea/14-roadmap.md +++ b/docs/idea/14-roadmap.md @@ -2,20 +2,40 @@ Twelve milestones. Each one ends in a **working demo app plus green `x verify`** — never a package that only compiles. -| # | Milestone | Ships | Done when | -|---|---|---|---| -| 0 | **Skeleton + error contract** | `packages/core` (`UltimateError`, ALS context, config loader), `schema` (ArkType wrapper `t`), root tooling, `scripts/boundaries.ts`, `x verify` shell | `x verify` runs and passes on an empty repo; a thrown `X_*` error renders identically in terminal and `--json`; boundary violations fail the build | -| 1 | **HTTP + entity + policy** | `http` over `Bun.serve`, `entity` on Drizzle, `policy`, typed env validated at boot | demo: a hello app with one entity, one protected route, one denial; `X_ENV_INVALID` fires in <100ms on a missing key | -| 2 | **`action` + `query` + typed client** | `action`, `query`, generated HTTP routes, OpenAPI, typed client, contract tests | demo: CRUD app driven entirely by the typed client, no hand-written fetch; `x verify` includes contract diff | -| 3 | **Rendering + router + `site`/`app` split** | Solid 2 integration, own router, five render modes, `stream` default, surfaces + hard `site/` → `app/` boundary | demo: landing page in `site/` at **0kb JS** and a streaming dashboard in `app/`; a deliberate cross-surface import fails the build | -| 4 | **SEO + images + budgets** | typed `meta`, `ld.*` helpers, sitemap/robots/RSS from the route table, image pipeline, per-route budgets in `x verify` | demo landing page scores 100 SEO with **CLS 0**; deleting a description is a build error; a budget regression names the import chain | -| 5 | **Jobs + tasks + mail + storage + scheduler** | outbox enqueue, `step.run` / `step.sleep` / `step.waitForEvent`, `pg` driver, `task` cron with leader election, mail, `Bun.s3` storage | demo: signup → onboarding job with a 3-day sleep, verified with the frozen clock; a failing step retries **only that step**; job tests in `x verify` | -| 6 | **Realtime tier 1–2 (+ reconnect benchmark)** | channels, live `query`, `replicator` role, incremental matcher, NATS fanout, `sync` role | demo: collaborative list updating live across two browsers; **50k-socket forced-restart benchmark measured and published** before topology is frozen ([`03-realtime.md`](./03-realtime.md)) | -| 7 | **Caching, four tiers, one tag graph** | request memo, in-process LRU, Redis tier, CDN headers + purge, `invalidates` fanout, ISR regeneration | demo: publish an ISR-backed post and observe memo/LRU/Redis/page/CDN all invalidate from one `invalidates: [tag.post]`; an untagged cached query fails `x verify` | -| 8 | **PWA + offline + version skew** | generated `sw.js`, precache derivation, manifest/icons/splash from one source icon, required offline fallback, immutable build ID, N-deploy retention, `AppUpdateAvailable` | demo: installable app, works offline, and **six deploys with a tab left open never 404s a chunk**; `x status` reports the client build-ID spread | -| 9 | **AI-first surface** | MCP dev server, `x.manifest.json`, every action as an MCP tool, `llm` gateway, versioned prompts + evals, pgvector hybrid search, branch environments | demo: an agent drives the demo app end-to-end through MCP only — migrate in a branch DB, run tests, publish a post — with **identical authz** to the UI; evals run in `x verify` | -| 10 | **Admin + generators + `x new`** | generated admin dashboard (with its own MCP surface), all `x gen` generators, `create-ultimate`, `/_x` dev dashboard complete | `bunx create-ultimate myapp && cd myapp && x dev` is <60s with **no Docker and no env editing**; every generator produces code that passes `x verify` unmodified | -| 11 | **Deploy + docs + 1.0** | `x build --target docker\|binary\|static`, dev/prod compose, Helm with per-role HPAs, graceful drain everywhere, docs site, error-code pages | the demo app runs on Hetzner+Compose **and** a K8s cluster from one image; a rolling restart is invisible to connected clients; every `X_*` code has a docs page | +Status markers are load-bearing, not decoration: `x verify`'s `roadmap` step (`scripts/roadmap.ts`) +reads this table and fails the build if a row loses its marker, or if a milestone marked ✅ is +missing the packages/files its own **Ships** column names. `As of 2026-08`. + +| Status | Meaning | +|---|---| +| ✅ | shipped — packages exist, tests pass, enforced by `x verify`'s `roadmap` step | +| 🚧 | in progress — some artifacts exist, the milestone is not yet closed | + +| # | Status | Milestone | Ships | Done when | +|---|---|---|---|---| +| 0 | ✅ | **Skeleton + error contract** | `packages/core` (`UltimateError`, ALS context, config loader), `schema` (Standard Schema over a dependency-free builtin provider, exposed as `t`), root tooling, `scripts/boundaries.ts`, `x verify` shell | `x verify` runs and passes on an empty repo; a thrown `X_*` error renders identically in terminal and `--json`; boundary violations fail the build | +| 1 | ✅ | **HTTP + entity + policy** | `http` over `Bun.serve`, `entity` over a hand-written `postgresDriver()`, `policy`, typed env validated at boot | demo: a hello app with one entity, one protected route, one denial; `X_ENV_MISSING` fires in <100ms on a missing key | +| 2 | ✅ | **`action` + `query` + typed client** | `action`, `query`, generated HTTP routes, OpenAPI, typed client, contract tests | demo: CRUD app driven entirely by the typed client, no hand-written fetch; `x verify` includes contract diff | +| 3 | ✅ | **Rendering + router + `site`/`app` split** | Solid 2 integration, own router, five render modes, `stream` default, surfaces + hard `site/` → `app/` boundary | demo: landing page in `site/` at **0kb JS** and a streaming dashboard in `app/`; a deliberate cross-surface import fails the build | +| 4 | ✅ | **SEO + images + budgets** | typed `meta`, `ld.*` helpers, sitemap/robots/RSS from the route table, image pipeline, per-route budgets in `x verify` | demo landing page scores 100 SEO with **CLS 0**; deleting a description is a build error; a budget regression names the import chain | +| 5 | ✅ | **Jobs + tasks + mail + storage + scheduler** | outbox enqueue, `step.run` / `step.sleep` / `step.waitForEvent`, `pg` driver, `task` cron with leader election, mail, `Bun.s3` storage | demo: signup → onboarding job with a 3-day sleep, verified with the frozen clock; a failing step retries **only that step**; job tests in `x verify` | +| 6 | ✅ | **Realtime tier 1–2** | channels, live `query`, `replicator` role, incremental matcher, NATS fanout, `sync` role | demo: collaborative list updating live across two browsers. The **50k-socket forced-restart benchmark** moved out of this row — see *Open at 1.0.0* below ([`03-realtime.md`](./03-realtime.md)) | +| 7 | ✅ | **Caching, four tiers, one tag graph** | request memo, in-process LRU, Redis tier, CDN headers + purge, `invalidates` fanout, ISR regeneration | demo: publish an ISR-backed post and observe memo/LRU/Redis/page/CDN all invalidate from one `invalidates: [tag.post]`; an untagged cached query fails `x verify` | +| 8 | ✅ | **PWA + offline + version skew** | generated `sw.js`, precache derivation, manifest/icons/splash from one source icon, required offline fallback, immutable build ID, N-deploy retention, `AppUpdateAvailable` | demo: installable app, works offline, and **six deploys with a tab left open never 404s a chunk**; `x status` reports the client build-ID spread | +| 9 | ✅ | **AI-first surface** | MCP dev server, `x.manifest.json`, every action as an MCP tool, `llm` gateway, versioned prompts + evals, pgvector hybrid search, branch environments | demo: an agent drives the demo app end-to-end through MCP only — migrate in a branch DB, run tests, publish a post — with **identical authz** to the UI; evals run in `x verify` | +| 10 | ✅ | **Admin + generators + `x new`** | generated admin dashboard (with its own MCP surface), all `x gen` generators, `create-ultimate`, `/_x` dev dashboard complete | `bunx create-ultimate myapp && cd myapp && x dev` is <60s with **no Docker and no env editing**; every generator produces code that passes `x verify` unmodified | +| 11 | 🚧 | **Deploy + docs + 1.0** | `x build --target docker\|binary\|static`, dev/prod compose, Helm with per-role HPAs, graceful drain everywhere, docs site, error-code pages | the demo app runs on Hetzner+Compose **and** a K8s cluster from one image; a rolling restart is invisible to connected clients; every `X_*` code has a docs page | + +## Open at 1.0.0 + +1.0.0 shipped the 28 packages, the docs and the three build targets. Two claims this table once made are still unproven, and are named here rather than marked ✅ — a status marker nobody can check is the thing the `roadmap` step exists to prevent. + +| Open | Why it is not closed | +|---|---| +| **50k-socket forced-restart benchmark** | no number has been measured or published. Realtime tiers 1–2 ship and their API is under semver, but every documented capacity figure is a target derived from Bun's WebSocket implementation, not a result | +| **Two-platform deploy proof** (milestone 11) | `x build --target docker\|binary\|static`, `docker/docker-compose.{dev,prod}.yml` and `docker/helm` all exist. Running the demo app on Hetzner+Compose **and** a K8s cluster from one image, with an invisible rolling restart, needs real infrastructure and has not been done | + +Both are measurements, not code. Neither blocks an app built on 1.0.0; both block the claims above being repeated as fact. ## One demo app, twelve stages @@ -61,7 +81,7 @@ A half-built sync engine is worth nothing. It cannot be shipped partially, it ca |---|---| | Milestones are strictly ordered; no parallel starts | one demo app grows through all twelve, so regressions surface immediately | | Every milestone ends green | `x verify` never carries known failures forward | -| Realtime is gated on a measured benchmark (M6) | topology decisions wait for numbers, not intuition | +| Realtime was gated on a measured benchmark (M6) — **waived at 1.0.0** | the gate was not met. M6 shipped on API surface and tests alone; the benchmark is tracked under *Open at 1.0.0* and closes only when a 50k-socket forced-restart number is measured **and** published. Until then no capacity figure is quoted as a result | | Tier 3 local-first is **out of v1** | it lands in v2 as `persist: true` on an existing query — a flag, not a rewrite | | Scope cuts come off the back, never the middle | dropping M11's Helm chart is acceptable; dropping M4's budgets is not | | A milestone that grows past its demo gets split | a milestone with no demo is a milestone with no definition of done | diff --git a/docs/idea/README.md b/docs/idea/README.md index d28401ab6..f021d7f53 100644 --- a/docs/idea/README.md +++ b/docs/idea/README.md @@ -33,7 +33,7 @@ Why Ultimate exists, what it locks down, and what it refuses to build. Read [`00 ## The one-paragraph version -Bun-only, opinionated, full-stack. Postgres + Drizzle, SolidJS 2, SCSS modules + tokens, ArkType, Better Auth. Eight primitives — `entity policy action mutator query job route task` — and nothing else ships. One `action` declaration projects to an HTTP route, an OpenAPI operation, a typed client function, a job handle, an MCP tool, and a test scaffold, all sharing **one** authz system. Realtime is a three-rung ladder with the same mutator shape at every rung. Jobs are durable steps enqueued through a transactional outbox. Caching is four tiers behind one tag graph. `site/` cannot import `app/` — build error. SEO, budgets, migration drift, and import boundaries are build failures, not guidelines. `x verify` green means shippable. Deploy target = anything that runs containers. +Bun-only, opinionated, full-stack. Postgres with no ORM, SolidJS 2, SCSS modules + tokens, Standard Schema behind a dependency-free builtin provider, Better Auth. Eight primitives — `entity policy action mutator query job route task` — and nothing else ships. One `action` declaration projects to an HTTP route, an OpenAPI operation, a typed client function, a job handle, an MCP tool, and a test scaffold, all sharing **one** authz system. Realtime is a three-rung ladder with the same mutator shape at every rung. Jobs are durable steps enqueued through a transactional outbox. Caching is four tiers behind one tag graph. `site/` cannot import `app/` — build error. SEO, budgets, migration drift, and import boundaries are build failures, not guidelines. `x verify` green means shippable. Deploy target = anything that runs containers. ## The axioms, in one table diff --git a/examples/dummy/CLAUDE.md b/examples/dummy/CLAUDE.md index 87ae83dc7..ee1da1283 100644 --- a/examples/dummy/CLAUDE.md +++ b/examples/dummy/CLAUDE.md @@ -65,7 +65,7 @@ Feature slice: `apps/web/app//{entity,repo,service,actions,mutator,live - Entities live in `packages/db`; a feature's `entity.ts` owns only that feature's view schemas. - One authz definition. `policy.ts` predicates are reused verbatim by HTTP, live queries, jobs, MCP tools, and admin. Never re-check authz inside `handle`. -- `t` is two different things by file kind: ArkType (`@ultimat3/schema`) in schema/entity/action +- `t` is two different things by file kind: the schema namespace (`@ultimat3/schema`) in schema/entity/action files, the i18n translator (`useI18n()`) in components. Never both in one file. - Money is `{ minor, currency }`. Arithmetic in `packages/core/src/billing.ts`; formatting only in `` at the edge. diff --git a/examples/dummy/apps/admin/package.json b/examples/dummy/apps/admin/package.json index 71ea400e3..a7183e7fd 100644 --- a/examples/dummy/apps/admin/package.json +++ b/examples/dummy/apps/admin/package.json @@ -19,6 +19,6 @@ "dependencies": { "@postly/db": "0.0.1", "@postly/web": "0.0.1", - "@ultimat3/admin": "0.0.1" + "@ultimat3/admin": "1.0.0" } } diff --git a/examples/dummy/apps/web/package.json b/examples/dummy/apps/web/package.json index e411c1217..7fd1fe189 100644 --- a/examples/dummy/apps/web/package.json +++ b/examples/dummy/apps/web/package.json @@ -25,20 +25,20 @@ "@postly/domain": "0.0.1", "@postly/i18n": "0.0.1", "@postly/ui": "0.0.1", - "@ultimat3/action": "0.0.1", - "@ultimat3/ai": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/jobs": "0.0.1", - "@ultimat3/mail": "0.0.1", - "@ultimat3/policy": "0.0.1", - "@ultimat3/query": "0.0.1", - "@ultimat3/realtime": "0.0.1", - "@ultimat3/render": "0.0.1", - "@ultimat3/schema": "0.0.1", - "@ultimat3/seo": "0.0.1", - "@ultimat3/testing": "0.0.1", - "@ultimat3/time": "0.0.1", - "@ultimat3/ui": "0.0.1", + "@ultimat3/action": "1.0.0", + "@ultimat3/ai": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/jobs": "1.0.0", + "@ultimat3/mail": "1.0.0", + "@ultimat3/policy": "1.0.0", + "@ultimat3/query": "1.0.0", + "@ultimat3/realtime": "1.0.0", + "@ultimat3/render": "1.0.0", + "@ultimat3/schema": "1.0.0", + "@ultimat3/seo": "1.0.0", + "@ultimat3/testing": "1.0.0", + "@ultimat3/time": "1.0.0", + "@ultimat3/ui": "1.0.0", "solid-js": "2.0.0-experimental.16" } } diff --git a/examples/dummy/packages/core/package.json b/examples/dummy/packages/core/package.json index 375bab44d..8aff35cee 100644 --- a/examples/dummy/packages/core/package.json +++ b/examples/dummy/packages/core/package.json @@ -17,8 +17,8 @@ }, "dependencies": { "@postly/domain": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/money": "0.0.1", - "@ultimat3/time": "0.0.1" + "@ultimat3/core": "1.0.0", + "@ultimat3/money": "1.0.0", + "@ultimat3/time": "1.0.0" } } diff --git a/examples/dummy/packages/db/package.json b/examples/dummy/packages/db/package.json index acbffc701..c44618f31 100644 --- a/examples/dummy/packages/db/package.json +++ b/examples/dummy/packages/db/package.json @@ -20,8 +20,8 @@ }, "dependencies": { "@postly/domain": "0.0.1", - "@ultimat3/cache": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/entity": "0.0.1" + "@ultimat3/cache": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/entity": "1.0.0" } } diff --git a/examples/dummy/packages/domain/package.json b/examples/dummy/packages/domain/package.json index 534640bf5..47436bd8d 100644 --- a/examples/dummy/packages/domain/package.json +++ b/examples/dummy/packages/domain/package.json @@ -16,7 +16,7 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/money": "0.0.1" + "@ultimat3/core": "1.0.0", + "@ultimat3/money": "1.0.0" } } diff --git a/examples/dummy/packages/i18n/package.json b/examples/dummy/packages/i18n/package.json index eab8efe98..bffff049c 100644 --- a/examples/dummy/packages/i18n/package.json +++ b/examples/dummy/packages/i18n/package.json @@ -17,6 +17,6 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/i18n": "0.0.1" + "@ultimat3/i18n": "1.0.0" } } diff --git a/examples/dummy/packages/mcp/package.json b/examples/dummy/packages/mcp/package.json index 42bb9a25a..5da22b8b6 100644 --- a/examples/dummy/packages/mcp/package.json +++ b/examples/dummy/packages/mcp/package.json @@ -19,10 +19,10 @@ "dependencies": { "@postly/core": "0.0.1", "@postly/domain": "0.0.1", - "@ultimat3/action": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/mcp": "0.0.1", - "@ultimat3/schema": "0.0.1", - "@ultimat3/testing": "0.0.1" + "@ultimat3/action": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/mcp": "1.0.0", + "@ultimat3/schema": "1.0.0", + "@ultimat3/testing": "1.0.0" } } diff --git a/examples/dummy/packages/ui/package.json b/examples/dummy/packages/ui/package.json index 74f658779..2bc73f355 100644 --- a/examples/dummy/packages/ui/package.json +++ b/examples/dummy/packages/ui/package.json @@ -18,8 +18,8 @@ "dependencies": { "@postly/domain": "0.0.1", "@postly/i18n": "0.0.1", - "@ultimat3/money": "0.0.1", - "@ultimat3/ui": "0.0.1", + "@ultimat3/money": "1.0.0", + "@ultimat3/ui": "1.0.0", "solid-js": "2.0.0-experimental.16" } } diff --git a/llms.txt b/llms.txt index d4ed4f98f..6ca8ad64d 100644 --- a/llms.txt +++ b/llms.txt @@ -1,8 +1,8 @@ # Ultimate -> A full-stack, Bun-only, opinionated web framework: Rails' philosophy applied to Bun + Postgres + SolidJS, where the primary user is an AI agent and the secondary user is a tired senior engineer working through their own AI agent and AI reviewer. Eight primitives, one authz system, and errors that carry an exact fix command. Pre-v1 as of 2026-07 — nothing is published to npm and no API is stable. +> A full-stack, Bun-only, opinionated web framework: Rails' philosophy applied to Bun + Postgres + SolidJS, where the primary user is an AI agent and the secondary user is a tired senior engineer working through their own AI agent and AI reviewer. Eight primitives, one authz system, and errors that carry an exact fix command. 1.0.0 as of 2026-08 — 27 `@ultimat3/*` packages plus the unscoped `create-ultimate` publish to npm in lockstep at one version, and semver applies from here: a breaking change to a documented API needs a major. Install with `bunx create-ultimate myapp`. -Everything in the framework is one of eight primitives: `entity`, `policy`, `action`, `mutator`, `query`, `job`, `route`, `task`. One `action` declaration projects into six artifacts (HTTP route, OpenAPI operation, typed client function, job handle, MCP tool, test scaffold) that all share the same `policy` — there is never a second authorization system. The CLI binary is `x`; every command and every error has a `--json` form, and every framework error carries a stable `X_*` code, a concrete cause, and the exact command that fixes it. `x verify` is the single gate: typecheck, lint, import boundaries, six test types, migration drift, contract diff, budgets, SEO + i18n, manifest freshness. The stack is locked (Bun >= 1.3, Postgres + Drizzle, ArkType as `t`, Better Auth, SolidJS 2, SCSS modules + tokens, OpenTelemetry always on) and the exclusions are permanent (GraphQL, multi-runtime, multi-ORM, a second CSS system, RSC, vendor edge/KV primitives, a plugin API before v1). Deployment is containers only: one image, six roles selected by `ROLE`. +Everything in the framework is one of eight primitives: `entity`, `policy`, `action`, `mutator`, `query`, `job`, `route`, `task`. One `action` declaration projects into six artifacts (HTTP route, OpenAPI operation, typed client function, job handle, MCP tool, test scaffold) that all share the same `policy` — there is never a second authorization system. The CLI binary is `x`; every command and every error has a `--json` form, and every framework error carries a stable `X_*` code, a concrete cause, and the exact command that fixes it. `x verify` is the single gate, 17 steps in cost order: typecheck, lint, boundaries, filesize, package-shape, errors, unit, contract, live, job, e2e, eval, drift, contract-diff, budgets, manifest, roadmap. The stack is locked (Bun >= 1.3, Postgres with no ORM dependency — `entity()` projects to hand-written SQL through `postgresDriver()`, Standard Schema behind `t` with a dependency-free builtin provider as the shipped default, Better Auth, SolidJS 2, SCSS modules + tokens, OpenTelemetry always on) and the exclusions are permanent (GraphQL, multi-runtime, multi-ORM, a second CSS system, RSC, vendor edge/KV primitives, a plugin API in 1.x). Deployment is containers only: one image, six roles selected by `ROLE`. ## Docs @@ -25,7 +25,7 @@ Everything in the framework is one of eight primitives: `entity`, `policy`, `act - [Topology](https://raw.githubusercontent.com/developerz-ai/ultimate/main/docs/idea/11-topology.md): one image, six roles, health endpoints, graceful drain. - [Build & deploy](https://raw.githubusercontent.com/developerz-ai/ultimate/main/docs/idea/12-build-deploy.md): docker / binary / static targets, compose, Helm. - [DX](https://raw.githubusercontent.com/developerz-ai/ultimate/main/docs/idea/13-dx.md): the first 60 seconds, no Docker, HMR that keeps state. -- [Roadmap](https://raw.githubusercontent.com/developerz-ai/ultimate/main/docs/idea/14-roadmap.md): twelve strictly ordered milestones, each ending in a demo app and a green verify. +- [Roadmap](https://raw.githubusercontent.com/developerz-ai/ultimate/main/docs/idea/14-roadmap.md): twelve strictly ordered milestones, each ending in a demo app and a green verify. 0–10 shipped; 11 is open on its two-platform deploy proof. The status markers are enforced by `x verify`'s `roadmap` step. - [Risks](https://raw.githubusercontent.com/developerz-ai/ultimate/main/docs/idea/15-risks.md): six risks, sized; the sync engine is ~70% of the effort. ## Architecture @@ -51,12 +51,12 @@ Everything in the framework is one of eight primitives: `entity`, `policy`, `act ## Primitives - [The eight primitives](https://raw.githubusercontent.com/developerz-ai/ultimate/main/docs/idea/02-primitives.md): canonical shape and rules for each. Read this before writing any app code. -- [entity](https://raw.githubusercontent.com/developerz-ai/ultimate/main/packages/entity/src/index.ts): table + domain type + invariants; projects to Drizzle, migrations, admin screens. +- [entity](https://raw.githubusercontent.com/developerz-ai/ultimate/main/packages/entity/src/index.ts): table + domain type + invariants; projects to parameterised SQL, migrations, admin screens. No ORM — `postgresDriver()` and `memoryDriver()` share one plan and one cursor codec. - [policy](https://raw.githubusercontent.com/developerz-ai/ultimate/main/packages/policy/src/index.ts): `can(permission, predicate)`, evaluated identically in HTTP, WS, jobs and MCP. - [action](https://raw.githubusercontent.com/developerz-ai/ultimate/main/packages/action/src/index.ts): server-authoritative mutation; six generated artifacts per declaration. - [query](https://raw.githubusercontent.com/developerz-ai/ultimate/main/packages/query/src/index.ts): reads, optionally `live: true`; requires deterministic, bounded SQL. - [jobs](https://raw.githubusercontent.com/developerz-ai/ultimate/main/packages/jobs/src/index.ts): durable steps, required `idempotencyKey`, `pg` driver by default. -- [realtime](https://raw.githubusercontent.com/developerz-ai/ultimate/main/packages/realtime/src/index.ts): channels, live-query transport, mutator rebase, cursors. +- [realtime](https://raw.githubusercontent.com/developerz-ai/ultimate/main/packages/realtime/src/index.ts): channels, live-query transport, mutator rebase, cursors. Tiers 1–2 ship at 1.0.0; tier 3 local-first (`persist: true`) is deferred to v2 behind the same interfaces. No published capacity benchmark — the figures in the docs are targets. - [render](https://raw.githubusercontent.com/developerz-ai/ultimate/main/packages/render/src/index.ts): `defineRoute`, the five render modes, hydration timing, budgets. - [cache](https://raw.githubusercontent.com/developerz-ai/ultimate/main/packages/cache/src/index.ts): four tiers and the entity-tag invalidation graph. - [seo](https://raw.githubusercontent.com/developerz-ai/ultimate/main/packages/seo/src/index.ts): typed `meta`, `ld.*` JSON-LD helpers, sitemap/robots/feeds. @@ -66,12 +66,14 @@ Everything in the framework is one of eight primitives: `entity`, `policy`, `act - [CLI reference](https://raw.githubusercontent.com/wiki/developerz-ai/ultimate/CLI-Reference.md): every `x` command and flag, with `--json` examples. Start here. - [cli source](https://raw.githubusercontent.com/developerz-ai/ultimate/main/packages/cli/src/index.ts): the implemented command surface. -- `x new ` / `bunx create-ultimate `: scaffold the monorepo. `x dev`: embedded Postgres, in-process NATS, MCP dev server. -- `x verify [--json]`: the single gate — typecheck, lint, boundaries, six test types, drift, contract diff, budgets, SEO, manifest freshness. -- `x g resource|entity|action|job|route `: generators that emit code plus failing test scaffolds. -- `x db gen ""` / `x db apply` / `x db drift` / `x db studio`: migrations; drift is a verify failure. -- `x jobs ls|show|retry|drain`, `x cache graph|bust|clear`, `x routes list`, `x actions list|describe`, `x policy explain`, `x i18n add|sync|check`: introspection, all with `--json`. -- `x build --target docker|binary|static`, `x deploy compose|static`, `x branch `, `x status`, `x upgrade`, `x doctor`, `x errors explain `. +- `x new ` / `bunx create-ultimate `: scaffold the monorepo, every `@ultimat3/*` dependency pinned to one exact version. `x dev`: embedded Postgres, in-process NATS, MCP dev server. +- `x verify [--json]`: the single gate — 17 steps: typecheck, lint, boundaries, filesize, package-shape, errors, the six test types (unit, contract, live, job, e2e, eval), drift, contract-diff, budgets, manifest, roadmap. +- `x g resource|action|mutator|job|route|policy|entity|query|task `: generators that emit code plus failing test scaffolds. +- `x db gen "" | migrate | reset | studio | branch `: migrations and branch databases; drift is a verify failure. +- `x jobs ls|show|retry|drain`, `x routes`, `x actions|queries|entities [list|describe ]`, `x manifest [--check]`, `x errors explain |list`: introspection, all with `--json`. +- `x build --target docker|binary|static`, `x deploy --image [--method compose|helm]`, `x doctor`, `x test`, `x fix boundary `, `x mcp tools|serve`. +- Planned commands are in the registry and labelled `(planned)`: `x policy`, `x tasks`, `x cache`, `x i18n`, `x branch`, `x status`, `x upgrade`, `x env`, `x logs`, `x token`, `x ai`, `x money`, `x config`. Each exits `X_NOT_IMPLEMENTED` with a `fix:` naming the closest shipped command — not `X_CLI_UNKNOWN_COMMAND`, because "not built yet" and "not a command" are different facts. +- `x branch [|rm ]` is a whole branch **environment** — copy-on-write database *plus* a preview URL *plus* a scoped MCP socket — which is why it stays planned while `x db branch `, the database-only half, ships today. Full table: [CLI reference](https://raw.githubusercontent.com/wiki/developerz-ai/ultimate/CLI-Reference.md). ## Error codes diff --git a/packages/action/package.json b/packages/action/package.json index cba7f636d..14e90eed5 100644 --- a/packages/action/package.json +++ b/packages/action/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/action", - "version": "0.0.1", + "version": "1.0.0", "description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests", "license": "MIT", "type": "module", @@ -29,10 +29,10 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/cache": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/http": "0.0.1", - "@ultimat3/policy": "0.0.1", - "@ultimat3/schema": "0.0.1" + "@ultimat3/cache": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/http": "1.0.0", + "@ultimat3/policy": "1.0.0", + "@ultimat3/schema": "1.0.0" } } diff --git a/packages/admin/package.json b/packages/admin/package.json index 1760b0453..687ac7e8a 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/admin", - "version": "0.0.1", + "version": "1.0.0", "description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin", "license": "MIT", "type": "module", @@ -30,18 +30,18 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/action": "0.0.1", - "@ultimat3/ai": "0.0.1", - "@ultimat3/cache": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/entity": "0.0.1", - "@ultimat3/i18n": "0.0.1", - "@ultimat3/jobs": "0.0.1", - "@ultimat3/mcp": "0.0.1", - "@ultimat3/money": "0.0.1", - "@ultimat3/policy": "0.0.1", - "@ultimat3/query": "0.0.1", - "@ultimat3/render": "0.0.1", - "@ultimat3/ui": "0.0.1" + "@ultimat3/action": "1.0.0", + "@ultimat3/ai": "1.0.0", + "@ultimat3/cache": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/entity": "1.0.0", + "@ultimat3/i18n": "1.0.0", + "@ultimat3/jobs": "1.0.0", + "@ultimat3/mcp": "1.0.0", + "@ultimat3/money": "1.0.0", + "@ultimat3/policy": "1.0.0", + "@ultimat3/query": "1.0.0", + "@ultimat3/render": "1.0.0", + "@ultimat3/ui": "1.0.0" } } diff --git a/packages/admin/src/registry.ts b/packages/admin/src/registry.ts index 55b06660d..14ba69df7 100644 --- a/packages/admin/src/registry.ts +++ b/packages/admin/src/registry.ts @@ -8,7 +8,7 @@ import type { Entity, Repo } from '@ultimat3/entity'; -/** One column as the admin needs to see it. A Drizzle column descriptor satisfies this. */ +/** One column as the admin needs to see it. An `@ultimat3/entity` column satisfies this. */ export interface AdminColumn { /** The SQL-ish type name: `text`, `varchar`, `timestamptz`, `numeric`, `jsonb`, … */ readonly type: string; diff --git a/packages/ai/package.json b/packages/ai/package.json index 84fffd83d..6b8e62c46 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/ai", - "version": "0.0.1", + "version": "1.0.0", "description": "LLM gateway, versioned prompts, evals as tests, embeddings, hybrid vector search, RAG", "license": "MIT", "type": "module", @@ -29,13 +29,13 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/action": "0.0.1", - "@ultimat3/cache": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/db": "0.0.1", - "@ultimat3/money": "0.0.1", - "@ultimat3/policy": "0.0.1", - "@ultimat3/schema": "0.0.1", - "@ultimat3/time": "0.0.1" + "@ultimat3/action": "1.0.0", + "@ultimat3/cache": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/db": "1.0.0", + "@ultimat3/money": "1.0.0", + "@ultimat3/policy": "1.0.0", + "@ultimat3/schema": "1.0.0", + "@ultimat3/time": "1.0.0" } } diff --git a/packages/auth/package.json b/packages/auth/package.json index 71f419e00..725be2183 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/auth", - "version": "0.0.1", + "version": "1.0.0", "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor", "license": "MIT", "type": "module", @@ -29,8 +29,8 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/db": "0.0.1", - "@ultimat3/schema": "0.0.1" + "@ultimat3/core": "1.0.0", + "@ultimat3/db": "1.0.0", + "@ultimat3/schema": "1.0.0" } } diff --git a/packages/cache/README.md b/packages/cache/README.md index f2356fe34..537ba0dd2 100644 --- a/packages/cache/README.md +++ b/packages/cache/README.md @@ -104,8 +104,11 @@ throw `X_NOT_IMPLEMENTED` with the fix line. For LLM calls, where "list my orders" and "show me my orders" must hit the same entry. `createMemorySemanticCache()` does cosine similarity at a 0.92 threshold (tight on -purpose — a false hit answers the wrong question, which is worse than a miss). Production -backing is pgvector over `x_semantic_cache.embedding`. +purpose — a false hit answers the wrong question, which is worse than a miss) and is the +only backing this package ships — it is O(n) and in-process. The interface (`SemanticCache`) +is a driver seam for that reason; a Postgres/pgvector-backed implementation does not exist +yet here (`@ultimat3/ai`'s `PgVectorStore` is a separate store, for RAG retrieval, not this +cache). ## Errors diff --git a/packages/cache/package.json b/packages/cache/package.json index 609c66e82..914ff7fd7 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/cache", - "version": "0.0.1", + "version": "1.0.0", "description": "Tagged caching: request memo, LRU, Redis, CDN — one invalidation graph", "license": "MIT", "type": "module", @@ -29,6 +29,6 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1" + "@ultimat3/core": "1.0.0" } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 91d459a90..2ec1421b4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/cli", - "version": "0.0.1", + "version": "1.0.0", "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy", "license": "MIT", "type": "module", @@ -33,23 +33,23 @@ "dev": "bun run src/bin.ts dev" }, "dependencies": { - "@ultimat3/action": "0.0.1", - "@ultimat3/admin": "0.0.1", - "@ultimat3/ai": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/db": "0.0.1", - "@ultimat3/entity": "0.0.1", - "@ultimat3/http": "0.0.1", - "@ultimat3/i18n": "0.0.1", - "@ultimat3/jobs": "0.0.1", - "@ultimat3/mail": "0.0.1", - "@ultimat3/manifest": "0.0.1", - "@ultimat3/mcp": "0.0.1", - "@ultimat3/policy": "0.0.1", - "@ultimat3/query": "0.0.1", - "@ultimat3/realtime": "0.0.1", - "@ultimat3/render": "0.0.1", - "@ultimat3/storage": "0.0.1", - "@ultimat3/testing": "0.0.1" + "@ultimat3/action": "1.0.0", + "@ultimat3/admin": "1.0.0", + "@ultimat3/ai": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/db": "1.0.0", + "@ultimat3/entity": "1.0.0", + "@ultimat3/http": "1.0.0", + "@ultimat3/i18n": "1.0.0", + "@ultimat3/jobs": "1.0.0", + "@ultimat3/mail": "1.0.0", + "@ultimat3/manifest": "1.0.0", + "@ultimat3/mcp": "1.0.0", + "@ultimat3/policy": "1.0.0", + "@ultimat3/query": "1.0.0", + "@ultimat3/realtime": "1.0.0", + "@ultimat3/render": "1.0.0", + "@ultimat3/storage": "1.0.0", + "@ultimat3/testing": "1.0.0" } } diff --git a/packages/cli/src/cmd-verify.test.ts b/packages/cli/src/cmd-verify.test.ts index 52245335c..64f14b930 100644 --- a/packages/cli/src/cmd-verify.test.ts +++ b/packages/cli/src/cmd-verify.test.ts @@ -66,6 +66,7 @@ describe('unit · x verify', () => { 'contract-diff', 'budgets', 'manifest', + 'roadmap', ]); expect(VERIFY_STEPS.every((step) => step.summary.length > 0)).toBe(true); }); @@ -186,6 +187,29 @@ describe('unit · x verify', () => { }); }); + describe('roadmap only runs when a host registers it', () => { + const step = VERIFY_STEPS.find((candidate) => candidate.name === 'roadmap'); + + test('skipped with no host check registered', async () => { + expect(await step?.applies?.(ctx)).toBe(false); + }); + + test('applies and surfaces the host findings once one is registered', async () => { + // `cmd-verify` carries `fix` through to both renderers verbatim, so the fixture holds a real + // one: an exact edit naming the file, the row and the marker, not advice. + const finding = { + code: 'X_ROADMAP_STATUS_MISSING', + cause: 'milestone 3 has no status marker', + fix: 'edit docs/idea/14-roadmap.md: put "✅" or "🚧" in the second cell of the row starting "| 3 |", then: bun run scripts/roadmap.ts --json', + }; + const withHost: VerifyContext = { ...ctx, hostChecks: { roadmap: async () => [finding] } }; + expect(await step?.applies?.(withHost)).toBe(true); + const outcome = await step?.run(withHost); + expect(outcome?.ok).toBe(false); + expect(outcome?.findings).toEqual([finding]); + }); + }); + test('a step that throws becomes a finding, not a crash', async () => { const boom: VerifyStep[] = [ { diff --git a/packages/cli/src/cmd-verify.ts b/packages/cli/src/cmd-verify.ts index 4da6c2172..513c2dceb 100644 --- a/packages/cli/src/cmd-verify.ts +++ b/packages/cli/src/cmd-verify.ts @@ -126,6 +126,14 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [ ]); }, }, + { + name: 'roadmap', + summary: "every roadmap milestone's status marker matches what is actually on disk", + // A generated app ships no `docs/idea/14-roadmap.md` — only the framework monorepo does, so + // only a host that registers this check has anything for the step to verify. + applies: async (ctx) => ctx.hostChecks?.roadmap !== undefined, + run: async (ctx) => fromFindings(await hostFindings(ctx, 'roadmap')), + }, ]; /** `assertNoDrift` throws `X_MANIFEST_DRIFT`; a step reports, so the error becomes a finding. */ diff --git a/packages/cli/src/drift.ts b/packages/cli/src/drift.ts index 9fda1509a..c8d819ab7 100644 --- a/packages/cli/src/drift.ts +++ b/packages/cli/src/drift.ts @@ -1,4 +1,4 @@ -// Migration drift detection. `x db gen` records the hash of the Drizzle schema next to the +// Migration drift detection. `x db gen` records the hash of the app's entity schema next to the // migration it produced; drift is "the schema hashes to something no migration recorded". The // hash file is committed beside the migration, so a fresh clone can detect drift with no local // state and CI needs no database to answer the question. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d92af7ee3..5786e2754 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -145,9 +145,12 @@ export type { export { VERIFY_STEP_NAMES } from './verify-step'; export type { TestType } from './verify-tests'; export { TEST_STEPS, TEST_TYPES, testStepCommand, typeFilterOf } from './verify-tests'; +export type { ManifestFacts } from './workspace-checks'; export { checkFileSizes, + checkLockstep, checkPackageShape, + frameworkDepsOf, hasWorkspacePackages, LINE_CEILING, PACKAGE_FILES, diff --git a/packages/cli/src/templates/scaffold-repo.ts b/packages/cli/src/templates/scaffold-repo.ts index b45535817..b07034314 100644 --- a/packages/cli/src/templates/scaffold-repo.ts +++ b/packages/cli/src/templates/scaffold-repo.ts @@ -404,7 +404,7 @@ export function repoFiles(app: NameSet, version: string): readonly GeneratedFile { path: 'packages/domain/src/index.test.ts', contents: domainTest() }, { path: 'packages/db/package.json', - contents: domainPackage(app, 'db', 'Drizzle schema and migrations, no business logic'), + contents: domainPackage(app, 'db', 'Entity re-exports and SQL migrations, no business logic'), }, { path: 'packages/db/src/index.ts', contents: dbIndex() }, { path: 'packages/db/src/schema.ts', contents: dbSchema(app) }, diff --git a/packages/cli/src/verify-step.ts b/packages/cli/src/verify-step.ts index 1ca15e69a..17dc96d2f 100644 --- a/packages/cli/src/verify-step.ts +++ b/packages/cli/src/verify-step.ts @@ -28,6 +28,7 @@ export const VERIFY_STEP_NAMES = [ 'contract-diff', 'budgets', 'manifest', + 'roadmap', ] as const; export type VerifyStepName = (typeof VERIFY_STEP_NAMES)[number]; diff --git a/packages/cli/src/workspace-checks.test.ts b/packages/cli/src/workspace-checks.test.ts index e8b54d516..9e1bf056e 100644 --- a/packages/cli/src/workspace-checks.test.ts +++ b/packages/cli/src/workspace-checks.test.ts @@ -2,17 +2,25 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { ManifestFacts } from './workspace-checks'; import { badVersionFinding, checkFileSizes, + checkLockstep, checkPackageShape, countLines, + frameworkDepsOf, hasWorkspacePackages, LINE_CEILING, PACKAGE_FILES, + pinSkewFinding, workspacePackages, } from './workspace-checks'; +/** A sibling pin the release script bumped everywhere except here — the skew that actually shipped. */ +const STALE_PEER = { '@ultimat3/action': '0.0.1' }; +const STALE_OPTIONAL = { '@ultimat3/db': '0.0.1' }; + const REPO_ROOT = new URL('../../..', import.meta.url).pathname.replace(/\/$/, ''); let dir = ''; @@ -120,3 +128,131 @@ describe('unit · the package shape', () => { expect(await hasWorkspacePackages(join(dir, 'app'))).toBe(false); }); }); + +const pkg = (over: Partial & { dir: string }): ManifestFacts => ({ + name: `@ultimat3/${over.dir}`, + version: '1.0.0', + private: false, + frameworkDeps: [], + ...over, +}); + +describe('checkLockstep', () => { + test('every published package at one version, pins included, is clean', () => { + expect( + checkLockstep([ + pkg({ dir: 'core' }), + pkg({ dir: 'jobs', frameworkDeps: [['@ultimat3/core', '1.0.0']] }), + ]), + ).toEqual([]); + }); + + test('a package left behind at the old version is a finding', () => { + const findings = checkLockstep([pkg({ dir: 'core' }), pkg({ dir: 'ui', version: '0.0.1' })]); + expect(findings).toHaveLength(1); + expect(findings[0]?.code).toBe('X_RELEASE_VERSION_SKEW'); + expect(findings[0]?.cause).toBe('packages/ui is at 0.0.1, not the lockstep version 1.0.0'); + expect(findings[0]?.fix).toBe('bun run scripts/release.ts --version 1.0.0'); + }); + + // The release this check exists for: every own version moved to 1.0.0 and every sibling pin + // stayed at 0.0.1, so @ultimat3/jobs@1.0.0 published naming a @ultimat3/core@0.0.1 that is not + // on the registry. Nothing in the repo failed; every install of the release did. + test('a stale sibling pin is a finding even when both versions are right', () => { + const findings = checkLockstep([ + pkg({ dir: 'core' }), + pkg({ dir: 'jobs', frameworkDeps: [['@ultimat3/core', '0.0.1']] }), + ]); + expect(findings).toHaveLength(1); + expect(findings[0]?.cause).toBe( + 'packages/jobs pins @ultimat3/core at 0.0.1, not the lockstep version 1.0.0', + ); + }); + + test('core is the anchor, so one stray package does not indict every other', () => { + const findings = checkLockstep([ + pkg({ dir: 'core' }), + pkg({ dir: 'http' }), + pkg({ dir: 'ui', version: '9.9.9' }), + ]); + expect(findings.map((finding) => finding.at)).toEqual(['packages/ui/package.json']); + }); + + // A generated app has `packages/*` too: private, on their own version line, depending on the + // framework by caret range. Reporting those would fail `x verify` on unmodified scaffold output. + test('private packages are exempt on both counts', () => { + expect( + checkLockstep([ + pkg({ dir: 'core' }), + pkg({ + dir: 'db', + private: true, + version: '0.0.1', + frameworkDeps: [['@ultimat3/entity', '^1.0.0']], + }), + ]), + ).toEqual([]); + }); + + test('no published package at all is a no-op, not a crash', () => { + expect(checkLockstep([pkg({ dir: 'db', private: true })])).toEqual([]); + expect(checkLockstep([])).toEqual([]); + }); +}); + +describe('frameworkDepsOf', () => { + test('reads @ultimat3 dependencies and ignores everything else', () => { + expect( + frameworkDepsOf({ + dependencies: { '@ultimat3/core': '1.0.0', 'solid-js': '^2.0.0' }, + devDependencies: { '@ultimat3/testing': '1.0.0' }, + }), + ).toEqual([['@ultimat3/core', '1.0.0']]); + }); + + test('peer and optional pins are published too, so they count', () => { + expect( + frameworkDepsOf({ + dependencies: { '@ultimat3/core': '1.0.0' }, + peerDependencies: { '@ultimat3/http': '1.0.0' }, + optionalDependencies: { '@ultimat3/schema': '1.0.0' }, + }), + ).toEqual([ + ['@ultimat3/core', '1.0.0'], + ['@ultimat3/http', '1.0.0'], + ['@ultimat3/schema', '1.0.0'], + ]); + }); + + test('devDependencies stay excluded — npm never installs them for a consumer', () => { + expect(frameworkDepsOf({ devDependencies: { '@ultimat3/testing': '0.0.1' } })).toEqual([]); + }); + + test('a manifest with no dependencies block reads as none', () => { + expect(frameworkDepsOf({})).toEqual([]); + expect(frameworkDepsOf(null)).toEqual([]); + }); +}); + +describe('a stale pin in any published field is skew', () => { + test('a peer pin left behind is reported', () => { + expect( + checkLockstep([ + pkg({ dir: 'core' }), + pkg({ dir: 'render', frameworkDeps: frameworkDepsOf({ peerDependencies: STALE_PEER }) }), + ]), + ).toEqual([pinSkewFinding('render', '@ultimat3/action', '0.0.1', '1.0.0')]); + }); + + test('an optional pin left behind is reported', () => { + expect( + checkLockstep([ + pkg({ dir: 'core' }), + pkg({ + dir: 'ai', + frameworkDeps: frameworkDepsOf({ optionalDependencies: STALE_OPTIONAL }), + }), + ]), + ).toEqual([pinSkewFinding('ai', '@ultimat3/db', '0.0.1', '1.0.0')]); + }); +}); diff --git a/packages/cli/src/workspace-checks.ts b/packages/cli/src/workspace-checks.ts index 28248abb4..7444985b8 100644 --- a/packages/cli/src/workspace-checks.ts +++ b/packages/cli/src/workspace-checks.ts @@ -65,6 +65,95 @@ export const badVersionFinding = (dir: string, found: unknown): Finding => ({ at: `packages/${dir}/package.json`, }); +/** + * What the lockstep rule needs from one manifest. Read once, in `checkPackageShape`, so the gate + * opens each package.json a single time. + */ +export interface ManifestFacts { + readonly dir: string; + readonly name: string; + readonly version: string; + readonly private: boolean; + /** Every `@ultimat3/*` pin npm publishes and the range it is pinned to, in declaration order. */ + readonly frameworkDeps: readonly (readonly [name: string, range: string])[]; +} + +/** + * The manifest fields a published package carries to the registry. `devDependencies` is absent + * deliberately: npm does not install it for a consumer, so a stale one there cannot break anybody's + * install — while a stale `peerDependencies` or `optionalDependencies` pin resolves at install time + * exactly like `dependencies` does, and skipping them let skew reach the registry unreported. + */ +export const PUBLISHED_DEP_FIELDS = [ + 'dependencies', + 'peerDependencies', + 'optionalDependencies', +] as const; + +export const versionSkewFinding = (dir: string, found: string, expected: string): Finding => ({ + code: 'X_RELEASE_VERSION_SKEW', + cause: `packages/${dir} is at ${found}, not the lockstep version ${expected}`, + fix: `bun run scripts/release.ts --version ${expected}`, + docs: docs('X_RELEASE_VERSION_SKEW'), + at: `packages/${dir}/package.json`, +}); + +export const pinSkewFinding = ( + dir: string, + dep: string, + range: string, + expected: string, +): Finding => ({ + code: 'X_RELEASE_VERSION_SKEW', + cause: `packages/${dir} pins ${dep} at ${range}, not the lockstep version ${expected}`, + fix: `bun run scripts/release.ts --version ${expected}`, + docs: docs('X_RELEASE_VERSION_SKEW'), + at: `packages/${dir}/package.json`, +}); + +/** + * Lockstep versioning, as a build error rather than a paragraph in PUBLISHING.md. Two ways a + * release breaks silently: a package left behind at the old version, and — the one that actually + * shipped — every package's own version bumped while its sibling pins stayed put, so + * `@ultimat3/jobs@1.0.0` names `@ultimat3/core@0.0.1`, a version that is not on the registry. + * npm resolves that at somebody else's install, which is far too late. + * + * Private packages are exempt on both counts: a generated app's `packages/*` are private, carry + * their own version line and depend on the framework by caret range. + */ +export function checkLockstep(manifests: readonly ManifestFacts[]): readonly Finding[] { + const published = manifests.filter((manifest) => !manifest.private); + // `core` is tier 0 and everything depends on it, so it is the version the rest must match. + const anchor = published.find((manifest) => manifest.dir === 'core') ?? published[0]; + if (anchor === undefined) return []; + const findings: Finding[] = []; + for (const manifest of published) { + if (manifest.version !== anchor.version) { + findings.push(versionSkewFinding(manifest.dir, manifest.version, anchor.version)); + } + for (const [dep, range] of manifest.frameworkDeps) { + if (range !== anchor.version) { + findings.push(pinSkewFinding(manifest.dir, dep, range, anchor.version)); + } + } + } + return findings; +} + +export function frameworkDepsOf(manifest: unknown): ManifestFacts['frameworkDeps'] { + const record = (typeof manifest === 'object' && manifest !== null ? manifest : {}) as Record< + string, + unknown + >; + return PUBLISHED_DEP_FIELDS.flatMap((field) => { + const deps = record[field]; + if (typeof deps !== 'object' || deps === null) return []; + return Object.entries(deps).flatMap(([name, range]) => + name.startsWith('@ultimat3/') && typeof range === 'string' ? [[name, range] as const] : [], + ); + }); +} + export async function workspacePackages(root: string): Promise { const dirs: string[] = []; for await (const path of new Bun.Glob('packages/*/package.json').scan({ @@ -84,19 +173,30 @@ export const hasWorkspacePackages = async (root: string): Promise => export async function checkPackageShape(root: string): Promise { const scaffolder = existsSync(join(root, 'scripts', 'new-package.ts')); const findings: Finding[] = []; + const facts: ManifestFacts[] = []; for (const dir of await workspacePackages(root)) { for (const file of PACKAGE_FILES) { if (existsSync(join(root, 'packages', dir, file))) continue; findings.push(missingFileFinding(dir, file, scaffolder)); } const manifest: unknown = await Bun.file(join(root, 'packages', dir, 'package.json')).json(); - const version = - typeof manifest === 'object' && manifest !== null - ? (manifest as { version?: unknown }).version - : undefined; + const record = (typeof manifest === 'object' && manifest !== null ? manifest : {}) as { + name?: unknown; + version?: unknown; + private?: unknown; + }; + const version = record.version; if (typeof version !== 'string' || !SEMVER.test(version)) { findings.push(badVersionFinding(dir, version)); + continue; } + facts.push({ + dir, + name: typeof record.name === 'string' ? record.name : `@ultimat3/${dir}`, + version, + private: record.private === true, + frameworkDeps: frameworkDepsOf(manifest), + }); } - return findings; + return [...findings, ...checkLockstep(facts)]; } diff --git a/packages/core/package.json b/packages/core/package.json index 3fd35444e..03faeb901 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/core", - "version": "0.0.1", + "version": "1.0.0", "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle", "license": "MIT", "type": "module", diff --git a/packages/create-ultimate/CLAUDE.md b/packages/create-ultimate/CLAUDE.md index ab9f30559..8b079ad46 100644 --- a/packages/create-ultimate/CLAUDE.md +++ b/packages/create-ultimate/CLAUDE.md @@ -1,7 +1,7 @@ # create-ultimate — boundary -Tier 5, sideways import of `@ultimat3/cli` only (the single exception in the tier table, declared -in `scripts/lib/tiers.ts`). +Unlisted — sits above tier 5, may import anything below it. Its only real import is +`@ultimat3/cli` (the sideways edge declared in `scripts/lib/tiers.ts`). | Rule | Detail | |---|---| diff --git a/packages/create-ultimate/package.json b/packages/create-ultimate/package.json index d9a9cf76d..e3691aa18 100644 --- a/packages/create-ultimate/package.json +++ b/packages/create-ultimate/package.json @@ -1,6 +1,6 @@ { "name": "create-ultimate", - "version": "0.0.1", + "version": "1.0.0", "description": "bunx create-ultimate myapp — scaffold an Ultimate monorepo", "license": "MIT", "type": "module", @@ -32,6 +32,6 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/cli": "0.0.1" + "@ultimat3/cli": "1.0.0" } } diff --git a/packages/db/CLAUDE.md b/packages/db/CLAUDE.md index cf06f4abc..71ff4c53b 100644 --- a/packages/db/CLAUDE.md +++ b/packages/db/CLAUDE.md @@ -7,7 +7,7 @@ reaches down to this package for it. **Never** import `entity`, `jobs`, `http` o | Rule | | |---|---| -| Deps | none. `@electric-sql/pglite` is an **optional peer**, imported by variable specifier inside `loadPgliteDriver()` so no consumer's `tsc` or bundler resolves it. Drizzle is documented, not depended on | +| Deps | none. `@electric-sql/pglite` is an **optional peer**, imported by variable specifier inside `loadPgliteDriver()` so no consumer's `tsc` or bundler resolves it. **No ORM** — `entity`'s hand-written `postgresDriver()` is the production backing | | SQL | `sql` binds `$n`; anything non-scalar and non-fragment throws `X_SQL_UNSAFE` | | Escape hatches | `raw()`, `identifier()`, `literal()` — each call is an audit point | | Errors | subclass `DbError`; never `throw new Error` | diff --git a/packages/db/README.md b/packages/db/README.md index 322e28965..e337e4d10 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -4,10 +4,11 @@ Postgres access for Ultimate: parameterised SQL, transactions, migrations, drift branch databases, and a read-only client for anything an LLM drives. Tier 1. Imports `@ultimat3/core` only. No runtime dependencies; `@electric-sql/pglite` is an -**optional peer**, loaded at first query and only by the embedded driver. **Drizzle is the -production backing for the query builder and entity schema definitions**; this package declares -the narrow structural types it consumes (`DbClient`, `SqlFragment`, `EntityDescriptionLike`) so -the SQL stays readable and the boundary stays thin. +**optional peer**, loaded at first query and only by the embedded driver. **No ORM backs any of +this** — `@ultimat3/entity`'s hand-written `postgresDriver()` compiles every statement out of the +`sql` / `identifier` / `join` fragments below; this package declares the narrow structural types it +consumes (`DbClient`, `SqlFragment`, `EntityDescriptionLike`) so the SQL stays readable and the +boundary stays thin. ## Public API diff --git a/packages/db/package.json b/packages/db/package.json index c681de9d2..dd8485107 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/db", - "version": "0.0.1", + "version": "1.0.0", "description": "Postgres access, transactions, migrations and drift detection", "license": "MIT", "type": "module", @@ -29,7 +29,7 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1" + "@ultimat3/core": "1.0.0" }, "peerDependencies": { "@electric-sql/pglite": ">=0.5.0" diff --git a/packages/entity/CLAUDE.md b/packages/entity/CLAUDE.md index 7ef569259..ba50cb5ee 100644 --- a/packages/entity/CLAUDE.md +++ b/packages/entity/CLAUDE.md @@ -9,8 +9,10 @@ Columns + invariants; the row type is derived from the columns. Tier 2. - `db` is tier 1 (it imports only `core`), which is what lets the Postgres driver live **here** rather than in a tier-3 package: `Driver` and its production implementation stay in one place. See [`docs/architecture/01-package-map.md`](../../docs/architecture/01-package-map.md). -- No `drizzle-orm`. `types.ts` declares the column vocabulary we consume; Drizzle is the - production backing, documented not imported. +- No `drizzle-orm` dependency, and none is the production backing — `postgresDriver()` + (`pg-driver.ts`/`pg-sql.ts`) is a hand-written SQL driver. `types.ts` declares the narrow + structural column vocabulary this package consumes so the generated SQL stays readable and + an agent can self-correct against it. ## Do not regress diff --git a/packages/entity/package.json b/packages/entity/package.json index b43f5cb91..ff491794e 100644 --- a/packages/entity/package.json +++ b/packages/entity/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/entity", - "version": "0.0.1", + "version": "1.0.0", "description": "A table + its domain type + invariants the database also enforces", "license": "MIT", "type": "module", @@ -30,8 +30,8 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/db": "0.0.1", - "@ultimat3/schema": "0.0.1" + "@ultimat3/core": "1.0.0", + "@ultimat3/db": "1.0.0", + "@ultimat3/schema": "1.0.0" } } diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index 056e6bd66..b9d714e05 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -1,6 +1,7 @@ -// The structural vocabulary of a column. Drizzle is the production backing for the physical -// layer (see README); declaring the shape we consume instead of depending on an ORM keeps the -// emitted SQL readable and keeps this package free of a dependency an agent must learn to read. +// The structural vocabulary of a column. The physical layer is this package's own hand-written +// `postgresDriver()` (`pg-driver.ts` / `pg-sql.ts`), not an ORM; declaring the narrow shape we +// consume keeps the emitted SQL readable and keeps this package free of a dependency an agent +// must learn to read. // // A column carries its TypeScript type in `$parse`, which is what lets the row type be derived // from the column set instead of being written a second time as a hand-maintained schema. diff --git a/packages/http/package.json b/packages/http/package.json index c4676d220..291b1d8ec 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/http", - "version": "0.0.1", + "version": "1.0.0", "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors", "license": "MIT", "type": "module", @@ -30,7 +30,7 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/schema": "0.0.1" + "@ultimat3/core": "1.0.0", + "@ultimat3/schema": "1.0.0" } } diff --git a/packages/http/src/validate.test.ts b/packages/http/src/validate.test.ts index 65d7648a1..8214fded0 100644 --- a/packages/http/src/validate.test.ts +++ b/packages/http/src/validate.test.ts @@ -1,5 +1,5 @@ // Validation reaches the app through Standard Schema rather than a vendor API, so this seam -// has to behave the same for a hand-written validator as for ArkType, and has to say +// has to behave the same for a hand-written validator as for the shipped `t`, and has to say // something an agent can act on when an async schema is used on the sync path. Neither is // visible in the type, so both are asserted here. import { describe, expect, test } from 'bun:test'; @@ -77,7 +77,7 @@ describe('validateSync', () => { }); }); - test('validates a real ArkType-backed schema synchronously', () => { + test('validates a real `t`-backed schema synchronously', () => { const schema = t.object({ name: t.string }); const ok = validateSync(schema, { name: 'ada' }); expect(ok).toEqual({ ok: true, value: { name: 'ada' } }); @@ -113,7 +113,7 @@ describe('validate', () => { expect(result).toEqual({ ok: true, value: 7 }); }); - test('validates a real ArkType-backed schema across valid and invalid values', async () => { + test('validates a real `t`-backed schema across valid and invalid values', async () => { const schema = t.object({ name: t.string }); const ok = await validate(schema, { name: 'grace' }); diff --git a/packages/http/src/validate.ts b/packages/http/src/validate.ts index 93ff2a3b3..9d918683f 100644 --- a/packages/http/src/validate.ts +++ b/packages/http/src/validate.ts @@ -1,5 +1,5 @@ // Validation goes through the Standard Schema interface, never through a vendor -// API, so ArkType (`t`) is a default rather than a dependency of the HTTP layer. +// API, so `t` is the schema layer's shipped default rather than a dependency of the HTTP layer. import type { StandardSchemaV1 } from '@ultimat3/schema'; export type Schema = StandardSchemaV1; diff --git a/packages/i18n/package.json b/packages/i18n/package.json index b5efa5b1b..fdc6196da 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/i18n", - "version": "0.0.1", + "version": "1.0.0", "description": "Dependency-free translator, catalog flattening, locale negotiation and loud missing-key rendering", "license": "MIT", "type": "module", @@ -29,6 +29,6 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1" + "@ultimat3/core": "1.0.0" } } diff --git a/packages/jobs/package.json b/packages/jobs/package.json index e1e56ead1..c80b5a7bc 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/jobs", - "version": "0.0.1", + "version": "1.0.0", "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface", "license": "MIT", "type": "module", @@ -29,9 +29,9 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/entity": "0.0.1", - "@ultimat3/schema": "0.0.1", - "@ultimat3/time": "0.0.1" + "@ultimat3/core": "1.0.0", + "@ultimat3/entity": "1.0.0", + "@ultimat3/schema": "1.0.0", + "@ultimat3/time": "1.0.0" } } diff --git a/packages/jobs/src/driver-nats.ts b/packages/jobs/src/driver-nats.ts index 4d4c61e2b..1f6ca1811 100644 --- a/packages/jobs/src/driver-nats.ts +++ b/packages/jobs/src/driver-nats.ts @@ -15,7 +15,10 @@ import type { import { JobsNotImplementedError } from './errors'; import type { StepRecord, StepStore } from './steps'; -const FIX = 'use driver: "pg" (default) or "memory" — see docs/jobs/drivers.md#nats'; +// Names the config edit that actually removes the stub, plus the runnable command for whatever +// is already queued. The nats driver lands in v2; there is no flag that turns this one on. +const FIX = + "set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs drain --to memory --json"; const unavailable = (method: string): never => { throw new JobsNotImplementedError({ feature: `nats jobs driver (${method})`, fix: FIX }); diff --git a/packages/jobs/src/driver-redis.ts b/packages/jobs/src/driver-redis.ts index 8056dc617..f993f41a4 100644 --- a/packages/jobs/src/driver-redis.ts +++ b/packages/jobs/src/driver-redis.ts @@ -18,7 +18,10 @@ import type { import { JobsNotImplementedError } from './errors'; import type { StepRecord, StepStore } from './steps'; -const FIX = 'use driver: "pg" (default) or "memory" — see docs/jobs/drivers.md#redis'; +// Names the config edit that actually removes the stub, plus the runnable command for whatever +// is already queued. The redis driver lands in v2; there is no flag that turns this one on. +const FIX = + "set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs drain --to memory --json"; const unavailable = (method: string): never => { throw new JobsNotImplementedError({ feature: `redis jobs driver (${method})`, fix: FIX }); diff --git a/packages/jobs/src/dsl.test.ts b/packages/jobs/src/dsl.test.ts index 63b9a33bb..9f98bd46c 100644 --- a/packages/jobs/src/dsl.test.ts +++ b/packages/jobs/src/dsl.test.ts @@ -18,7 +18,7 @@ import { resetJobsFacade } from './outbox'; import type { TaskEnqueueEntry, TaskHandle } from './scheduler'; import { resetTasks, task } from './scheduler'; -/** Minimal Standard Schema so these tests do not depend on ArkType's surface. */ +/** Minimal Standard Schema so these tests do not depend on the shipped provider's surface. */ function passthrough(): StandardSchemaV1 { return { '~standard': { diff --git a/packages/jobs/src/inspect.ts b/packages/jobs/src/inspect.ts index d1adc1410..c5c2f65d2 100644 --- a/packages/jobs/src/inspect.ts +++ b/packages/jobs/src/inspect.ts @@ -79,7 +79,7 @@ function requireIntrospection(driver: JobDriver): NonNullable(): StandardSchemaV1 { return { '~standard': { diff --git a/packages/jobs/src/outbox.test.ts b/packages/jobs/src/outbox.test.ts index efc68f7da..6be9e6814 100644 --- a/packages/jobs/src/outbox.test.ts +++ b/packages/jobs/src/outbox.test.ts @@ -15,7 +15,7 @@ import { setJobsFacade, } from './outbox'; -/** Minimal Standard Schema so these tests do not depend on ArkType's surface. */ +/** Minimal Standard Schema so these tests do not depend on the shipped provider's surface. */ function passthrough(): StandardSchemaV1 { return { '~standard': { diff --git a/packages/mail/package.json b/packages/mail/package.json index ba004d709..f39a063a2 100644 --- a/packages/mail/package.json +++ b/packages/mail/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/mail", - "version": "0.0.1", + "version": "1.0.0", "description": "Transactional email as data: one template renders HTML and text, sent through a job.", "license": "MIT", "type": "module", @@ -29,10 +29,10 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/i18n": "0.0.1", - "@ultimat3/jobs": "0.0.1", - "@ultimat3/schema": "0.0.1", - "@ultimat3/time": "0.0.1" + "@ultimat3/core": "1.0.0", + "@ultimat3/i18n": "1.0.0", + "@ultimat3/jobs": "1.0.0", + "@ultimat3/schema": "1.0.0", + "@ultimat3/time": "1.0.0" } } diff --git a/packages/manifest/package.json b/packages/manifest/package.json index 660a182f5..4441acb2b 100644 --- a/packages/manifest/package.json +++ b/packages/manifest/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/manifest", - "version": "0.0.1", + "version": "1.0.0", "description": "x.manifest.json: deterministic generated facts, contract diff, AGENTS.md budget", "license": "MIT", "type": "module", @@ -29,10 +29,10 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/action": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/entity": "0.0.1", - "@ultimat3/jobs": "0.0.1", - "@ultimat3/query": "0.0.1" + "@ultimat3/action": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/entity": "1.0.0", + "@ultimat3/jobs": "1.0.0", + "@ultimat3/query": "1.0.0" } } diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 8c8e6bddf..79ddc93bb 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/mcp", - "version": "0.0.1", + "version": "1.0.0", "description": "MCP server, dev tools, and the action-to-tool projection — one authz system, two surfaces", "license": "MIT", "type": "module", @@ -29,12 +29,12 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/action": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/entity": "0.0.1", - "@ultimat3/jobs": "0.0.1", - "@ultimat3/policy": "0.0.1", - "@ultimat3/query": "0.0.1", - "@ultimat3/schema": "0.0.1" + "@ultimat3/action": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/entity": "1.0.0", + "@ultimat3/jobs": "1.0.0", + "@ultimat3/policy": "1.0.0", + "@ultimat3/query": "1.0.0", + "@ultimat3/schema": "1.0.0" } } diff --git a/packages/money/package.json b/packages/money/package.json index a9cf5a3f2..50663bd5e 100644 --- a/packages/money/package.json +++ b/packages/money/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/money", - "version": "0.0.1", + "version": "1.0.0", "description": "Integer minor units with an attached currency: arithmetic, allocation, rounding, Intl formatting", "license": "MIT", "type": "module", @@ -29,6 +29,6 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1" + "@ultimat3/core": "1.0.0" } } diff --git a/packages/policy/package.json b/packages/policy/package.json index 8ec0be71a..ae36cc8a3 100644 --- a/packages/policy/package.json +++ b/packages/policy/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/policy", - "version": "0.0.1", + "version": "1.0.0", "description": "The one authz rule, evaluated identically in every surface", "license": "MIT", "type": "module", @@ -30,6 +30,6 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1" + "@ultimat3/core": "1.0.0" } } diff --git a/packages/pwa/package.json b/packages/pwa/package.json index a3d9d4925..2f5b4db1e 100644 --- a/packages/pwa/package.json +++ b/packages/pwa/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/pwa", - "version": "0.0.1", + "version": "1.0.0", "description": "Generated service worker, web manifest, icons, push and version-skew handling.", "license": "MIT", "type": "module", @@ -29,6 +29,6 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1" + "@ultimat3/core": "1.0.0" } } diff --git a/packages/query/package.json b/packages/query/package.json index 630f076e4..af77ca731 100644 --- a/packages/query/package.json +++ b/packages/query/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/query", - "version": "0.0.1", + "version": "1.0.0", "description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher", "license": "MIT", "type": "module", @@ -29,9 +29,9 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/cache": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/policy": "0.0.1", - "@ultimat3/schema": "0.0.1" + "@ultimat3/cache": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/policy": "1.0.0", + "@ultimat3/schema": "1.0.0" } } diff --git a/packages/query/src/source.ts b/packages/query/src/source.ts index 88982e10c..5198b8ed1 100644 --- a/packages/query/src/source.ts +++ b/packages/query/src/source.ts @@ -1,7 +1,8 @@ /** - * The `SqlSource` contract every read must satisfy, plus `from()` — the in-memory - * reference implementation used by tests and fixtures. A real app returns a - * Drizzle-backed source from `sql:`; it only has to answer these four questions. + * The `SqlSource` contract every read must satisfy, plus `from()` — the reference + * implementation used by tests, fixtures and app queries alike. No ORM backs it: a real + * app's `sql:` returns `from()` over an `@ultimat3/entity` repo, and a source only has to + * answer these four questions. */ import type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape'; import { compareRows, compareValues, matchesFilters } from './shape'; diff --git a/packages/realtime/package.json b/packages/realtime/package.json index 9be80ab5b..532e91c41 100644 --- a/packages/realtime/package.json +++ b/packages/realtime/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/realtime", - "version": "0.0.1", + "version": "1.0.0", "description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape", "license": "MIT", "type": "module", @@ -29,7 +29,7 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/query": "0.0.1" + "@ultimat3/core": "1.0.0", + "@ultimat3/query": "1.0.0" } } diff --git a/packages/render/package.json b/packages/render/package.json index 541be6a77..1a16e8433 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/render", - "version": "0.0.1", + "version": "1.0.0", "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.", "license": "MIT", "type": "module", @@ -29,8 +29,8 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/cache": "0.0.1", - "@ultimat3/core": "0.0.1", - "@ultimat3/seo": "0.0.1" + "@ultimat3/cache": "1.0.0", + "@ultimat3/core": "1.0.0", + "@ultimat3/seo": "1.0.0" } } diff --git a/packages/schema/README.md b/packages/schema/README.md index 979d5dfb0..92e8bdca8 100644 --- a/packages/schema/README.md +++ b/packages/schema/README.md @@ -71,9 +71,10 @@ both: `registerErrorCodes(SCHEMA_ERROR_CODES)`. ## Swapping the library -The builtin validators are small and dependency-free so a fresh install has zero deps. -**ArkType is the intended production default**; Zod and Valibot work identically because all -three implement Standard Schema v1. +The builtin validators (`validators.ts`) are the shipped default — small, dependency-free, no +adapter to install. No ArkType, Zod or Valibot adapter ships in this package; swapping to one +means writing the ~40-line adapter below yourself. All three implement Standard Schema v1, so +any of them works identically once wired. ```ts import { configureSchemaProvider } from '@ultimat3/schema'; diff --git a/packages/schema/package.json b/packages/schema/package.json index 433a5dc6b..796474719 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/schema", - "version": "0.0.1", + "version": "1.0.0", "description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output", "license": "MIT", "type": "module", diff --git a/packages/schema/src/validators.ts b/packages/schema/src/validators.ts index 262e27c64..20618dca4 100644 --- a/packages/schema/src/validators.ts +++ b/packages/schema/src/validators.ts @@ -1,5 +1,5 @@ // Single responsibility: the builtin, dependency-free validators behind `t`. Small on purpose — -// ArkType or Zod replace them wholesale via `configureSchemaProvider()`; the IR and the +// ArkType or Zod replace them wholesale via `configureSchemaProvider()` — neither ships; the IR and the // Standard Schema surface are what the rest of the framework actually depends on. import { diff --git a/packages/seo/package.json b/packages/seo/package.json index ca05e6fe2..79bfd4781 100644 --- a/packages/seo/package.json +++ b/packages/seo/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/seo", - "version": "0.0.1", + "version": "1.0.0", "description": "Enforced SEO: typed meta, JSON-LD, sitemap, robots, feeds, responsive images, perf budgets", "license": "MIT", "type": "module", @@ -29,6 +29,6 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1" + "@ultimat3/core": "1.0.0" } } diff --git a/packages/storage/package.json b/packages/storage/package.json index 0f27aa210..301e32d62 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/storage", - "version": "0.0.1", + "version": "1.0.0", "description": "Named disks over Bun.file and Bun.s3: safe keys, signed URLs, sniffed uploads", "license": "MIT", "type": "module", @@ -29,6 +29,6 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1" + "@ultimat3/core": "1.0.0" } } diff --git a/packages/testing/package.json b/packages/testing/package.json index d342317d4..02c18fc2b 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/testing", - "version": "0.0.1", + "version": "1.0.0", "description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types", "license": "MIT", "type": "module", @@ -30,10 +30,10 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/db": "0.0.1", - "@ultimat3/jobs": "0.0.1", - "@ultimat3/mail": "0.0.1", - "@ultimat3/time": "0.0.1" + "@ultimat3/core": "1.0.0", + "@ultimat3/db": "1.0.0", + "@ultimat3/jobs": "1.0.0", + "@ultimat3/mail": "1.0.0", + "@ultimat3/time": "1.0.0" } } diff --git a/packages/testing/src/matchers.ts b/packages/testing/src/matchers.ts index 3c5cdb1d0..2f98a2744 100644 --- a/packages/testing/src/matchers.ts +++ b/packages/testing/src/matchers.ts @@ -32,7 +32,7 @@ const isStandardSchema = (value: unknown): value is StandardSchema => async function hasIssues(schema: unknown, input: unknown): Promise { if (!isStandardSchema(schema)) { throw new TypeError( - 'toRejectInput expects a Standard Schema (ArkType `t`) — pass action.input, not the action', + 'toRejectInput expects a Standard Schema (`t`) — pass action.input, not the action', ); } const result = await schema['~standard'].validate(input); diff --git a/packages/time/package.json b/packages/time/package.json index ebc5df1c4..d99043ca6 100644 --- a/packages/time/package.json +++ b/packages/time/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/time", - "version": "0.0.1", + "version": "1.0.0", "description": "UTC instants, DST-correct zone math, cron, durations and Intl formatting with an explicit timezone", "license": "MIT", "type": "module", @@ -29,6 +29,6 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1" + "@ultimat3/core": "1.0.0" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index c09ea3107..392edc16e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@ultimat3/ui", - "version": "0.0.1", + "version": "1.0.0", "description": "SolidJS design system: semantic design tokens, dark/RTL-ready SCSS modules, a11y primitives", "license": "MIT", "type": "module", @@ -33,10 +33,10 @@ "test": "bun test" }, "dependencies": { - "@ultimat3/core": "0.0.1", - "@ultimat3/i18n": "0.0.1", - "@ultimat3/money": "0.0.1", - "@ultimat3/time": "0.0.1" + "@ultimat3/core": "1.0.0", + "@ultimat3/i18n": "1.0.0", + "@ultimat3/money": "1.0.0", + "@ultimat3/time": "1.0.0" }, "peerDependencies": { "solid-js": "^2.0.0" diff --git a/scripts/lib/workspaces.ts b/scripts/lib/workspaces.ts index 4bf9757cf..ede3bfe59 100644 --- a/scripts/lib/workspaces.ts +++ b/scripts/lib/workspaces.ts @@ -45,5 +45,25 @@ export async function listWorkspaces(root: string): Promise workspaces.filter((workspace) => !workspace.private); +/** + * Every workspace manifest the root package.json claims, `packages/*` and the reference app alike. + * A release rewrites `@ultimat3/*` pins in all of them: the example workspaces are private and + * never publish, but they resolve those pins out of the same lockfile, so one left at the old + * version makes `bun install --frozen-lockfile` reach npm for a version that is not there. + */ +export async function workspaceManifests(root: string): Promise { + const manifest = (await Bun.file(join(root, 'package.json')).json()) as { + readonly workspaces?: readonly string[]; + }; + const paths: string[] = []; + for (const pattern of manifest.workspaces ?? []) { + const glob = new Bun.Glob(`${pattern}/package.json`); + for await (const relative of glob.scan({ cwd: root, absolute: false })) { + paths.push(join(root, relative)); + } + } + return paths.sort(); +} + export const hasFile = (workspace: Workspace, file: string): boolean => existsSync(join(workspace.path, file)); diff --git a/scripts/release.test.ts b/scripts/release.test.ts new file mode 100644 index 000000000..a3ed5bb3f --- /dev/null +++ b/scripts/release.test.ts @@ -0,0 +1,125 @@ +// The release script writes the artefacts nobody can un-publish, so its three text rewrites are +// pinned here: the manifest's own version, the sibling pins that make a lockstep release +// installable, and where a new section lands in a newest-first changelog. + +import { describe, expect, test } from 'bun:test'; +import { + changelogEntry, + insertRelease, + nextVersion, + repinFrameworkDeps, + setOwnVersion, +} from './release'; + +const MANIFEST = `{ + "name": "@ultimat3/jobs", + "version": "0.0.1", + "private": false, + "dependencies": { + "@ultimat3/core": "0.0.1", + "@ultimat3/entity": "0.0.1" + } +} +`; + +describe('nextVersion', () => { + test('0.0.1 majors to 1.0.0', () => { + expect(nextVersion('0.0.1', 'major')).toBe('1.0.0'); + }); + + test('minor and patch move the right field', () => { + expect(nextVersion('1.2.3', 'minor')).toBe('1.3.0'); + expect(nextVersion('1.2.3', 'patch')).toBe('1.2.4'); + }); + + test('a prerelease suffix is dropped, not carried forward', () => { + expect(nextVersion('1.0.0-rc.1', 'patch')).toBe('1.0.1'); + }); +}); + +describe('setOwnVersion', () => { + test('rewrites the manifest version and nothing below it', () => { + const out = setOwnVersion(MANIFEST, '1.0.0'); + expect(out).toContain('"version": "1.0.0"'); + // The dependency pins are a separate rewrite — this one must not reach them. + expect(out).toContain('"@ultimat3/core": "0.0.1"'); + }); + + test('preserves key order and the trailing newline', () => { + const out = setOwnVersion(MANIFEST, '1.0.0'); + expect(out.split('\n')[1]).toBe(' "name": "@ultimat3/jobs",'); + expect(out.endsWith('}\n')).toBe(true); + }); +}); + +describe('repinFrameworkDeps', () => { + test('moves every exact @ultimat3 pin to the release version', () => { + const out = repinFrameworkDeps(MANIFEST, '1.0.0'); + expect(out).toContain('"@ultimat3/core": "1.0.0"'); + expect(out).toContain('"@ultimat3/entity": "1.0.0"'); + }); + + // This is the bug the function exists for: a release that bumped only each package's own + // version published @ultimat3/jobs@1.0.0 depending on @ultimat3/core@0.0.1 — a version that is + // not on the registry, so every install of the new release fails. + test('leaves the manifest version to setOwnVersion', () => { + expect(repinFrameworkDeps(MANIFEST, '1.0.0')).toContain('"version": "0.0.1"'); + }); + + // Caught mid-release: a `[a-z-]+` class skipped `@ultimat3/i18n`, so four manifests published + // 1.0.0 still pinning i18n at 0.0.1. Every package name with a digit in it is this test. + test('a package name with digits is repinned like any other', () => { + const raw = '{ "@ultimat3/i18n": "0.0.1" }'; + expect(repinFrameworkDeps(raw, '1.0.0')).toBe('{ "@ultimat3/i18n": "1.0.0" }'); + }); + + test('a caret range or a tag is somebody intent, not skew', () => { + const ranges = '{"dependencies":{"@ultimat3/core":"^1.0.0","@ultimat3/ui":"next"}}'; + expect(repinFrameworkDeps(ranges, '2.0.0')).toBe(ranges); + }); + + test('a third-party dependency at the same version is untouched', () => { + const raw = '{ "@ultimat3/core": "0.0.1", "solid-js": "0.0.1" }'; + expect(repinFrameworkDeps(raw, '1.0.0')).toBe( + '{ "@ultimat3/core": "1.0.0", "solid-js": "0.0.1" }', + ); + }); +}); + +describe('insertRelease', () => { + const changelog = ['# Changelog', '', '## [Unreleased]', '', '### Added', '', '- a thing', '']; + + test('lands under [Unreleased] and above every previous version', () => { + const once = insertRelease( + `${changelog.join('\n')}\n## 1.0.0\n\n- first\n`, + '## 1.1.0\n\n- next\n', + ); + const headings = once.split('\n').filter((line) => line.startsWith('## ')); + expect(headings).toEqual(['## [Unreleased]', '## 1.1.0', '## 1.0.0']); + }); + + // Appending was the old behaviour: correct for the first release, and wrong for every one after, + // because the file then read oldest-first from its third entry on. + test('a second release does not sort below the first', () => { + const first = insertRelease(`${changelog.join('\n')}\n`, '## 1.0.0\n\n- first\n'); + const second = insertRelease(first, '## 1.0.1\n\n- fix\n'); + expect(second.indexOf('## 1.0.1')).toBeLessThan(second.indexOf('## 1.0.0')); + }); + + test('appends when the file has no version heading yet', () => { + expect(insertRelease('# Changelog\n', '## 1.0.0\n')).toBe('# Changelog\n\n## 1.0.0\n'); + }); +}); + +describe('changelogEntry', () => { + test('groups conventional subjects under Keep a Changelog headings', () => { + const entry = changelogEntry('1.0.0', ['feat(cli): x verify', 'fix(http): 401 on anonymous']); + expect(entry).toContain('## 1.0.0'); + expect(entry).toContain('### Added\n\n- x verify'); + expect(entry).toContain('### Fixed\n\n- 401 on anonymous'); + }); + + test('an unconventional subject still lands somewhere, verbatim', () => { + expect(changelogEntry('1.0.0', ['tidy up'])).toContain('### Changed\n\n- tidy up'); + }); +}); diff --git a/scripts/release.ts b/scripts/release.ts index 883b1bd43..f2af1beef 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -9,10 +9,13 @@ import { join } from 'node:path'; import { flagBool, flagString, parseScriptArgs } from './lib/args'; import { report } from './lib/log'; import { repoRoot, run } from './lib/run'; -import { listWorkspaces, publishOrder } from './lib/workspaces'; +import { listWorkspaces, publishOrder, workspaceManifests } from './lib/workspaces'; export type Bump = 'patch' | 'minor' | 'major'; +/** The only dependency range a lockstep release rewrites — a caret or a tag is somebody's intent. */ +const EXACT_PIN = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)*$/; + export function nextVersion(current: string, bump: Bump): string { const [major = 0, minor = 0, patch = 0] = current @@ -24,6 +27,38 @@ export function nextVersion(current: string, bump: Bump): string { return `${major}.${minor}.${patch + 1}`; } +/** + * The manifest's own `"version"`, which is the second key in every package.json here and so the + * first match. Rewriting text rather than re-serialising JSON keeps key order, comments-by-blank- + * line and the trailing newline exactly as committed — a release diff should be one line per file. + */ +export const setOwnVersion = (raw: string, version: string): string => + raw.replace(/"version":\s*"[^"]+"/, `"version": "${version}"`); + +/** + * Every `@ultimat3/*` dependency pin, exact ones only. Lockstep means a published package names + * the sibling version it was tested against; leaving these behind is how `@ultimat3/jobs@1.0.0` + * ships depending on `@ultimat3/core@0.0.1`, which is not on the registry and never will be. + */ +export const repinFrameworkDeps = (raw: string, version: string): string => + // `[a-z0-9-]`, not `[a-z-]`: `@ultimat3/i18n` carries digits in its name, and a class that + // missed it left four manifests pinning i18n at the previous version straight through a release. + raw.replace(/"(@ultimat3\/[a-z0-9-]+)":\s*"([^"]+)"/g, (match, name: string, range: string) => + EXACT_PIN.test(range) ? `"${name}": "${version}"` : match, + ); + +/** + * Keep a Changelog is newest-first. Appending put the second release below the first and every + * release below that, so the file read oldest-first from its third entry on. The new section goes + * directly under the `## [Unreleased]` block — above every previous version, below the preamble. + */ +export function insertRelease(changelog: string, entry: string): string { + const lines = changelog.split('\n'); + const at = lines.findIndex((line) => /^## /.test(line) && !line.includes('[Unreleased]')); + if (at === -1) return `${changelog.trimEnd()}\n\n${entry}`; + return [...lines.slice(0, at), ...`${entry}\n`.split('\n'), ...lines.slice(at)].join('\n'); +} + /** Conventional-commit subjects since the last tag, grouped. Bodies are left to the git log. */ export function changelogEntry(version: string, subjects: readonly string[]): string { const groups: Readonly> = { @@ -68,17 +103,22 @@ if (import.meta.main) { const log = await run(['git', 'log', '--pretty=format:%s', `v${current}..HEAD`], { cwd: root }); const subjects = log.ok ? log.output.split('\n').filter((line) => line.trim().length > 0) : []; + // Own version for the packages that publish; `@ultimat3/*` pins in every workspace, the private + // reference app included, because they all resolve out of one lockfile. + const published = new Set(publishable.map((workspace) => join(workspace.path, 'package.json'))); + const manifests = await workspaceManifests(root); + if (!dryRun) { - for (const workspace of publishable) { - const path = join(workspace.path, 'package.json'); + for (const path of manifests) { const raw = await Bun.file(path).text(); - await Bun.write(path, raw.replace(/"version":\s*"[^"]+"/, `"version": "${version}"`)); + const own = published.has(path) ? setOwnVersion(raw, version) : raw; + await Bun.write(path, repinFrameworkDeps(own, version)); } const changelogPath = join(root, 'CHANGELOG.md'); const existing = await Bun.file(changelogPath) .text() .catch(() => '# Changelog\n\n'); - await Bun.write(changelogPath, `${existing.trimEnd()}\n\n${changelogEntry(version, subjects)}`); + await Bun.write(changelogPath, insertRelease(existing, changelogEntry(version, subjects))); } report( @@ -91,19 +131,21 @@ if (import.meta.main) { findings: mismatched.map((workspace) => ({ code: 'X_RELEASE_VERSION_SKEW', cause: `${workspace.name} was at ${workspace.version}, not ${current}`, - fix: 'lockstep versioning: this release realigns it — review the diff before committing', + fix: `git diff packages/${workspace.dir}/package.json — this release realigns it to ${version}`, at: `packages/${workspace.dir}/package.json`, })), lines: [ ` version ${current} -> ${version}`, ` packages ${publishable.map((workspace) => workspace.name).join(', ')}`, + ` manifests ${manifests.length} rewritten (${published.size} published, the rest repinned)`, ` commits ${subjects.length}`, - ` next commit, tag v${version}, then publish a GitHub Release`, + ` next bun install, commit, tag v${version}, then publish a GitHub Release`, ], data: { version, previous: current, packages: publishable.map((workspace) => workspace.name), + manifests: manifests.length, commits: subjects.length, dryRun, }, diff --git a/scripts/roadmap.test.ts b/scripts/roadmap.test.ts new file mode 100644 index 000000000..11fb5a5d7 --- /dev/null +++ b/scripts/roadmap.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { repoRoot } from './lib/run'; +import { + checkRoadmap, + MILESTONE_NUMBERS, + milestoneRow, + REQUIRED_ARTIFACTS, + ROADMAP_FILE, + STATUS_MARK, +} from './roadmap'; + +const TABLE_HEAD = '| # | Status | Milestone | Ships | Done when |\n|---|---|---|---|---|\n'; + +/** A roadmap whose every milestone row carries `mark`, so a test can vary one row at a time. */ +const roadmapWith = (rows: Readonly>): string => + TABLE_HEAD + + MILESTONE_NUMBERS.map((n) => `| ${n} | ${rows[n] ?? ''} | **m${n}** | ships | done |`).join('\n'); + +async function inTempRepo( + markdown: string | undefined, + fn: (dir: string) => Promise, +): Promise { + const dir = await mkdtemp(join(tmpdir(), 'ultimate-roadmap-')); + try { + if (markdown !== undefined) await Bun.write(join(dir, ROADMAP_FILE), markdown); + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +describe('unit · the roadmap table is the gate', () => { + test('this repo passes its own roadmap check', async () => { + expect(await checkRoadmap(repoRoot())).toEqual([]); + }); + + test('a deleted roadmap is a finding, not a silent pass', async () => { + await inTempRepo(undefined, async (dir) => { + const findings = await checkRoadmap(dir); + expect(findings).toHaveLength(1); + expect(findings[0]?.code).toBe('X_ROADMAP_FILE_MISSING'); + expect(findings[0]?.at).toBe(ROADMAP_FILE); + expect(findings[0]?.fix).toBe(`git checkout -- ${ROADMAP_FILE}`); + }); + }); + + test('a milestone marked shipped with a missing artifact is caught', async () => { + await inTempRepo(roadmapWith({ 0: STATUS_MARK.shipped }), async (dir) => { + const findings = await checkRoadmap(dir); + const codes = findings.map((finding) => finding.code); + expect(codes).toContain('X_ROADMAP_MILESTONE_UNVERIFIED'); + // Every other row is blank, so each one is also missing its marker. + expect(codes).toContain('X_ROADMAP_STATUS_MISSING'); + const unverified = findings.find( + (finding) => finding.code === 'X_ROADMAP_MILESTONE_UNVERIFIED', + ); + expect(unverified?.cause).toContain('milestone 0 ("m0")'); + expect(unverified?.fix).toStartWith('git checkout -- packages/core/src/index.ts'); + }); + }); + + test('a milestone row with no status marker is caught', async () => { + await inTempRepo(roadmapWith({}), async (dir) => { + const findings = await checkRoadmap(dir); + expect(findings).toHaveLength(MILESTONE_NUMBERS.length); + expect(findings[0]?.code).toBe('X_ROADMAP_STATUS_MISSING'); + expect(findings[0]?.cause).toContain('milestone 0'); + expect(findings[0]?.fix).toContain('| 0 |'); + }); + }); + + test('a milestone with no row at all is caught the same way', async () => { + await inTempRepo( + `${TABLE_HEAD}| 0 | ${STATUS_MARK['in-progress']} | **m0** | s | d |`, + async (dir) => { + const codes = (await checkRoadmap(dir)).map((finding) => finding.code); + expect(codes).toEqual(MILESTONE_NUMBERS.slice(1).map(() => 'X_ROADMAP_STATUS_MISSING')); + }, + ); + }); + + test('an in-progress milestone is not held to its artifacts', async () => { + const marks = Object.fromEntries(MILESTONE_NUMBERS.map((n) => [n, STATUS_MARK['in-progress']])); + await inTempRepo(roadmapWith(marks), async (dir) => { + expect(await checkRoadmap(dir)).toEqual([]); + }); + }); +}); + +describe('unit · status and title are read from the table, never mirrored', () => { + test('the marker in the row decides the status', () => { + const markdown = roadmapWith({ 0: STATUS_MARK.shipped, 1: STATUS_MARK['in-progress'] }); + expect(milestoneRow(markdown, 0)).toEqual({ n: 0, status: 'shipped', title: 'm0' }); + expect(milestoneRow(markdown, 1)).toEqual({ n: 1, status: 'in-progress', title: 'm1' }); + }); + + test('a row with no marker, and an absent row, are both undefined', () => { + expect(milestoneRow(roadmapWith({}), 0)).toBeUndefined(); + expect(milestoneRow(TABLE_HEAD, 0)).toBeUndefined(); + }); + + test('the title is read without its markdown emphasis', () => { + const markdown = `${TABLE_HEAD}| 3 | ${STATUS_MARK.shipped} | **Rendering + router** | s | d |`; + expect(milestoneRow(markdown, 3)?.title).toBe('Rendering + router'); + }); + + test('flipping the real table to in-progress flips the status, with no code change', async () => { + const real = await Bun.file(join(repoRoot(), ROADMAP_FILE)).text(); + expect(milestoneRow(real, 0)?.status).toBe('shipped'); + const flipped = real.replace( + `| 0 | ${STATUS_MARK.shipped} |`, + `| 0 | ${STATUS_MARK['in-progress']} |`, + ); + expect(milestoneRow(flipped, 0)?.status).toBe('in-progress'); + }); + + test('every milestone the table names has an artifact list, and the reverse', async () => { + const real = await Bun.file(join(repoRoot(), ROADMAP_FILE)).text(); + for (const n of MILESTONE_NUMBERS) { + expect(milestoneRow(real, n)).toBeDefined(); + expect(REQUIRED_ARTIFACTS[n]?.length ?? 0).toBeGreaterThan(0); + } + }); +}); diff --git a/scripts/roadmap.ts b/scripts/roadmap.ts new file mode 100644 index 000000000..083ee0345 --- /dev/null +++ b/scripts/roadmap.ts @@ -0,0 +1,166 @@ +// Turns `docs/idea/14-roadmap.md` from prose into a gate step. Three rules, all decidable without +// running the demo app: (1) the roadmap file exists at all — deleting it must not silently pass +// every other rule; (2) every milestone row carries a status marker — the table this file guards +// shipped with zero, and a silent regression back to that state is exactly the bug a reader would +// not notice; (3) a milestone the table marks shipped still has every artifact its own "Ships" +// column names on disk — a status marker nobody checks is a claim, not a gate. +// +// bun run scripts/roadmap.ts [--json] + +// `existsSync` and `join` have no Bun equivalent for this job: `Bun.file().exists()` is async and +// answers `false` for a directory, and one required artifact (`packages/cli/src/templates`) is a +// directory. +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Finding, HostCheck } from '@ultimat3/cli'; +import { parseScriptArgs } from './lib/args'; +import { report } from './lib/log'; +import { repoRoot } from './lib/run'; + +export const ROADMAP_FILE = 'docs/idea/14-roadmap.md'; + +export type MilestoneStatus = 'shipped' | 'in-progress'; + +export const STATUS_MARK: Readonly> = { + shipped: '✅', + 'in-progress': '🚧', +}; + +const STATUS_BY_MARK: ReadonlyMap = new Map( + Object.entries(STATUS_MARK).map(([status, mark]) => [mark, status as MilestoneStatus]), +); + +/** + * Repo-relative paths each milestone's own "Ships" column claims exist, checked only once the + * table marks that milestone shipped — an in-progress milestone's artifacts are still landing, so + * their absence is not yet a regression. + * + * Status and title are **not** mirrored here: they are read back out of the table itself, so the + * roadmap is the one place either is stated (axiom 2). Only these paths live in code, because the + * "Ships" column is prose naming packages, not a machine-readable path list. + */ +export const REQUIRED_ARTIFACTS: Readonly> = { + 0: [ + 'packages/core/src/index.ts', + 'packages/schema/src/index.ts', + 'scripts/boundaries.ts', + 'packages/cli/src/cmd-verify.ts', + ], + 1: ['packages/http/src/index.ts', 'packages/entity/src/index.ts', 'packages/policy/src/index.ts'], + 2: [ + 'packages/action/src/index.ts', + 'packages/query/src/index.ts', + 'packages/cli/src/app-openapi.ts', + ], + 3: ['packages/render/src/index.ts', 'packages/cli/src/app-boundaries.ts'], + 4: ['packages/seo/src/index.ts', 'packages/cli/src/budgets.ts'], + 5: ['packages/jobs/src/index.ts', 'packages/mail/src/index.ts', 'packages/storage/src/index.ts'], + 6: ['packages/realtime/src/index.ts'], + 7: ['packages/cache/src/index.ts'], + 8: ['packages/pwa/src/index.ts'], + 9: ['packages/ai/src/index.ts', 'packages/mcp/src/index.ts'], + 10: [ + 'packages/admin/src/index.ts', + 'packages/create-ultimate/src/index.ts', + 'packages/cli/src/templates', + ], + 11: [ + 'docker/Dockerfile', + 'docker/docker-compose.dev.yml', + 'docker/docker-compose.prod.yml', + 'docker/helm', + 'packages/cli/src/cmd-build.ts', + 'wiki/Error-Codes.md', + 'CHANGELOG.md', + ], +}; + +export const MILESTONE_NUMBERS: readonly number[] = Object.keys(REQUIRED_ARTIFACTS) + .map(Number) + .sort((a, b) => a - b); + +export interface MilestoneRow { + readonly n: number; + readonly status: MilestoneStatus; + readonly title: string; +} + +/** + * Read milestone `n` back out of its own `| n | marker | **Title** | ships | done when |` row. + * `undefined` means the row is absent, or present with no marker this table defines — the two + * cases rule (2) exists to catch, and the caller reports them identically. + */ +export function milestoneRow(markdown: string, n: number): MilestoneRow | undefined { + const line = markdown + .split('\n') + .find((candidate) => new RegExp(`^\\|\\s*${n}\\s*\\|`).test(candidate)); + if (line === undefined) return undefined; + const cells = line.split('|').map((cell) => cell.trim()); + const marker = [...STATUS_BY_MARK.keys()].find((mark) => cells[2]?.includes(mark) === true); + const status = marker === undefined ? undefined : STATUS_BY_MARK.get(marker); + if (status === undefined) return undefined; + return { n, status, title: (cells[3] ?? '').replaceAll('*', '').trim() }; +} + +const docs = (code: string): string => `https://ultimate.dev/errors/${code}`; + +const missingFileFinding = (): Finding => ({ + code: 'X_ROADMAP_FILE_MISSING', + cause: `${ROADMAP_FILE} does not exist, so no milestone status or shipped artifact can be checked`, + fix: `git checkout -- ${ROADMAP_FILE}`, + docs: docs('X_ROADMAP_FILE_MISSING'), + at: ROADMAP_FILE, +}); + +const missingStatusFinding = (n: number): Finding => ({ + code: 'X_ROADMAP_STATUS_MISSING', + cause: `milestone ${n} has no row in the milestone table, or its row's status cell holds neither ${STATUS_MARK.shipped} nor ${STATUS_MARK['in-progress']}`, + fix: `edit ${ROADMAP_FILE}: put "${STATUS_MARK.shipped}" or "${STATUS_MARK['in-progress']}" in the second cell of the row starting "| ${n} |", then: bun run scripts/roadmap.ts --json`, + docs: docs('X_ROADMAP_STATUS_MISSING'), + at: ROADMAP_FILE, +}); + +const unverifiedFinding = (row: MilestoneRow, missing: readonly string[]): Finding => ({ + code: 'X_ROADMAP_MILESTONE_UNVERIFIED', + cause: `milestone ${row.n} ("${row.title}") is marked ${STATUS_MARK.shipped} but ${missing.join(', ')} ${missing.length === 1 ? 'does' : 'do'} not exist`, + fix: `git checkout -- ${missing.join(' ')} — or edit ${ROADMAP_FILE} and put "${STATUS_MARK['in-progress']}" in the status cell of the row starting "| ${row.n} |"`, + docs: docs('X_ROADMAP_MILESTONE_UNVERIFIED'), + at: ROADMAP_FILE, +}); + +/** Each milestone's "Done when" as a build error rather than a sentence nobody re-reads. */ +export const checkRoadmap: HostCheck = async (root) => { + const path = join(root, ROADMAP_FILE); + if (!existsSync(path)) return [missingFileFinding()]; + const markdown = await Bun.file(path).text(); + const findings: Finding[] = []; + for (const n of MILESTONE_NUMBERS) { + const row = milestoneRow(markdown, n); + if (row === undefined) { + findings.push(missingStatusFinding(n)); + continue; + } + if (row.status !== 'shipped') continue; + const missing = (REQUIRED_ARTIFACTS[n] ?? []).filter((rel) => !existsSync(join(root, rel))); + if (missing.length > 0) findings.push(unverifiedFinding(row, missing)); + } + return findings; +}; + +if (import.meta.main) { + const args = parseScriptArgs(Bun.argv.slice(2)); + const findings = await checkRoadmap(repoRoot()); + report( + { + ok: findings.length === 0, + script: 'roadmap', + summary: + findings.length === 0 + ? `${MILESTONE_NUMBERS.length} milestones, every status marked and every shipped artifact present` + : `${findings.length} roadmap finding(s) across ${MILESTONE_NUMBERS.length} milestones`, + findings, + data: { file: ROADMAP_FILE, milestones: MILESTONE_NUMBERS }, + }, + args.json, + ); +} diff --git a/scripts/verify.test.ts b/scripts/verify.test.ts index 4af0fc0b1..f5984823c 100644 --- a/scripts/verify.test.ts +++ b/scripts/verify.test.ts @@ -4,6 +4,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { verifyStepNames } from '@ultimat3/cli'; import { repoRoot } from './lib/run'; +// Only the integration assertion below lives here — `checkRoadmap`'s own cases are in +// `scripts/roadmap.test.ts`, next to their source. +import { checkRoadmap } from './roadmap'; import { ERROR_REFERENCE, errorCodeDocs, @@ -16,7 +19,7 @@ describe('unit · the repo gate is the CLI gate', () => { test('the repo adds rules to steps, never steps of its own', () => { const names: readonly string[] = verifyStepNames(); for (const step of Object.keys(HOST_CHECKS)) expect(names).toContain(step); - expect(Object.keys(HOST_CHECKS)).toEqual(['boundaries', 'errors', 'manifest']); + expect(Object.keys(HOST_CHECKS)).toEqual(['boundaries', 'errors', 'manifest', 'roadmap']); }); test('the error reference is enforced through the errors step', async () => { @@ -57,5 +60,6 @@ describe('unit · the repo gate is the CLI gate', () => { expect(await tierBoundaries(root)).toEqual([]); expect(await frameworkManifest(root)).toEqual([]); expect(await errorCodeDocs(root)).toEqual([]); + expect(await checkRoadmap(root)).toEqual([]); }); }); diff --git a/scripts/verify.ts b/scripts/verify.ts index 01602fe60..fc299f1b9 100644 --- a/scripts/verify.ts +++ b/scripts/verify.ts @@ -26,6 +26,7 @@ import { import { flagBool, parseScriptArgs } from './lib/args'; import { repoRoot } from './lib/run'; import { buildManifest, DEFAULT_OUT } from './manifest'; +import { checkRoadmap } from './roadmap'; /** * Two rules on one step. The tier table: a package may import only from a strictly lower tier. @@ -72,6 +73,7 @@ export const HOST_CHECKS: Partial> = { boundaries: tierBoundaries, errors: errorCodeDocs, manifest: frameworkManifest, + roadmap: checkRoadmap, }; if (import.meta.main) { diff --git a/site/README.md b/site/README.md index 891d44a5f..218feab4c 100644 --- a/site/README.md +++ b/site/README.md @@ -40,7 +40,15 @@ cheaper than the machinery to avoid one. ## Files ``` -build.ts # markdown + highlighting + templating + sitemap/robots/feed +build.ts # the one entry point: parse pages, fill templates, emit dist/ +lib/config.ts # origin, roots, PAGE_ORDER, STYLE_ORDER — shared by every stage +lib/text.ts # escaping, slugs, {{var}} fills, frontmatter parsing +lib/markdown.ts # the block + inline grammar the pages use +lib/highlight.ts # local syntax highlighting, no dependency +lib/html.ts # nav, table of contents, pager, JSON-LD +lib/seo.ts # the title/description gate that fails the build +lib/feed.ts # feed.xml, parsed back out of the changelog page +lib/assets.ts # SCSS assembly, theme script, asset copying templates/layout.html # document shell: head, theme script, nav, footer templates/home.html # hero frame; {{hero_code}} is the first fenced block of index.md templates/doc.html # breadcrumb, article, TOC, pager @@ -70,7 +78,7 @@ Frontmatter, all required unless noted: | `headline` | home page only: the `

`, which differs from the `` | | `updated` | `YYYY-MM-DD`, used for `lastmod` and `dateModified` | -Page order — nav order, pager order, sitemap order — is the `PAGE_ORDER` array in `build.ts`. +Page order — nav order, pager order, sitemap order — is the `PAGE_ORDER` array in `lib/config.ts`. Adding a page means adding a file and one array entry. Markdown supported: headings (`##`–`####`, auto-linked anchors), paragraphs, lists, GFM tables @@ -116,7 +124,9 @@ GitHub Pages, from `.github/workflows/pages.yml` (owned by another part of the r ```yaml - run: bun run site/build.ts -- uses: actions/upload-pages-artifact@v3 +- uses: actions/configure-pages@v6 + with: { enablement: true } +- uses: actions/upload-pages-artifact@v5 with: { path: site/dist } ``` diff --git a/site/build.ts b/site/build.ts index cca35ca85..03f179881 100644 --- a/site/build.ts +++ b/site/build.ts @@ -7,7 +7,7 @@ import { mkdirSync, rmSync } from 'node:fs'; import { compileScript, compileStyles, copyDir, readText } from './lib/assets'; import type { Page } from './lib/config'; -import { DIST, ORIGIN, PAGE_ORDER, REPO_ROOT, ROOT } from './lib/config'; +import { DIST, FRAMEWORK_VERSION, ORIGIN, PAGE_ORDER, REPO_ROOT, ROOT } from './lib/config'; import { feedXml } from './lib/feed'; import { renderCode } from './lib/highlight'; import { jsonLd, navHtml, pagerHtml, tocHtml } from './lib/html'; @@ -61,12 +61,15 @@ async function build(): Promise<void> { } const rendered = markdown(source); + // `version` reaches every fill map, not just JSON-LD: the release number is one fact, and a + // template that hardcodes it is the drift the manifest read exists to remove. const body = isHome ? fill(homeTemplate, { headline: escapeHtml(page.meta.headline ?? page.meta.title ?? ''), lede: inline(page.meta.lede ?? page.meta.description ?? ''), hero_code: heroCode, content: rendered.html, + version: FRAMEWORK_VERSION, }) : fill(docTemplate, { breadcrumb: @@ -78,6 +81,7 @@ async function build(): Promise<void> { content: rendered.html, toc: tocHtml(rendered.headings), pager: pagerHtml(pages, index), + version: FRAMEWORK_VERSION, }); const previous = pages[index - 1]; @@ -110,6 +114,7 @@ async function build(): Promise<void> { body, built, build_id: `build ${buildId}`, + version: FRAMEWORK_VERSION, }); await Bun.write(isHome ? `${DIST}/index.html` : `${DIST}/${page.slug}/index.html`, html); diff --git a/site/lib/config.ts b/site/lib/config.ts index e548823ec..344813642 100644 --- a/site/lib/config.ts +++ b/site/lib/config.ts @@ -1,11 +1,58 @@ -// The constants every stage of the site build shares: origin, source and output roots, and the -// fixed stylesheet and page order. Defined once here so no two stages can disagree. +// The constants every stage of the site build shares: origin, source and output roots, the +// framework version, and the fixed stylesheet and page order. Defined once here so no two stages +// can disagree. + +import { readFileSync } from 'node:fs'; export const ORIGIN = 'https://ultimate.developerz.ai'; // `..` because this module sits in site/lib/ — ROOT must still resolve to the site directory. export const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, ''); export const REPO_ROOT = ROOT.replace(/\/site$/, ''); export const DIST = `${ROOT}/dist`; + +const CORE_PACKAGE = 'packages/core/package.json'; + +/** Loose on purpose: the shape of a release version, not a re-implementation of semver. */ +const SEMVER = /^\d+\.\d+\.\d+(?:-[\w.]+)*(?:\+[\w.]+)*$/; + +/** + * `site/` is its own bundle graph (axiom 6) and imports zero `@ultimat3/*` packages, so + * `UltimateError` is out of reach here. The message is hand-built to the same contract instead — + * code, cause, executable fix — because this string is the only instruction the build gives. + */ +function versionFailure(cause: string): Error { + return new Error( + `X_APP_PACKAGE_INVALID: ${CORE_PACKAGE} supplies no usable version\n` + + ` cause: ${cause}\n` + + ' fix: cd packages/core && bun pm pkg set version=1.0.0', + ); +} + +/** + * The version the site publishes to search engines and prints in its own chrome, read from the + * same manifest `@ultimat3/core`'s `FRAMEWORK_VERSION` reads. Hardcoding it left the JSON-LD graph + * advertising `0.0.1` for the whole of 1.0.0 — machine-readable drift nobody sees in a browser. + * Validated rather than cast: an unchecked `as` would publish `undefined` just as quietly. + */ +function readFrameworkVersion(): string { + const path = `${REPO_ROOT}/${CORE_PACKAGE}`; + let manifest: unknown; + try { + manifest = JSON.parse(readFileSync(path, 'utf-8')); + } catch (error) { + throw versionFailure(`${path} is unreadable or is not JSON — ${String(error)}`); + } + const version = + typeof manifest === 'object' && manifest !== null && 'version' in manifest + ? manifest.version + : undefined; + if (typeof version !== 'string' || !SEMVER.test(version)) { + throw versionFailure(`its "version" field is ${JSON.stringify(version)}, not a semver string`); + } + return version; +} + +export const FRAMEWORK_VERSION: string = readFrameworkVersion(); export const STYLE_ORDER = ['tokens', 'base', 'layout', 'components', 'syntax'] as const; export const PAGE_ORDER = [ 'index', diff --git a/site/lib/html.ts b/site/lib/html.ts index a7427e45a..ff801e7b4 100644 --- a/site/lib/html.ts +++ b/site/lib/html.ts @@ -2,7 +2,7 @@ // previous/next pager, and the JSON-LD graph. import type { Page } from './config'; -import { ORIGIN } from './config'; +import { FRAMEWORK_VERSION, ORIGIN } from './config'; import { inline } from './markdown'; import { escapeHtml } from './text'; @@ -54,7 +54,7 @@ export function jsonLd(page: Page, isHome: boolean): string { operatingSystem: 'Linux, macOS', description: page.meta.description, url: `${ORIGIN}/`, - softwareVersion: '0.0.1', + softwareVersion: FRAMEWORK_VERSION, license: 'https://opensource.org/licenses/MIT', codeRepository: 'https://github.com/developerz-ai/ultimate', runtimePlatform: 'Bun >= 1.3', diff --git a/site/pages/ai-first.md b/site/pages/ai-first.md index a0d4e7fa9..31441d340 100644 --- a/site/pages/ai-first.md +++ b/site/pages/ai-first.md @@ -4,7 +4,7 @@ menu: true nav: AI-first description: An MCP dev server, every action exposed as a tool with identical authz, generated facts instead of generated prose, and --json on everything. lede: Not a chat widget. The framework is built so an agent can read it, drive it, and verify its own work — and so the apps it generates have the same property. -updated: 2026-07-26 +updated: 2026-08-10 --- ## Built-in MCP dev server @@ -40,7 +40,7 @@ That line is the entire integration. | MCP requirement | Source | |---|---| | tool name | action name | -| JSON Schema for input | the ArkType `input` (Standard Schema → JSON Schema) | +| JSON Schema for input | the action's `input` (Standard Schema → JSON Schema) | | output schema | `output` | | description | `mcp.description` | | **authorization** | the action's `policy` — unchanged, unwrapped, identical | @@ -74,7 +74,7 @@ code you already wrote. | `x.manifest.json` | **generated**, every build | routes, entities, actions, mutators, queries, jobs, tasks, policies, cache tags, MCP tools, budgets, build ID | never hand-edited; drift is a `x verify` failure | | `openapi.json` | **generated** | HTTP surface from action/query declarations | contract diff in `x verify` | | `AGENTS.md` | **human-authored**, short | project-specific conventions an agent cannot infer | never generated, never auto-appended | -| `CLAUDE.md` | **human-authored**, short | same, compressed-config style | +| `CLAUDE.md` | **human-authored**, short | same, compressed-config style | same rule: never generated, never auto-appended | LLM-generated context files measurably reduce task success: a model writing "here is what this codebase does" produces confident, plausible, partly-wrong prose that the next agent treats as @@ -112,15 +112,18 @@ evals file — no evals is a `x verify` failure**. `eval` is one of the six test ## Branch environments ```text -x branch feat-new-billing +x db branch feat-new-billing ✓ database myapp_feat_new_billing (copy-on-write from dev template) - ✓ build build id 8f2a1c… - ✓ preview http://feat-new-billing.localhost:3000 - ✓ mcp ws://localhost:9229/feat-new-billing ``` -An agent can migrate, seed, test and browse a preview without risking anything shared. The -branch build ID scopes the service-worker cache, so a preview can never poison prod caches. +An agent can migrate, seed and test against that database without risking anything shared. +`x db branch <name>` clones the database and computes the preview URL — nothing more; it produces +no build ID, so it scopes no service-worker cache. + +| Surface | Status | Ships | +|---|---|---| +| `x db branch <name>` | shipped | copy-on-write database + the preview URL, in `--json` as `database` and `preview` | +| `x branch <name>` | planned | the same clone plus its own build ID, a served preview and a scoped MCP socket — and with the build ID, a service-worker cache namespace a preview can never leak into prod | ## `--json` everywhere diff --git a/site/pages/changelog.md b/site/pages/changelog.md index fd4173da8..da3d7c0ff 100644 --- a/site/pages/changelog.md +++ b/site/pages/changelog.md @@ -2,10 +2,35 @@ title: Changelog nav: Changelog description: Every Ultimate release, newest first, with the milestone it belongs to — plus an RSS feed generated from this page at build time. -lede: Newest first. Subscribe via [RSS](/feed.xml). Versions are milestone markers, not stability promises — nothing is published to npm yet. -updated: 2026-07-26 +lede: Newest first. Subscribe via [RSS](/feed.xml). One version for the whole framework — 28 packages, released in lockstep to npm. +updated: 2026-08-10 --- +## 1.0.0 — 2026-08-10 + +First major. 27 `@ultimat3/*` packages plus the unscoped `create-ultimate` — 28 in all — publish +at 1.0.0 in lockstep: one version, one commit, one tag, pushed to npm by OIDC trusted publishing +with no `NPM_TOKEN`. **Semver applies from here.** + +- **The eight primitives** — `entity`, `policy`, `action`, `mutator`, `query`, `job`, `route`, `task` — with their shapes frozen under semver. A ninth primitive is not a feature request; a new capability arrives as a factory over an existing one. +- **One authz object across every surface.** A `policy` is evaluated for the HTTP call, the typed client call, the job execution, the MCP tool call and the live-query subscription. No trusted-tool mode, no second permission table. +- **The error contract.** Every failure is an `UltimateError` with a stable `X_*` code, a cause, a runnable `fix:` and `--json`. `x verify` fails a `fix:` that names no command. +- **AI-first surface.** MCP dev server, every exposed action as a tool with identical authz, `x.manifest.json` generated every build, `llm()` as an action factory with budgets and semantic caching, and evals as a gate step — a prompt with no eval fails `x verify`. +- **Postgres entity driver** (`postgresDriver()`) plus PGlite, so `x dev` needs no Docker and no `.env` scavenger hunt. +- **Realtime tiers 1–2**: channels and live queries over a Postgres logical-replication change feed, an incremental matcher, a NATS bus for fanout and a stateless `sync` role. +- **Mail and OAuth**, alongside storage, four-tier caching with one tag graph, PWA and offline, i18n, money, time and SEO. +- **`x verify` is 17 steps**: typecheck, lint, boundaries, filesize, package-shape, errors, unit, contract, live, job, e2e, eval, drift, contract-diff, budgets, manifest, roadmap. No `--only`, no `--skip` — green means shippable or it means nothing. +- **`bunx create-ultimate myapp`** scaffolds an app whose generated code passes `x verify` unmodified, with every `@ultimat3/*` dependency pinned to one exact version. + +Not claimed in 1.0.0, and named here rather than buried: no published realtime benchmark — the +50k-socket forced-restart number is still unmeasured, so capacity figures are targets, not +results. Milestone 11's two-platform deploy proof — the demo app on Compose **and** Kubernetes +from one image, with a rolling restart invisible to connected clients — is not yet demonstrated, +though the three build targets, both compose files and the Helm chart all ship. Deferred to v2 +behind the same interfaces: tier 3 local-first (`persist: true`), a plugin API, multi-region +replication, and the `redis` / `nats` job drivers, which throw `X_NOT_IMPLEMENTED` with a +runnable `fix:` rather than pretending to work. + ## 0.0.1 — 2026-07-26 Repository bootstrap. Milestone 0 in progress; nothing published, no API stable. @@ -22,7 +47,8 @@ Repository bootstrap. Milestone 0 in progress; nothing published, no API stable. | Rule | Detail | |---|---| | Version | one number for the whole framework. Every `@ultimat3/*` package moves together | -| Pre-v1 | no semver promise. Breaking changes land with a codemod in `x upgrade` and a changelog entry | +| Semver | from 1.0.0, a breaking change to a documented API needs a major. The `X_*` codes, the eight primitive shapes, the `x` CLI surface and the tier table are all covered | +| Lockstep | all 28 packages publish at one version, from one commit and one tag, to npm via OIDC trusted publishing — no `NPM_TOKEN`. Pin exactly; never mix versions | | Milestone | each release names the milestone it advances; see the [roadmap](/roadmap/) | | Feed | this page is the source of `feed.xml` — the RSS is generated at build time, never hand-maintained | | Detection | a breaking action or query contract fails `x verify` as `X_CONTRACT_DRIFT` before it can ship | diff --git a/site/pages/deploy.md b/site/pages/deploy.md index 8ef6bd4b3..770d65ff1 100644 --- a/site/pages/deploy.md +++ b/site/pages/deploy.md @@ -4,7 +4,7 @@ menu: true nav: Deploy description: One image, six roles selected by an env var, a graceful drain that redistributes sockets, and a deploy target defined as "runs containers". lede: Build once; `ROLE` selects behavior. Zero platform primitives — if it needs a specific host, it isn't in the framework. -updated: 2026-07-26 +updated: 2026-08-10 --- ## One image, N roles @@ -69,16 +69,18 @@ sticky session to honour. ## `x build` ```bash -x build --target docker # one image, all roles (default) +x build --target docker # one image, all roles (default); --tag names it x build --target binary # single Bun-compiled executable, no runtime install x build --target static # site/ output only: HTML, assets, sitemap, feeds ``` | Target | Output | Use | |---|---|---| -| `docker` | one OCI image, `ROLE` selects behavior | the normal path | -| `binary` | `dist/myapp` — `bun build --compile`, all roles inside | VMs, systemd, air-gapped | -| `static` | `dist/static/` — 0kb-JS pages, hashed assets, `sitemap.xml`, `robots.txt`, feeds | CDN / object storage | +| `docker` | one OCI image, `ROLE` selects behavior; `--tag` names it | the normal path | +| `binary` | `.x/app` — `bun build --compile`, all roles inside | VMs, systemd, air-gapped | +| `static` | `.x/static/` — 0kb-JS pages, hashed assets, `sitemap.xml`, `robots.txt`, feeds | CDN / object storage | + +`--out` overrides the path for the `binary` and `static` targets. All targets share one build ID (content hash), stamped into the image, the HTML, the assets, `sw.js` and `x.manifest.json`. `x build` runs `x verify`'s static checks first — a build that @@ -113,7 +115,9 @@ services: ## Kubernetes -Generated by `x build --target docker --helm`, one `Deployment` per role. +The chart ships in the repo at `docker/helm`, one `Deployment` per role. Copy it into your app +and apply it with `x deploy --image <ref> --method helm`; `--dry-run` prints the plan and runs +nothing. | Role | HPA metric | Notes | |---|---|---| @@ -128,17 +132,27 @@ CPU autoscaling is wrong for `sync` and `worker`: a node holding idle sockets is and near-capacity, and a worker blocked on a slow HTTP call is idle CPU with a growing backlog. Both metrics are exported as OTel metrics, so wiring an HPA is configuration, not instrumentation. +:::warn not yet demonstrated +Everything on this page ships in 1.0.0 — all three build targets, the dev and prod compose files, +and the Helm chart. The **proof** does not: the demo app running on Compose *and* on Kubernetes +from one image, with a rolling restart invisible to connected clients, has not been demonstrated. +It is the last open item on [milestone 11](/roadmap/). +::: + ## The static path deploys independently ```bash -x build --target static && x deploy static --to <cdn> +x build --target static --out dist/static # then upload dist/static/ with your own tooling ``` +The framework has no `--to <cdn>` flag and never will: a vendor upload adapter is a platform +primitive, and axiom 7 keeps those out. The output is plain files. + | Property | Consequence | |---|---| | Static build excludes the app image | a copy change or a new blog post does not redeploy the API | | Independent version, shared build-ID namespace | assets stay resolvable across N deploys | -| ISR pages regenerate server-side and push to the CDN | no full rebuild for one changed record | +| ISR pages regenerate server-side and are served by `web` | no full rebuild for one changed record; the framework uploads nothing — the CDN re-fetches once your `PurgeDriver` drops the surrogate key | | Rollback is a pointer swap | seconds, no container churn | | Cache purge | tag-driven, one hop from the write | @@ -153,7 +167,7 @@ are optional in small deployments — Postgres covers queue and pubsub, a local | Fly.io | one app per role, or process groups | | Railway / Render | one service per role, same image, `ROLE` per service | | AWS ECS / Fargate | one task definition per role; ALB for `web`, NLB for `sync` | -| Any Kubernetes | the generated Helm chart — EKS, GKE, AKS, k3s | +| Any Kubernetes | the Helm chart at `docker/helm` — EKS, GKE, AKS, k3s | | Bare VM | `--target binary` + systemd units per role | Not supported, by design: vendor edge runtimes, serverless-function-per-route, vendor @@ -163,13 +177,15 @@ the second implementation is where behavior diverges. ## Release checklist ```bash -x verify # the gate — green means shippable -x build --target docker -ROLE=migrate <image> # pre-deploy, must exit 0 -<roll web + sync> # drain-aware; clients reconnect with backoff +x verify # the gate — 17 steps; green means shippable +x build --target docker --tag myapp:$BUILD_ID +x deploy --image myapp:$BUILD_ID --dry-run # migrate first, then the serving roles +x deploy --image myapp:$BUILD_ID # drain-aware; clients reconnect with backoff x build --target static # independently, whenever copy changes -x status --json # build-ID distribution of connected clients ``` +`/_x` reports the build-ID distribution of connected clients. Its CLI form, `x status`, is +planned: today it exits `X_NOT_IMPLEMENTED` with `x doctor --json` as the `fix:`. + Rollback is redeploying the previous image tag. The previous build's assets are still served under the N-deploy retention window, so a rollback does not 404 anyone mid-session. diff --git a/site/pages/faq.md b/site/pages/faq.md index 0690f07ba..9927e3b47 100644 --- a/site/pages/faq.md +++ b/site/pages/faq.md @@ -4,20 +4,30 @@ menu: true nav: FAQ description: Straight answers about Bun-only, no GraphQL, no Tailwind, no serverless, production readiness, and how Ultimate differs from Rails, Next and Meteor. lede: Short answers, including the unflattering ones. -updated: 2026-07-26 +updated: 2026-08-10 --- ## Status -**Is it production-ready?** No. `As of 2026-07` Ultimate is pre-v1: milestones 0–5 come first, -nothing is published to npm, and no API is stable. Treat it as a design under construction. +**Is it production-ready?** `As of 2026-08` Ultimate is 1.0.0, which means a stable API under +semver — not a promise about your infrastructure. Breaking a documented API needs a major from +here; the `X_*` codes, the eight primitive shapes, the `x` CLI surface and the tier table are all +covered. Two things are not proven and are named as such: there is no published realtime +benchmark, and the two-platform deploy proof — the demo app on Compose **and** Kubernetes from +one image, rolling restart invisible — has not been demonstrated. -**Are the benchmarks on this page real?** There are none. The reconnect benchmark is milestone -6 and has not been run; no throughput, latency or adoption number appears anywhere on this site -until it exists. +**Are the benchmarks on this page real?** There are none. The 50k-socket forced-restart benchmark +has still not been run; no throughput, latency or adoption number appears anywhere on this site +until it exists. Capacity figures elsewhere in these docs are targets, not results. -**Can I use it today?** You can read it, clone it, and argue with it. Building a business on it -is premature. +**Can I use it today?** Yes — `bunx create-ultimate myapp`. 27 `@ultimat3/*` packages plus +`create-ultimate` publish at 1.0.0 in lockstep, one version and one tag, to npm via OIDC trusted +publishing. Versions are pinned exactly and move together; never mix them. + +**What is deferred to v2?** Tier 3 local-first (`persist: true`), a plugin API, multi-region +replication, and the `redis` / `nats` job drivers. All four sit behind the interfaces they will +land on; the job drivers throw `X_NOT_IMPLEMENTED` with a runnable `fix:` rather than pretending +to work. ## Stack choices @@ -42,8 +52,10 @@ component dialect and no hydration pass over the shell. **Why your own router?** The router must own render mode, hydration timing and offline strategy to make those route-level properties enforceable. That cannot be a third-party dependency. -**Why Drizzle and not Prisma?** Its generated SQL is legible, so an agent can read the statement -it produced and self-correct. That is the whole selection criterion. +**Why no ORM?** Two query builders would mean two migration stories and two sets of generated +SQL for an agent to learn — so there is not even one. `entity()` is the single table declaration, +and `@ultimat3/entity`'s hand-written `postgresDriver()` compiles it to parameterised SQL an +agent can read back and self-correct against. That legibility is the whole selection criterion. ## Design rules @@ -69,7 +81,9 @@ transitively, at build time. directory for S3. Docker is for parity checks and for building the production image. **Do I need Redis or NATS?** No. Postgres covers the queue and pubsub; both are optional in -small deployments. They exist as drivers behind one interface, not as prerequisites. +small deployments, and they sit behind one interface rather than being prerequisites. The +`redis` and `nats` **job** drivers are the v2 exception — interface-complete stubs that throw +`X_NOT_IMPLEMENTED` rather than dropping work silently. **Can I deploy to the edge or to serverless functions?** No, by design. Deploy target means "runs containers, plus Postgres". Vendor edge/KV/cron primitives would each need a second @@ -91,8 +105,9 @@ diverges. ## Extending it -**Where do plugins fit?** Nowhere before v1. Plugins freeze internals; extension points earn -their existence from real forks. Fork the blessed path in the meantime. +**Where do plugins fit?** Nowhere in 1.0.0 — a plugin API is deferred to v2. Plugins freeze +internals; extension points earn their existence from real forks. Fork the blessed path in the +meantime. **Can I use tier 2 realtime but not tier 3?** Yes — that is the expected shape. Tier 3 is a per-query `persist: true`, planned for v2. diff --git a/site/pages/index.md b/site/pages/index.md index 97ad51873..12ddab4bf 100644 --- a/site/pages/index.md +++ b/site/pages/index.md @@ -5,7 +5,7 @@ nav: Home headline: Rails' philosophy for a stack whose primary user is an AI agent. lede: One way to do each thing. Eight primitives. One authz system across HTTP, WebSockets, jobs and MCP. Bun, Postgres and SolidJS, with the boring 40% of every app already in the box. description: Ultimate is a Bun-only, agent-first full-stack framework — eight primitives, one authz system, and a 0kb JS baseline on the static path. -updated: 2026-07-26 +updated: 2026-08-10 --- ```bash @@ -99,7 +99,7 @@ If a feature doesn't fit a primitive, it doesn't ship. </div> <div class="grid"> -<article class="primitive"><span class="primitive__name">entity</span><p>A table + its domain type + invariants. Projects to a Drizzle table, a migration, a repo type, an admin screen and a seed factory.</p></article> +<article class="primitive"><span class="primitive__name">entity</span><p>A table + its domain type + invariants. Projects to a Postgres table, a migration, a repo type, an admin screen and a seed factory.</p></article> <article class="primitive"><span class="primitive__name">policy</span><p>An authz rule, evaluated in every surface — HTTP guard, live-query row filter, job actor check, MCP tool gate, admin visibility.</p></article> <article class="primitive"><span class="primitive__name">action</span><p>A mutation or command, server-authoritative. Six generated artifacts, one declaration.</p></article> <article class="primitive"><span class="primitive__name">mutator</span><p>An action with an optimistic local twin. <code>local</code> runs on the client, <code>server</code> is the truth, <code>conflict</code> decides the rebase.</p></article> @@ -211,24 +211,32 @@ with one authz system. <div class="band__head"> <span class="eyebrow">Status</span> -## Honest state, As of 2026-07 +## Honest state, As of 2026-08 </div> <p class="pill-row"> -<span class="pill pill--warn">pre-v1</span> -<span class="pill pill--danger">not production-ready</span> -<span class="pill pill--info">nothing published to npm yet</span> +<span class="pill pill--ok">v1.0.0</span> +<span class="pill pill--info">28 packages, one version</span> +<span class="pill pill--warn">no realtime benchmark</span> +<span class="pill pill--warn">deploy proof outstanding</span> </p> -The plan is 12 milestones, each ending in a working demo app and a green `x verify`. -Milestones 0–5 ship before any realtime work, because a framework that cannot render, migrate, -enqueue and verify has no business synchronising anything. Realtime tiers 1–2 are v1 work; -tier 3 (local-first) is v2. Milestone 6 is a reconnect benchmark — 50k sockets, a forced `sync` -restart, measured time-to-consistent — and the sync topology is **not frozen** until that -number exists. The sync engine is roughly 70% of the total effort and the single largest risk; -if the benchmark says our matcher is the bottleneck, adopting an existing protocol beats -inventing one. +1.0.0 shipped on 2026-08-10: 27 `@ultimat3/*` packages plus `create-ultimate`, one version, one +commit, one tag, published to npm by OIDC trusted publishing. **Semver applies from here** — a +breaking change to a documented API needs a major. The `X_*` codes were always stable; the eight +primitive shapes, the `x` CLI surface and the tier table now are too. + +What 1.0.0 does not claim: + +| Not proven | Detail | +|---|---| +| **No published realtime benchmark** | the 50k-socket forced-restart number is still unmeasured. Every capacity figure is a target, not a result | +| **No two-platform deploy proof** | the `docker`, `binary` and `static` build targets and the Helm chart all ship; the demo app running on Compose **and** Kubernetes from one image, with an invisible rolling restart, has not been demonstrated | +| **Deferred to v2** | tier 3 local-first (`persist: true`), a plugin API, multi-region replication, and the `redis` / `nats` job drivers — all behind the interfaces they will land on | + +The `redis` and `nats` job drivers throw `X_NOT_IMPLEMENTED` with a runnable `fix:` rather than +pretending to work. The sync engine remains the largest single risk in the project. No adoption numbers, no benchmark charts and no testimonials appear anywhere on this site, because none of them exist yet. diff --git a/site/pages/jobs.md b/site/pages/jobs.md index 3808a3d72..9895423fa 100644 --- a/site/pages/jobs.md +++ b/site/pages/jobs.md @@ -4,7 +4,7 @@ menu: true nav: Jobs description: A Postgres queue with a transactional outbox, durable steps, and an idempotency key that the type system refuses to let you forget. lede: Postgres queue by default. Durable steps. `idempotencyKey` required by the type. Drivers swap without touching job code. -updated: 2026-07-26 +updated: 2026-08-10 --- ## Transactional outbox by default @@ -99,7 +99,7 @@ export const syncCrm = job({ | `concurrency.limit` | max simultaneous runs sharing a key | advisory lock / lease count in the driver | | `rateLimit` | max starts per window per key | token bucket row, checked at claim time | | `queue` | named pool; the `worker` role runs one pool per config | `WORKER_QUEUES=default,integrations` | -| `retry.attempts` / `backoff` | `'exponential' \| 'linear' \| 'fixed'`, jittered | driver scheduler | +| `retry.attempts` / `backoff` | `'exponential'`, `'linear'` or `'fixed'`, jittered | driver scheduler | | terminal failure | after `attempts`, moves to dead-letter with the full step trace | `x jobs retry <id>` replays from the failed step | A rate-limited or concurrency-blocked job is deferred, never dropped — it stays queued with a @@ -107,11 +107,17 @@ later `runAt`. ## One driver interface -| Driver | When | Trade-off | -|---|---|---| -| `pg` (default) | always, up to the throughput a single Postgres sustains | outbox is free (same DB, same tx); `SELECT … FOR UPDATE SKIP LOCKED` claiming; zero extra infra | -| `redis` | high-throughput, short jobs | needs the outbox relay; loses "queue state in one backup" | -| `nats` | very high fanout, multi-region, JetStream retention | strongest delivery semantics, most operational surface | +| Driver | Status | When | Trade-off | +|---|---|---|---| +| `pg` (default) | shipped | always, up to the throughput a single Postgres sustains | outbox is free (same DB, same tx); `SELECT … FOR UPDATE SKIP LOCKED` claiming; zero extra infra | +| `memory` | shipped | `x dev` and tests | the same claim/ack/nack path as `pg` — visibility timeout, idempotency dedupe, dead-letter — with zero infrastructure | +| `redis` | v2 | high-throughput, short jobs | needs the outbox relay; loses "queue state in one backup" | +| `nats` | v2 | very high fanout, multi-region, JetStream retention | strongest delivery semantics, most operational surface | + +`redis` and `nats` are **interface-complete and not implemented** in 1.0.0. Every method raises +`X_NOT_IMPLEMENTED` — an app can be written and typechecked against the interface, and nothing is +ever dropped silently. Getting off a stub is one command: `x jobs drain --to memory --json`. Which +driver the app runs on afterwards is a config line, not the drain — see below. ```ts export interface JobDriver { @@ -126,9 +132,13 @@ export interface JobDriver { } ``` -Switching drivers is a config line plus a migration of in-flight rows -(`x jobs drain --to redis`). Because `saveStep` / `loadSteps` are driver methods, step -persistence works identically on all three. **Job code never changes.** +Switching drivers is a config line — `jobs: { driver: 'postgres' }` in `app.config.ts` — plus a +migration of in-flight rows (`x jobs drain --to memory|redis|nats`, `--dry-run` for the plan). +Because `saveStep` / `loadSteps` are driver methods, step persistence works identically on every +driver. **Job code never changes.** Draining *to* `redis` or `nats` moves nothing until those +drivers land in v2: the stub's `enqueue` raises `X_NOT_IMPLEMENTED` per record, `x jobs drain` +reports each one in `findings` and exits non-zero, and every leased job goes back to the source +without burning an attempt. ## Scheduling diff --git a/site/pages/primitives.md b/site/pages/primitives.md index 6da7ab7f8..7cff915fa 100644 --- a/site/pages/primitives.md +++ b/site/pages/primitives.md @@ -25,7 +25,7 @@ the parse boundary. | Aspect | Rule | |---|---| -| Projects to | Drizzle table, domain type, migration, repo type, admin screen, seed factory | +| Projects to | Postgres table, domain type, migration, repo (`postgresRepo`), admin screen, seed factory | | Owns | column types, defaults, invariants, tenant column | | Never | business logic, I/O, HTTP awareness, policy decisions | diff --git a/site/pages/pwa-offline.md b/site/pages/pwa-offline.md index c3262c8ce..ee53a9054 100644 --- a/site/pages/pwa-offline.md +++ b/site/pages/pwa-offline.md @@ -3,7 +3,7 @@ title: PWA and offline nav: PWA description: The service worker is a build artifact generated from the route table, and version skew — not caching strategy — is what actually breaks progressive web apps. lede: `sw.js` is generated from the route table and never hand-edited. Editing it is a build error. -updated: 2026-07-26 +updated: 2026-08-10 --- ## Why generated @@ -102,7 +102,7 @@ user opens app (build A) → keeps tab open 3 days → you deploy 6 times | 3 | **N-deploy asset retention** | old builds' assets stay served for N deploys (default 10) or a minimum window (default 7d), whichever is longer | | 4 | **`AppUpdateAvailable` signal, not a 404** | a Solid signal flips when the server reports a newer build. The app renders its own "Update available — reload" affordance. No forced navigation, no lost form state | | 5 | **Forced reload after a grace period** | `x deploy --critical` sets a deadline; the client shows a countdown, saves in-flight state via the mutator queue, then reloads. Grace default 30m | -| 6 | **Skew is observable** | `/_x` and `x status --json` report the build-ID distribution of connected clients | +| 6 | **Skew is observable** | `/_x` reports the build-ID distribution of connected clients. The CLI form, `x status`, is planned — it exits `X_NOT_IMPLEMENTED` today with `x doctor --json` as its `fix:` | | 7 | **Build ID scopes the SW cache** | preview/branch builds get their own cache namespace and SW scope, so a preview can never poison prod caches | | Request type | Response on a stale build ID | diff --git a/site/pages/quickstart.md b/site/pages/quickstart.md index b491a04b7..f248a4210 100644 --- a/site/pages/quickstart.md +++ b/site/pages/quickstart.md @@ -4,7 +4,7 @@ menu: true nav: Quickstart description: From zero to a running Ultimate app in 60 seconds — no Docker, no .env scavenger hunt, one action, one green verify. lede: No Docker install. No `.env` scavenger hunt. Embedded Postgres, in-process NATS, S3 to a local directory — then one `action` that becomes six artifacts. -updated: 2026-07-26 +updated: 2026-08-10 --- ## Requirements @@ -30,6 +30,10 @@ $ bunx create-ultimate myapp && cd myapp && x dev ✓ ready http://localhost:3000 ``` +`create-ultimate` pins every `@ultimat3/*` dependency to one exact version. 27 `@ultimat3/*` +packages plus `create-ultimate` release in lockstep at `1.0.0` — one version, one tag. Never mix +versions. + ## What just started | Surface | URL | Render | JS baseline | Auth | @@ -86,14 +90,22 @@ const view = await publishPost({ postId }); // typed client, no fetch, no ``` ```bash -$ curl -sX POST localhost:3000/_x/action/publish-post \ - -H 'content-type: application/json' -d '{"postId":"1b9d…"}' +curl -sX POST localhost:3000/_x/action/publish-post \ + -H 'content-type: application/json' -d '{"postId":"1b9d…"}' ``` ```bash -$ x mcp call publish-post --json '{"postId":"1b9d…"}' +curl -sX POST localhost:3000/mcp \ + -H 'authorization: Bearer <agent-token>' -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call", + "params":{"name":"postly.publishPost","arguments":{"postId":"1b9d…"}}}' ``` +`mcp: { expose: true }` puts the action in the **app's** catalog: `defineAppMcp` projects it and +mounts `POST /mcp` once the app resolves agent tokens, under the app's own tool name +(`postly.publishPost` in the reference app). `x mcp serve` is a different server — the framework's +dev tools, routes and schema and policies and tests, never an app action. + An unauthorised caller gets the same answer in all three, with the same code: ```text @@ -106,16 +118,20 @@ X_POLICY_DENIED: policy denied this actor ```bash $ x verify - ✓ typecheck ✓ lint ✓ boundaries ✓ unit ✓ contract ✓ live ✓ job ✓ e2e - ✗ migration drift + ✓ typecheck ✓ lint ✓ boundaries ✓ filesize ✓ errors + ✓ unit ✓ contract ✓ live ✓ job ✓ e2e ✓ eval + ✗ drift X_DB_DRIFT: schema differs from migrations cause: table "posts" has column "publish_at" not present in any migration fix: x db gen "add publish_at" ``` -One command means shippable. CI runs exactly `x verify` — a check that lives only in CI is a -check developers cannot run. `x verify --json` emits the same content machine-readably, so an -agent parses the failure and runs the `fix` line without a human. +One command means shippable. The gate is 17 named steps — typecheck, lint, boundaries, filesize, +package-shape, errors, the six test types, drift, contract-diff, budgets, manifest, roadmap — +with no `--only` and no `--skip`, because a gate you can narrow means whatever the caller chose. +CI runs exactly `x verify`: a check that lives only in CI is a check developers cannot run. +`x verify --json` emits the same content machine-readably, so an agent parses the failure and +runs the `fix` line without a human. ## Point your agent at it diff --git a/site/pages/realtime.md b/site/pages/realtime.md index 61abb1b60..6d1030383 100644 --- a/site/pages/realtime.md +++ b/site/pages/realtime.md @@ -4,7 +4,7 @@ menu: true nav: Realtime description: Three tiers — channels, live queries, local-first — with one mutator shape at every rung, so climbing is a config change rather than a rewrite. lede: Channels → live queries → local-first. A ladder, not three products. Same `mutator` at every rung. -updated: 2026-07-26 +updated: 2026-08-10 --- ## The ladder @@ -97,7 +97,7 @@ rolling restart becomes a self-inflicted thundering herd that outlasts the deplo | # | Mitigation | Detail | |---|---|---| -| 1 | **Prototype before locking topology** | milestone 6 is a reconnect benchmark: 50k sockets, forced `sync` restart, measure time-to-consistent and DB load. Topology is not frozen until that number is known | +| 1 | **Benchmark before any capacity claim** | the reconnect benchmark — 50k sockets, forced `sync` restart, time-to-consistent and DB load — is **still unrun at 1.0.0**. Until it exists, no capacity figure is published and every number here is a target | | 2 | **Bounded per-query change buffer** | the `replicator` keeps a ring buffer of recent changes per query-hash. Reconnect within the window = delta replay from the buffer, zero DB work | | 3 | **Snapshot fallback, not WAL replay** | outside the window the client gets a fresh snapshot at a current LSN. Cost is one bounded query, never history traversal | | 4 | **Jittered reconnect-with-backoff, server-directed** | draining `sync` nodes send a `reconnect` frame with a per-client delay so clients redistribute instead of stampeding | diff --git a/site/pages/roadmap.md b/site/pages/roadmap.md index ac6193d36..a3c8a5586 100644 --- a/site/pages/roadmap.md +++ b/site/pages/roadmap.md @@ -2,66 +2,71 @@ title: Roadmap nav: Roadmap description: Twelve strictly ordered milestones, each ending in a working demo app and a green verify — plus the six risks, sized honestly and named before they bite. -lede: One demo app, twelve stages. Every milestone ends in a working demo and a green `x verify`. Ship 0–5 before touching realtime. -updated: 2026-07-26 +lede: One demo app, twelve stages. Milestones 0–10 shipped in `1.0.0`; milestone 11 is in progress, with the two-platform deploy proof still outstanding. +updated: 2026-08-10 --- -## Status, As of 2026-07 +## Status, As of 2026-08 <p class="pill-row"> -<span class="pill pill--warn">pre-v1</span> -<span class="pill pill--danger">not production-ready</span> -<span class="pill pill--info">nothing published to npm</span> +<span class="pill pill--ok">v1.0.0</span> +<span class="pill pill--info">28 packages, one version</span> +<span class="pill pill--warn">milestone 11 in progress</span> </p> -The repository is a working skeleton: the contract, the package tiers, the error registry, and -the docs you are reading. No release has been cut, no package is on npm, and no API is stable. -Everything below marked *planned* is a plan, not a shipped feature. +1.0.0 shipped on 2026-08-10 — 27 `@ultimat3/*` packages plus `create-ultimate`, released in +lockstep to npm. Semver applies from here. Milestones 0–10 are shipped and enforced: `x verify`'s +`roadmap` step reads the milestone table in the repo and fails the build if a milestone claims a +status the files on disk do not support. ## Twelve milestones -| # | Milestone | Contents | Done when | -|---|---|---|---| -| 0 | **Skeleton + error contract** | `core` (`UltimateError`, ALS context, config loader), `schema` (ArkType as `t`), root tooling, `scripts/boundaries.ts`, the `x verify` shell | a thrown `X_*` renders identically in terminal and `--json`; boundary violations fail the build | -| 1 | **HTTP + entity + policy** | `http` over `Bun.serve`, `entity` on Drizzle, `policy`, typed env validated at boot | demo: one entity, one protected route, one denial; a missing env key fails in milliseconds | -| 2 | **`action` + `query` + typed client** | generated HTTP routes, OpenAPI, the typed client, contract tests | demo: CRUD driven entirely by the typed client, no hand-written fetch; contract diff in `x verify` | -| 3 | **Rendering + router + site/app split** | Solid 2 integration, our router, five render modes, `stream` default, the hard `site/` → `app/` boundary | demo: a 0kb-JS landing page and a streaming dashboard; a deliberate cross-surface import fails the build | -| 4 | **SEO + images + budgets** | typed `meta`, `ld.*` helpers, sitemap/robots/RSS from the route table, image pipeline, per-route budgets | demo: CLS 0; deleting a description is a build error; a budget regression names the import chain | -| 5 | **Jobs + tasks + mail + storage + scheduler** | outbox enqueue, `step.run` / `sleep` / `waitForEvent`, `pg` driver, cron with leader election | demo: signup → onboarding job with a 3-day sleep, verified on the frozen clock; a failing step retries only that step | -| 6 | **Realtime tiers 1–2 + reconnect benchmark** | channels, live `query`, `replicator`, incremental matcher, NATS fanout, stateless `sync` | demo: a list updating live across two browsers, and the 50k-socket forced-restart benchmark measured **before topology is frozen** | -| 7 | **Caching — four tiers, one tag graph** | request memo, in-process LRU, Redis tier, CDN headers + purge, `invalidates` fanout, ISR regen | demo: one `invalidates: [tag.post]` evicts memo, LRU, Redis, page and CDN; an untagged cached query fails `x verify` | -| 8 | **PWA + offline + version skew** | generated `sw.js`, precache derivation, manifest/icons from one source icon, immutable build IDs, N-deploy retention | demo: installable, works offline, and six deploys with a tab left open never 404 a chunk | -| 9 | **AI-first surface** | MCP dev server, `x.manifest.json`, every action as a tool, `llm` gateway, versioned prompts + evals, pgvector hybrid search, branch environments | demo: an agent migrates, tests and publishes through MCP only, with authz identical to the UI | -| 10 | **Admin + generators + `x new`** | generated admin dashboard with its own MCP surface, every `x g` generator, `create-ultimate`, the `/_x` dev dashboard | `bunx create-ultimate myapp && cd myapp && x dev` with no Docker and no env editing; generated code passes `x verify` unmodified | -| 11 | **Deploy + docs + 1.0** | `x build --target docker\|binary\|static`, compose, Helm with per-role HPAs, graceful drain everywhere, error-code pages | the demo app runs on Compose **and** Kubernetes from one image; a rolling restart is invisible to connected clients | - -Milestone 0 is in progress. Milestones 1–11 are planned. +| # | Status | Milestone | Contents | Done when | +|---|---|---|---|---| +| 0 | shipped | **Skeleton + error contract** | `core` (`UltimateError`, ALS context, config loader), `schema` (Standard Schema over a dependency-free builtin provider, exposed as `t`), root tooling, `scripts/boundaries.ts`, the `x verify` shell | a thrown `X_*` renders identically in terminal and `--json`; boundary violations fail the build | +| 1 | shipped | **HTTP + entity + policy** | `http` over `Bun.serve`, `entity` over a hand-written `postgresDriver()`, `policy`, typed env validated at boot | demo: one entity, one protected route, one denial; a missing env key fails in milliseconds | +| 2 | shipped | **`action` + `query` + typed client** | generated HTTP routes, OpenAPI, the typed client, contract tests | demo: CRUD driven entirely by the typed client, no hand-written fetch; contract diff in `x verify` | +| 3 | shipped | **Rendering + router + site/app split** | Solid 2 integration, our router, five render modes, `stream` default, the hard `site/` → `app/` boundary | demo: a 0kb-JS landing page and a streaming dashboard; a deliberate cross-surface import fails the build | +| 4 | shipped | **SEO + images + budgets** | typed `meta`, `ld.*` helpers, sitemap/robots/RSS from the route table, image pipeline, per-route budgets | demo: CLS 0; deleting a description is a build error; a budget regression names the import chain | +| 5 | shipped | **Jobs + tasks + mail + storage + scheduler** | outbox enqueue, `step.run` / `sleep` / `waitForEvent`, `pg` driver, cron with leader election | demo: signup → onboarding job with a 3-day sleep, verified on the frozen clock; a failing step retries only that step | +| 6 | shipped | **Realtime tiers 1–2** | channels, live `query`, `replicator`, incremental matcher, NATS fanout, stateless `sync` | demo: a list updating live across two browsers. The 50k-socket forced-restart benchmark is **still unrun** — see [Realtime](/realtime/) | +| 7 | shipped | **Caching — four tiers, one tag graph** | request memo, in-process LRU, Redis tier, CDN headers + purge, `invalidates` fanout, ISR regen | demo: one `invalidates: [tag.post]` evicts memo, LRU, Redis, page and CDN; an untagged cached query fails `x verify` | +| 8 | shipped | **PWA + offline + version skew** | generated `sw.js`, precache derivation, manifest/icons from one source icon, immutable build IDs, N-deploy retention | demo: installable, works offline, and six deploys with a tab left open never 404 a chunk | +| 9 | shipped | **AI-first surface** | MCP dev server, `x.manifest.json`, every action as a tool, `llm` gateway, versioned prompts + evals, pgvector hybrid search, branch environments | demo: an agent migrates, tests and publishes through MCP only, with authz identical to the UI | +| 10 | shipped | **Admin + generators + `x new`** | generated admin dashboard with its own MCP surface, every `x g` generator, `create-ultimate`, the `/_x` dev dashboard | `bunx create-ultimate myapp && cd myapp && x dev` with no Docker and no env editing; generated code passes `x verify` unmodified | +| 11 | in progress | **Deploy + docs + 1.0** | the `docker`, `binary` and `static` build targets, dev/prod compose, Helm with per-role HPAs, graceful drain everywhere, error-code pages | the demo app runs on Compose **and** Kubernetes from one image; a rolling restart is invisible to connected clients | + +Milestone 11 ships its artifacts — the three build targets, both compose files, the Helm chart — +but not yet its proof. Running the demo app on Compose **and** on Kubernetes from one image, +with a rolling restart invisible to connected clients, has not been demonstrated. ## Sequencing rule -**Ship 0–5 before touching realtime.** A framework with excellent DX, durable jobs and enforced -SEO is already shippable. A half-built sync engine is worth nothing: it cannot be shipped -partially, cannot be demoed honestly, and consumes the attention everything else needs. +**Ship 0–5 before touching realtime.** The rule 1.0.0 was built under, kept to the letter. A +framework with excellent DX, durable jobs and enforced SEO is already shippable. A half-built +sync engine is worth nothing: it cannot be shipped partially, cannot be demoed honestly, and +consumes the attention everything else needs. | Rule | Consequence | |---|---| | Milestones are strictly ordered; no parallel starts | one demo app grows through all twelve, so regressions surface immediately | | Every milestone ends green | `x verify` never carries known failures forward | -| Realtime is gated on a measured benchmark (M6) | topology decisions wait for numbers, not intuition | +| Realtime is gated on a measured benchmark (M6) | the one rule 1.0.0 **waived**: tiers 1–2 shipped, the benchmark did not run | | Tier 3 local-first is out of v1 | it lands in v2 as `persist: true` on an existing query — a flag, not a rewrite | | Scope cuts come off the back, never the middle | dropping M11's Helm chart is acceptable; dropping M4's budgets is not | | A milestone that grows past its demo gets split | a milestone with no demo has no definition of done | -## The v1 boundary +## The 1.0.0 boundary -| In v1 | Deferred | +| In 1.0.0 | Deferred to v2 | |---|---| -| Milestones 0–11 | tier 3 local-first (`persist: true`) | +| Milestones 0–10, plus milestone 11's build targets, compose files and Helm chart | tier 3 local-first (`persist: true`) | | Realtime tiers 1–2 | a plugin API | -| `pg` job driver, with redis/nats behind the interface | multi-region replication | -| Postgres + pgvector | mobile/desktop targets beyond placeholders | -| One admin dashboard | theming marketplace, template gallery | -| Docker / binary / static targets | vendor-specific deploy adapters — never, per axiom 7 | +| `pg` job driver; `redis` and `nats` are interface-complete stubs that throw `X_NOT_IMPLEMENTED` with a runnable `fix:` | the `redis` and `nats` job drivers | +| Postgres + pgvector | multi-region replication | +| One admin dashboard | mobile/desktop targets beyond placeholders | +| Docker / binary / static targets | theming marketplace, template gallery | +| Mail, OAuth, storage, caching, PWA, MCP, `llm()`, evals | vendor-specific deploy adapters — never, per axiom 7 | ## Risks, sized @@ -74,13 +79,21 @@ partially, cannot be demoed honestly, and consumes the attention everything else | 5 | **Bun-only constraints** | medium-high | no native addons by design; Bun natives cover image, hashing, Postgres, Redis, S3; explicit memory-profiling and soak tests at M6 and M11 | | 6 | **Static path rot** | medium | 0kb default on `site/`, emitting JS without an explicit `hydrate` + `budget` is a build error, and the starter landing page lives in `site/` | -**Kill criterion, stated in advance:** if tier 2 is not correct and benchmarked by the end of -milestone 6, v1 ships with tier 1 only and live queries move to v1.1. +**Kill criterion, waived — not resolved.** Tier 2 shipped in 1.0.0 by exception: the 50k-socket +forced-restart benchmark it was gated on has never been run, and an unmeasured criterion cannot be +called met. + +| Waiver | Terms | +|---|---| +| What was gated | tier 2 realtime ships only on a measured 50k-socket forced-restart benchmark | +| Why it shipped anyway | tiers 1–2 are complete and under semver; the benchmark needs infrastructure the release did not have | +| Replacement condition | the criterion is resolved when the benchmark runs and its throughput, latency and socket-count numbers are published — not before | +| Held meanwhile | no throughput, latency or socket-count figure appears anywhere on this site; every capacity number is a target, not a result | ## What is deliberately never coming GraphQL. A second runtime. A second ORM. A second CSS system. React Server Components. Vendor -edge/KV/queue primitives. A plugin API before v1. Each is a permanent no, not a "later" — -removing an alternative is a feature. +edge/KV/queue primitives. Each is a permanent no, not a "later" — removing an alternative is a +feature. Follow releases in the [changelog](/changelog/) or its [RSS feed](/feed.xml). diff --git a/site/templates/home.html b/site/templates/home.html index 8a4d5460e..4e6f8fd3d 100644 --- a/site/templates/home.html +++ b/site/templates/home.html @@ -9,7 +9,7 @@ <h1>{{headline}}</h1> <a class="btn btn--ghost" href="/primitives/">The eight primitives</a> </div> <p class="pill-row pill-row--spaced"> - <span class="pill pill--warn">status: pre-v1</span> + <span class="pill pill--ok">v{{version}} on npm</span> <span class="pill pill--info">0kb JS baseline</span> <span class="pill pill--info">containers only</span> </p> diff --git a/site/templates/layout.html b/site/templates/layout.html index 9aa6bf9f4..0fce2a723 100644 --- a/site/templates/layout.html +++ b/site/templates/layout.html @@ -49,7 +49,7 @@ stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" /> </svg> <span>Ultimate</span> - <span class="brand__tag">pre-v1</span> + <span class="brand__tag">v{{version}}</span> </a> <nav class="site-nav" aria-label="Primary"> @@ -139,7 +139,7 @@ <h2>Project</h2> </div> <div class="site-footer__bottom"> <p> - Ultimate is pre-v1 and not production-ready. Built by + Ultimate is v{{version}} — stable API, semver from here. Built by <a href="https://github.com/developerz-ai">developerz-ai</a>. MIT licensed. </p> <p>Docs generated {{built}} · {{build_id}}</p> diff --git a/wiki/Actions.md b/wiki/Actions.md index 13239f354..e7d50818b 100644 --- a/wiki/Actions.md +++ b/wiki/Actions.md @@ -23,7 +23,7 @@ Declared in `api/` or a feature's `actions.ts`. Named export, never default. The | Field | Type | Required | Meaning | |---|---|---|---| -| `input` | Standard Schema (ArkType as `t`) | yes | parsed before anything else runs; drives the TS type, JSON Schema, OpenAPI request body, MCP tool schema | +| `input` | Standard Schema (`t` from `@ultimat3/schema`) | yes | parsed before anything else runs; drives the TS type, JSON Schema, OpenAPI request body, MCP tool schema. `t` is the shipped dependency-free builtin provider; ArkType, Zod and Valibot are optional swaps behind `configureSchemaProvider` and ship no adapter | | `output` | Standard Schema | yes | the response contract; drives the typed client return type and the OpenAPI response | | `policy` | `Policy` from `can(...)` | yes | the one authz decision, evaluated on every surface. Omitting it is a build error | | `cache.invalidates` | `readonly CacheTag[]` | no | tags dropped from every cache tier after `handle` settles; unknown tag = compile error | @@ -56,7 +56,7 @@ Declared in `api/` or a feature's `actions.ts`. Named export, never default. The | 3 | **Typed client function** | `input` + `output` | `await api.publishPost({ postId })` in `app/` — no fetch, no codegen step to remember | | 4 | **Job handle** | the whole declaration | `publishPost.job()` — a namespaced name, an `idempotencyKey` from the payload, and an `invoke` that runs the same handler durably. Register it with the queue; `.enqueue()` belongs to a declared `job` | | 5 | **MCP tool** | `mcp` + `input` + `policy` | one tool per exposed action, JSON Schema from `input`, authz unchanged | -| 6 | **Test scaffold** | `input` + `policy` | schema round-trip plus a denial test per policy branch | +| 6 | **Test** | `input` + `policy` | schema round-trip plus a denial test per policy branch — generated green, not as a `TODO` | Plus cache invalidation: `cache.invalidates` fans out to request memo, in-process LRU (all instances, over NATS), Redis, ISR pages, and the CDN purge webhook in one hop ([Caching and invalidation](Caching-And-Invalidation)). @@ -151,14 +151,14 @@ $ x actions describe publishPost --json "rateLimit":null} ``` -`invalidates` is sorted and de-duplicated, so descriptor output never depends on declaration order — a diffable contract. The same data is the MCP `actions.list` tool and the `/_x` **Routes** panel. +`invalidates` is sorted and de-duplicated, so descriptor output never depends on declaration order — a diffable contract. The same data is the MCP `actions.describe` tool (actions and queries in one call) and the `/_x` **Routes** panel. ## Generated contract test -Emitted with the action; fails until filled in. +Emitted with the action, and green on the first run: it pins the invariant the action owns — a non-owner is denied — rather than a `TODO` someone has to notice. ```ts -// contract test — generated as a scaffold with the action +// contract test — generated with the action test('publishPost denies a non-owner', async ({ seed, actorFor }) => { const { post, stranger } = await seed('two-orgs'); await expect(publishPost.as(actorFor(stranger), { postId: post.id })) diff --git a/wiki/Admin-Dashboard.md b/wiki/Admin-Dashboard.md index 3ebc30355..d47d0a1b7 100644 --- a/wiki/Admin-Dashboard.md +++ b/wiki/Admin-Dashboard.md @@ -102,7 +102,7 @@ Own routes in your own app. Never fork the framework. | Branding | edit `apps/admin/shared/tokens/` | | Hide an entity | the entity's policy denies `admin:read` — visibility is authz, not configuration | -No plugin API before v1 ([axiom](Home)). The extension point is that the admin is your app. +No plugin API in 1.0 ([axiom](Home)). The extension point is that the admin is your app. ## Deployment diff --git a/wiki/CLI-Reference.md b/wiki/CLI-Reference.md index 101521b2c..736341599 100644 --- a/wiki/CLI-Reference.md +++ b/wiki/CLI-Reference.md @@ -19,7 +19,7 @@ x version # CLI version ## Command index -`As of 2026-07`. **shipped** = implemented in `packages/cli`; **planned** = specified, not yet built — calling it exits with `X_NOT_IMPLEMENTED` and a `fix:` line pointing at the closest shipped command. +`As of 2026-08`. **shipped** = implemented in `packages/cli`; **planned** = specified, not yet built — calling it exits with `X_NOT_IMPLEMENTED` and a `fix:` line pointing at the closest shipped command. | Command | Does | Status | |---|---|---| @@ -27,7 +27,7 @@ x version # CLI version | `x dev` | all roles in one process: embedded services, sub-second reload, `/_x` mounted | shipped | | `x g <kind> <name>` | scaffold a primitive with its test | shipped | | `x db <sub>` | gen, migrate, reset, studio, branch | shipped | -| `x verify` | the gate: typecheck, lint, boundaries, errors, all tests, drift, contract, budgets, manifest | shipped | +| `x verify` | the gate — 17 steps, in this order: typecheck, lint, boundaries, filesize, package-shape, errors, unit, contract, live, job, e2e, eval, drift, contract-diff, budgets, manifest, roadmap | shipped | | `x build` | container image, single binary, or prerendered static site | shipped | | `x deploy` | run the container deploy plan: migrate first, then the serving roles | shipped | | `x manifest` | regenerate `x.manifest.json` and `openapi.json` | shipped | @@ -128,7 +128,7 @@ rather than an empty tab. | `NATS_URL` | in-process fanout | that NATS server | | `S3_ENDPOINT` | `.x/storage` on disk | that S3 | -`migrate` and `replicator` are real roles but not dev roles: `migrate` is `x db apply`, and the +`migrate` and `replicator` are real roles but not dev roles: `migrate` is `x db migrate`, and the replicator needs logical replication the embedded database does not serve. Naming either is `X_CLI_BAD_FLAG`, never a silently ignored value. Errors: `X_CLI_BAD_FLAG`, `X_PORT_IN_USE`, `X_ENV_MISSING`, `X_DB_DRIFT`. @@ -161,11 +161,15 @@ x db gen "add publish_at" | migrate | reset | studio | branch <name> |---|---|---| | `gen "<name>"` | diff entities against migrations and write the next migration | the message is required and becomes the filename | | `migrate` | apply pending migrations | the same code path as `ROLE=migrate` | -| `reset` | drop, recreate, migrate, seed | dev only; refuses when `NODE_ENV=production` | +| `reset` | delete the embedded data directory, then migrate | **embedded database only** — against an external Postgres it exits `X_NOT_IMPLEMENTED` and tells you to drop and recreate it yourself | | `studio` | open the Drizzle studio against the dev database | read/write, dev only | -| `branch <name>` | `CREATE DATABASE … TEMPLATE` copy-on-write clone | the isolation an agent should use before migrating | +| `branch <name>` | `CREATE DATABASE … TEMPLATE` copy-on-write clone (PGlite: a copied data directory) | the isolation an agent should use before migrating | -Errors: `X_DB_DRIFT`, `X_DB_GEN_FAILED`, `X_DB_MIGRATE_FAILED`, `X_DB_BRANCH_FAILED`, `X_DB_STUDIO_FAILED`, `X_MIGRATE_CONCURRENT`. +`gen`, `migrate` and `reset` shell out to `bunx drizzle-kit generate|migrate` for the migration +files, and `studio` to `bunx drizzle-kit studio`. Nothing in the request path goes through an ORM: +reads and writes run on `@ultimat3/entity`'s hand-written `postgresDriver()`. + +Errors: `X_DB_DRIFT`, `X_DB_GEN_FAILED`, `X_DB_MIGRATE_FAILED`, `X_DB_BRANCH_FAILED`, `X_DB_STUDIO_FAILED`, `X_MIGRATE_CONCURRENT`, `X_NOT_IMPLEMENTED`. ## x verify @@ -196,6 +200,7 @@ reports as skipped (`-`), never as passed. | `contract-diff` | published actions vs `openapi.json` | | `budgets` | per-route JS bytes and LCP | | `manifest` | `x.manifest.json` freshness | +| `roadmap` | framework repo only — every `docs/idea/14-roadmap.md` milestone carries a status marker, and a milestone marked shipped still has the artifacts its own row names | A test's type is its filename suffix — `*.contract.test.ts`, `*.live.test.ts`, `*.job.test.ts`, `*.e2e.test.ts` (or any test under `e2e/`), `*.eval.test.ts`. Everything else is a unit test, so no @@ -209,7 +214,7 @@ reviewable diff. ```bash $ x verify --json -{"ok":false,"command":"verify","summary":"1 of 16 steps failed","steps":[ +{"ok":false,"command":"verify","summary":"1 of 17 steps failed","steps":[ {"name":"budgets","ok":false,"durationMs":812,"skipped":false,"findings":[ {"code":"X_BUDGET_EXCEEDED","cause":"site/pricing ships 61kb of JS, over the 40kb budget", "fix":"x fix boundary site/pricing/page.tsx", @@ -227,10 +232,10 @@ x build --target docker|binary|static [--tag name] [--out path] [--json] | Flag | Type | Default | Meaning | |---|---|---|---| | `--target` | string | `docker` | `docker` (one image, all roles), `binary` (`bun build --compile`), `static` (prerendered `site/`) | -| `--tag` | string | app name + build id | image tag, docker target | -| `--out` | string | `dist/` | output path, binary and static targets | +| `--tag` | string | `ultimate-app:dev` | image tag, docker target | +| `--out` | string | `.x/app` (`.x/static` for `static`) | output path, binary and static targets | -Runs `x verify`'s static checks first — a build that would fail `x verify` does not produce an artifact. All targets share one content-hash build ID, stamped into the image, the HTML, the assets, `sw.js` and `x.manifest.json`. Errors: `X_BUILD_FAILED`, `X_BUDGET_EXCEEDED`, `X_PWA_NO_ICON_SOURCE`, `X_PWA_NO_FALLBACK`. +Execs exactly one command per target and nothing else: `docker build -f docker/Dockerfile` for `docker`, `bun build --compile` over `apps/web/server.ts` for `binary`, `apps/web/prerender.ts` for `static`. It does **not** run `x verify` or any part of it — run the gate yourself first, because a build of code that fails the gate still produces an artifact. The content-hash build ID every target shares is `x.manifest.json`'s, written by `x manifest`, not computed here. Errors: `X_BUILD_FAILED`; an unknown `--target` is `X_CLI_UNKNOWN_COMMAND`. ## x deploy @@ -240,12 +245,17 @@ x deploy --image repo/app:tag [--method compose|helm] [--dry-run] [--critical] [ | Flag | Type | Default | Meaning | |---|---|---|---| -| `--image` | string | required | image reference to deploy | +| `--image` | string | `ultimate-app:dev` | image reference to deploy | | `--method` | string | `compose` | `compose` or `helm` | | `--dry-run` | boolean | `false` | print the plan, run nothing | | `--critical` | boolean | `false` | security deploy: clients are forced to reload after the grace period | -The plan is always migrate-first, then the serving roles, drain-aware. Errors: `X_DEPLOY_FAILED`, `X_MIGRATE_CONCURRENT`. +`compose` is five ordered steps against `docker/docker-compose.prod.yml` — `run --rm migrate` to +completion, then `up -d` for `web`, `sync`, `worker`, `scheduler`. `helm` is one +`helm upgrade --install app docker/helm --set image=<ref>`; the chart is **committed** at +`docker/helm`, there is no `--helm` flag and nothing generates it. `--method helm` in an app with +no `docker/helm` exits `X_NOT_IMPLEMENTED` naming `x deploy --method compose`. Errors: +`X_DEPLOY_FAILED`, `X_NOT_IMPLEMENTED`, `X_MIGRATE_CONCURRENT`. ## x manifest diff --git a/wiki/Caching-And-Invalidation.md b/wiki/Caching-And-Invalidation.md index ba97450d9..318d1336b 100644 --- a/wiki/Caching-And-Invalidation.md +++ b/wiki/Caching-And-Invalidation.md @@ -2,7 +2,7 @@ Four tiers, one invalidation graph. You declare what a write touches; the framework decides what to evict. -Pre-v1. Not production-ready. +v1.0.0 `As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)). ## The four tiers diff --git a/wiki/Configuration.md b/wiki/Configuration.md index d39a0d4cf..ac67f6802 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -2,48 +2,59 @@ One file: `app.config.ts` at the repo root. There is no per-environment config directory, no `config/production.ts`, no `.env.local` cascade. Environment differences are **env vars**, validated once at boot. -Pre-v1. Field names may change until v1; `x upgrade` codemods them ([Upgrading](Upgrading)). +v1.0.0 `As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)). Field names are covered by semver: renaming or removing one needs a major, and `x upgrade` codemods it. ```ts -import { defineConfig, env } from '@ultimat3/core'; -import { t } from '@ultimat3/schema'; +import { defineConfig, defineEnv } from '@ultimat3/core'; + +// Module scope, in `app.config.ts` itself: the file is imported at boot, so the env gate runs +// before any listener binds. There is no separate `env.ts` — one config file means one. +export const env = defineEnv({ + APP_URL: { type: 'url' }, + DATABASE_URL: { type: 'url' }, + SESSION_SECRET: { type: 'string', secret: true }, +}); export const config = defineConfig({ name: 'postly', - url: env.APP_URL, - db: { url: env.DATABASE_URL }, - pwa: { offline: { fallback: '/offline' } }, - env: t.object({ - APP_URL: t.string.url, - DATABASE_URL: t.string.url, - SESSION_SECRET: t.string.atLeastLength(32), - }), + 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 }, + jobs: { driver: 'postgres', queues: ['postly-default'], concurrency: 8 }, + pwa: { enabled: true, offline: 'runtime' }, }); ``` Everything derivable from code is **not** in this file — routes, actions, policies, jobs, tags all live in the generated `x.manifest.json`. Inspect the resolved config with `x config show --json`. +`AppConfigInput` ([`packages/core/src/config.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/core/src/config.ts)) carries exactly thirteen keys `As of 2026-08`: `name`, `locales`, `defaultLocale`, `defaultTimeZone`, `defaultCurrency`, `theme`, `pwa`, `roles`, `database`, `cache`, `jobs`, `realtime`, `ai`. That type is the contract — a section below naming anything outside it describes a design-spec surface, not a field `defineConfig` accepts today. + ## Top level | field | type | default | notes | |---|---|---|---| | `name` | `string` | required | `^[a-z][a-z0-9-]{1,63}$`. Names the dev DB, the image, the queue prefix | -| `url` | `string` (URL) | required | canonical origin. Drives absolute SEO URLs, OAuth callbacks, `start_url` | | `locales` | `string[]` | `['en']` | BCP-47. Every locale needs a complete catalog or `X_CATALOG_MISSING_KEYS` | | `defaultLocale` | `string` | `'en'` | must appear in `locales` | -| `timeZone` | IANA zone | `'UTC'` | display default only; a user's own `tz` column always wins | -| `currency` | ISO 4217 | `'USD'` | default for `Money` formatting. Never a conversion rate | -| `env` | schema | required | typed env schema, see [Env vars](#env-vars) | +| `defaultTimeZone` | IANA zone | `'UTC'` | display default only; a user's own `tz` column always wins | +| `defaultCurrency` | ISO 4217 | `'USD'` | default for `Money` formatting. Never a conversion rate | +| `theme.defaultMode` | `'light' \| 'dark' \| 'system'` | `'system'` | `theme.tokens` is the semantic token map; raw hex is a lint error in components | +| `roles` | `Role[]` | every `ROLE` | which runtime roles this app runs. Empty is `X_CONFIG_INVALID` | + +There is no `url` field. The canonical origin is an env key the app reads at its point of use (`APP_URL`), so the same image deploys to every environment. -## `db` +## `database` | field | type | default | notes | |---|---|---|---| -| `db.url` | `string` | required | always `env.DATABASE_URL`, never a literal | -| `db.pool` | `number` | `10` | per process. `web`×replicas + `worker`×concurrency must stay under Postgres `max_connections` | -| `db.ssl` | `boolean \| 'require'` | `false` | `'require'` on managed Postgres | -| `db.statementTimeout` | duration | `'10s'` | server-side cap; a longer query aborts with `X_TIMEOUT` | -| `db.entities` | module id | `'@app/db'` | where `entity()` declarations live; `db.schema` defaults to `'public'` | +| `database.urlEnv` | `string` | `'DATABASE_URL'` | the env **key** holding the connection string, never the string itself | +| `database.driver` | `'postgres'` | `'postgres'` | one driver; `postgresDriver()` from `@ultimat3/entity` is its only implementation | +| `database.poolSize` | `number` | `10` | per process. `web`×replicas + `worker`×concurrency must stay under Postgres `max_connections` | +| `database.ssl` | `boolean` | `false` | `true` on managed Postgres | +| `database.schema` | `string` | `'public'` | the Postgres schema `entity()` tables live in | ## `auth` @@ -62,8 +73,7 @@ Better Auth, wrapped. Sessions live in Postgres. Authorization is **not** here | field | type | default | notes | |---|---|---|---| -| `jobs.driver` | `'pg' \| 'redis' \| 'nats'` | `'pg'` | `pg` needs no extra infra. Redis/NATS still use the outbox ([Jobs and workflows](Jobs-And-Workflows)) | -| `jobs.url` | `string` | — | required for `redis` / `nats` | +| `jobs.driver` | `'postgres' \| 'redis' \| 'nats'` | `'postgres'` | `postgres` needs no extra infra and is the only shipped production driver. **`redis` and `nats` are v2** — the stubs throw `X_NOT_IMPLEMENTED` ([Jobs and workflows](Jobs-And-Workflows)) | | `jobs.queues` | `string[]` | `['default']` | a `worker` runs one pool per queue in `WORKER_QUEUES` | | `jobs.concurrency` | `number` | `8` | per pool, per process | | `jobs.retry.attempts` | `number` | `5` | per-job `retry` overrides | @@ -107,33 +117,25 @@ Tiers are read in fixed order `memo → lru → redis → cdn → origin`. See [ ## `pwa` -`offline.fallback` is **required by the type — omitting it is a compile error.** A PWA without one shows the browser's dinosaur. +`offline` is an `OfflineStrategy` **string**, not an object. Five booleans, one strategy — every field is optional and every default is off. ```ts -pwa: { - offline: { fallback: '/offline' }, // required -} +pwa: { enabled: true, offline: 'runtime' }, ``` | field | type | default | notes | |---|---|---|---| -| `pwa.offline.fallback` | route path | **required** | scaffolded in `site/` at 0kb JS by `x new` | -| `pwa.icon` | file path | — | one SVG or >=1024px PNG. All icons, splashes, favicons generated from it | -| `pwa.precache.maxBytes` | size | `'3mb'` | a budget; exceeding it fails `x verify` | -| `pwa.retention.deploys` | `number` | `10` | asset retention, whichever is longer with `retention.window` | -| `pwa.retention.window` | duration | `'7d'` | | -| `pwa.push` | `{ enabled, vapid }` | `{ enabled: false }` | generates SW handler + subscription action + send job | -| `pwa.backgroundSync` | `{ enabled, queues }` | `{ enabled: false }` | wires the SW sync event to the mutator queue | -| `pwa.badging` | `{ enabled, count }` | `{ enabled: false }` | Chromium-only | -| `pwa.shareTarget` | `{ enabled, accept }` | `{ enabled: false }` | the target route gets a required policy | -| `pwa.fileHandlers` | `FileHandler[]` | `[]` | `{ action, accept }` — OS file association | -| `pwa.periodicSync` | `{ enabled }` | `{ enabled: false }` | rarely granted; always have a fallback path | +| `pwa.enabled` | `boolean` | `false` | off means no service worker is generated at all | +| `pwa.offline` | `'precache' \| 'runtime' \| 'network-only'` | `'network-only'` | the app-wide strategy; a `route` may narrow its own | +| `pwa.installPrompt` | `boolean` | `false` | render your own install affordance from the deferred event | +| `pwa.backgroundSync` | `boolean` | `false` | wires the SW sync event to the mutator queue | +| `pwa.push` | `boolean` | `false` | generates the SW handler, the subscription action and the send job | ## `seo` | field | type | default | notes | |---|---|---|---| -| `seo.siteUrl` | `string` | `url` | absolute-URL base for sitemap, feeds, canonical, OG | +| `seo.siteUrl` | `string` | `env.APP_URL` | absolute-URL base for sitemap, feeds, canonical, OG | | `seo.sitemap` | `boolean` | `true` | generated from the route table | | `seo.robots.allow` | `string[]` | `['/']` | | | `seo.robots.disallow` | `string[]` | `['/app', '/admin', '/_x']` | | @@ -168,31 +170,27 @@ Per surface, overridable per route via `budget` on `defineRoute`. | `otel.endpoint` | `string` | — | OTLP collector. Absent = spans still recorded, exported nowhere | | `otel.sampling` | `number` | `0.1` | head sampling ratio; errors are always sampled. Tracing is **always on, not a flag** | -## `ai`, `i18n`, `mcp` +## `ai` + +Three fields, and `ai.mcp` is where the app's own MCP surface is configured — there is no top-level `mcp` block. | field | type | default | notes | |---|---|---|---| -| `ai.models` | `string[]` | `[]` | ordered; index 0 is primary | -| `ai.fallback` | `'ordered' \| 'none'` | `'ordered'` | a fallback is recorded in the OTel span, never silent | -| `ai.cache.semantic.threshold` | number | `0.97` | cosine similarity; never below `0.9` | -| `ai.cache.semantic.ttl` | duration | `'7d'` | | -| `ai.budget.perCall` | `Money` | — | `{ minor, currency }`. Exceeding throws before spending | -| `ai.budget.perTenantMonthly` | `Money` | — | | -| `i18n.catalogs` | module id | `'@app/i18n'` | flat key catalog; a miss renders `⟦key⟧` and fails `x verify` | -| `i18n.fallbackChain` | `boolean` | `false` | off by design — a silent English fallback hides a missing key | -| `mcp.expose` | `boolean` | `true` | the app's own MCP surface. Actions still opt in per `mcp.expose` | -| `mcp.server` | module id | — | the app's hand-written tools, on top of the generated ones | -| `mcp.devSocket` | `string` | `'ws://localhost:9229'` | `x dev` only. Never bound in `ROLE=web` | +| `ai.modelEnv` | `string` | — | the env **key** for the model id, so no model string is baked into the image | +| `ai.mcp.expose` | `boolean` | `true` | the app's own MCP surface. Actions still opt in per action `mcp.expose` | +| `ai.mcp.path` | `string` | `'/mcp'` | where the HTTP transport mounts. Never bound in `ROLE=web` | + +`ai.models`, `ai.fallback`, `ai.cache` and `ai.budget` are per-`llm()` declarations, not config ([MCP and AI](MCP-And-AI)). i18n has no config block either: top-level `locales` and `defaultLocale` are the whole surface. ## Env vars -One typed schema, in `app.config.ts`, validated at boot. A missing key fails in **~40ms** with `X_ENV_MISSING` — never a 500 an hour later. +One typed schema, declared with `defineEnv` at module scope **in `app.config.ts`**, validated at boot. There is no `env.ts` — the one config file is also the one env gate. A missing or malformed key fails in **~40ms** with `X_ENV_MISSING`, every offender named in one error — never a 500 an hour later. | var | roles | required | notes | |---|---|---|---| | `ROLE` | all | no — default `web` | `web \| sync \| worker \| scheduler \| migrate \| replicator \| all`. Invalid → `X_ROLE_INVALID` | | `DATABASE_URL` | all | yes | | -| `APP_URL` | `web`, `sync` | yes | must match `config.url`'s shape | +| `APP_URL` | `web`, `sync` | yes | the canonical origin. An app-read key, not a config field — declare it in `defineEnv` | | `SESSION_SECRET` | `web`, `sync` | yes | >=32 chars | | `WORKER_QUEUES` | `worker` | no — default `default` | comma-separated; one pool per name | | `REALTIME_TRANSPORT_URL` | `sync`, `replicator` | if transport ≠ `memory` | missing → `X_TRANSPORT_UNAVAILABLE` at readiness | @@ -207,7 +205,8 @@ Rules: | Rule | Detail | |---|---| | Secrets are env or a mounted file | the framework never talks to a vendor secret API ([axiom 7](Home)) | -| `env.X` in `app.config.ts` reads through the schema | an unschema'd key is a `X_CONFIG_INVALID` at load | +| `env.X` reads through `defineEnv`'s schema | a declared key that is missing or malformed is `X_ENV_MISSING` at boot, every offender in one error. A `process.env` read outside the schema is a lint error, never a runtime one | +| `X_CONFIG_INVALID` is `app.config.ts` only | it is what `defineConfig`'s own validation throws — a bad locale, an unknown time zone, `poolSize < 1`. Env failures never carry it | | No runtime mutation | config is frozen after `defineConfig`; there is no `setConfig` | | Same image, all environments | only env differs. That is what makes staging a real rehearsal ([Deployment](Deployment)) | diff --git a/wiki/Contributing.md b/wiki/Contributing.md index 19ce9295b..c7d84dcd6 100644 --- a/wiki/Contributing.md +++ b/wiki/Contributing.md @@ -1,6 +1,6 @@ # Contributing -Bun-only monorepo. One gate: `x verify`. Pre-v1 — internals move, so read the tier table before adding an import. +Bun-only monorepo. One gate: `x verify`. v1.0.0 `As of 2026-08` — semver covers the documented surface, so read the tier table before adding an import and assume a rename is a major ([Upgrading](Upgrading)). ``` bun install @@ -28,7 +28,7 @@ Root also holds `scripts/` (verify, boundaries, manifest, setup), `docs/idea/` ( ```json { "name": "@ultimat3/<name>", - "version": "0.0.1", + "version": "1.0.0", "description": "<one line>", "license": "MIT", "type": "module", @@ -71,12 +71,14 @@ A package may import from **strictly lower** tiers only — never sideways withi | Tier | Packages | May import | |---|---|---| | 0 | `core`, `schema` | nothing internal | -| 1 | `i18n`, `money`, `time`, `cache`, `seo` | tier 0 | -| 2 | `entity`, `policy`, `http` | tier 0–1 | +| 1 | `i18n`, `money`, `time`, `cache`, `seo`, `db`, `storage` | tier 0 | +| 2 | `entity`, `policy`, `http`, `auth` | tier 0–1 | | 3 | `action`, `query`, `jobs`, `realtime` | tier 0–2 | -| 4 | `render`, `pwa`, `mcp`, `ai`, `manifest` | tier 0–3 | +| 4 | `render`, `pwa`, `mcp`, `ai`, `manifest`, `mail` | tier 0–3 | | 5 | `ui`, `admin`, `testing`, `cli` | tier 0–4 | +[`scripts/lib/tiers.ts`](https://github.com/developerz-ai/ultimate/blob/main/scripts/lib/tiers.ts) is the executable copy of that table — change it there first. Four sideways edges are declared and no others: `admin → ui`, `realtime → query`, `cli → admin`, `create-ultimate → cli`. + Enforced by [`scripts/boundaries.ts`](https://github.com/developerz-ai/ultimate/blob/main/scripts/boundaries.ts): `bun run boundaries`. A violation is `X_BOUNDARY_VIOLATION` with the **transitive chain**, not just the offending line. It runs on pre-push and inside `x verify` — a lint warning would not count as enforcement. In a generated app the same mechanism enforces `site/` cannot import `app/`, routes never touch the DB, components hold no business logic, services never import HTTP. @@ -170,7 +172,7 @@ Everything in `wiki/`, `docs/`, and every `README.md` / `CLAUDE.md` uses compres | Tables for any >=3-row structured data | | | Code, paths, and commands verbatim | compress the prose around them, never the command | | No meta-framing, no rhetoric, no trailing summary | "This section covers…" is deleted in review | -| Date load-bearing claims | `As of 2026-07` | +| Date load-bearing claims | `As of 2026-08` | | No fabricated numbers | no benchmarks that were not run, no adoption counts, no invented dates | | `CLAUDE.md` per package | <40 lines: boundary, deps, commands | @@ -180,7 +182,7 @@ Never generate prose documentation at runtime. Facts come from code (`x.manifest | Expectation | Detail | |---|---| -| Green `x verify` | typecheck, lint, boundaries, all six test types, migration drift, contract diff, budgets, SEO + i18n, manifest freshness | +| Green `x verify` | all 17 steps, in this order: typecheck, lint, boundaries, filesize, package-shape, errors, unit, contract, live, job, e2e, eval, drift, contract-diff, budgets, manifest, roadmap | | No new dependency without justification in the PR body | target is **under 40 direct dependencies for the whole framework**. A Bun native beats a package | | No new alternative for something already locked | a second CSS system, a second ORM, a second validator, a second runtime is a closed PR ([Home](Home)) | | Feature fits one of the eight primitives | if it doesn't fit, it doesn't ship ([The eight primitives](The-Eight-Primitives)) | @@ -188,4 +190,6 @@ Never generate prose documentation at runtime. Facts come from code (`x.manifest | Deep infra may ship interface-only | an in-memory or PGlite-shaped default driver plus a clearly-labelled `X_NOT_IMPLEMENTED` throw carrying a `fix:`. Never a bare `// TODO` | | Milestone discipline | each of the 12 milestones ends in a working demo app plus green `x verify`. A package that only compiles is not a milestone | +The step list has one source of truth: `VERIFY_STEP_NAMES` in [`packages/cli/src/verify-step.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/src/verify-step.ts). Adding, removing or reordering a step means editing that constant first; the wiki is plain markdown with no build step, so the copies on this page, [Getting started](Getting-Started), [Testing](Testing) and [FAQ](FAQ) are hand-synced in the same PR. + Security issues go through [`SECURITY.md`](https://github.com/developerz-ai/ultimate/blob/main/SECURITY.md), never a public issue. diff --git a/wiki/Deployment.md b/wiki/Deployment.md index 4511d75d7..1a2ad8f71 100644 --- a/wiki/Deployment.md +++ b/wiki/Deployment.md @@ -2,7 +2,7 @@ One image, N roles. Build once; the `ROLE` env var selects behavior. No role-specific Dockerfile, no per-role dependency set, no drift between what you tested and what runs. -Pre-v1: the deploy path is milestone 11 ([roadmap](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/14-roadmap.md)). Milestones 0–5 ship first. `As of 2026-07` no packages are published to npm. +v1.0.0 `As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)). All three build targets ship — `x build --target docker`, `x build --target binary`, `x build --target static` — and so do the compose files and the Helm chart. Milestone 11 is 🚧 on one thing ([roadmap](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/14-roadmap.md)): the two-platform proof — the demo app on Compose **and** on K8s from one image, with a rolling restart invisible to connected clients. ``` docker build -t myapp . # once @@ -86,7 +86,7 @@ Closing 50,000 sockets at once means 50,000 simultaneous reconnects, all resubsc | Clients redistribute | the LB places them across remaining nodes; no sticky session to honour | | Client-side backoff is a floor, not the mechanism | a client that loses the socket without a frame still backs off exponentially with jitter | -Tune with `realtime.drain` in [Configuration](Configuration). Topology is **not frozen** until milestone 6's benchmark exists: 50k sockets, forced `sync` restart, measured time-to-consistent and DB load ([Realtime](Realtime)). +Tune with `realtime.drain` in [Configuration](Configuration). Topology is **not frozen** until the reconnect benchmark exists — 50k sockets, forced `sync` restart, measured time-to-consistent and DB load. That number has never been measured; the roadmap lists it under *Open at 1.0.0*, pinned to no milestone ([Realtime](Realtime)). ## `x build` diff --git a/wiki/Entities-And-Migrations.md b/wiki/Entities-And-Migrations.md index ae8181268..46f1ca704 100644 --- a/wiki/Entities-And-Migrations.md +++ b/wiki/Entities-And-Migrations.md @@ -4,60 +4,71 @@ An `entity` is a table + its domain type + its invariants. The single source of | Aspect | Rule | |---|---| -| Projects to | Drizzle table, domain type, migration, repo type, admin screen, seed factory | +| Projects to | SQL DDL, domain type (`typeof posts.$row`), migration, repo type, admin screen, seed factory | | Owns | column types, defaults, invariants, tenant column | | Never | business logic, I/O, HTTP awareness, policy decisions | -Declared in `<feature>/entity.ts`; the Drizzle schema and migrations live in `packages/db` and hold **no business logic**. +One `entity()` call per table, in `packages/db/src/schema/<name>.ts`; a feature's own `entity.ts` holds only that feature's view schemas. Migrations sit beside the entities in `packages/db/migrations/` as plain SQL. Neither holds **any business logic**. Reads and writes go through `@ultimat3/entity`'s own `postgresDriver()`, which compiles a query plan to parameterised SQL — no ORM in the request path ([`pg-driver.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/entity/src/pg-driver.ts)). ## Six projections | Projection | Where it lands | Consumed by | |---|---|---| -| Drizzle table | `packages/db/schema.ts` | `repo.ts` — the only file that touches SQL | -| Domain type | `packages/domain` | actions, queries, Solid component props | -| Migration | `packages/db/migrations/` | `x db apply`, `ROLE=migrate` | +| SQL DDL | the generated migration — columns, CHECKs, indexes | Postgres | +| Domain type | `export type Post = typeof posts.$row` | actions, queries, Solid component props | +| Migration | `packages/db/migrations/*.sql` | `x db apply`, `ROLE=migrate` | | Repo type | the feature's `repo.ts` signature | `ctx.<service>` inside `handle` | | Admin screen | `apps/admin/` | operators, and the admin app's MCP surface | | Seed factory | `seed(name)` fixtures | all six test types | One inferred chain, no hand-typed link: -``` -Drizzle table → entity type + invariants → action input/output → typed client + MCP tool → component props +```text +entity('posts', { columns }) → typeof posts.$row + invariants → action input/output → typed client + MCP tool → component props ``` Rename a column and the entity type changes, the action's output stops matching, and the component prop errors — all at typecheck, before a test runs. ## Shape -`As of 2026-07` (`entity` lands in milestone 1): +`As of 2026-08`: ```ts -export const post = entity({ - table: 'posts', - tenant: 'orgId', +export const posts = entity('posts', { columns: { - id: c.uuid.primary, - orgId: c.uuid.references(org), - title: c.text, - body: c.text, - publishedAt: c.timestamptz.nullable, + id: uuid().primaryKey(), + orgId: uuid().references(() => orgs.id, { onDelete: 'cascade' }).tenant(), + title: text({ max: TITLE_MAX }), + body: text(), + status: enumerated(POST_STATUSES).default('draft'), + likeCount: integer().default(0), + publishedAt: timestamp().nullable(), + createdAt: timestamp().defaultNow(), + updatedAt: timestamp().defaultNow().onUpdateNow(), }, + tenant: 'orgId', // said out loud; inferred from `.tenant()` or an `orgId` column if omitted invariants: [ - inv('published-post-has-title', (p) => p.publishedAt === null || p.title.length > 0), + invariant('post_title_present', (c) => c.title.trimmed().minLength(1)), + invariant('post_like_count_non_negative', (c) => c.likeCount.atLeast(0)), ], - embed: { field: 'body', model: 'text-embedding-3-large' }, + indexes: [{ on: ['orgId', 'publishedAt'], order: 'desc' }], }); + +export type Post = typeof posts.$row; ``` +The table name is the first argument. Everything else is the init object: + | Field | Meaning | |---|---| -| `table` | physical table name; snake_case, plural | -| `tenant` | the tenant column. Required on any multi-tenant table | -| `columns` | types + defaults + FKs. Money is `{ minor, currency }`, never a float; timestamps are `timestamptz`, stored UTC | -| `invariants` | named predicates enforced on write, projected to a CHECK constraint where expressible | -| `embed` | opt-in vector column + HNSW index + backfill job | +| `columns` | types + defaults + FKs. Money is `bigint` minor units + `char(3)` currency, never a float; timestamps are `timestamptz`, stored UTC | +| `tenant` | the tenant column. Omitted, it is inferred from `.tenant()` or a column named `orgId` — silence never means unscoped | +| `invariants` | named predicates enforced on write, projected to a CHECK or UNIQUE constraint where expressible | +| `indexes` | composite and partial indexes; a single unique or indexed column declares it on the column instead | +| `primaryKey` | composite keys only — a single key is `.primaryKey()` on the column | +| `tags` | extra cache tags this entity participates in, beyond its own `entity:<name>` | + +Presence of a `deletedAt` column is what makes an entity soft-deletable — not a flag. ## Tenant column rule @@ -130,7 +141,7 @@ X_DB_DRIFT: schema differs from migrations | DB has what migrations lack | someone changed the database by hand; generate a migration or revert the change | | Migrations have what the entity lacks | a stale migration or a deleted column; reconcile before shipping | -There is no separate migration tool and no "regenerate types" step. Drift is check 5 of nine in `x verify` ([Testing](Testing)). +There is no separate migration tool and no "regenerate types" step. `drift` is one of `x verify`'s seventeen steps — the list, in order, is in [Testing](Testing). ## Reversible or marked diff --git a/wiki/Error-Codes.md b/wiki/Error-Codes.md index 41b534ca2..1feac0cc8 100644 --- a/wiki/Error-Codes.md +++ b/wiki/Error-Codes.md @@ -67,7 +67,7 @@ One pipeline in `@ultimat3/core` serves `storage`, `seo` and `pwa`. It **decodes | Code | Means | Typical cause | Fix | |---|---|---|---| | `X_VALIDATION_FAILED` | value did not match its schema | a parse boundary rejected input | read `cause` for the failing path; correct the value or widen the schema deliberately | -| `X_SCHEMA_UNSUPPORTED` | the active schema provider cannot do this | a Standard Schema implementation without JSON Schema export | use ArkType (`t`), the blessed default | +| `X_SCHEMA_UNSUPPORTED` | the active schema provider cannot do this | a Standard Schema implementation without JSON Schema export | drop the `configureSchemaProvider()` call and use `t`, the shipped dependency-free builtin provider | ## HTTP @@ -361,6 +361,9 @@ One pipeline in `@ultimat3/core` serves `storage`, `seo` and `pwa`. It **decodes | `X_DECLARATION_UNKNOWN` | no declaration with this name is registered | a typo, or a module that never imported | `x actions list --json` (or `queries` / `entities`); the nearest real name is in `fix` | | `X_JOB_UNKNOWN` | the queue holds no job with this id | a stale id, or a job already reaped | `x jobs ls --json` | | `X_FIX_TARGET_UNKNOWN` | the named file is not one of the app's source files | a path outside `apps/*/{site,app,api,shared}`, or a typo | `x fix boundary <nearest real path>` — `fix` carries it | +| `X_ROADMAP_FILE_MISSING` | `docs/idea/14-roadmap.md` does not exist, so no status or artifact can be checked | the roadmap was deleted or moved — every other roadmap rule would otherwise pass silently | `git checkout -- docs/idea/14-roadmap.md` | +| `X_ROADMAP_STATUS_MISSING` | a milestone has no row, or its row's status cell holds neither ✅ nor 🚧 | `docs/idea/14-roadmap.md` edited without keeping the marker | put ✅ or 🚧 in the second cell of the row `fix` names, then `bun run scripts/roadmap.ts --json` | +| `X_ROADMAP_MILESTONE_UNVERIFIED` | a milestone the table marks ✅ is missing a package or file its own **Ships** column names | the artifact was deleted or renamed after the milestone was marked shipped | `git checkout -- <the paths in `fix`>`, or put 🚧 in that row's status cell | ## Names used in the design docs diff --git a/wiki/FAQ.md b/wiki/FAQ.md index 55bdf808d..3147ccab4 100644 --- a/wiki/FAQ.md +++ b/wiki/FAQ.md @@ -6,15 +6,23 @@ Honest answers. Where something is not built yet, it says so. ### Is it production ready? -No. Pre-v1, `As of 2026-07`. No packages are published to npm and there is no stability promise. Milestones 0–5 (skeleton, HTTP + entity + policy, actions + queries, rendering + surfaces, SEO + budgets, jobs + tasks) ship before realtime is touched. Remote drivers — Redis, NATS, real S3, Postgres logical replication — are interface-complete and throw `X_NOT_IMPLEMENTED` with a `fix:` line rather than pretending to work. +**v1.0.0 `As of 2026-08`.** Stable API, semver from here, 27 `@ultimat3/*` packages plus the unscoped `create-ultimate` — 28 in all — published to npm in lockstep. That is exactly what 1.0.0 claims — a stable API under semver, not a promise about your infrastructure. + +What it does **not** claim: + +| Not claimed | Detail | +|---|---| +| A realtime benchmark | none published. The 50k-socket forced-restart number is still unmeasured, and per-node socket capacity is a target, not a result ([Realtime](Realtime)) | +| The two-platform deploy proof | all three build targets ship — `x build --target docker`, `x build --target binary`, `x build --target static` — and so do the compose files and the Helm chart. The demo app running on Compose **and** K8s from one image, with a rolling restart invisible to connected clients, is milestone 11's remaining item ([Deployment](Deployment)) | +| The v2 set | realtime tier 3 (`persist: true`, local-first), the plugin API, multi-region replication, and the Redis/NATS **job** drivers — all behind the interfaces that ship today. The job drivers throw `X_NOT_IMPLEMENTED` with a runnable `fix:` line rather than pretending to work | ### What is actually finished? -Package skeletons with complete public types, implemented happy paths, and typed throws for every named error. The 12-milestone roadmap is the honest picture: each milestone ends in a **working demo app plus green `x verify`**, and the same demo app grows through all twelve. See [`docs/idea/14-roadmap.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/14-roadmap.md). +All 28 packages, implemented and tested — not skeletons. The eight primitives, HTTP, rendering, caching, realtime tiers 1–2, auth, mail, storage, jobs, the AI-first surface (MCP, `llm()`, evals), and admin + generators + `x new`. Milestones 0–10 are ✅ and enforced by `x verify`'s `roadmap` step; milestone 11 is 🚧, open on its two-platform deploy proof. Each milestone ends in a **working demo app plus green `x verify`**, and the same demo app grows through all twelve. [`docs/idea/14-roadmap.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/14-roadmap.md) is the source of truth for those markers. -### When is v1? +### What is left after 1.0.0? -When milestones 0–11 are done, not on a date. Publishing a release date for pre-alpha work is how roadmaps become fiction. Scope cuts come off the back (M11's Helm chart), never the middle (M4's budgets). +Two things, both listed under *Open at 1.0.0* in the roadmap: milestone 11's two-platform deploy proof, and the **50k-socket forced-restart benchmark**. The benchmark belongs to no milestone number — realtime tiers 1–2 shipped in milestone 6 and the number was never measured, so it is named as open rather than marked ✅. Both land when they are measured, not on a date — publishing a date is how roadmaps become fiction. Everything in milestones 0–10 is shipped and gated. Scope cuts come off the back, never the middle (M4's budgets). ## The stack @@ -40,11 +48,13 @@ Wrong runtime for RSC, and the mental model taxes exactly the audience being opt ### Why SolidJS 2 and your own router? -The router must own render mode, offline strategy, and metadata — those are framework concerns, so it cannot be a third-party dependency that disagrees with the build. `As of 2026-07` SolidJS 2 is in beta and the ecosystem around it is thin, which is also why the UI kit is ours. +The router must own render mode, offline strategy, and metadata — those are framework concerns, so it cannot be a third-party dependency that disagrees with the build. `As of 2026-08` SolidJS 2 is still pre-release (`2.0.0-experimental.16`) and the ecosystem around it is thin, which is also why the UI kit is ours. + +### Which schema library? -### Why ArkType? +None. `@ultimat3/schema` ships its own dependency-free validators (`vendor: 'ultimate'`), exposed as `t`. One schema drives runtime parse, TS type, OpenAPI, and the MCP tool's JSON Schema. -One schema drives runtime parse, TS type, OpenAPI, and the MCP tool's JSON Schema. It is exposed as `t` and sits behind the Standard Schema interface, so the blessed default is swappable at the framework level — not per app. +Everything sits behind the Standard Schema v1 interface, so the default is swappable at the framework level — not per app. No ArkType, Zod or Valibot adapter ships; swapping to one means writing the ~40-line `configureSchemaProvider()` adapter yourself ([`packages/schema/README.md`](https://github.com/developerz-ai/ultimate/blob/main/packages/schema/README.md)). A dependency the framework does not need is a dependency every app pays for. ## Design decisions people push back on @@ -62,7 +72,7 @@ A convention that isn't a build error doesn't exist. A missing `description`, a ### Is `x verify` really the only gate? -Yes. CI runs exactly `x verify` — no bespoke pipeline steps, because a check that lives only in CI is a check you cannot run locally. It covers typecheck, lint, import boundaries, all six test types, migration drift, contract diff, budgets, SEO + i18n, and manifest freshness. See [Testing](Testing). +Yes. CI runs exactly `x verify` — no bespoke pipeline steps, because a check that lives only in CI is a check you cannot run locally. Seventeen steps, in this order: typecheck, lint, boundaries, filesize, package-shape, errors, unit, contract, live, job, e2e, eval, drift, contract-diff, budgets, manifest, roadmap. No `--only`, no `--skip`. See [Testing](Testing). ### Why are there only eight primitives? @@ -88,13 +98,13 @@ One container running `ROLE=all` and one Postgres. Split roles when a signal tel ### Can I use it without the realtime tiers? -Yes. `realtime.tier: 1` with `transport: 'memory'` is the default, and a tier-1 app needs no `sync` role, no `replicator`, and no NATS. Milestones 0–5 are a complete product without any realtime at all: entities, actions, a typed client, five render modes, a 0kb static path, and durable jobs. +Yes. `realtime.tier: 1` with `transport: 'memory'` is the default, and a tier-1 app needs no `sync` role, no `replicator`, and no NATS. That is a complete product without any realtime at all: entities, actions, a typed client, five render modes, a 0kb static path, and durable jobs. ## Risk ### What happens if the sync engine doesn't work out? -It is roughly **70% of total effort** and the single largest risk. Milestone 6 is a reconnect benchmark — 50k sockets, a forced `sync` restart, measured time-to-consistent and DB load — and **topology is not frozen until that number exists**. If the incremental matcher is the bottleneck, wrapping an existing protocol (Zero's) is an accepted fallback. Tiers 1–2 target v1; tier 3 local-first is v2. +It is roughly **70% of total effort** and the single largest risk. Tiers 1–2 shipped in milestone 6 and are under semver; tier 3 local-first is v2. What did not ship is the reconnect benchmark — 50k sockets, a forced `sync` restart, measured time-to-consistent and DB load. **That number has never been measured**, which is why the roadmap lists it under *Open at 1.0.0* instead of marking it ✅, and why **topology is not frozen**. If the incremental matcher turns out to be the bottleneck, wrapping an existing protocol (Zero's) is an accepted fallback. ### Why ship realtime last if it's the differentiator? @@ -102,13 +112,13 @@ A half-built sync engine is worth nothing: it cannot be shipped partially, demoe ### What if Bun has a problem under sustained load? -Stated risk, not a hidden one. `As of 2026-07` long-running Bun processes are less proven than Node's, so memory profiling under sustained socket load is explicit roadmap work. See [`docs/idea/15-risks.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/15-risks.md). +Stated risk, not a hidden one. `As of 2026-08` long-running Bun processes are less proven than Node's, so memory profiling under sustained socket load is explicit roadmap work. See [`docs/idea/15-risks.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/15-risks.md). ## Scope ### Where do plugins fit? -Nowhere, before v1. Plugins freeze internals, and internals are still moving. Fork the blessed path if you need something else; extension points earn their existence from real forks, not from speculation. +Nowhere in 1.0 — the plugin API is v2. Semver covers the documented surface, not internals, and a plugin API freezes internals permanently. Fork the blessed path if you need something else; extension points earn their existence from real forks, not from speculation. ### Will you add an adapter for my host or my ORM? diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md index 12c8a4a36..ba415b1e2 100644 --- a/wiki/Getting-Started.md +++ b/wiki/Getting-Started.md @@ -34,14 +34,14 @@ Nothing to install first. No Docker daemon, no `.env` scavenger hunt, no service | Landing page | `apps/web/site/` | `static`, **0kb JS**, real meta + JSON-LD | | Dashboard | `apps/web/app/` | `stream`, auth'd | | Admin app | `apps/admin/` | already exposes MCP over your actions | -| Green gate | `x verify` | typecheck, lint, boundaries, six test types, drift, contracts, budgets, SEO, manifest | +| Green gate | `x verify` | 17 steps, in this order: typecheck, lint, boundaries, filesize, package-shape, errors, unit, contract, live, job, e2e, eval, drift, contract-diff, budgets, manifest, roadmap | `x dev` runs **every role in one process** with isolation simulated, not skipped: separate ALS contexts, a real Postgres queue, real logical replication, a real SIGTERM drain on `x dev restart`. Nothing in the framework branches on `if (dev)` — only the drivers differ. ## 1. Write your first action ``` -x gen action publish-post +x g action publish-post ``` ```ts @@ -123,10 +123,11 @@ Introspection an agent should use instead of grepping: One command. Green means shippable. -``` +```text $ x verify - ✓ typecheck ✓ lint ✓ boundaries ✓ unit ✓ contract ✓ live ✓ job ✓ e2e - ✗ migration drift + ✓ typecheck ✓ lint ✓ boundaries ✓ filesize ✓ package-shape ✓ errors + ✓ unit ✓ contract ✓ live ✓ job ✓ e2e ✓ eval + ✗ drift X_DB_DRIFT: schema differs from migrations cause: table "posts" has column "publish_at" not present in any migration fix: x db gen "add publish_at" @@ -154,4 +155,4 @@ $ x verify ## Status -`As of 2026-07`: pre-v1, not production-ready. Twelve milestones, each ending in a working demo app plus green `x verify`; milestones 0–5 ship before realtime. Realtime tiers 1–2 are v1, tier 3 (local-first) is v2. Milestone 6 is a 50k-socket forced-reconnect benchmark — realtime topology is not frozen until that number exists. See [FAQ](FAQ). +v1.0.0 `As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)). 27 `@ultimat3/*` packages plus the unscoped `create-ultimate` — 28 in all — publish at 1.0.0 in lockstep. Milestones 0–10 are ✅; milestone 11 is 🚧, open on its two-platform deploy proof. Realtime tiers 1–2 are v1, tier 3 (local-first) is v2. No realtime benchmark is published — the **50k-socket forced-restart number is still unmeasured**, so topology is not frozen. Status markers come from [`docs/idea/14-roadmap.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/14-roadmap.md). See [FAQ](FAQ). diff --git a/wiki/Home.md b/wiki/Home.md index ade4b168a..a15ec1e92 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -2,7 +2,9 @@ A full-stack, Bun-only, opinionated framework: Rails' philosophy applied to Bun + Postgres + SolidJS, where the primary user is an AI agent and the secondary user is a tired senior engineer working through their own AI agent and AI reviewer. -**Pre-v1 `As of 2026-07`.** Nothing is published to npm, no API is stable, and no benchmark numbers exist yet. The marketing site is [ultimate.developerz.ai](https://ultimate.developerz.ai/); this wiki is the deeper reference. +**v1.0.0 `As of 2026-08`.** 27 `@ultimat3/*` packages plus the unscoped `create-ultimate` — 28 in all — publish at 1.0.0 in lockstep to npm; the API is stable and semver applies from here ([Upgrading](Upgrading)). Milestones 0–10 are ✅; milestone 11 is 🚧, open on its two-platform deploy proof. No benchmark numbers exist yet — the **50k-socket forced-restart number is unmeasured**, so every capacity figure in this wiki is a target, not a result. The marketing site is [ultimate.developerz.ai](https://ultimate.developerz.ai/); this wiki is the deeper reference. + +Those facts are repeated on several pages because the wiki is plain markdown with no build step. Change them at the source first, then here: [`docs/idea/14-roadmap.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/14-roadmap.md) owns milestone status, [`CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md) owns the version, and `VERIFY_STEP_NAMES` in [`packages/cli/src/verify-step.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/src/verify-step.ts) owns the `x verify` step list. ```bash bunx create-ultimate myapp && cd myapp && x dev diff --git a/wiki/Installation.md b/wiki/Installation.md index aeea4053e..6f8fe07c4 100644 --- a/wiki/Installation.md +++ b/wiki/Installation.md @@ -53,15 +53,21 @@ What `x new` writes, and who owns it afterwards: ## Typed env, validated at boot +Declared at module scope in `app.config.ts`, next to `defineConfig`. There is no `env.ts` — `app.config.ts` is the one file the CLI and the runtime both load, so it is the one place the env gate can run before anything binds. + ```ts // app.config.ts -env: t.object({ - DATABASE_URL: t.string.url, - NATS_URL: t.string.url.optional(), - S3_BUCKET: t.string, - VAPID: t.string.optional(), - STRIPE_KEY: t.string.matching(/^sk_/), -}), +import { defineConfig, defineEnv } from '@ultimat3/core'; + +export const env = defineEnv({ + DATABASE_URL: { type: 'url' }, + NATS_URL: { type: 'url', required: false }, + S3_BUCKET: { type: 'string' }, + VAPID: { type: 'string', required: false }, + STRIPE_KEY: { type: 'string', pattern: /^sk_/, secret: true }, +}); + +export const config = defineConfig({ name: 'myapp' /* … */ }); ``` Boot parses this before any listener binds. Failure costs ~40ms and exit 1 — not a 3am `undefined` in a payment handler. @@ -113,19 +119,23 @@ Stated cost: no native-addon packages, and Bun's long-running-process maturity i claude mcp add ultimate --transport ws ws://localhost:9229 ``` +Thirteen tools, `As of 2026-08` — the full catalog, and the exact names to call: + | Tool | Replaces the agent's usual guess | |---|---| | `routes.list` | grepping a router directory | | `schema.describe` | reading migration files in order | | `policies.list` | "is this endpoint protected?" | -| `actions.list` | reading `api/` by hand | -| `manifest.get` | ten separate reads | -| `tests.run` | parsing terminal output | -| `logs.tail` | scrollback archaeology | +| `actions.describe` | reading `api/` by hand — actions and queries in one call | +| `jobs.inspect` | reading `jobs.ts` for retry policy and step names | +| `queue.depth` | guessing whether the worker is keeping up | +| `manifest.read` | ten separate reads | +| `errors.explain` | a web search for an error string | | `db.query` | inventing a query and hoping (read-only; 100-row default, 1000-row maximum) | | `db.migrate` | mutating the dev DB — writes land in a **branch DB only** | -| `errors.explain` | a web search for an error string | -| `budgets.report` | bisecting bundles | +| `tests.run` | parsing terminal output | +| `verify.run` | guessing whether the work is shippable | +| `logs.tail` | scrollback archaeology | Read tools are unrestricted in dev; write tools are scoped to branch environments. The dev server is never exposed under `ROLE=web`. `db.query` refuses a batch, a write keyword anywhere at statement level (a data-modifying CTE included), a locking clause, `EXPLAIN ANALYZE`, and `pg_read_file`-class functions — `X_MCP_QUERY_REJECTED`, before the host sees the string. Use `x mcp` for a standalone server (CI, remote agents). Editor config: Biome is the only formatter/linter — one binary, one config, no ESLint or Prettier. @@ -138,4 +148,4 @@ Read tools are unrestricted in dev; write tools are scoped to branch environment | Remove dev state (embedded PG, storage, caches) | `rm -rf .x` | | Remove the CLI | it ships with the app; deleting the repo is the uninstall | -Version pinning: `As of 2026-07`, Bun 1.3 is the floor and 2.0 the target; SolidJS 2 is in beta; ArkType and Drizzle are pinned exactly and their upgrades are framework work, not app work. Details and codemod inventory in [Upgrading](Upgrading). Boot failures and port conflicts in [Troubleshooting](Troubleshooting). +Version pinning: `As of 2026-08`, Bun 1.3 is the floor and 2.0 the target; SolidJS 2 is in beta and is pinned exactly, and its upgrade is framework work, not app work. There is no ArkType or Drizzle pin to carry — `@ultimat3/schema` ships its own dependency-free validators and `@ultimat3/entity` its own `postgresDriver()`. Details and codemod inventory in [Upgrading](Upgrading). Boot failures and port conflicts in [Troubleshooting](Troubleshooting). diff --git a/wiki/Jobs-And-Workflows.md b/wiki/Jobs-And-Workflows.md index 645561321..b9e6ab145 100644 --- a/wiki/Jobs-And-Workflows.md +++ b/wiki/Jobs-And-Workflows.md @@ -2,7 +2,7 @@ Durable background work, optionally multi-step. Postgres queue by default. `idempotencyKey` is required by the type. Drivers swap without touching job code. -Pre-v1. Not production-ready. +v1.0.0 `As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)). ## The canonical shape @@ -108,28 +108,36 @@ A rate-limited or concurrency-blocked job is **deferred, never dropped** — it ## Driver interface -One interface, three implementations. **Job code never changes.** +One interface. **Job code never changes.** Step persistence hangs off the same object (`steps`), so it is identical on every implementation. ```ts export interface JobDriver { - enqueue(job: JobRef, input: unknown, opts: EnqueueOpts, tx?: Tx): Promise<JobId>; - claim(queue: string, limit: number): Promise<ClaimedJob[]>; - heartbeat(id: JobId): Promise<void>; - complete(id: JobId, result: unknown): Promise<void>; - fail(id: JobId, err: SerializedError, retryAt: Date | null): Promise<void>; - saveStep(id: JobId, name: string, result: unknown): Promise<void>; - loadSteps(id: JobId): Promise<Record<string, unknown>>; - sleepUntil(id: JobId, at: Date): Promise<void>; + readonly name: string; + /** Step persistence lives with the queue: one store, one transaction boundary. */ + readonly steps: StepStore; + enqueue(request: EnqueueRequest): Promise<EnqueueResult>; + claim(options: ClaimOptions): Promise<readonly ClaimedJob[]>; + ack(jobId: string): Promise<void>; + nack(jobId: string, options: NackOptions): Promise<void>; + heartbeat(jobId: string, options: { readonly visibilityTimeoutMs: number }): Promise<void>; + stats(): Promise<readonly QueueStats[]>; + readonly introspect?: JobIntrospection; + close?(): Promise<void>; } ``` -| Driver | When | Trade-off | -|---|---|---| -| `pg` (default) | always, up to ~thousands of jobs/sec | outbox is free (same DB, same tx); `SELECT ... FOR UPDATE SKIP LOCKED` claiming; zero extra infra | -| `redis` | high-throughput, short jobs | needs the outbox relay; loses "queue state in one backup" | -| `nats` | very high fanout, multi-region, JetStream retention | strongest delivery semantics, most operational surface | +Two implementations ship in 1.0.0. Two more are **v2** — interface-complete stubs, so an app typechecks against them, and every method throws `X_NOT_IMPLEMENTED` with a runnable `fix:` rather than silently dropping a job. + +| Driver | Status `As of 2026-08` | When | Trade-off | +|---|---|---|---| +| `postgres` (default) | **shipped** | always, up to ~thousands of jobs/sec. `x dev` runs it too, against the embedded PGlite | outbox is free (same DB, same tx); `SELECT ... FOR UPDATE SKIP LOCKED` claiming; zero extra infra | +| `memory` | **shipped**, not a `jobs.driver` value | tests and fixtures — reached through `createMemoryDriver()`, and as `x jobs drain --to memory` | in-process; nothing survives a restart | +| `redis` | **v2 — throws `X_NOT_IMPLEMENTED`** | high-throughput, short jobs | would need the outbox relay; loses "queue state in one backup" | +| `nats` | **v2 — throws `X_NOT_IMPLEMENTED`** | very high fanout, multi-region, JetStream retention | strongest delivery semantics, most operational surface | + +`jobs.driver` in `app.config.ts` accepts `'postgres' | 'redis' | 'nats'` — and only `'postgres'` runs. Setting it to `redis` or `nats` typechecks and boots, then throws on the first enqueue: deliberate, and why the stubs exist instead of an absent export. -Switching is a config line in `app.config.ts` plus a migration of in-flight rows (`x jobs drain --to redis`). Because `saveStep` / `loadSteps` are driver methods, step persistence is identical on all three. +`x jobs drain --to <driver>` moves in-flight rows between drivers, and `--to memory` is the only target that completes today: `--to redis` and `--to nats` construct the target and fail on the first enqueue with `X_NOT_IMPLEMENTED`. The cross-driver migration procedure is v2 — see [Upgrading](Upgrading). ## Dead letter @@ -150,7 +158,7 @@ Draining a worker mid-job is safe: it finishes the current step, persists it, an | `/_x` dev panel | queue depth per queue, in-flight, failed, step timeline per job | | `x jobs ls --json` | one row per job: state, queue, attempts, `runAt`, idempotency key | | `x jobs show <id> --json` | machine-readable state, step results, next retry, dead-letter reason | -| MCP tools | `jobs.list`, `jobs.status`, `jobs.retry` — same authz as the actions | +| MCP dev tools | `jobs.inspect` (definitions, retry policy, steps) and `queue.depth` (pending/running/failed per queue) — scope `dev:read`, never reachable in `ROLE=web` | | OpenTelemetry | one span per job, one child span per step, trace linked to the enqueuing request | Every command supports `--json`. See [CLI reference](CLI-Reference). @@ -165,6 +173,7 @@ Every command supports `--json`. See [CLI reference](CLI-Reference). | `X_IDEMPOTENCY_CONFLICT` | same key, different payload, or still in flight | fresh key for a different payload; otherwise retry after the first settles | | `X_DRAINING` | claim attempted on a worker that received SIGTERM | none — the job stays queued and another worker claims it | | `X_POLICY_DENIED` | the job's actor fails the originating action's policy | grant the permission, or enqueue as a system actor | +| `X_NOT_IMPLEMENTED` | the `redis` or `nats` driver was reached — both are v2 | set `jobs.driver: 'postgres'` in `app.config.ts` (it is already the default) | Full index: [Error codes](Error-Codes). Verbatim error shapes live in each package's `src/errors.ts`. diff --git a/wiki/MCP-And-AI.md b/wiki/MCP-And-AI.md index 526ea806d..1532d6b46 100644 --- a/wiki/MCP-And-AI.md +++ b/wiki/MCP-And-AI.md @@ -2,33 +2,36 @@ The differentiator. Not a chat widget, not an "AI SDK integration" — the framework is built so an agent can read it, drive it, and verify its own work, and so the apps it generates have the same property. -Pre-v1, not production-ready. `As of 2026-07`: the MCP registry, wire protocol, dev-tool catalog, read-only SQL guard, and action projection are built; the `llm()` gateway, prompt versioning, vector search, and eval runner are contracted and partially implemented. +v1.0.0 `As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)). The MCP registry, wire protocol, dev-tool catalog, read-only SQL guard, and action projection are built, and so are the four that used to be contracted: `llm()` is an action factory ([`packages/ai/src/llm.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/ai/src/llm.ts)), prompts are versioned, `PgVectorStore` fuses pgvector cosine with Postgres FTS via RRF, and evals gate on a committed baseline inside `x verify`'s `eval` step. ## Built-in MCP dev server `x dev` starts an MCP server on the dev socket. Point Claude Code (or any MCP client) at it and the agent stops guessing. +Thirteen tools `As of 2026-08` — the whole catalog, spelled exactly as they must be called. No aliases; renaming one is a major. + | Tool | Introspects / does | Replaces the agent's usual guess | |---|---|---| -| `routes.list` | route table: path, render mode, hydrate, offline, budget, meta status | grepping a router directory | -| `schema.describe` | tables, columns, types, indexes, FKs, invariants | reading migration files in order | -| `policies.list` | every `policy`, which actions/queries use it, its denial reason | "is this endpoint protected?" | -| `actions.list` | inputs, outputs, tags, MCP exposure | reading `api/` by hand | -| `manifest.get` | the whole `x.manifest.json` | ten separate reads | -| `tests.run` | run a test type or a single file, structured results | parsing terminal output | -| `logs.tail` | structured logs + OTel spans, filterable | scrollback archaeology | +| `routes.list` | route table: url, render mode, hydrate, offline, budget | grepping a router directory | +| `schema.describe` | entities with columns, types and invariants | reading migration files in order | +| `policies.list` | every `policy`: permission, subject, where it is enforced | "is this endpoint protected?" | +| `actions.describe` | every action **and query**: input/output schema, policy, cache tags, MCP exposure | reading `api/` by hand | +| `jobs.inspect` | job definitions, retry policy and steps; omit `name` for all | reading `jobs.ts` and guessing the retry | +| `queue.depth` | pending, running and failed counts per queue | tailing a worker to see if it keeps up | +| `manifest.read` | the whole `x.manifest.json`, as text | ten separate reads | +| `errors.explain` | `X_*` code → cause, exact fix command, docs URL | web search | | `db.query` | **read-only** SQL, 100-row default and 1000-row maximum, `EXPLAIN` on request | inventing a query and hoping | -| `db.migrate` | generate + apply migrations **in a branch DB only** | mutating the dev database | -| `errors.explain` | `X_*` code → cause, fix command, docs URL | web search | -| `budgets.report` | per-route bytes/LCP with the import chain that caused a regression | bisecting bundles | - -Implemented names `as of 2026-07` differ slightly from the design table: `actions.describe` (actions + queries in one call), `manifest.read`, `jobs.inspect`, `queue.depth`, `verify.run`. Aliases land before v1. +| `db.migrate` | apply pending migrations **in a branch DB only** | mutating the dev database | +| `tests.run` | run the suite or a substring filter, structured results | parsing terminal output | +| `verify.run` | the whole gate; `fix: true` applies safe autofixes | guessing whether the work is shippable | +| `logs.tail` | last N structured log lines, filterable by runtime role | scrollback archaeology | | Class | Tools | Exposure | |---|---|---| -| read | `routes.list`, `schema.describe`, `policies.list`, `actions.list`, `manifest.get`, `errors.explain` | unrestricted in dev | -| gated read | `db.query`, `logs.tail`, `budgets.report` | scope `db:read` / `dev:logs` | -| write | `db.migrate`, `tests.run` | scope `db:migrate` / `dev:test`, **branch environments only** | +| read | `routes.list`, `schema.describe`, `policies.list`, `actions.describe`, `jobs.inspect`, `queue.depth`, `manifest.read`, `errors.explain` | scope `dev:read`, unrestricted in dev | +| gated read | `db.query`, `logs.tail` | scope `db:read` / `dev:logs` | +| executes code | `tests.run`, `verify.run` | scope `dev:test`; both declare `destructive: true`, so neither is metered as read chatter | +| write | `db.migrate` | scope `db:migrate`, **branch environments only** | None of them is exposed in `ROLE=web`. `db.query` accepts one statement, whose leading keyword must be `SELECT`/`WITH`/`EXPLAIN`/`SHOW`/`TABLE`/`VALUES` — necessary, never sufficient. Batches, any write keyword at statement level (a data-modifying CTE included), locking clauses (`FOR UPDATE`/`FOR SHARE`), `EXPLAIN ANALYZE`, and functions that reach outside the database (`pg_read_file`, `pg_sleep`, `dblink`, `lo_import`, …) are **refused**, not discouraged — `X_MCP_QUERY_REJECTED`, enforced before the host sees the string. Its Postgres SELECT-only role is conditional on the connection's own rights; the answer's `guards` array names the defences that engaged. `db.migrate` refuses a target that is not a branch database — `X_MCP_NOT_BRANCH_DB`. @@ -43,7 +46,7 @@ That line is the entire integration. From the existing declaration: | MCP requirement | Source | |---|---| | tool name | action name | -| JSON Schema for input | the ArkType `input` (Standard Schema → JSON Schema) | +| JSON Schema for input | the `input` schema (Standard Schema → JSON Schema, via `introspect()`) | | output schema | `output` | | description | `mcp.description` | | **authorization** | the action's `policy` — unchanged, unwrapped, identical | @@ -88,7 +91,7 @@ Rationale for each: [`docs/architecture/11-ai-surface.md`](https://github.com/de | `x.manifest.json` | **generated**, every build | routes, entities, actions, mutators, queries, jobs, tasks, policies, cache tags, MCP tools, budgets, build ID | never hand-edited; drift is a `x verify` failure | | `openapi.json` | **generated** | HTTP surface from action/query declarations | contract diff in `x verify` | | `AGENTS.md` | **human-authored**, short | project-specific conventions an agent cannot infer | never generated, never auto-appended | -| `CLAUDE.md` | **human-authored**, short | same, compressed-config style, <600 lines | +| `CLAUDE.md` | **human-authored**, short | same, compressed-config style, <600 lines | never generated, never auto-appended | LLM-generated context files measurably reduce task success. A model writing "here is what this codebase does" produces confident, plausible, partly-wrong prose, and the next agent treats it as ground truth — errors compound and cannot be distinguished from facts. So: facts come from code (structured, verifiable, regenerated every build), conventions come from a human (short, opinionated, stable). Ultimate never generates prose documentation at runtime, and `x new` scaffolds `AGENTS.md` as a terse human-editable stub, not an essay. diff --git a/wiki/Money.md b/wiki/Money.md index e4781a66e..c8281ef31 100644 --- a/wiki/Money.md +++ b/wiki/Money.md @@ -42,7 +42,7 @@ Entities declare a money field once and the generated migration emits both colum ## Operations -Allowed, total, and typed. `As of 2026-07` the package ships the currency table, the rounding modes, and the error codes; the arithmetic surface below is the contracted API. +Allowed, total, and typed. `As of 2026-08` the package ships the currency table, the rounding modes, the error codes, and the arithmetic surface below. | Operation | Signature | Behavior | |---|---|---| diff --git a/wiki/PWA-And-Offline.md b/wiki/PWA-And-Offline.md index ebd11e545..f141b7d65 100644 --- a/wiki/PWA-And-Offline.md +++ b/wiki/PWA-And-Offline.md @@ -2,7 +2,7 @@ `sw.js` is a build artifact, generated from the route table. Hand-editing it is a build error (`X_SW_HAND_EDITED`, checksum mismatch). -Pre-v1. Not production-ready. +v1.0.0 `As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)). ## Why generated diff --git a/wiki/Project-Layout.md b/wiki/Project-Layout.md index 41ed2fde6..6919c2ead 100644 --- a/wiki/Project-Layout.md +++ b/wiki/Project-Layout.md @@ -15,12 +15,13 @@ myapp/ desktop/ # placeholder + README (Tauri/Electron later) packages/ domain/ # pure types + constants, no I/O - db/ # Drizzle schema + migrations, no business logic + db/ # entity() declarations + plain-SQL migrations, no business logic i18n/ # app catalogs (en, es, ...) ui/ # app-specific Solid components on top of @ultimat3/ui mcp/ # the app's own MCP tools (its dashboards are AI-first too) bin/ # setup, dev, check — thin wrappers over `x` - docker/ # compose (dev) + per-role compose (prod) + Dockerfile + docker/ # Dockerfile + docker-compose.dev.yml; the prod compose and the + # Helm chart are copied from the framework repo's docker/ app.config.ts # the one config file x.manifest.json # GENERATED: routes, entities, actions, jobs, policies AGENTS.md # short, human-authored @@ -105,7 +106,7 @@ A feature imports another feature only through that feature's `service.ts` or it | Package | Rule | |---|---| | `packages/domain` | pure types + constants, **no I/O** | -| `packages/db` | Drizzle schema + migrations, **no business logic** ([Entities and migrations](Entities-And-Migrations)) | +| `packages/db` | `entity()` declarations + plain-SQL migrations, **no business logic**; no ORM in the request path ([Entities and migrations](Entities-And-Migrations)) | | `packages/i18n` | flat catalogs; a missing key renders `⟦key⟧` and fails `x verify` ([I18n](I18n)) | | `packages/ui` | app components on `@ultimat3/ui`; same byte budgets as `shared/` ([Theming](Theming)) | | `packages/mcp` | the app's own MCP tools ([MCP and AI](MCP-And-AI)) | @@ -116,13 +117,15 @@ Inside the framework repo, a package may import from **strictly lower** tiers on | Tier | Packages | May import | |---|---|---| -| 0 | `core`, `schema` | nothing | -| 1 | `i18n`, `money`, `time`, `cache`, `seo` | tier 0 | -| 2 | `entity`, `policy`, `http` | tier 0–1 | +| 0 | `core`, `schema` | nothing internal | +| 1 | `i18n`, `money`, `time`, `cache`, `seo`, `db`, `storage` | tier 0 | +| 2 | `entity`, `policy`, `http`, `auth` | tier 0–1 | | 3 | `action`, `query`, `jobs`, `realtime` | tier 0–2 | -| 4 | `render`, `pwa`, `mcp`, `ai`, `manifest` | tier 0–3 | +| 4 | `render`, `pwa`, `mcp`, `ai`, `manifest`, `mail` | tier 0–3 | | 5 | `ui`, `admin`, `testing`, `cli` | tier 0–4 | +Four sideways edges are declared and no others: `admin → ui`, `realtime → query`, `cli → admin`, `create-ultimate → cli`. [`scripts/lib/tiers.ts`](https://github.com/developerz-ai/ultimate/blob/main/scripts/lib/tiers.ts) is the executable copy of both tables. + Per-package layout is fixed: `package.json`, `tsconfig.json`, `README.md`, `CLAUDE.md`, `src/index.ts` (explicit exports, no `export *` outside pure-type modules), `src/errors.ts` (this package's `X_*` codes), one `src/<concern>.ts` per responsibility with `<concern>.test.ts` beside it. Target < 200 LOC per file, hard ceiling ~500. See [Contributing](Contributing). ## Generated vs authored diff --git a/wiki/Queries-And-Live-Queries.md b/wiki/Queries-And-Live-Queries.md index c5653c068..a5f4f3947 100644 --- a/wiki/Queries-And-Live-Queries.md +++ b/wiki/Queries-And-Live-Queries.md @@ -2,7 +2,7 @@ A `query` is a read. `live: true` makes it subscribable. Never writes, never enqueues, never sends mail. -Pre-v1. Not production-ready. Tiers 1–2 of [Realtime](Realtime) ship in v1; `persist: true` (tier 3, local-first) lands in v2. +v1.0.0 `As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)). Tiers 1–2 of [Realtime](Realtime) ship in v1; `persist: true` (tier 3, local-first) lands in v2. ## The canonical shape @@ -20,11 +20,11 @@ export const liveFeed = query({ | Field | Required | Rule | |---|---|---| -| `input` | yes | Standard Schema; ArkType exposed as `t`. Parsed before `policy`, before `sql`. Becomes the GET query string, the client hook argument, and the MCP tool's JSON Schema | +| `input` | yes | Standard Schema; `t` re-exported from `@ultimat3/query`, so a query file imports one package. The shipped provider is `@ultimat3/schema`'s dependency-free builtin — ArkType, Zod and Valibot are optional swaps behind `configureSchemaProvider`, and no adapter ships. Parsed before `policy`, before `sql`. Becomes the GET query string, the client hook argument, and the MCP tool's JSON Schema | | `policy` | yes | `can('<perm>')`, optionally with a predicate over `{ input, actor }`. Evaluated at HTTP call, client hook, subscribe, **and per delivered row** | | `live` | no — default `false` | registers the query with the incremental matcher. Requires a deterministic, bounded `sql` | | `persist` | no — default `false` | tier 3. Swaps the client result store from memory to IndexedDB and makes the mutator queue durable. Implies `live: true`. v2 | -| `sql` | yes | `(input) => builder`. Drizzle-shaped, SQL-transparent — the generated SQL is printable so an agent can read it and self-correct | +| `sql` | yes | `(input) => SqlSource`. Built with `from()` from `@ultimat3/query` or an `@ultimat3/entity` repo plan — no ORM in the graph. SQL-transparent: `toSQL()` prints the statement verbatim so an agent can read it and self-correct | | `mcp` | no — default not exposed | `{ expose: true, description }` makes the read an MCP tool. Opt-in, unlike an action: a read hands rows to an agent, so silence exposes nothing | | cache tags | derived | acquired automatically from the tables `sql` touches. Never hand-declared on a query | @@ -142,7 +142,7 @@ Verbatim shapes: [`packages/realtime/src/errors.ts`](https://github.com/develope | Reconnect delta | resume from an LSN produces the same state as a fresh snapshot | | Policy-filtered row never delivered | the per-row re-check actually runs | -Every `query({ live: true })` emits a scaffold covering snapshot + one patch + one policy-filtered row. The scaffold fails until filled in — an untested live query is a red build. +Every `query({ live: true })` emits a test covering snapshot + one patch + one policy-filtered row, green on the first run. Extend it as the query grows — an untested live query is a red build. ``` x test live --json diff --git a/wiki/Realtime.md b/wiki/Realtime.md index b2d977289..57845617a 100644 --- a/wiki/Realtime.md +++ b/wiki/Realtime.md @@ -2,7 +2,7 @@ Three tiers, one ladder. Same mutator shape at every rung — climbing is a config change, never a rewrite. -Tiers 1–2 ship in v1. Tier 3 (local-first) ships in v2. Pre-v1, not production-ready. +v1.0.0 `As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)). Tiers 1–2 ship in v1. Tier 3 (local-first) ships in v2. ## The ladder @@ -108,14 +108,14 @@ Every frame carries an LSN. The client's last-seen LSN is what makes reconnect a | # | Mitigation | Detail | |---|---|---| -| 1 | **Prototype before locking topology** | milestone 6 is a reconnect benchmark: 50k sockets, forced `sync` restart, measure time-to-consistent and DB load. **That number does not exist yet.** Topology is not frozen until it does | +| 1 | **Prototype before locking topology** | the reconnect benchmark: 50k sockets, forced `sync` restart, measure time-to-consistent and DB load. **That number does not exist yet** — the roadmap lists it under *Open at 1.0.0*, pinned to no milestone, because tiers 1–2 shipped in milestone 6 without it. Topology is not frozen until it does | | 2 | **Bounded per-query change buffer** | the `replicator` keeps a ring buffer of recent changes per query-hash. Reconnect within the window = delta replay from the buffer, zero DB work | | 3 | **Snapshot fallback, not WAL replay** | outside the window the client gets a fresh snapshot at a current LSN. Cost is one bounded query, never history traversal | | 4 | **Jittered reconnect-with-backoff, server-directed** | draining `sync` nodes send a `reconnect` frame with a per-client delay so clients redistribute instead of stampeding | | 5 | **Per-tenant subscription caps** | a registered-query explosion is a load-shedding decision, made with a limit and a typed `X_LIVE_QUERY_LIMIT`, not by falling over | | 6 | **Consider wrapping an existing protocol** | if the benchmark says our matcher is the bottleneck, adopting Zero's protocol beats inventing one | -No throughput or latency figure is published for the realtime path. `As of 2026-07` there is no measured reconnect number, and per-node socket capacity is a plausible target derived from Bun's native WebSocket implementation, not a benchmark result. Long-running Bun processes are also less battle-proven than Node's; sustained-socket memory profiling is explicit roadmap work. +No throughput or latency figure is published for the realtime path. `As of 2026-08` there is no measured reconnect number, and per-node socket capacity is a plausible target derived from Bun's native WebSocket implementation, not a benchmark result. Long-running Bun processes are also less battle-proven than Node's; sustained-socket memory profiling is explicit roadmap work. ## `sync` drain diff --git a/wiki/Routes-And-Render-Modes.md b/wiki/Routes-And-Render-Modes.md index 63afeeae5..088968334 100644 --- a/wiki/Routes-And-Render-Modes.md +++ b/wiki/Routes-And-Render-Modes.md @@ -2,7 +2,7 @@ A `route` is a URL + render mode + metadata + offline strategy. Render mode is a per-route property. SEO is enforced by the build, not described in a guide. -Pre-v1. Not production-ready. +v1.0.0 `As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)). ## The canonical shape diff --git a/wiki/Scheduled-Tasks.md b/wiki/Scheduled-Tasks.md index 49d0db911..8433e1dfc 100644 --- a/wiki/Scheduled-Tasks.md +++ b/wiki/Scheduled-Tasks.md @@ -2,7 +2,7 @@ A `task` is a cron trigger that enqueues jobs. It never does work itself. -Pre-v1. Not production-ready. +v1.0.0 `As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)). ## The canonical shape diff --git a/wiki/Testing.md b/wiki/Testing.md index 5308b4517..0d3dd98d9 100644 --- a/wiki/Testing.md +++ b/wiki/Testing.md @@ -132,22 +132,40 @@ Every primitive emits a test scaffold that fails until filled in — an untested The single gate. Green means shippable. -| # | Check | Fails on | -|---|---|---| -| 1 | typecheck | any error; `any` is banned by lint, not tolerated by a cast | -| 2 | lint (Biome) | formatting, `any`, default exports, bare `Error`, raw hex colours, hardcoded user-facing strings, `Intl` date formatting with no `timeZone` | -| 3 | **import boundaries** | `site/` → `app/`, routes → DB, services → HTTP, tier violations in framework packages | -| 4 | all six test types | any failure; flakes are failures | -| 5 | **migration drift** | schema differs from migrations, or a migration is not reversible-or-marked | -| 6 | **contract diff** | a breaking change to a published action/query without a version bump | -| 7 | budgets | per-route JS bytes, LCP/CLS, Lighthouse thresholds, precache size | -| 8 | SEO + i18n | missing title/description, duplicate meta, broken internal link, missing i18n key | -| 9 | manifest freshness | `x.manifest.json` / `openapi.json` differ from what the code produces | +Seventeen steps, one list, in cost order. There is no `--only` and no `--skip` — "green" has to mean +the same thing for everyone. A step with nothing to check reports as skipped (`-`), never as passed. -``` +The list is defined once, as `VERIFY_STEP_NAMES` in +[`packages/cli/src/verify-step.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/src/verify-step.ts). +This table is a hand-synced copy of it ([Contributing](Contributing)). + +| Step | Fails on | +|---|---| +| `typecheck` | any error; `any` is banned by lint, not tolerated by a cast | +| `lint` | formatting, `any`, default exports, bare `Error`, raw hex colours, hardcoded user-facing strings, `Intl` date formatting with no `timeZone` | +| `boundaries` | `site/` → `app/`, routes → DB, services → HTTP, tier violations in framework packages | +| `filesize` | a source file over 500 lines | +| `package-shape` | a workspace package missing `README.md`, `CLAUDE.md`, `tsconfig.json`, or `src/index.ts` | +| `errors` | an `X_*` code with no runnable fix or no docs page | +| `unit` | pure logic — services, money, policy predicates, matchers | +| `contract` | action/query schemas, policy denials, emitted OpenAPI and MCP shapes | +| `live` | live-query snapshot, incremental patches, reconnect delta, policy-filtered rows | +| `job` | step replay, idempotency dedupe, retry/backoff, concurrency, outbox atomicity | +| `e2e` | the built output under Playwright, offline and SW update included | +| `eval` | a prompt scoring below its committed baseline, or a prompt with no eval at all | +| `drift` | schema differs from migrations, or a migration is not reversible-or-marked | +| `contract-diff` | a breaking change to a published action/query without a version bump | +| `budgets` | per-route JS bytes and LCP | +| `manifest` | `x.manifest.json` / `openapi.json` differ from what the code produces | +| `roadmap` | framework repo only — a milestone missing its status marker, or a shipped milestone missing an artifact its own row names | + +Any failure fails the gate; flakes are failures. + +```text $ x verify - ✓ typecheck ✓ lint ✓ boundaries ✓ unit ✓ contract ✓ live ✓ job ✓ e2e - ✗ migration drift + ✓ typecheck ✓ lint ✓ boundaries ✓ filesize ✓ package-shape ✓ errors + ✓ unit ✓ contract ✓ live ✓ job ✓ e2e ✓ eval + ✗ drift X_DB_DRIFT: schema differs from migrations cause: table "posts" has column "publish_at" not present in any migration fix: x db gen "add publish_at" diff --git a/wiki/The-Eight-Primitives.md b/wiki/The-Eight-Primitives.md index 552205577..e760b5c28 100644 --- a/wiki/The-Eight-Primitives.md +++ b/wiki/The-Eight-Primitives.md @@ -1,6 +1,6 @@ # The eight primitives -Everything in the framework is one of these. `x gen feature <name>` scaffolds one of each, wired end-to-end, with failing test scaffolds. +Everything in the framework is one of these. `x g resource <name>` scaffolds a whole feature slice — an entity + repo, a policy, **two** actions (`create-*`, `archive-*`), a live query, a job, an app route, plus service, UI and i18n files — each with a test that **passes on the first run**. Not one of each: the slice emits no `mutator` and no `task`. Those have their own generators. ``` entity — a table + its domain type + invariants @@ -15,14 +15,14 @@ task — a scheduled trigger (cron) that enqueues jobs | Primitive | Declared in | Generator | Deep page | |---|---|---|---| -| `entity` | `<feature>/entity.ts` | `x gen entity <name>` | [Entities and migrations](Entities-And-Migrations) | -| `policy` | `<feature>/policy.ts` | — (written inline with `can`) | [Policies and authz](Policies-And-Authz) | -| `action` | `api/` or `<feature>/actions.ts` | `x gen action <name>` | [Actions](Actions) | -| `mutator` | `<feature>/actions.ts` | `x gen mutator <name>` | [Realtime](Realtime) | -| `query` | `<feature>/live.ts` | `x gen query <name>` | [Queries and live queries](Queries-And-Live-Queries) | -| `job` | `<feature>/jobs.ts` | `x gen job <name>` | [Jobs and workflows](Jobs-And-Workflows) | -| `route` | a route folder's `config` export | `x gen route <path>` | [Routes and render modes](Routes-And-Render-Modes) | -| `task` | `<feature>/jobs.ts` | `x gen task <name>` | [Scheduled tasks](Scheduled-Tasks) | +| `entity` | `<feature>/entity.ts` | `x g entity <name>` | [Entities and migrations](Entities-And-Migrations) | +| `policy` | `<feature>/policy.ts` | `x g policy <name>` | [Policies and authz](Policies-And-Authz) | +| `action` | `api/` or `<feature>/actions.ts` | `x g action <name>` | [Actions](Actions) | +| `mutator` | `<feature>/actions.ts` | `x g mutator <name>` | [Realtime](Realtime) | +| `query` | `<feature>/live.ts` | `x g query <name>` | [Queries and live queries](Queries-And-Live-Queries) | +| `job` | `<feature>/jobs.ts` | `x g job <name>` | [Jobs and workflows](Jobs-And-Workflows) | +| `route` | a route folder's `config` export | `x g route <path>` | [Routes and render modes](Routes-And-Render-Modes) | +| `task` | `<feature>/jobs.ts` | `x g task <name>` | [Scheduled tasks](Scheduled-Tasks) | ## `entity` @@ -30,7 +30,7 @@ A table + its domain type + invariants. The single source of the DB schema, the | Aspect | Rule | |---|---| -| Projects to | Drizzle table, domain type, migration, repo type, admin screen, seed factory | +| Projects to | Postgres table, domain type, migration, repo (`postgresRepo`), admin screen, seed factory | | Owns | column types, defaults, invariants, tenant column | | Never | business logic, I/O, HTTP awareness, policy decisions | @@ -69,7 +69,7 @@ export const publishPost = action({ | Aspect | Rule | |---|---| -| Projects to | HTTP route, OpenAPI operation, typed client function, job handle, MCP tool, test scaffold | +| Projects to | HTTP route, OpenAPI operation, typed client function, job handle, MCP tool, contract test | | Owns | input/output contract, its policy, what it invalidates, MCP exposure | | Never | read headers or cookies, render, authorize inside `handle`, do slow work inline | @@ -135,7 +135,7 @@ export const onboardOrg = job({ | Aspect | Rule | |---|---| -| Projects to | queue row, per-step persistence, retry schedule, dashboard entry, MCP `jobs.status` tool | +| Projects to | queue row, per-step persistence, retry schedule, dashboard entry, MCP `jobs.inspect` tool | | Owns | retries, steps, concurrency class | | Never | assume it runs once — assume at-least-once. Durable business state lives in your tables, never only in the payload | @@ -178,7 +178,7 @@ export const nightlyDigest = task({ | Aspect | Rule | |---|---| -| Projects to | scheduler entry (advisory-lock leader), next-run introspection, MCP `tasks.list` | +| Projects to | scheduler entry (advisory-lock leader), next-run introspection, a `tasks` row in `x.manifest.json` | | Owns | cron expression + explicit `tz` | | Never | contain a handler body. If it does work, it is a `job` | @@ -191,14 +191,14 @@ entity ──> policy ──> action ──> job ──> task └──> mutator (action + local twin) ``` -Feature slicing puts one of each in a folder, not one layer per app: +Feature slicing puts a feature's primitives in one folder, not one layer per app: ``` apps/web/app/<feature>/{entity,repo,service,actions,live,jobs,policy,ui}.ts ``` -Every primitive emits a test scaffold that fails until filled in — an untested action is a red build, not a backlog item ([Testing](Testing)). Every primitive appears in `x.manifest.json`, so `x manifest --json` and the MCP `manifest.get` tool describe the whole app as data. +Every primitive emits its test beside it, and that test **passes on the first run** — a generator that emits a `TODO` has moved the work, not done it. What it pins is the distant invariant the primitive owns: a policy denial, an idempotency key, a budget. Extend it as the feature grows ([Testing](Testing)). Every primitive appears in `x.manifest.json`, so `x manifest --json` and the MCP `manifest.read` tool describe the whole app as data. ## The rule -**If a feature doesn't fit a primitive, it doesn't ship.** No ninth primitive, no escape-hatch `mode:` option, no plugin API before v1. Removing an alternative is a feature — ambiguity is the tax agents pay. Source: [`docs/idea/02-primitives.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/02-primitives.md). +**If a feature doesn't fit a primitive, it doesn't ship.** No ninth primitive, no escape-hatch `mode:` option, no plugin API in 1.0. Removing an alternative is a feature — ambiguity is the tax agents pay. Source: [`docs/idea/02-primitives.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/02-primitives.md). diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index de05a39c3..fcec8d0d7 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -9,7 +9,7 @@ Run these first, in this order. All support `--json`. | Command | Answers | |---|---| | `x doctor --json` | Bun version, env schema, DB/transport/storage reachability, port conflicts | -| `x verify --json` | the gate: typecheck, lint, boundaries, six test types, drift, contract diff, budgets, SEO + i18n, manifest freshness | +| `x verify --json` | the gate — 17 steps, in this order: typecheck, lint, boundaries, filesize, package-shape, errors, unit, contract, live, job, e2e, eval, drift, contract-diff, budgets, manifest, roadmap | | `x errors explain <CODE> --json` | cause, fix command, docs URL for any `X_*` code | | `x status --json` | roles up, build IDs, client build-ID distribution, queue depth, socket counts | | `x logs tail --json` | structured logs + OTel spans, filterable by role, trace, or code | @@ -20,8 +20,7 @@ The `--json` form is the same content as the terminal form. Paste the JSON into | Symptom | Likely cause | Fix | |---|---|---| -| Process exits in ~40ms, `X_ENV_MISSING` | a key in the `env` schema is not set in this environment | set the key; `x doctor --json` lists every missing one at once | -| `X_ENV_INVALID` | key present but fails its schema (short secret, non-URL, bad enum) | the cause names the key and the constraint; fix the value | +| Process exits in ~40ms, `X_ENV_MISSING` | a key in the `defineEnv` schema is missing, or present but fails its schema (short secret, non-URL, bad enum) | the cause names every offending key and its constraint at once; `x doctor --json` lists them too | | `X_CONFIG_INVALID` at load | `app.config.ts` field invalid — `defaultLocale` not in `locales`, `db.pool < 1`, non-IANA `timeZone`, `realtime.transport` set without `realtime.url` | `x config show --json`, then edit the field named in `cause` | | `X_ROLE_INVALID` | `ROLE` is not one of `web sync worker scheduler migrate replicator all` | fix the env var on that service | | `X_BUN_VERSION` | below the Bun 1.3 floor | upgrade Bun | @@ -32,7 +31,7 @@ The `--json` form is the same content as the terminal form. Paste the JSON into | Symptom | Likely cause | Fix | |---|---|---| -| `X_DB_DRIFT` | schema differs from migrations (a column added by hand, or a generated migration never applied) | `x db gen "<message>"` then `x db apply` | +| `X_DB_DRIFT` | schema differs from migrations (a column added by hand, or a generated migration never applied) | `x db gen "<message>"` then `x db migrate` | | `X_MIGRATE_CONCURRENT` | another version's `ROLE=migrate` holds the advisory lock | wait for it to exit 0; never run two deploys' migrations at once | | `ROLE=migrate` exits non-zero, deploy blocked | migration failure — correct, the roll is supposed to stop | read the SQL error, fix the migration, re-run. Do not start `web` on the old schema | | `X_TIMEOUT` on one query | past `db.statementTimeout` (default `'10s'`) | add the index the plan wants, or narrow the query. Raising the timeout hides it | diff --git a/wiki/Upgrading.md b/wiki/Upgrading.md index 0368ee035..13e3011a6 100644 --- a/wiki/Upgrading.md +++ b/wiki/Upgrading.md @@ -1,17 +1,36 @@ # Upgrading -`As of 2026-07`: pre-v1. **No semver promise, no published npm packages, no stability guarantee.** Every `@ultimat3/*` version is pinned exactly and moves in lockstep. Anything below can change until v1. +**v1.0.0 `As of 2026-08`. Semver applies from here.** A breaking change to a documented API needs a major. Every `@ultimat3/*` version is pinned exactly and moves in lockstep — never mix versions. -## Pre-v1 policy +## What semver covers + +| Surface | From | +|---|---| +| `X_*` error codes | already stable forever — a shipped code never changes meaning and is never reused | +| The eight primitive shapes | `entity`, `policy`, `action`, `mutator`, `query`, `job`, `route`, `task` and their declared fields — 1.0.0 | +| The `x` CLI surface | commands, flags, exit codes, and `--json` output shape — 1.0.0 | +| The import tier table | which package may import which — 1.0.0 | +| `app.config.ts` field names | renaming or removing a field is a major — 1.0.0 | + +| Bump | Means | Examples | +|---|---|---| +| **major** | a covered surface changed incompatibly | a removed config field, a renamed CLI flag, a changed primitive field, a narrowed tier | +| **minor** | additive, old code still compiles and still passes `x verify` | a new optional field, a new command, a new driver behind an existing interface | +| **patch** | no surface change | a bug fix, a perf change, a corrected `fix:` line | + +1.0.0 means a stable API under semver. It is not a claim about your infrastructure. + +## Release policy | Rule | Detail | |---|---| | Pinned exact versions | no `^`, no `~`, in the framework or in a generated app. A range is a silent upgrade | -| Lockstep releases | one release bumps every `@ultimat3/*` package to the same version. A mixed set is unsupported | +| Lockstep releases | one release bumps all 28 packages — 27 `@ultimat3/*` plus `create-ultimate` — to the same version. One version, one commit, one tag. A mixed set is unsupported | +| Published with provenance | npm via OIDC trusted publishing, no `NPM_TOKEN` | | Breaking changes land with codemods | if `x upgrade` cannot codemod it, the changelog carries the manual step | -| Dependency upgrades are framework work | ArkType, Drizzle, and SolidJS 2 are pre-1.0-stable in places. Bumping them is a framework release, never an app-level `bun update` | +| Dependency upgrades are framework work | SolidJS 2 is pre-1.0-stable in places. Bumping it is a framework release, never an app-level `bun update`. There is no ArkType or Drizzle pin to carry: `@ultimat3/schema` ships dependency-free builtin validators (ArkType is an optional provider you adapt yourself) and `@ultimat3/entity` ships its own `postgresDriver()` | | Bun floor | `>=1.3`, target 2.0. Below the floor → `X_BUN_VERSION` | -| Milestone order is the release order | milestones 0–5 ship before realtime; tiers 1–2 in v1; tier 3 (local-first) in v2 | +| Deferred to v2, behind the interfaces that ship today | realtime tier 3 (`persist: true`, local-first), the plugin API, multi-region replication, and the Redis/NATS **job** drivers — the last throw `X_NOT_IMPLEMENTED` with a runnable `fix:` rather than pretending to work | Do not upgrade a transitive dependency of a `@ultimat3/*` package by hand. Open an issue instead — the pin is deliberate. @@ -77,22 +96,21 @@ Server behavior on a stale build ID: Full detail: [PWA and offline](PWA-And-Offline). -## Migrating jobs between drivers +## Migrating jobs between drivers — **v2** -Switching `jobs.driver` is a config line plus a migration of in-flight rows. Job code never changes — `saveStep` / `loadSteps` are driver methods, so step persistence works identically on all three drivers. +Two job drivers ship in 1.0.0: `postgres` (the default) and `memory`. `redis` and `nats` are interface-complete stubs that throw `X_NOT_IMPLEMENTED`, so **there is no 1.0.0 driver migration to perform** — `x jobs drain --to redis` constructs the target and fails on its first enqueue. -``` -x jobs drain --to redis --json # move in-flight rows, then flip the config -``` +`x jobs drain --to memory` works today, and it is the same command, so the procedure below is written against the interface that already ships and applies unchanged the moment a driver does: | Order | Step | |---|---| | 1 | deploy with the old driver still configured | -| 2 | `x jobs drain --to <driver>` — stops claiming from the old queue, relays committed outbox rows to the new one | -| 3 | flip `jobs.driver` in `app.config.ts`, `x verify`, deploy | -| 4 | confirm with `x jobs ls --json` that the old queue is empty before removing its infra | +| 2 | `x jobs drain --to <driver> --dry-run --json` — read the plan; a skipped candidate is a job whose `runAt` has not arrived, not an error | +| 3 | `x jobs drain --to <driver>` — leases the batch off the old queue, copies steps, enqueues, then acks | +| 4 | flip `jobs.driver` in `app.config.ts`, `x verify`, deploy | +| 5 | confirm with `x jobs ls --json` that the old queue is empty before removing its infra | -The outbox table stays the transactional record on every driver. At-least-once delivery is preserved; atomicity is not negotiable ([Jobs and workflows](Jobs-And-Workflows)). +Job code never changes across a driver: `steps` is a driver member, so step persistence is identical on all of them. The outbox table stays the transactional record. At-least-once delivery is preserved; atomicity is not negotiable ([Jobs and workflows](Jobs-And-Workflows)). ## Migrating realtime tiers @@ -107,7 +125,7 @@ The outbox table stays the transactional record on every driver. At-least-once d | Source | Contents | |---|---| | [`CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md) | Keep a Changelog format. `Added` / `Changed` / `Removed`, plus a **Migration** block per breaking change with the codemod name | -| [`docs/idea/14-roadmap.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/14-roadmap.md) | the 12 milestones. Each ends in a working demo app plus green `x verify` | +| [`docs/idea/14-roadmap.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/14-roadmap.md) | the twelve milestones, 0–10 shipped. Milestone 11's two-platform deploy proof is the one item still open | | [`docs/idea/15-risks.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/15-risks.md) | what could still change shape — the sync engine is roughly 70% of total effort | | `x.manifest.json` | generated, per build. Diff two manifests to see exactly what a release changed in your app | diff --git a/wiki/_Footer.md b/wiki/_Footer.md index 3a978cc65..288b42b71 100644 --- a/wiki/_Footer.md +++ b/wiki/_Footer.md @@ -1,4 +1,4 @@ -**Ultimate** — pre-v1, not production-ready `As of 2026-07`. MIT licensed. +**Ultimate** — v1.0.0 `As of 2026-08`. Stable API, semver from here. MIT licensed. [Site](https://ultimate.developerz.ai/) · [Repository](https://github.com/developerz-ai/ultimate) · [Issues](https://github.com/developerz-ai/ultimate/issues) · [Changelog](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md) · [llms.txt](https://github.com/developerz-ai/ultimate/blob/main/llms.txt)