diff --git a/docker/Dockerfile b/docker/Dockerfile index 18ee2c025..de6a398ed 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -109,11 +109,13 @@ USER nonroot:nonroot # `/bin/sh -c`, which is the very thing this stage does not have. RUN ["/app/x", "--version"] # No HEALTHCHECK, deliberately. One image serves six roles and `worker`, `scheduler` and -# `replicator` open no HTTP listener at all, so any single probe baked in here is wrong for half of +# `replicator` open no APPLICATION listener — only the metrics socket above, which answers +# METRICS_PATH and 404s everything else — so any single probe baked in here is wrong for half of # them — the exact shape of the bug that made sync's readiness probe meaningless. The probe belongs # where the role is chosen: `healthcheck:` per service in docker-compose.prod.yml, and per-role # readiness/liveness in docker/helm/templates/_helpers.tpl. # -# All roles that serve HTTP expose /healthz and /readyz; every role drains on SIGTERM. +# Only `web` and `sync` expose /healthz and /readyz — they are the only roles that construct a +# server. Every role drains on SIGTERM. ENTRYPOINT ["/app/x"] CMD ["dev", "--once"] diff --git a/docker/README.md b/docker/README.md index b1cceb48d..1fd35b2e5 100644 --- a/docker/README.md +++ b/docker/README.md @@ -13,7 +13,7 @@ Build once. `ROLE` selects behaviour at start; nothing else differs between proc | `web` | RPS | `:3000` | `:9090` | `/readyz` + `/healthz` on `:3000` | | `sync` | concurrent websockets | `:3001` | `:9090` | `/readyz` + `/healthz` on `:3001` | | `worker` | queue depth | — | `:9090` | liveness on `/metrics` | -| `scheduler` | fixed 1 (advisory-lock leader election) | — | `:9090` | liveness on `/metrics` | +| `scheduler` | fixed 1 — leader election is an expiring **row**, never `pg_try_advisory_lock`: that grant is session-scoped and dies when a pooled connection returns, so every node reads itself as leader | — | `:9090` | liveness on `/metrics` | | `replicator` | 1 per database | — | `:9090` | liveness on `/metrics` | | `migrate` | run-once, pre-deploy | — | — | none — it exits | diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index a8115c273..282fffa3e 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -73,7 +73,9 @@ services: deploy: replicas: 2 - # Exactly one. Leader election is an advisory lock, so a second instance is harmless but idle. + # Exactly one. Leader election is an expiring lease row in x_scheduler_leader (NOT an advisory + # lock, which is session-scoped and dies with a pooled connection), so a second instance is + # harmless but idle. scheduler: <<: *common environment: { ROLE: scheduler } diff --git a/docker/helm/templates/pdb.yaml b/docker/helm/templates/pdb.yaml index de5f2a712..d1dd0a2af 100644 --- a/docker/helm/templates/pdb.yaml +++ b/docker/helm/templates/pdb.yaml @@ -12,7 +12,7 @@ a drain always finishes. A `minAvailable` equal to the replica count deadlocks t which is how a PDB turns into an outage of its own. Emitted only where the floor is above 1. `scheduler` is pinned to one replica by design (leader -election is a Postgres advisory lock), and a budget over a single pod means that pod can never be +election is an expiring lease row), and a budget over a single pod means that pod can never be evicted — so those roles get none, deliberately. */}} {{- if and .Values.podDisruptionBudget .Values.podDisruptionBudget.enabled }} diff --git a/docker/helm/values.yaml b/docker/helm/values.yaml index 91587a19c..0d5dab234 100644 --- a/docker/helm/values.yaml +++ b/docker/helm/values.yaml @@ -101,7 +101,10 @@ roles: scheduler: enabled: true - replicas: 1 # fixed 1; leader election is a Postgres advisory lock + # Fixed 1. Leader election is an expiring row in x_scheduler_leader, NOT an advisory lock: that + # grant is session-scoped and the executor is a pool, so it dies when the connection returns and + # every replica reads itself as leader. A second replica here is safe but pointless — it stands by. + replicas: 1 resources: requests: { cpu: 50m, memory: 128Mi } limits: { memory: 256Mi } diff --git a/docs/architecture/02-boundaries.md b/docs/architecture/02-boundaries.md index 8b149c122..c703f08b5 100644 --- a/docs/architecture/02-boundaries.md +++ b/docs/architecture/02-boundaries.md @@ -2,65 +2,84 @@ Every boundary is a **build error**, never a lint warning, never a review convention. Axiom 3: a convention that isn't a build error doesn't exist ([`../idea/00-thesis.md`](../idea/00-thesis.md)). -One error code for the whole family: `X_BOUNDARY_VIOLATION`, plus a `rule` field naming which rule broke. One code keeps `errors.explain` and the docs URL stable while rules are added. - -## Framework rules (tiers) - -| Rule id | Forbids | Prevents | -|---|---|---| -| `tier-upward` | tier N importing tier > N | `core` depending on `render` — a cycle that makes every package a build-order problem | -| `tier-sideways` | tier N importing tier N (except `schema` → `core`) | `pwa` ↔ `manifest` cycles; two packages that must be released together | -| `tier-cycle` | any cycle at all, including through types | `tsc --build` deadlock, unresolvable publish order | -| `barrel-star` | `export *` from a non-type module | an accidental public API that cannot be removed without a major | -| `deep-import` | `@ultimat3/x/src/internal/y` | reaching past `src/index.ts`; every internal file becomes public surface | -| `no-any` | `any`, `as any`, `@ts-expect-error` without a code reference | the escape hatch that erases the whole type chain ([`05-type-chain.md`](./05-type-chain.md)) | -| `node-api` | `node:*` imports without an allowlist entry | silently breaking the Bun-only guarantee ([`../idea/01-stack.md`](../idea/01-stack.md)) | - -## Generated-app rules - -| Rule id | Forbids | Prevents concretely | -|---|---|---| -| `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` / `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 | -| `site-emits-js` | a `site/` route emitting JS without an explicit `hydrate` | the 0kb baseline decaying invisibly ([`09-rendering-internals.md`](./09-rendering-internals.md)) | -| `raw-img` | `` in `site/` | CLS, no `srcset`, no AVIF — use `` | -| `raw-hex` | a colour literal in any component or `.scss` | breaking dark theme in one component ([`10-cross-cutting.md`](./10-cross-cutting.md)) | -| `hardcoded-string` | a user-facing literal outside `t()` | a string that ships untranslated to every locale | -| `date-no-tz` | `Intl.DateTimeFormat` / `toLocaleString` without `timeZone` | the bug that only appears for a user in Auckland | - -## How `scripts/boundaries.ts` enforces them +**One code per condition, not one code for the family.** `X_BOUNDARY_VIOLATION` is this repo's own +tier finding; a generated app's surface rules each carry their own `X_BOUNDARY_*` code, so +`x errors explain` answers the specific mistake instead of a category. + +## Framework rules — this repo, `scripts/boundaries.ts` + +The script imports no workspace package and reads source through Bun's own transpiler, so the CI job +that runs it needs no `bun install`. Three rules, `As of 2026-08`: + +| Rule | Code | Forbids | Prevents | +|---|---|---|---| +| the tier table over `packages/*/src` | `X_BOUNDARY_VIOLATION` | importing anything but a strictly lower tier — sideways and upward both fail, unless the edge is in `SIDEWAYS_ALLOW` | `core` depending on `render`; two packages that must be released together | +| the leaf rule over a tracked app's `shared/` | `X_BOUNDARY_SHARED_LEAF` | `shared/` loading an `app/` or `site/` **module**. Naming its type is allowed | `shared/` stops being a leaf; the graph becomes bidirectional and unbudgetable | +| the admin one-flattener rule | `X_ADMIN_FLATTENER_VIOLATION` | a second flattener beside `entity-columns.ts` | two answers to "how does a row become columns" | + +`X_CATALOG_KEY_UNREACHABLE` rides on the same step — the framework catalog defining a key no +framework source can reach. It is a boundary in the same sense: a rule this repo makes about itself +that the framework cannot know. + +The **executable** tier table is [`scripts/lib/tiers.ts`](../../scripts/lib/tiers.ts), and the prose +copy in the root `CLAUDE.md` must agree with it — `tier-table-drift.test.ts` asserts them row for +row. The five declared sideways edges are `admin → ui`, `realtime → query`, `cli → admin`, +`cli → testing` and `create-ultimate → cli`. **`schema → core` is not one of them and never was**: +`packages/schema/src/errors.ts` states outright that schema may not import `@ultimat3/core`, because +core's error machinery would make tier 0 a cycle. + +## Generated-app rules — `packages/cli/src/app-boundaries.ts` + +Five, each with its own code. `x verify` reports them as findings and `x fix boundary` re-reports the +same violation as a cut, off one rule → code table. + +| Rule id | Code | Forbids | Prevents concretely | +|---|---|---|---| +| `site-imports-app` | `X_BOUNDARY_SITE_TO_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-is-a-leaf` | `X_BOUNDARY_SHARED_LEAF` | `shared/` → `site/` or `app/` at runtime | `shared/` stops being a leaf | +| `app-imports-api-at-runtime` | `X_BOUNDARY_APP_TO_API` | `app/` → `api/` as a **value** import | bundling server handlers into the client. `import type` is allowed; call the typed client instead | +| route → db | `X_BOUNDARY_ROUTE_TO_DB` | `page.tsx` / `layout.tsx` / `route.ts` importing `@ultimat3/db`, a `*/db` specifier or `drizzle-orm` | N+1 queries in a `` computation, SQL no policy guards, a route that cannot be unit-tested | +| service → http | `X_BOUNDARY_SERVICE_TO_HTTP` | `service.ts` importing `@ultimat3/http` or a `*/http` specifier | a service that only works inside a request — so the identical logic gets re-implemented in a job | + +**Seven rules this page claimed and no checker implements**, `As of 2026-08`: `component-holds-logic`, +`cross-feature-repo`, `site-emits-js`, `raw-img`, `raw-hex`, `hardcoded-string`, `date-no-tz`. Nor do +the framework-side `tier-cycle`, `barrel-star`, `deep-import`, `no-any` or `node-api` rule ids exist +as rules — `no-any` is Biome's `noExplicitAny`, and the rest are conventions in +[`00-conventions.md`](./00-conventions.md) that no build error enforces. Per axiom 3 an unenforced +convention does not exist; each is a check somebody has to write before this table can claim it. +The two that *are* separately mechanised have their own steps, not boundary rules: JS bytes are +`X_BUDGET_EXCEEDED` on the `budgets` step, and a surface crossing inside `@ultimat3/render` is +`X_SURFACE_BOUNDARY`. + +## How it runs ``` -scripts/boundaries.ts - 1. discover workspaces from package.json, surfaces from app.config.ts - 2. parse Bun.Transpiler().scanImports on every .ts/.tsx — no full typecheck needed - 3. resolve specifier → owning package or surface (tsconfig paths + workspace map) - 4. build graph module → module edges, kind: 'value' | 'type' - 5. evaluate each rule over the graph; type-only edges skip value-only rules - 6. shortest on violation, BFS the shortest path from the entry module to the offender - 7. report UltimateError per violation, with the chain and a fix command + 1. read every .ts/.tsx under the scanned root + 2. parse Bun.Transpiler().scanImports — no full typecheck needed + 3. resolve specifier → owning package (framework) or surface (app) + 4. evaluate each rule over the edges; type-only edges skip value-only rules + 5. report one Finding per violation, with the chain and a fix command ``` | Property | Detail | |---|---| -| Runs in | the gate (`bun run verify` in this repo, `x verify` in an app) **and** the dev server, as you type | -| Cost | import scan only, no type resolution — whole-repo pass in tens of ms `As of 2026-07` | -| Transitive | yes. Direct-only checking misses the case that actually costs bytes | -| Type-aware | `import type` edges are erased at build, so they are exempt from bundle rules and from `app-imports-api-runtime` | +| Runs in | the `boundaries` step of the gate — `bun run verify` in this repo, `x verify` in an app. **Not** in `x dev`: nothing in `cmd-dev.ts` runs a boundary check, so a violation is found at the gate, not as you type | +| Cost | import scan only, no type resolution | +| Transitive | app rules, yes — `appImportGraph` walks module → module edges. The framework tier rule is per-specifier, which is enough: a package's own imports are what the tier table constrains | +| Type-aware | the app's runtime rules read `scanRuntimeImports`, so an `import type` edge is exempt from `app-imports-api-at-runtime`. The **tier** rule reads `allImportsOf` and applies to type-only imports too — a type edge is still a build-order edge | | Output | human + `--json`, identical content | ``` -X_BOUNDARY_VIOLATION: site/ imported app/ +X_BOUNDARY_SITE_TO_APP: site/ imported app/ cause: site/pricing/page.tsx → shared/ui/button.tsx → app/charts/sparkline.tsx → chart.js fix: x fix boundary site/pricing/page.tsx (or move sparkline out of shared/ui) ``` -`x fix boundary ` is not magic: it prints the minimal cut (which edge to delete) and, for the `shared/` fattening case, generates the split — `shared/ui/button.tsx` stays, `sparkline.tsx` moves to `app/charts/`. +`x fix boundary ` is **analysis and a plan only — it never rewrites a file, and there is no +`--write` flag.** It prints the minimal cut (which edge to delete) and, for the `shared/` fattening +case, the split: the `git mv` plus every import specifier the move invalidates, each named with its +file. `--json` carries the same plan structured, so an agent applies it without re-parsing the +sentence. ## Why the chain matters @@ -75,26 +94,37 @@ Same reasoning for budgets: a failure names the *transitive import that added th | 1 | Pick the tier from what it must import — **not** from what it feels like | [`01-package-map.md`](./01-package-map.md) | | 2 | Write the one-line responsibility. If it needs "and", stop and split | `packages//README.md` | | 3 | Scaffold | `bun run scripts/new-package.ts --tier ` | -| 4 | Declare deps explicitly in `package.json` — the graph is checked against declarations, not just imports | `packages//package.json` | -| 5 | If it needs a same-tier sibling: move the shared **type** to `core`, wire the concrete value in `cli` | `packages/core/src/contracts.ts` | -| 6 | Add the tier row and the graph edge | `docs/architecture/01-package-map.md` | -| 7 | Add its `X_*` codes | `packages//src/errors.ts` + [`04-error-contract.md`](./04-error-contract.md) | -| 8 | Prove the graph still holds | `bun run boundaries` then `bun run verify` | +| 4 | Declare deps explicitly in `package.json`, pinned at the lockstep version | `packages//package.json` | +| 5 | If it needs a same-tier sibling: move the shared **type** down to `core`, and wire the concrete value where both are already visible — or declare the edge, with its reason | `packages/core/src/` · [`scripts/lib/tiers.ts`](../../scripts/lib/tiers.ts) | +| 6 | Add its tier row to **both** copies of the table — the executable one and the prose one | [`scripts/lib/tiers.ts`](../../scripts/lib/tiers.ts) + root `CLAUDE.md` | +| 7 | Add its `X_*` codes, and a row per code in the error reference | `packages//src/errors.ts` + [`wiki/Error-Codes.md`](../../wiki/Error-Codes.md) | +| 8 | Add it to the root `tsconfig.json` `references` — `tsc -b` builds referenced projects and nothing else | root `tsconfig.json` (`X_PACKAGE_UNREFERENCED`) | +| 9 | Prove the graph still holds | `bun run boundaries` then `bun run verify` | Rules that keep the graph healthy: - A new package at tier 0 or 1 needs justification in its README — low tiers are load-bearing for everything above. - Never raise a package's tier to fix an import. Invert the dependency instead. -- An undeclared dependency that resolves via hoisting is a violation (`rule: undeclared-dep`), because it breaks on publish. -- Cycles are rejected before rules are evaluated; a cycle report names the smallest cycle, not the whole SCC. - -## Escape hatches - -There are two, both loud: - -| Hatch | Syntax | Cost | -|---|---|---| -| Allowlist a `node:` import | `boundaries.allow` entry in `package.json` with a reason string | shows up in `x verify --json` output as a waiver | -| Waive a rule for one module | `// boundary-allow: — — ` | expires: `x verify` fails on a waiver older than 90 days | - -No global disable. No config flag that turns boundaries off. +- A sibling pinned at a version that is not the lockstep one is `X_RELEASE_VERSION_SKEW` on the `package-shape` step. An **undeclared** dependency that resolves through hoisting is not caught by any step `As of 2026-08` — a convention, not a rule, and the check that should exist would compare each package's imports against its own `dependencies`. +- Cycles are not detected as cycles: the tier rule makes most of them unreachable, and `tsc -b` refuses the rest at build time with its own message rather than an `X_*` code. + +## There is no escape hatch + +No waiver comment, no `boundaries.allow` key, no global disable, no config flag. This page +documented both of the first two until 2026-08 and **neither has ever existed** — a grep of +`scripts/` and `packages/cli/src/` finds no reader for either, so an agent that wrote one got a +build error with no explanation. They are removed rather than built: a second way to make a rule +not apply is axiom 1 broken, and the framework already has one way. + +**Widening the table is the only way to add an edge**, and it is a deliberate act: +`SIDEWAYS_ALLOW` in [`scripts/lib/tiers.ts`](../../scripts/lib/tiers.ts), with a doc-block row +saying why the edge is not a lower-tier extraction. `cli → testing` is the worked example — it was +already live as a relative specifier the checker could not see, and declaring it made the rule +enforce what shipping already assumed. + +**Adding a rule of your own is `guards/`.** A file in an app's `guards/` directory +([`packages/cli/src/guards.ts`](../../packages/cli/src/guards.ts)) returns `Finding[]` and rides on +the `boundaries` step, so "green" keeps meaning what it meant. Discovered, never registered — no +`defineGuard`, no index, no manifest row. `x g guard ` writes one and its test. What it +returns is held to the error contract: an `X_SCREAMING_SNAKE` code, a non-empty cause and a `fix:` +that passes the same rule every shipped `fix:` passes, or `X_GUARD_FINDING_INVALID`. diff --git a/docs/architecture/03-request-lifecycle.md b/docs/architecture/03-request-lifecycle.md index cfda9554f..499c25462 100644 --- a/docs/architecture/03-request-lifecycle.md +++ b/docs/architecture/03-request-lifecycle.md @@ -14,7 +14,7 @@ Order is fixed. Each row states why it cannot move earlier or later. | 4 | `route-match` | router resolves path + method → route or action/query ref | a `404` must cost no session lookup, no body read | | 5 | `locale-negotiate` | path prefix → cookie → `Accept-Language` → default; sets `locale` + `tz` in ALS | **before anything user-facing.** Stages 6–17 can all produce a message (a denial, a validation error); a message chosen before the locale is a message in the wrong language | | 6 | `session-resolve` | Better Auth: cookie/bearer/passkey → `actor` (identity only, zero permissions) | authz needs an actor; tenancy is derived from actor + host | -| 7 | `tenant-resolve` | host / path / actor claim → `tenantId` in ALS; `X_TENANT_MISMATCH` on conflict | before rate limits, which are keyed per tenant; before any repo call, which requires the tenant | +| 7 | `tenant-resolve` | host / path / actor claim → `tenantId` in ALS; `X_TENANCY_ACTOR_MISMATCH` on conflict | before rate limits, which are keyed per tenant; before any repo call, which requires the tenant | | 8 | `rate-limit` | token bucket on `(tenant, actor, route)` | after the actor exists (per-tenant fairness), before the body is read (a flood must not cost a parse) | | 9 | `body-parse` | bounded read, content-type dispatch, size cap | after rate limiting; before validation | | 10 | `input-validate` | `input` schema parse → typed `input`; failure → `X_INPUT_INVALID` with the field path | authz reads validated input | diff --git a/docs/architecture/04-error-contract.md b/docs/architecture/04-error-contract.md index 9280b2455..69e36229a 100644 --- a/docs/architecture/04-error-contract.md +++ b/docs/architecture/04-error-contract.md @@ -35,7 +35,7 @@ throw new UltimateError({ |---|---| | Prefix | `X_` always. The prefix is what makes a code greppable across logs, JSON, and prose | | Case | SCREAMING_SNAKE, words separated by `_` | -| Shape | `X__`: `X_DB_DRIFT`, `X_JOB_STEP_FAILED`, `X_SEO_NO_TITLE` | +| Shape | `X__`: `X_DB_DRIFT`, `X_JOB_MAX_ATTEMPTS`, `X_ROUTE_META_MISSING` | | Stability | **stable forever once shipped.** A code is a public API — agents match on it, docs URLs resolve to it, dashboards group by it | | Deprecation | never rename. Add the new code, keep the old one throwing with `data.supersededBy` | | Ownership | each package declares its own codes in `src/errors.ts` and subclasses `UltimateError` | @@ -105,14 +105,30 @@ Over HTTP the same object is the problem+json body, with `httpStatus` as the sta ```ts // packages/entity/src/errors.ts — one file per package, no codes declared inline -export class EntityError extends UltimateError {} +import { registerErrorCodes, UltimateError } from '@ultimat3/core'; + +/** Codes this package declares and owns. Borrowed codes go in a second list, never titled twice. */ +export const ENTITY_OWNED_ERROR_CODES = ['X_INVARIANT_VIOLATED', 'X_TENANCY_UNSCOPED'] as const; + +export type EntityOwnedErrorCode = (typeof ENTITY_OWNED_ERROR_CODES)[number]; + +export const ENTITY_ERROR_TITLES: Readonly> = { + X_INVARIANT_VIOLATED: 'a domain invariant rejected this row', + X_TENANCY_UNSCOPED: 'a tenant-scoped query has no org predicate', +}; -export const ENTITY_ERRORS = { - X_DB_DRIFT: { title: 'schema differs from migrations', httpStatus: 500, retryable: false }, - X_TENANT_MISMATCH: { title: 'row tenant does not match request tenant', httpStatus: 403, retryable: false }, -} as const; +// Registration is the import's side effect: `format()` renders the title, and a second package +// claiming one of these fails as X_ERROR_CODE_DUPLICATE rather than silently winning the race. +registerErrorCodes( + Object.fromEntries(Object.entries(ENTITY_ERROR_TITLES).map(([code, title]) => [code, { title }])), +); + +export class EntityError extends UltimateError {} ``` +The status is **not** declared here. `ERROR_STATUS` in `@ultimat3/http` is the one place a code +becomes a status, for the reason the next section gives. + ## Code → HTTP status `ERROR_STATUS` in [`packages/http/src/error-map.ts`](../../packages/http/src/error-map.ts) is the @@ -146,11 +162,11 @@ to be paged declares a 5xx and gets one. | ❌ Rejected `fix` | ✅ Accepted | |---|---| -| `check your database connection` | `x db status --json` | +| `check your database connection` | `x doctor --json` | | `add a description` | `add description to meta in site/pricing/page.tsx` | -| `see the docs` | `x errors explain X_SW_UNCACHEABLE` | +| `see the docs` | `x errors explain X_SW_SCOPE_INVALID` | | `this is not supported` | `set jobs.driver = 'pg' in app.config.ts` | -| `retry later` | `x jobs retry 8f2a1c --from nudge` | +| `retry later` | `x jobs retry 8f2a1c --from-step nudge` | Enforced by the **`errors` step** of `x verify`. It reads every `fix:` string literal in shipped source — test files and `.d.ts` excluded — and treats a `${…}` interpolation as unknown, so nothing inside one counts as a command. @@ -158,10 +174,13 @@ Enforced by the **`errors` step** of `x verify`. It reads every `fix:` string li |---|---| | an empty `fix` | `X_ERROR_FIX_INVALID` | | a `fix` carrying `check`, `make sure`, `try` or `see the docs` with no command token | `X_ERROR_FIX_INVALID` | +| a `fix` citing an `x ` the registry does not hold, or one that is **planned** | `X_ERROR_FIX_INVALID` | | a declared `X_*` code with no row in `wiki/Error-Codes.md` | `X_ERROR_CODE_UNDOCUMENTED` | A **command token** is the `x` CLI, a known tool (`bun`, `bunx`, `git`, `docker`, `psql`, `curl`, …), a literal call expression (`name(`), or a file path (`app.config.ts`, `apps/web/api/index.ts`). "check `x doctor --json`" passes; "check your database connection" does not. +The citation rule is **conditional, and that is load-bearing**: *if* a `fix` names `x `, `citationProblem` (`packages/cli/src/fix-command.ts`) resolves it against the registry — the same one `x help` prints — and a second word is judged as a subcommand only when the spec declares subcommands. A `fix` that names no command at all is fine when it is executable on its own (`set OTEL_EXPORTER_OTLP_ENDPOINT=…`). A **planned** command fails the rule: `x logs tail` parses and `x help` lists it, and running it hands the reader `X_NOT_IMPLEMENTED` instead of the fix. + The docs half is a **host check** the framework repo contributes to the `errors` step — the same seam the tier table uses on `boundaries`. "Which codes exist?" has one implementation, `collectDeclaredCodes` in `packages/cli/src/error-contract.ts`: one walk of every shipped source file, one entry per code, the owning registry preferred over any throw site and over any registry that borrowed it. The docs check reads it, and so does `framework.manifest.json` — a second scanner over a narrower file set is how the manifest came to omit 26 codes and misattribute a 27th. @@ -199,13 +218,17 @@ Thrown by more than one package, or by the gate about any of them; every package | `X_BOUNDARY_VIOLATION` | `cli` | build | an import rule broke; `data.chain` is the path | `x fix boundary ` | | `X_ERROR_FIX_INVALID` | `cli` | build | a `fix:` literal is empty, or advice with no command token | `rewrite the fix at : as a runnable command` | | `X_ERROR_CODE_UNDOCUMENTED` | `cli` | build | a declared `X_*` code has no row in the error reference | `add a row for to wiki/Error-Codes.md` | -| `X_MANIFEST_STALE` | `manifest` | build | `x.manifest.json` / `openapi.json` differ from the code | `x manifest write` | +| `X_MANIFEST_STALE` | `manifest` | build | `x.manifest.json` / `openapi.json` differ from the code | `x manifest` | | `X_BUDGET_EXCEEDED` | `render` | build | route bytes / LCP over budget; `data.cause` names the import | `x fix boundary ` | | `X_BUILD_SKEW` | `http` | 409 | client build ID incompatible with the server contract | `reload` (client-side signal) | | `X_RATE_LIMITED` | `http` | 429 | token bucket exhausted for `(tenant, actor, route)` | retry after `data.retryAfterMs` | -| `X_I18N_MISSING_KEY` | `i18n` | build | a key rendered as `⟦key⟧` | `x i18n add ` | -| `X_MONEY_CURRENCY_MISMATCH` | `money` | 500 | arithmetic across two currencies | convert explicitly first | -| `X_TIME_NO_ZONE` | `time` | build | formatting without an explicit IANA zone | pass `timeZone: ctx.tz` | -| `X_TEST_NETWORK_EGRESS` | `testing` | test | unmocked egress inside a test | mock the named URL | +| `X_CATALOG_MISSING_KEYS` | `i18n` | build | a locale's catalog lacks a key source calls `t()` with | `x i18n sync ` | +| `X_CURRENCY_MISMATCH` | `money` | 500 | arithmetic across two currencies | convert explicitly first | +| `X_TIMEZONE_INVALID` | `time` | 500 | a `zone` that is not an IANA identifier | pass an IANA id such as `Europe/Berlin`, never `CET` | +| `X_TEST_NETWORK_SEALED` | `testing` | test | unmocked egress inside a test | mock the named URL | + +There is no runtime code for *formatting with no zone at all*: `FormatContext.zone` is required +(`packages/time/src/format.ts:14-18`), so an omitted zone is a type error and never reaches a throw. +`X_TIMEZONE_INVALID` covers the case a type cannot — a string that is not an IANA name. Package-local codes live with their subsystem: jobs in [`08-jobs-internals.md`](./08-jobs-internals.md), realtime in [`07-realtime-internals.md`](./07-realtime-internals.md), rendering/SEO/PWA in [`09-rendering-internals.md`](./09-rendering-internals.md). diff --git a/docs/architecture/05-type-chain.md b/docs/architecture/05-type-chain.md index 11aac497a..8901bec45 100644 --- a/docs/architecture/05-type-chain.md +++ b/docs/architecture/05-type-chain.md @@ -100,9 +100,9 @@ Rename `excerpt` → `summary` in `packages/db/src/schema/posts.ts` and change n | `apps/web/app/posts/repo.ts` | `tsc` | any explicit column list referencing `excerpt` | | `apps/web/api/posts.contract.test.ts` | `tsc` | the generated contract test asserts the view's keys | | migrations | `X_DB_DRIFT` | `cause: table "posts" has column "summary" not present in any migration` / `fix: x db gen "rename excerpt to summary"` | -| `openapi.json`, `x.manifest.json` | `X_MANIFEST_STALE` | `fix: x manifest write` | +| `openapi.json`, `x.manifest.json` | `X_MANIFEST_STALE` | `fix: x manifest` | -Eight failures, each naming a file and a line. Zero silent behavior changes. The rename is **complete when the build is green** — there is no seventh place to remember. +Eight failures, each naming a file and a line. Zero silent behavior changes. The rename is **complete when the build is green** — there is no ninth place to remember. Contrast: the same rename in a stack where the API response is hand-written and the client types are a copied interface. Nothing fails. The field arrives as `undefined`, renders as an empty paragraph, and the SEO description silently becomes blank on every blog post. diff --git a/docs/architecture/06-data-layer.md b/docs/architecture/06-data-layer.md index 6f3d3c676..f302f5a5e 100644 --- a/docs/architecture/06-data-layer.md +++ b/docs/architecture/06-data-layer.md @@ -126,8 +126,8 @@ Defense in depth, because a single missed `WHERE` is a data breach. |---|---|---| | 1. Context | `ctx.tenantId` set once, at stage 7 of the pipeline ([`03-request-lifecycle.md`](./03-request-lifecycle.md)) | a repo call with no tenant in context throws `X_NO_CONTEXT` | | 2. Repo | every generated statement injects `tenant_col = $ctx.tenantId` | a hand-written repo query without the filter fails a static check in `x verify` | -| 3. Write guard | insert/update stamps the tenant from context, refuses a mismatching literal | `X_TENANT_MISMATCH` | -| 4. Read guard | a returned row whose tenant ≠ context tenant is a bug, not a filter miss | `X_TENANT_MISMATCH`, logged with the query hash | +| 3. Write guard | insert/update stamps the tenant from context, refuses a mismatching literal (`tenancyRowMismatch`) | `X_TENANCY_ACTOR_MISMATCH` | +| 4. Plan guard | a plan built with no org predicate, or one naming a tenant the actor does not act as | `X_TENANCY_UNSCOPED` / `X_TENANCY_ACTOR_MISMATCH` — one code for the predicate and the row, because it is one mistake in two places | | 5. Postgres RLS | optional, opt-in per entity; policy uses a session variable set on checkout | last line of defense for raw SQL and admin sessions | | 6. Cache keys | query name + parsed-input fingerprint + tags, never hand-built. `As of 2026-08` the actor is **not** a part, so the tenant must be in the read's input | a `cache:` read scoped by actor rather than by input is a cross-tenant hit ([`../idea/05-caching.md`](../idea/05-caching.md)) | | 7. Live queries | subject includes the tenant; policy re-checked per delivered row | [`07-realtime-internals.md`](./07-realtime-internals.md) | @@ -242,12 +242,12 @@ stage 14 post-commit |---|---| | Schema is the source; migrations are the ledger | `x db gen ""` diffs schema vs. applied migrations and writes SQL | | Applied set is recorded in-DB | `x_migrations` table: name, checksum, applied_at, build id | -| Editing an applied migration | checksum mismatch → `X_MIGRATION_TAMPERED`, `fix: x db gen ""` | +| Editing an applied migration | checksum mismatch → `X_MIGRATION_CONFLICT`, `fix: x db gen ""` | | Drift in the source | `X_DB_DRIFT` from `checkSourceDrift`, naming the schema and migration hashes that moved — `x verify`, no database needed | | Drift in the database | `X_DB_DRIFT` from `checkDrift`, naming the table, column or index the live catalog disagrees on — `x db migrate` and `ROLE=migrate` | -| Irreversible migrations | allowed only with `-- irreversible: `; otherwise `X_MIGRATION_NOT_REVERSIBLE` | +| Irreversible migrations | `x db gen` refuses to **generate** a drop whose `down` cannot restore the rows: `X_MIGRATION_IRREVERSIBLE`, whose `fix:` is the same command plus `--allow-destructive` | | Concurrent versions | `ROLE=migrate` takes an advisory lock; a second version in flight is `X_MIGRATE_CONCURRENT` | -| Destructive statements | `DROP COLUMN` / `DROP TABLE` require `--allow-destructive`, and are refused outright against a production-tagged URL | +| Destructive statements | a committed `up` that drops, truncates or retypes must carry the `-- destructive: true` line, or `x verify`'s `drift` step refuses to **ship** it: `X_MIGRATION_DESTRUCTIVE`, one finding per file. `@ultimat3/db`'s `destructive.ts` owns the classifier both the generator and the gate read, so they cannot disagree about one file | ``` X_DB_DRIFT: schema differs from migrations @@ -264,7 +264,7 @@ bun test --workers 8 once: migrate + seed → myapp_test_tpl per worker: CREATE DATABASE myapp_test_N TEMPLATE myapp_test_tpl (~100-400ms) per file: truncate the tables that file touched - teardown: drop on exit (--keep-db to inspect) + teardown: drop on exit ``` Real Postgres, truly parallel. Rejected alternatives and why, plus worker→DB assignment mechanics: [`14-testing-internals.md`](./14-testing-internals.md). @@ -276,12 +276,14 @@ The short version of why not transaction-rollback isolation: the outbox commits, | Code | Meaning | Fix | |---|---|---| | `X_DB_DRIFT` | schema ≠ migrations ≠ catalog | `x db gen ""` | -| `X_MIGRATION_TAMPERED` | an applied migration's checksum changed | `x db gen ""` | -| `X_MIGRATION_NOT_REVERSIBLE` | no down path and no `irreversible:` marker | add the marker or a down migration | -| `X_MIGRATE_CONCURRENT` | another version's migration is in flight | wait, then `x db status --json` | -| `X_TENANT_MISMATCH` | row tenant ≠ request tenant | scope the query to `ctx.tenantId` | +| `X_MIGRATION_CONFLICT` | the ledger disagrees with this build — an applied migration's checksum changed | `x db gen ""` | +| `X_MIGRATION_IRREVERSIBLE` | generating this plan would drop rows the `down` cannot restore | `x db gen "" --allow-destructive` | +| `X_MIGRATION_DESTRUCTIVE` | a committed `up` destroys data and does not say so | add `-- destructive: true` to the migration file | +| `X_MIGRATE_CONCURRENT` | another migrator holds the advisory lock | `psql "$DATABASE_URL" -c "select pid, state from pg_stat_activity join pg_locks using (pid) where locktype = 'advisory'"` — terminate the wedged backend, then `x db migrate` | +| `X_TENANCY_ACTOR_MISMATCH` | a predicate, row or patch named a tenant other than the actor's | drop the `orgId` argument, or `crossTenant('', fn)` | +| `X_TENANCY_UNSCOPED` | a tenant-scoped plan has no org predicate | `scopedPlan('', tenantColumn, '', plan)` | | `X_CURSOR_INVALID` | signature mismatch, or a cursor from another query, filter or sort order | restart pagination from `after: null` | | `X_INVARIANT_VIOLATED` | a write broke a declared invariant; `data.invariant` names it | fix the value, or relax the invariant | -| `X_QUERY_UNBOUNDED` | `list`/live query without `limit` + total order | add `limit` and a unique tiebreak column | +| `X_MATCHER_UNSUPPORTED` | a `live:` query the matcher cannot patch incrementally — no total order, or an operator outside `= != in > >= < <=` | set `live: false` and poll, or reshape to equality filters + `orderBy` + `limit` | | `X_WRITE_UNFILTERED` | `deleteWhere({})` / `updateWhere({}, patch)` — no filter, so every row | name the columns that bound it; a whole-table write is a migration, `x db gen ""` | | `X_PATCH_EMPTY` | `updateWhere(filter, {})` — nothing to write | name the columns to write | diff --git a/docs/architecture/07-realtime-internals.md b/docs/architecture/07-realtime-internals.md index 8f2533e85..85b3a6047 100644 --- a/docs/architecture/07-realtime-internals.md +++ b/docs/architecture/07-realtime-internals.md @@ -91,8 +91,9 @@ For each `ChangeEvent`: Constraints that make step 2 cheap enough to be honest about: - `live: true` requires a **deterministic, bounded** `sql`: total `orderBy` + `limit`, no non-deterministic functions. What enforces it is `assertMatchable` (`packages/query/src/matcher.ts`), which refuses a shape the matcher cannot patch incrementally — an unsupported clause, or a filter operator outside `= != in > >= < <=` — with `X_MATCHER_UNSUPPORTED` and the fix "set `live: false` and poll, or reshape to equality filters + `orderBy` + `limit`". There is no separate `X_QUERY_UNBOUNDED`. -- Predicates must be evaluable against a single row. A live query joining more than the configured table count falls back to **re-execution on change** — correct, more expensive, and reported by `x live explain ` so the cost is never a surprise. -- Aggregates (`count`, `sum`) are supported as incremental deltas only for `+1/-1` shapes; anything else re-executes. +- Predicates must be evaluable against a **single row of a single entity**. `match` returns no patch at all when `event.entity !== shape.entity` (`packages/query/src/matcher.ts:56`), so a join is not a slower live query — it is not one. `Builder#raw(feature)` is how a source declares a shape the matcher cannot patch, and `assertMatchable` refuses it at subscribe time. +- **There is no re-execution fallback, and no aggregate deltas.** `As of 2026-08` the refusal is the whole behaviour: unmatchable is `X_MATCHER_UNSUPPORTED`, never a more expensive correct answer. An honest refusal beats a silently wrong result set, and a fallback nobody wrote is worse than both. +- **Nothing reports which class a query is in before it is subscribed to.** `assertMatchable` runs inside `toLiveQuery` (`packages/query/src/live.ts:125`), which nothing outside `@ultimat3/query` calls, so the refusal arrives at subscribe time and not at build time. There is no explain command — shipped, planned or otherwise. ## Policy is per subscriber, never per query @@ -264,7 +265,7 @@ Removing it moved **no** `PROTOCOL_VERSION` (still `1`), and both skews are read | Reconnect outside the window | snapshot | 1 bounded query per subscription | bounded by `limit`, index-backed | | Cold subscribe | snapshot | 1 bounded query | same as any page load | | Steady state | matcher + fanout | 0 | changes only | -| Buffer sizing | `buffer.window` per query group; default 30s of ops, capped by bytes | memory | tuned by `x live explain` | +| Buffer sizing | `buffer.window` per query group; default 30s of ops, capped by bytes | memory | tuned by hand — no command reports the window | | Never | replaying arbitrary WAL history per client | — | rejected design: it makes reconnect O(history) | **Snapshot fallback, never WAL replay.** Outside the window the client gets a fresh snapshot at a current LSN. Cost is one bounded query, never history traversal. @@ -328,7 +329,7 @@ Requirements this places on `mutator.local`: pure function of `(tx, input)` — | Limit | Reality | |---|---| -| Matcher generality | single-row predicates are fast; multi-table joins and non-trivial aggregates re-execute. `x live explain` tells you which class your query is in | +| Matcher generality | single-row, single-entity predicates only. Multi-table joins and non-trivial aggregates are **refused** at subscribe time (`X_MATCHER_UNSUPPORTED`), never re-executed, and no command tells you the class in advance | | Matcher throughput | CPU on one `replicator` per database. A high-write table with many distinct query shapes is the bottleneck, not socket count | | Memory per subscriber | grows with subscribed-result size. 100k sockets holding 50-row results is fine; 100k holding 5k-row results is not | | Ordering | per table, by LSN. There is no cross-table transactional snapshot on the wire; a UI that requires one must read via a query, not a subscription | diff --git a/docs/architecture/08-jobs-internals.md b/docs/architecture/08-jobs-internals.md index b0e0b3b59..54aa68b3f 100644 --- a/docs/architecture/08-jobs-internals.md +++ b/docs/architecture/08-jobs-internals.md @@ -6,12 +6,14 @@ Postgres queue by default, one driver interface, durable steps. Why an outbox an `run()` is re-entered from the top on every attempt. Completed steps are **not re-executed** — their stored results are returned. -```ts -// packages/jobs/src/step-executor.ts — one responsibility: replay a run deterministically -export function createStep(jobId: JobId, memo: Record, driver: JobDriver) { - return { +Sketch of `createStepRunner` ([`packages/jobs/src/steps.ts`](../../packages/jobs/src/steps.ts)) — +the shape, not the source: + +```text +// one responsibility: replay a run deterministically +createStepRunner(options) => { async run(name: string, fn: () => Promise): Promise { - assertUniqueStepName(jobId, name); // X_JOB_DUPLICATE_STEP + assertUniqueStepName(jobId, name); // X_STEP_DUPLICATE if (name in memo) return memo[name] as T; // replay: no call, no side effect const result = await fn(); // executed once, ever await driver.saveStep(jobId, name, result); // durable before returning @@ -23,9 +25,8 @@ export function createStep(jobId: JobId, memo: Record, driver: if (key in memo) return; await driver.saveStep(jobId, key, true); await driver.sleepUntil(jobId, addDuration(now(), duration)); - throw SUSPEND; // releases the worker; no held connection + throw StepSuspension; // releases the worker; no held connection }, - }; } ``` @@ -139,24 +140,31 @@ RETURNING id, name, attempt; | Long step | heartbeats keep it alive; a step exceeding `maxStepMs` is killed and retried, never left leased forever | | SIGKILL | no heartbeat → the lease expires → the reaper requeues. Completed steps are memoized, so recovery resumes at the failed step | | Clock | `now()` is the **database's** clock, so a skewed worker cannot steal or hold leases | -| Reaped job | logged with `X_JOB_LEASE_EXPIRED` and the attempt count; repeated reaping is the signal for a stuck external call | +| Reaped job | `logger.error('jobs.lease.lost', …)` (`packages/jobs/src/heartbeat.ts:71`); the run itself fails as `X_JOB_LEASE_LOST`. Repeated reaping is the signal for a stuck external call | ## Scheduler leader election -`scheduler` is fixed-1 by design. Election is a **session-level Postgres advisory lock**, so a crash releases it automatically — no TTL, no heartbeat table, no split brain from a paused process. +`scheduler` is fixed-1 by design. Election is an **expiring row**, `createPgLeaseLeader` +([`packages/jobs/src/scheduler-pg.ts`](../../packages/jobs/src/scheduler-pg.ts)) — one row per +`lock_key` in `x_scheduler_leader`, holder plus expiry, and `acquire()` is also the renewal. -```sql -SELECT pg_try_advisory_lock(hashtext('ultimate:scheduler')); -- true = leader -``` +**Not `pg_try_advisory_lock`, and that is the whole point.** An advisory lock is *session*-scoped, +and the executor this package is handed is a **pool** — the grant dies the moment the connection +goes back, so every node reads itself as leader and a rolling update double-fires every task. +`createPgLeader` does not exist; `@ultimat3/realtime`'s `PgAdvisoryLock` solves the same problem by +owning its connection, and this package holds no wire protocol, so it solves it with a row. | Property | Detail | |---|---| -| Held for | the process's DB session lifetime. Connection dies → lock released by Postgres | -| A non-leader | reports `/readyz` **not ready** by design and stays a warm standby; it never dispatches | -| Handover | the leader releases on SIGTERM (drain step 4), so the standby promotes within one lock-retry interval (default 5s) | -| Missed tick | fires **late** rather than being skipped | +| Held for | `ttlMs`, default `DEFAULT_LEADER_TTL_MS` = 30s, against a 1s tick. Comfortably longer than the tick, or a slow round loses the lock mid-dispatch | +| Renewal | the scheduler's per-round `acquire()`. It both extends the lease and answers the round the node stops being leader | +| Holder identity | a per-process uuid, never a hostname a pod reuses | +| A non-leader | stays a warm standby and never dispatches. It reports **no** readiness — the `scheduler` role opens no HTTP socket at all, only the metrics listener on `DEFAULT_METRICS_PORT` (`packages/cli/src/metrics-endpoint.ts:22-26`) | +| Crash | the lease is reclaimed by expiry, with nothing to clean up — the one property the advisory lock had, and one a plain `insert … on conflict do nothing` would not | +| Single node | `soleLeader()`, which acquires unconditionally | +| Missed tick | decided against the durable watermark in `x_scheduler_state` (`pgSchedulerState`), per `catchUp` — `skip` (default), `run-once` or `run-all` bounded by `maxCatchUp` | | Double fire during handover | absorbed by the enqueued job's `idempotencyKey` | -| `replicator` | same mechanism, key `x:replicator:`; a second instance exits non-zero with `X_REPLICATOR_SLOT_HELD` rather than double-delivering | +| `replicator` | a second instance exits non-zero with `X_REPLICATOR_SLOT_HELD` rather than double-delivering | `task` only enqueues. A `task` with a handler body is a rejected design — if it does work, it is a `job` ([`../idea/02-primitives.md`](../idea/02-primitives.md)). @@ -183,7 +191,7 @@ export const onboardOrg = job({ |---|---| | Enforcement | unique partial index: `CREATE UNIQUE INDEX ON x_jobs (idempotency_key) WHERE state <> 'done'` | | Duplicate enqueue with a live key | the insert conflicts; `enqueue` returns the existing handle, no new row, no error | -| Key must be | deterministic from `input` only. No timestamps, no randomness, no `ctx` — checked by `x verify` (`X_JOB_KEY_NONDETERMINISTIC`) | +| Key must be | deterministic from `input` only. No timestamps, no randomness, no `ctx`. **A convention, not a rule** — `As of 2026-08` nothing checks it: no code, no step, no lint. A non-deterministic key is a duplicate charge the unique index cannot see | | Uniqueness window | `retention` per queue; default 24h after terminal state | | Non-idempotent external call inside a step | pass the provider's idempotency header keyed `${jobId}:${stepName}` | @@ -235,10 +243,15 @@ Rule: after the queue is wiped, the business must be reconstructible from Postgr | Code | Meaning | Fix | |---|---|---| -| `X_JOB_NO_IDEMPOTENCY_KEY` | runtime guard behind the compile-time requirement | add `idempotencyKey` to the job | -| `X_JOB_KEY_NONDETERMINISTIC` | key derived from time/randomness/`ctx` | derive it from `input` only | -| `X_JOB_DUPLICATE_STEP` | two `step.run` calls with the same name in one `run` | rename one step | -| `X_JOB_STEP_FAILED` | a step exhausted its attempts; `data.step` names it | `x jobs retry --from ` | -| `X_JOB_LEASE_EXPIRED` | a claimed job's lease expired and it was requeued | raise `leaseMs` or split the step | -| `X_JOB_QUEUE_UNKNOWN` | `queue` not present in `WORKER_QUEUES` anywhere | add the queue to a worker role | -| `X_NOT_IMPLEMENTED` | a driver path with no implementation yet | `set jobs.driver = "pg" in app.config.ts` | +| `X_IDEMPOTENCY_REQUIRED` | runtime guard behind the compile-time requirement | add `idempotencyKey` to the job definition — the factory's own fix line writes the interpolation out | +| `X_STEP_DUPLICATE` | two `step.run` calls share a name in one `run` | `rename one of them, e.g. step.run('-2', ...)` — step names are the replay key | +| `X_JOB_MAX_ATTEMPTS` | the job exhausted its retries | `x jobs retry ` | +| `X_JOB_TIMEOUT` | the job exceeded its wall-clock limit | `raise timeout on the job definition, or split the work into step.run() calls` | +| `X_JOB_LEASE_LOST` | the queue took this job back mid-run | `x jobs show --json` | +| `X_JOB_SLOT_LOST` | the fleet concurrency slot was taken by another worker | `x jobs ls --state running --json` | +| `X_JOB_NOT_CANCELLABLE` | the driver cannot cancel | `set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs cancel --json` | +| `X_JOB_TENANT_REQUIRED` | the job declares no tenant | `add tenant: (input) => input.orgId to the job — or tenant: 'none', which declares NO org` | +| `X_JOB_CONCURRENCY_UNENFORCEABLE` | `concurrency` declared on a driver that cannot enforce it | `remove concurrency from the job, or set jobs: { driver: 'postgres' } in app.config.ts` | +| `X_OUTBOX_NO_TX` | `enqueue` outside a transaction | `wrap the call in ctx.tx(async (tx) => ...), or enqueue with { outbox: false }` | +| `X_DRIVER_UNAVAILABLE` | the queue driver is unreachable | the factory takes the `fix` from the driver — it names the connection to repair | +| `X_NOT_IMPLEMENTED` | a driver path with no implementation yet | `set jobs: { driver: 'postgres' } in app.config.ts` | diff --git a/docs/architecture/09-rendering-internals.md b/docs/architecture/09-rendering-internals.md index 9080c4f76..8d0cd8b12 100644 --- a/docs/architecture/09-rendering-internals.md +++ b/docs/architecture/09-rendering-internals.md @@ -70,7 +70,7 @@ Emits: | Rule | Detail | |---|---| -| Props cross a boundary | must be structured-clone serializable. A function or class instance is a build error (`X_ISLAND_PROPS_UNSERIALIZABLE`) | +| Props cross a boundary | must be structured-clone serializable. A function or class instance is `X_ISLAND_PROPS_INVALID` | | Props transport | inlined JSON for small payloads, a separate `p` fetch above the size cap | | Children | server-rendered HTML by default; a child needing its own timing becomes its own island, nested | | Chunking | one chunk per island, content-hashed. Two islands importing the same module share a sub-chunk **within the same graph** | @@ -142,7 +142,7 @@ GET /blog/hello | Regeneration | a `job`, not an inline render — so a burst of 10k requests costs one render and the origin is never the bottleneck | | Storage | tier 3 (Redis) is **required** for ISR: regeneration happens on a different instance than the write ([`../idea/05-caching.md`](../idea/05-caching.md)) | | Serving during regen | the stale copy, with `stale-while-revalidate` so the CDN does the same | -| Failure | regen failure keeps the stale copy and records `X_ISR_REGEN_FAILED` with the trace; a stale page never becomes a 500 | +| Failure | regen failure keeps the stale copy and logs `isr.regenerate.failed` (`packages/render/src/render-isr.ts:250-256`); a stale page never becomes a 500. It throws no error and carries no `X_*` code — the request already had an answer | | Prerender | `prerender()` supplies the initial param set at build; params discovered at runtime are recorded for the reverse index below | ## Tag → page dependency lookup @@ -174,16 +174,32 @@ Fanout is enqueued in the same transaction as the write and executed post-commit Live queries are a **separate path**: the same commit already flows through logical replication ([`07-realtime-internals.md`](./07-realtime-internals.md)). Realtime never depends on cache invalidation succeeding. -A rolled-back write never purges. A committed write always does. `x cache graph --json` prints exactly what a write will evict, before you run it. +A rolled-back write never purges. A committed write always does. `x cache graph --json` would print +what a write will evict before you run it — **planned, not shipped**: `x cache` is in the registry +and `x help` lists it, and running it exits `X_NOT_IMPLEMENTED` naming its own fix, +`x dev` and the cache panel at `/_x`. The graph itself is real — `x.manifest.json` carries it — and +`recentInvalidations()` is what the panel reads. ## Codes | Code | Meaning | Fix | |---|---|---| | `X_BUDGET_EXCEEDED` | route bytes / LCP over budget; `data.cause` names the import chain | `x fix boundary ` | -| `X_ISLAND_PROPS_UNSERIALIZABLE` | a non-clonable prop crossed an island boundary | pass an id and look it up inside the island | -| `X_ISR_REGEN_FAILED` | background regeneration threw; the stale copy stands | read the linked trace, then `x cache purge ` | -| `X_SEO_NO_TITLE` / `X_SEO_NO_DESCRIPTION` | required metadata missing | add it to `meta` in the named route file | -| `X_SW_UNCACHEABLE` | `offline: 'precache'` on an `ssr` route | change `offline` or the render mode | -| `X_SW_HAND_EDITED` | `sw.js` checksum mismatch | `x build` — never edit the artifact | +| `X_ISLAND_PROPS_INVALID` | a non-clonable prop crossed an island boundary | pass an id and look it up inside the island | +| `X_ISLAND_INVALID` | an island declaration cannot become a client entry | correct the `src` to the `.island.tsx` file the cause names | +| `X_ISLAND_NOT_HYDRATED` | a page renders an island that nothing would ever boot | declare it on the route, or drop the render | +| `X_ROUTE_META_MISSING` | required metadata missing (`@ultimat3/render`) | `add description to meta in ` | +| `X_SEO_META_MISSING` | a `site/` route is missing required metadata (`@ultimat3/seo`) | same edit, named by `packages/seo/src/validate.ts` | +| `X_ROUTE_OFFLINE_MISSING` | `defineRoute()` with no `offline`, or one outside `precache \| runtime \| network-only` | `add offline: 'precache' \| 'runtime' \| 'network-only' to defineRoute` | +| `X_ROUTE_MODE_INVALID` | render mode not allowed on this surface | change `render` to one this surface permits | +| `X_SURFACE_BOUNDARY` | a surface imported across the hard boundary | `x fix boundary ` | +| `X_PRERENDER_FAILED` | a prerendered path threw during build | fix the `load` the cause names | | `X_BUILD_SKEW` | client build ID incompatible with the current contract | client-side reload signal | + +Two names the design docs use are **reserved, not raised** `As of 2026-08` +([Error codes → Reserved codes](../../wiki/Error-Codes.md#reserved-codes)): + +| Reserved | What happens today | +|---|---| +| `X_SW_UNCACHEABLE` | an `offline` strategy contradicting the route's `render` mode is **accepted**. `X_ROUTE_OFFLINE_MISSING` checks the strategy's own validity, never its agreement with the mode; `X_SW_SCOPE_INVALID` covers only the scope half | +| `X_SW_HAND_EDITED` | `sw.js` carries no checksum, so a hand edit survives `x build` and is silently overwritten on the next one | diff --git a/docs/architecture/10-cross-cutting.md b/docs/architecture/10-cross-cutting.md index b21b2bc7b..968bf85ed 100644 --- a/docs/architecture/10-cross-cutting.md +++ b/docs/architecture/10-cross-cutting.md @@ -17,7 +17,7 @@ Pattern per concern: the rule, the mechanism that makes violating it fail, and t ```ts t('post.publish'); // static key — extractable t('cart.items', { count: 3 }); // plural — selects cart.items.{one,other,…} -t('greeting', { name: user.firstName }); // typed slot — a missing var is a compile error +t('greeting', { name: user.firstName }); // vars are open: a missing one renders the placeholder ``` ```json @@ -28,24 +28,25 @@ t('greeting', { name: user.firstName }); // typed slot — a missing var is a c | Mechanism | Detail | |---|---| -| Lint rule `hardcoded-string` | a user-facing literal outside `t()` is a build error ([`02-boundaries.md`](./02-boundaries.md)) | +| No hardcoded user-facing string | **a convention, not a rule.** `As of 2026-08` no Biome rule and no gate step refuses a literal outside `t()` — `biome.json` declares no such rule, and `x i18n check` reads `t()` calls, never the strings beside them. Per axiom 3 this is a review discipline until a check exists | | Loud miss | dev renders `⟦key⟧`; the string is impossible to miss in a screenshot or a snapshot test | -| Extraction gate in `x verify` | AST-scan every `t()` call → the key set; diff against every configured locale's catalog. A key missing in **any** locale is `X_I18N_MISSING_KEY` with `fix: x i18n add ` | -| Unused keys | reported; `x i18n prune --json` removes them. Failing on unused is opt-in, since a key can be used by a template | -| Dynamic keys | extraction cannot see `t(someVar)`. Declare the set: `t.oneOf(POST_STATES, state)` where `POST_STATES` is a const union — then extraction enumerates it. A raw `t(variable)` is a build error | -| Typed slots | interpolation variables are inferred from the catalog string, so `t('greeting', {})` fails to compile | -| Plural completeness | a locale's catalog must supply every CLDR category that locale uses (`ru` needs `one/few/many/other`) — missing category is a build error, not a runtime fallback | +| Extraction gate | `x i18n check` — scan every `t()` call → the key set; diff against every configured locale's catalog. A key missing in **any** locale is `X_CATALOG_MISSING_KEYS` with `fix: x i18n sync ` | +| `x i18n add ` | takes a **locale**, not a key: it creates that locale's catalog file. `x i18n sync ` is the one that writes the missing keys into it | +| Unused keys | reported by `x i18n check` and never a failure — only `missing` becomes a finding (`packages/cli/src/cmd-i18n.ts:102-108`). **No command removes them**: there is no `prune`, planned or shipped. Delete the key from `packages/i18n/catalogs/.json` by hand | +| Dynamic keys | extraction cannot see `t(someVar)`. `x i18n check` **reports** each one with its file, line and expression, and `runtimeKeyPatterns` uses them to stop the unused half firing on a key only a template reaches. It is not a build error and there is no `t.oneOf` | +| Typed keys | `TranslationKey` makes an unknown **key** a compile error. The interpolation **vars** are not typed against the catalog string — `vars?: TranslateVars` is optional and open (`packages/i18n/src/translator.ts:46`), so a missing slot renders the placeholder rather than failing to compile | +| Plural completeness | selection is `Intl.PluralRules`, CLDR categories, never an English `n === 1` (`packages/i18n/src/interpolate.ts:57-70`). A **missing** category falls back at runtime; no gate step requires a locale to supply every category it uses | | SEO | `hreflang` reciprocal set + per-locale prerender come from the route table ([`../idea/07-rendering-seo.md`](../idea/07-rendering-seo.md)) | ### Bugs prevented | Bug | Why it cannot happen | |---|---| -| "1 items" | plural category comes from CLDR, and the catalog must define the categories the locale uses | +| "1 items" | plural category comes from CLDR (`Intl.PluralRules`), never `count === 1`. A category the catalog omits falls back down `pluralKeyCandidates` rather than rendering the English form | | Russian/Polish/Arabic plurals silently wrong | same — `one/other` is not a valid `ru` catalog | -| English leaking into `/es/` | a key missing from `es` fails the build, not the page | -| A string that never gets translated | it could not be written in the first place | -| Sentences broken by concatenation | slots are typed and ordered by the catalog, so word order is the translator's decision | +| English leaking into `/es/` | a key missing from `es` renders `⟦key⟧`, and `x i18n check` reports it as `X_CATALOG_MISSING_KEYS`. `x verify` has no i18n step, so this one is a command you run, not a step you inherit | +| A string that never gets translated | `x i18n check` names it — but only once it is inside a `t()`. A literal outside one is still reachable, see Enforcement above | +| Sentences broken by concatenation | the whole sentence is one catalog entry, so word order is the translator's decision rather than the caller's | ## Theming @@ -80,12 +81,12 @@ Generated from it: the CSS custom properties for both themes, the TS union of va | Mechanism | Detail | |---|---| -| Lint rule `raw-hex` | any colour literal (`#hex`, `rgb()`, `hsl()`, a named colour) in a component or `.scss` module is a build error | -| Token existence | `var(--foo)` referencing a token not in `tokens.ts` is a build error — the generated CSS and the checked set come from the same file | -| Palette isolation | `palette.*` is importable **only** by `tokens.ts`; anywhere else is a boundary violation. A component cannot reach a raw colour even indirectly | -| Contrast | generated pairs are checked against WCAG AA at build; a failing pair is a build error with the measured ratio | +| No raw colour | **a convention, not a rule.** `As of 2026-08` there is no `raw-hex` lint rule and no gate step that refuses a colour literal in a component or `.scss` module. The check that should exist would scan the same file set `errors` and `filesize` already walk | +| Token existence | `ColorRole` is a union derived from `colorTokens`, so naming a token TypeScript does not know is a compile error **in TS**. A `var(--foo)` in SCSS is not checked against it — SCSS is not typechecked | +| Contrast | `contrastRatio` / `meetsContrast` measure every pairing against `AA_TEXT` (4.5) and `AA_LARGE` (3), and `packages/ui/src/tokens/contrast.test.ts` fails on a pair that misses. That is the framework's own palette and a brand override run through the same function; it is a **test**, so it reaches the gate through the `unit` step, not through a check of its own | | Specificity | generated CSS emits `@media (prefers-color-scheme: dark)` **first**, then `:root[data-theme="dark"]` / `:root[data-theme="light"]` overrides — so an explicit choice always wins in both directions | -| Pre-paint script | a byte-capped inline `