From 840172c6d4bad4f4395317092bf010466f247540 Mon Sep 17 00:00:00 2001 From: sebi Date: Mon, 10 Aug 2026 10:37:32 -0500 Subject: [PATCH 1/7] docs(action): document the fluent surface, fix the projected HTTP path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The façade itself shipped in cdc5d42/1a536e8 — .input/.output/.policy/.mcp lifted, .as/.tool/.openapi/.client/.job/.contract bound in facade.ts, .def gone. What was missing was the reference an agent actually reads. - wiki/Actions.md gains "The fluent surface", the section its sibling Queries-And-Live-Queries.md has had since query got the same treatment: one row per member, plus why `handle` is unreachable (X_ACTION_FOREIGN) and why registerActions() names the export in place. - The six-artifacts table now names the method behind each projection — only .job() was named before. - Fix the projected route: derivePath('publishPost') is POST /api/posts/publish, never /_x/action/publish-post. The wrong path was copied verbatim into the spec, the site, the quickstart curl and the reference app's MCP README; naming.test.ts pins the real one. - Fix the introspection samples: MCP tool is publish_post, not publishPost, and both subcommands wrap the descriptor in the standard CLI envelope. Verified against the running CLI (x actions list/describe --json) and a scratch probe of all ten members, not read off the source. Co-Authored-By: Claude --- docs/idea/02-primitives.md | 2 +- examples/dummy/packages/mcp/README.md | 2 +- site/pages/index.md | 2 +- site/pages/quickstart.md | 4 +- wiki/Actions.md | 57 +++++++++++++++++++-------- 5 files changed, 45 insertions(+), 22 deletions(-) diff --git a/docs/idea/02-primitives.md b/docs/idea/02-primitives.md index 4821b3019..fa52903b1 100644 --- a/docs/idea/02-primitives.md +++ b/docs/idea/02-primitives.md @@ -51,7 +51,7 @@ Localised agent-facing text is a separate, locale-resolved projection, not a sec | # | Artifact | Derived from | Notes | |---|---|---|---| -| 1 | **HTTP route** | name + `input` | `POST /_x/action/publish-post`, body parsed by `input`, errors are `UltimateError` JSON | +| 1 | **HTTP route** | name + `input` | `POST /api/posts/publish` — verb first, pluralized resource after. Body parsed by `input`, errors are `UltimateError` JSON | | 2 | **OpenAPI operation** | `input` + `output` + `mcp.description` | emitted into `x.manifest.json` and `openapi.json`; contract diff runs in `x verify` | | 3 | **Typed client function** | `input` + `output` | `await publishPost({ postId, orgId })` in `app/`, no fetch, no codegen step to remember | | 4 | **Job handle** | the whole declaration | `publishPost.job()` — the action's `input`, a namespaced job name, an `idempotencyKey` derived from the payload, and an `invoke` that lands in the same execution core with `surface: 'job'`. It is the shape a queue driver *registers*, not one you call: `.enqueue()` belongs to a declared `job` like `notifySubscribers` above; see [`04-jobs.md`](./04-jobs.md) | diff --git a/examples/dummy/packages/mcp/README.md b/examples/dummy/packages/mcp/README.md index c0390e8d9..864bbaab4 100644 --- a/examples/dummy/packages/mcp/README.md +++ b/examples/dummy/packages/mcp/README.md @@ -26,7 +26,7 @@ An MCP call and an HTTP call reach the **same** `policy` object with the same ac ``` agent → tool postly.publishPost → policy post:publish → ownsPost(actor, postId) -human → POST /_x/action/publish-post → policy post:publish → ownsPost(actor, postId) +human → POST /api/posts/publish → policy post:publish → ownsPost(actor, postId) ``` There is no MCP-specific permission table, no trusted-tool mode, and no service account with diff --git a/site/pages/index.md b/site/pages/index.md index 12ddab4bf..97f040788 100644 --- a/site/pages/index.md +++ b/site/pages/index.md @@ -70,7 +70,7 @@ export const publishPost = action({ | # | Generated | Derived from | Notes | |---|---|---|---| -| 1 | **HTTP route** | name + `input` | `POST /_x/action/publish-post`, body parsed by `input`, errors are `UltimateError` JSON | +| 1 | **HTTP route** | name + `input` | `POST /api/posts/publish` — verb first, pluralized resource after. Body parsed by `input`, errors are `UltimateError` JSON | | 2 | **OpenAPI operation** | `input` + `output` + `mcp.description` | emitted into `x.manifest.json` and `openapi.json`; contract diff runs in `x verify` | | 3 | **Typed client function** | `input` + `output` | `await publishPost({ postId })` in `app/` — no fetch, no codegen step to remember | | 4 | **Job handle** | the whole declaration | `ctx.jobs.enqueue(publishPost, input)` runs the same handler durably | diff --git a/site/pages/quickstart.md b/site/pages/quickstart.md index f248a4210..6849043d0 100644 --- a/site/pages/quickstart.md +++ b/site/pages/quickstart.md @@ -40,7 +40,7 @@ versions. |---|---|---|---|---| | `site/` | `/` | `static` / `isr` | **0kb** | none | | `app/` | `/app` | `stream` | per-route budget | required | -| `api/` | `/_x/action/*` | none | n/a | policy per action | +| `api/` | `/api//` | none | n/a | policy per action | | admin | `:3001` | `stream` | per-route budget | required, MCP exposed | | dev panel | `/_x` | — | — | dev only | | MCP dev server | `ws://localhost:9229` | — | — | dev only, never in `ROLE=web` | @@ -90,7 +90,7 @@ const view = await publishPost({ postId }); // typed client, no fetch, no ``` ```bash -curl -sX POST localhost:3000/_x/action/publish-post \ +curl -sX POST localhost:3000/api/posts/publish \ -H 'content-type: application/json' -d '{"postId":"1b9d…"}' ``` diff --git a/wiki/Actions.md b/wiki/Actions.md index e7d50818b..5c9c8ab1c 100644 --- a/wiki/Actions.md +++ b/wiki/Actions.md @@ -47,16 +47,36 @@ Declared in `api/` or a feature's `actions.ts`. Named export, never default. The | `ctx.signal` | `AbortSignal` | client disconnect / drain; pass it to long calls | | `ctx.role` / `ctx.buildId` | `Role` / `string` | which runtime role is executing, and which build | +## The fluent surface + +Every projection is a method on the action — `publishPost.tool()`, never `toMcpTool(publishPost)` — and every declared field is lifted onto it. An action has no `.def`. + +| Member | Is | Rule | +|---|---|---| +| `publishPost(input, options?)` | the mutation | parse `input` → load `row` → evaluate `policy` → `handle` → parse `output` | +| `.as(actor, input, options?)` | the same mutation, as someone else | keeps the surrounding context whole — services, clock, locale, trace — and swaps only the actor. `null` is the signed-out caller | +| `.tool()` | the MCP tool descriptor | `publishPost.tool().policy === publishPost.policy` — one authz object, never a copy | +| `.openapi()` | the OpenAPI 3.1 operation | byte-stable; `x verify` diffs it for contract drift | +| `.client({ baseUrl })` | the typed RPC method | derives `POST /api/posts/publish` by string math, so the browser imports no server code | +| `.job()` | the durable-work handle | the same handler, run through the queue as `action:publishPost` | +| `.contract()` | the generated assertions | garbage rejected, anonymous denied, spec present | +| `.describe()` | the manifest row | name, verb, resource, path, capability, schemas, invalidated tags, `mcp`, `rateLimit` | +| `.input` `.output` `.policy` `.mcp` | the declaration, lifted | readable. `handle` is not among them | + +`handle` is unreachable by design. The declaration lives in a private store inside `invoke.ts` and `@ultimat3/action` exports no reader for it, so `invoke` is the only thing that can run it — one execution path and one authz path, structurally rather than by convention. A hand-rolled object with `kind: 'action'` is `X_ACTION_FOREIGN`, never a registered action. + +Every projection needs the name `registerActions()` stamps on. It names the export in place, so `import { publishPost }` is the action that projects once the app has booted — there is no second, differently-named twin to remember. + ## Six generated artifacts | # | Artifact | Derived from | Notes | |---|---|---|---| -| 1 | **HTTP route** | name + `input` | `POST /_x/action/publish-post`, body parsed by `input`, errors are `UltimateError` JSON | -| 2 | **OpenAPI operation** | `input` + `output` + `mcp.description` | emitted into `x.manifest.json` and `openapi.json`; contract diff runs in `x verify` | -| 3 | **Typed client function** | `input` + `output` | `await api.publishPost({ postId })` in `app/` — no fetch, no codegen step to remember | +| 1 | **HTTP route** | name + `input` | `POST /api/posts/publish` — first word is the verb, the rest is the pluralized resource. Body parsed by `input`, errors are `UltimateError` JSON | +| 2 | **OpenAPI operation** | `input` + `output` + `mcp.description` | `publishPost.openapi()`, emitted into `x.manifest.json` and `openapi.json`; contract diff runs in `x verify` | +| 3 | **Typed client function** | `input` + `output` | `await api.publishPost({ postId })` in `app/`, or `publishPost.client({ baseUrl })` for one method — 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** | `input` + `policy` | schema round-trip plus a denial test per policy branch — generated green, not as a `TODO` | +| 5 | **MCP tool** | `mcp` + `input` + `policy` | `publishPost.tool()` — one `publish_post` per exposed action, JSON Schema from `input`, authz unchanged | +| 6 | **Test** | `input` + `policy` | `publishPost.contract()` — 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)). @@ -133,22 +153,25 @@ Every code carries the same `{ code, cause, fix, docs }` in the terminal, the de ``` $ x actions list --json -{"actions":[{"name":"publishPost","path":"/_x/action/publish-post","capability":"post:publish", - "idempotent":false,"mcp":{"expose":true,"tool":"publishPost"}, - "invalidates":["post","feed"]}]} +{"ok":true,"command":"actions","summary":"1 action","findings":[], + "data":[{"kind":"action","name":"publishPost","verb":"publish","resource":"posts", + "method":"POST","path":"/api/posts/publish","capability":"post:publish", …}]} ``` +Both subcommands emit the same `describe()` row — `list` one per action, `describe` the named one. It is exactly what `publishPost.describe()` returns in process, so the CLI has no private view of a primitive: + ``` $ x actions describe publishPost --json -{"kind":"action","name":"publishPost","verb":"publish","resource":"posts", - "method":"POST","path":"/_x/action/publish-post","capability":"post:publish", - "input":{"type":"object","required":["postId"], - "properties":{"postId":{"type":"string","format":"uuid"}, - "notify":{"type":"boolean","default":true}}}, - "output":{"$ref":"#/components/schemas/PostView"}, - "invalidates":["feed","post"],"idempotent":false, - "mcp":{"expose":true,"tool":"publishPost","description":"Publish a draft post"}, - "rateLimit":null} +{"ok":true,"command":"actions","summary":"action publishPost","findings":[], + "data":{"kind":"action","name":"publishPost","verb":"publish","resource":"posts", + "method":"POST","path":"/api/posts/publish","capability":"post:publish", + "input":{"type":"object","required":["postId"], + "properties":{"postId":{"type":"string","format":"uuid"}, + "notify":{"type":"boolean","default":true}}}, + "output":{"$ref":"#/components/schemas/PostView"}, + "invalidates":["feed","post"],"idempotent":false, + "mcp":{"expose":true,"tool":"publish_post","description":"Publish a draft post"}, + "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.describe` tool (actions and queries in one call) and the `/_x` **Routes** panel. From 3e3c7a676142705a9166f7aa168f7405a210e274 Mon Sep 17 00:00:00 2001 From: sebi Date: Mon, 10 Aug 2026 10:50:37 -0500 Subject: [PATCH 2/7] fix(action): one authz evaluation per action route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invocation core (parse → policy → handle → parse output) and the private declaration store already shipped; this closes the second authz path that survived them. - The HTTP pipeline evaluated an action's policy in its `authz` stage AND `invoke` evaluated it again. The stage runs before the handler, so it decides with `row: null` — every action declaring `row:` with a row-level rule 403'd for the row's own author and never reached the evaluation that had the row. - `RouteMeta.enforcedBy: 'pipeline' | 'handler'` says which layer decides; `toRoute` sets `'handler'` and the stage stands down. `meta.policy` stays set, so `x routes` and the manifest still name the guard. - `dev-hooks.ts` drops its action lookup — a decision it can no longer make correctly is a second authz system waiting for a caller. - `index.test.ts` now enforces what CLAUDE.md only claimed: the barrel never re-exports `defOf`/`stashDef`/`hasDef`, and no projection carries `handle` out. - New `http.test.ts` drives a row-level action over the real pipeline and counts evaluations: author 200, stranger 403, exactly one either way. Both new gates negative-proved. Co-Authored-By: Claude --- packages/action/CLAUDE.md | 11 +++- packages/action/src/http.test.ts | 102 +++++++++++++++++++++++++++++ packages/action/src/http.ts | 5 ++ packages/action/src/index.test.ts | 44 +++++++++++++ packages/cli/src/dev-hooks.test.ts | 54 +++++++-------- packages/cli/src/dev-hooks.ts | 24 ++++--- packages/http/CLAUDE.md | 7 ++ packages/http/src/pipeline.test.ts | 32 ++++++++- packages/http/src/pipeline.ts | 6 +- packages/http/src/router.test.ts | 3 + packages/http/src/router.ts | 15 +++++ 11 files changed, 254 insertions(+), 49 deletions(-) create mode 100644 packages/action/src/http.test.ts diff --git a/packages/action/CLAUDE.md b/packages/action/CLAUDE.md index 45542aee4..c1f7b376f 100644 --- a/packages/action/CLAUDE.md +++ b/packages/action/CLAUDE.md @@ -17,7 +17,7 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3. | `facade.ts` | the fluent surface — binds each projection to the action, re-implements none | | `mutator.ts` | action + optimistic `.local` twin + authoritative `.server` + `.conflict` | | `registry.ts` | export-name registration, collisions, `describeActions()` | -| `http.ts` | route projection + OpenAPI operation | +| `http.ts` | route projection (`enforcedBy: 'handler'`) + OpenAPI operation | | `openapi.ts` | deterministic OpenAPI 3.1 document | | `client.ts` | typed RPC client (browser-safe: no server imports) | | `mcp-tool.ts` | MCP descriptor, same `invoke` | @@ -32,7 +32,14 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3. - Every surface goes through `invoke`: parse input, evaluate policy, handle, parse output. Adding a second execution path is the one unforgivable change here. - The declaration never leaves `invoke.ts`. `defOf`/`stashDef` are internal and must - never be re-exported from `src/index.ts` — that export is the enforcement. + never be re-exported from `src/index.ts` — that absence is the enforcement, and + `index.test.ts` is what makes it one. +- **`toRoute` sets `enforcedBy: 'handler'`, so the HTTP pipeline's authz stage stands down.** + `invoke` is the route's single evaluation and the only one holding the row `def.row` loaded; + a stage deciding first would decide the same policy from `row: null`, deny the row's own + author, and never reach the evaluation that had the row. `meta.policy` stays set — dropping + it would read as "this action is unguarded" in `x routes` and the manifest. `http.test.ts` + drives a row-level action over the real pipeline and counts the evaluations: exactly one. - An action has no `.def`. Inside the package read it with `defOf(target)`; outside, read the lifted `.input`/`.output`/`.policy`/`.mcp` or `describe()`. - App code reaches a projection through the action (`publishPost.tool()`), never through diff --git a/packages/action/src/http.test.ts b/packages/action/src/http.test.ts new file mode 100644 index 000000000..b30b98d44 --- /dev/null +++ b/packages/action/src/http.test.ts @@ -0,0 +1,102 @@ +// The route projection, proven over the real pipeline rather than by calling `route.handler`. +// What it pins is the task's whole point: an action route has ONE authz evaluation, it happens +// inside `invoke`, and it happens after `row` has loaded. A pipeline that decided first would +// decide from `row: null` — a denial for the row's own author, issued by an authz system that +// never saw the row. That is the bug these tests exist to keep fixed. + +import { describe, expect, test } from 'bun:test'; +import type { Actor } from '@ultimat3/core'; +import { userActor } from '@ultimat3/core'; +import { createServer } from '@ultimat3/http'; +import { can } from '@ultimat3/policy'; +import { t } from '@ultimat3/schema'; +import { action } from './action'; +import { toRoute } from './http'; + +const Input = t.object({ postId: t.uuid }); +const Output = t.object({ id: t.uuid, published: t.boolean }); +const POST_ID = '00000000-0000-4000-8000-0000000000aa'; + +type Parsed = { readonly postId: string }; +type Draft = { readonly authorId: string }; + +/** + * One action per test, so the evaluation counter is its own. The predicate fails closed on + * `null` — "no loader" and "no such row" are one value, and neither is evidence of permission — + * which is exactly why an evaluation without the row denies the author. + */ +function publisher(authorId: string, evaluations: { count: number }) { + return action({ + input: Input, + output: Output, + policy: can('post:publish', ({ actor, row }) => { + evaluations.count += 1; + return row !== null && row.authorId === actor?.id; + }), + row: () => ({ authorId }), + handle: ({ input }) => ({ id: input.postId, published: true }), + }).named('publishPost'); +} + +const editor = (id: string): Actor => ({ ...userActor({ id }), permissions: ['post:publish'] }); + +function serve(target: ReturnType, actor: Actor | null) { + return createServer({ + routes: [toRoute(target)], + // No `authorize` hook. An action route must not need one: wiring a second opinion is how a + // host grows a second authz system, and the pipeline used to 403 any route that declared a + // policy without one. + hooks: { authenticate: () => actor }, + }); +} + +const publish = (server: ReturnType): Promise => + server.fetch( + new Request('http://dev.test/api/posts/publish', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ postId: POST_ID }), + }), + ); + +describe('an action route over the pipeline', () => { + test('declares that its handler holds the one evaluation', () => { + const evaluations = { count: 0 }; + const route = toRoute(publisher('u1', evaluations)); + + // The policy is still named — dropping it would read as "this action is unguarded". + expect(route.meta.policy).toBe('post:publish'); + expect(route.meta.enforcedBy).toBe('handler'); + expect(evaluations.count).toBe(0); + }); + + test("the row's own author is allowed, and the policy runs exactly once", async () => { + const evaluations = { count: 0 }; + const response = await publish(serve(publisher('u1', evaluations), editor('u1'))); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ id: POST_ID, published: true }); + // Twice would mean the pipeline decided too — with `row: null`, so it would have denied. + expect(evaluations.count).toBe(1); + }); + + test('a stranger is denied by that same evaluation, with the policy’s own code', async () => { + const evaluations = { count: 0 }; + const response = await publish(serve(publisher('u1', evaluations), editor('u2'))); + const body = (await response.json()) as { code?: string }; + + expect(response.status).toBe(403); + expect(body.code).toBe('X_FORBIDDEN'); + expect(evaluations.count).toBe(1); + }); + + test('an anonymous caller is refused before the policy is ever reached', async () => { + const evaluations = { count: 0 }; + const response = await publish(serve(publisher('u1', evaluations), null)); + + // The `auth` stage answers this one: `meta.auth` is 'required' for any non-`allow` policy. + // Authentication, not authorization — which is why the count stays at zero. + expect(response.status).toBe(401); + expect(evaluations.count).toBe(0); + }); +}); diff --git a/packages/action/src/http.ts b/packages/action/src/http.ts index 319bacbd3..6a31951a0 100644 --- a/packages/action/src/http.ts +++ b/packages/action/src/http.ts @@ -68,6 +68,11 @@ export function toRoute(target: AnyAction): Route { // what keeps "forgot the policy" from ever looking like "meant to be public". auth: def.policy.kind === 'allow' ? 'public' : 'required', policy: policyCapability(def.policy), + // Named so the pipeline's authz stage stands down: `invoke` is this route's one + // evaluation, and it is the only one that has run `row` by the time it decides. A + // stage deciding first would decide from `row: null` — a denial for the row's own + // author, from an authz system that never saw the row. + enforcedBy: 'handler', input: def.input, cache: { mode: 'no-store', tags: tagKeys(def.cache?.invalidates ?? []) }, tags: [resource], diff --git a/packages/action/src/index.test.ts b/packages/action/src/index.test.ts index 66ed7c2b3..9f88aae01 100644 --- a/packages/action/src/index.test.ts +++ b/packages/action/src/index.test.ts @@ -1,7 +1,25 @@ import { describe, expect, test } from 'bun:test'; +import { can } from '@ultimat3/policy'; import { t as schemaT } from '@ultimat3/schema'; +import * as surface from './index'; import { t } from './index'; +/** + * What `invoke.ts` keeps private. Re-exporting any of them hands `handle` a second caller, + * and a second caller is a second parse, a second authz evaluation and a second output check. + * `CLAUDE.md` calls this absence the enforcement — this test is what makes that true. + */ +const PRIVATE_TO_INVOKE = ['defOf', 'stashDef', 'hasDef'] as const; + +const publishPost = surface + .action({ + input: schemaT.object({ postId: schemaT.uuid }), + output: schemaT.object({ id: schemaT.uuid }), + policy: can('post:publish'), + handle: ({ input }) => ({ id: input.postId }), + }) + .named('publishPost'); + describe('@ultimat3/action public surface', () => { test('re-exports the one `t`, not a copy of it', () => { // A spread or a re-implementation would still typecheck but would stop tracking @@ -16,4 +34,30 @@ describe('@ultimat3/action public surface', () => { }); expect(() => schema.parse({ postId: 'nope' })).toThrow(); }); + + test('never exports a reader of the declaration store', () => { + const exported = Object.keys(surface); + + for (const name of PRIVATE_TO_INVOKE) expect(exported).not.toContain(name); + // The positive half: the one execution path IS exported, because every surface needs it. + expect(exported).toContain('invoke'); + }); + + test('no projection carries the declaration out with it', () => { + const projections: readonly [string, object][] = [ + ['describe', publishPost.describe()], + ['tool', publishPost.tool()], + ['openapi', publishPost.openapi()], + ['job', publishPost.job()], + ['route', surface.toRoute(publishPost)], + ]; + + for (const [label, projection] of projections) { + // `route` legitimately carries a `handler` — a closure over `invoke`. A `handle` or a + // `def` would be the declaration itself, reachable from whoever holds the projection. + expect({ [label]: Object.keys(projection) }).toEqual({ + [label]: expect.not.arrayContaining(['handle', 'def']), + }); + } + }); }); diff --git a/packages/cli/src/dev-hooks.test.ts b/packages/cli/src/dev-hooks.test.ts index 50a71887b..de67facd1 100644 --- a/packages/cli/src/dev-hooks.test.ts +++ b/packages/cli/src/dev-hooks.test.ts @@ -1,6 +1,7 @@ // The dev server's authorizer must decide from the app's own policy objects. A dev-only // approximation here is how "it worked locally" happens, so these tests pin the lookup: the -// action registry for actions, the route table's declared permission for pages, deny otherwise. +// route table's declared permission for pages, deny otherwise — and nothing at all for an +// action, whose route says `enforcedBy: 'handler'` because `invoke` is its one evaluation. import { afterEach, describe, expect, test } from 'bun:test'; import { action, registerAction, resetRegistry, t, toRoute } from '@ultimat3/action'; @@ -41,22 +42,9 @@ afterEach(() => { }); describe('unit · x dev authorizes from the app’s own policies', () => { - test('a public action is allowed — the pipeline no longer denies for want of an authorizer', async () => { - const echo = action({ - input: t.object({ word: t.string }), - output: t.object({ word: t.string }), - policy: allow(), - handle: async ({ input }) => input, - }); - registerAction('echoWord', echo); - - expect(await decide(toRoute(echo.named('echoWord')), context('/api/words/echo'))).toEqual({ - allowed: true, - }); - }); - - test('a gated action is denied for an actor without the permission, with policy’s own reason', async () => { + test('an action route is never brought here — its handler holds the one evaluation', async () => { definePermissions(['post:publish'] as const); + defineRoles({ editor: { grants: ['post:publish'] } }); const publish = action({ input: t.object({ id: t.uuid }), output: t.object({ id: t.uuid }), @@ -65,28 +53,30 @@ describe('unit · x dev authorizes from the app’s own policies', () => { }); registerAction('publishPost', publish); - const decision = await decide( - toRoute(publish.named('publishPost')), - context('/api/posts/publish'), - ); + // The route says so, and the pipeline's authz stage reads exactly this field. A dev + // authorizer that answered for an action would be the second authz system — one that + // decides before `row` has loaded, and therefore denies the row's own author. + const route = toRoute(publish.named('publishPost')); + expect(route.meta.enforcedBy).toBe('handler'); + + // Asked anyway — a wiring accident, a hand-rolled host — it refuses rather than guessing. + const decision = await decide(route, context('/api/posts/publish')); expect(decision.allowed).toBe(false); - expect(decision).toMatchObject({ code: 'X_UNAUTHENTICATED' }); + expect(decision).toMatchObject({ reason: expect.stringContaining('no policy is registered') }); }); - test('an actor holding the permission is allowed', async () => { - definePermissions(['post:publish'] as const); - defineRoles({ editor: { grants: ['post:publish'] } }); - const publish = action({ - input: t.object({ id: t.uuid }), - output: t.object({ id: t.uuid }), - policy: can('post:publish'), + test('a public action is allowed by its own handler, so the stage is skipped too', () => { + const echo = action({ + input: t.object({ word: t.string }), + output: t.object({ word: t.string }), + policy: allow(), handle: async ({ input }) => input, }); - registerAction('publishPost', publish); + registerAction('echoWord', echo); - const ctx = context('/api/posts/publish'); - ctx.actor = { kind: 'user', id: 'u1', roles: ['editor'], scopes: [] }; - expect(await decide(toRoute(publish.named('publishPost')), ctx)).toEqual({ allowed: true }); + const route = toRoute(echo.named('echoWord')); + expect(route.meta.auth).toBe('public'); + expect(route.meta.enforcedBy).toBe('handler'); }); test("a page route's declared permission is evaluated, not waved through", async () => { diff --git a/packages/cli/src/dev-hooks.ts b/packages/cli/src/dev-hooks.ts index 04b92cfc2..715946e5a 100644 --- a/packages/cli/src/dev-hooks.ts +++ b/packages/cli/src/dev-hooks.ts @@ -1,9 +1,13 @@ // The two seams `@ultimat3/http` leaves open, bound to the packages that own them. `authorize` -// evaluates the SAME `Policy` object every other surface evaluates — the action registry's for an -// action route, the route table's declared permission for a page — so a denial in `x dev` is the -// denial production produces, not a dev-only approximation. +// evaluates the SAME `Policy` object every other surface evaluates — the route table's declared +// permission — so a denial in `x dev` is the denial production produces, not a dev-only +// approximation. +// +// It decides for pages only. An action route carries `enforcedBy: 'handler'`, so the pipeline +// never asks: `invoke` is its one evaluation, and the only one holding the row a row-level rule +// reads. Reconstructing that decision here would be the second authz system, one row short. -import { actorOf, getAction } from '@ultimat3/action'; +import { actorOf } from '@ultimat3/action'; import type { AuthzDecision, ServerHooks } from '@ultimat3/http'; import { asCtx } from '@ultimat3/http'; import type { KnownPermission, Policy } from '@ultimat3/policy'; @@ -17,14 +21,8 @@ import { routeFor } from '@ultimat3/render'; */ const isPermission = (value: string): value is KnownPermission => /^[^:]+:[^:]+$/.test(value); -/** - * An action carries its policy object; a `route` carries only the permission label, because that - * is all `RouteGuard` keeps. Reconstructing the label's check with `can()` is exact for a route - * and would be a guess for an action — which is why actions are looked up, never reconstructed. - */ -function policyFor(name: string, path: string): Policy | undefined { - const action = getAction(name); - if (action !== undefined) return action.policy; +/** A page carries only the permission label, because that is all `RouteGuard` keeps. */ +function policyFor(path: string): Policy | undefined { const permission = routeFor(path)?.config.policy?.permission; return permission !== undefined && isPermission(permission) ? can(permission) : undefined; } @@ -32,7 +30,7 @@ function policyFor(name: string, path: string): Policy | undef export function devHooks(): ServerHooks { return { authorize: (route, _request, ctx): AuthzDecision => { - const policy = policyFor(route.meta.name, route.path); + const policy = policyFor(route.path); if (policy === undefined) { return { allowed: false, diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 998fd1dc6..2f97a3b07 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -12,6 +12,13 @@ Owned request lifecycle over `Bun.serve`. Tier 2. ## Rules - Route `meta.auth` is required. Never default a route to public. +- **`meta.enforcedBy` says who evaluates `meta.policy`, and the `authz` stage obeys it.** + `'pipeline'` (the default, and what a page wants) means the stage decides through + `hooks.authorize`; `'handler'` means the handler is the one evaluation and the stage returns + without deciding — no hook required, and none consulted. An action route says `'handler'` + because `@ultimat3/action`'s `invoke` loads the row a row-level rule reads and this stage + cannot. Deciding in both places is two authz systems, and the one that answers first is the + one holding less. - **`ctx.actor` is never null.** `asCtx` publishes the request context itself as core's `Ctx`, and `Ctx.actor` is an `Actor` — so "nobody" is core's anonymous actor, not `null`. The `authenticate` hook still says it with `null`; the `auth` stage is where that becomes diff --git a/packages/http/src/pipeline.test.ts b/packages/http/src/pipeline.test.ts index da0a400d3..70e38e41a 100644 --- a/packages/http/src/pipeline.test.ts +++ b/packages/http/src/pipeline.test.ts @@ -47,6 +47,12 @@ const routes: readonly Route[] = [ meta: { name: 'guarded', auth: 'public', policy: 'post:publish' }, handler: () => text('never reached'), }, + { + method: 'GET', + path: '/self-guarded', + meta: { name: 'self-guarded', auth: 'public', policy: 'post:publish', enforcedBy: 'handler' }, + handler: () => text('the handler decided'), + }, ]; const config = defineHttpConfig({ dev: false, buildId: null, hostname: '127.0.0.1' }); @@ -56,6 +62,8 @@ interface PipelineTestOptions { decision?: AuthzDecision; buildId?: string | null; rateLimitCapacity?: number; + /** Fires whenever the `authorize` hook is consulted, so "never asked" is assertable. */ + onAuthorize?: () => void; } const pipelineWith = (options: PipelineTestOptions) => { @@ -65,6 +73,11 @@ const pipelineWith = (options: PipelineTestOptions) => { : defineHttpConfig({ dev: false, buildId: options.buildId }); const decision = options.decision; const actorId = options.actorId; + const onAuthorize = options.onAuthorize; + const authorize = (): AuthzDecision => { + onAuthorize?.(); + return decision ?? { allowed: true }; + }; return createPipeline({ table: createRouter(routes), config: active, @@ -79,7 +92,7 @@ const pipelineWith = (options: PipelineTestOptions) => { }), hooks: { authenticate: () => (actorId === undefined ? null : ({ id: actorId } as never)), - ...(decision === undefined ? {} : { authorize: () => decision }), + ...(decision === undefined ? {} : { authorize }), }, }); }; @@ -191,6 +204,23 @@ describe('lifecycle', () => { ); }); + test('a route enforced by its handler needs no authorizer and gets no second opinion', async () => { + let asked = 0; + const pipeline = pipelineWith({ + // Allowed, so a denial cannot be what proves the point — only the count can. + decision: { allowed: true }, + onAuthorize: () => { + asked += 1; + }, + }); + + // Two runs, one with a hook wired and one without: the stage stands down either way, + // because the handler is where this route's policy is evaluated. + expect((await pipeline.handle(get('/self-guarded'), { role: 'web' })).status).toBe(200); + expect(asked).toBe(0); + expect((await pipelineWith({}).handle(get('/self-guarded'), { role: 'web' })).status).toBe(200); + }); + test('a policy denial becomes 403 and keeps the reason', async () => { const pipeline = pipelineWith({ decision: { allowed: false, reason: 'actor does not own post', code: 'X_FORBIDDEN' }, diff --git a/packages/http/src/pipeline.ts b/packages/http/src/pipeline.ts index 656cd113d..594656aad 100644 --- a/packages/http/src/pipeline.ts +++ b/packages/http/src/pipeline.ts @@ -95,7 +95,7 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [ { name: 'authz', phase: 'request', - why: 'the last gate: one policy, evaluated here exactly as it is in jobs, live queries and MCP tools', + why: 'the last gate, and only for a route whose policy nothing downstream evaluates: one policy, decided here exactly as it is in jobs, live queries and MCP tools. A route marked enforcedBy: handler is skipped, because its handler holds the row this stage cannot load', }, { name: 'handler', phase: 'terminal', why: 'the only stage that is app code' }, { @@ -249,6 +249,10 @@ const runners = (deps: PipelineDeps, config: HttpConfig, limiter: RateLimiter) = authz: async (request, ctx) => { const route = ctx.route; if (route === undefined || route.meta.policy === undefined) return undefined; + // The handler owns this route's single evaluation (`RouteMeta.enforcedBy`). Deciding + // here as well would be a second authz system holding strictly less than the first — + // no row — and it is the one that answers first, so it is the one that would win. + if (route.meta.enforcedBy === 'handler') return undefined; if (hooks.authorize === undefined) { // A declared policy with no evaluator is a wiring bug, and failing open // here is exactly how a framework ends up with two authz systems. diff --git a/packages/http/src/router.test.ts b/packages/http/src/router.test.ts index a7f26cebb..861de124e 100644 --- a/packages/http/src/router.test.ts +++ b/packages/http/src/router.test.ts @@ -106,5 +106,8 @@ describe('describeRoutes', () => { const comments = described.find((entry) => entry.name === 'comments.show'); expect(comments?.params).toEqual(['id', 'commentId']); expect(comments?.auth).toBe('public'); + // Named on every row, never left to be inferred from its absence: a described policy + // with no stated evaluator reads as an unguarded one. + expect(described.every((entry) => entry.enforcedBy === 'pipeline')).toBe(true); }); }); diff --git a/packages/http/src/router.ts b/packages/http/src/router.ts index 26b35afc6..95300ea34 100644 --- a/packages/http/src/router.ts +++ b/packages/http/src/router.ts @@ -42,6 +42,18 @@ export interface RouteMeta { readonly auth: 'public' | 'required'; /** Name of the policy the authz stage must satisfy. Resolved by tier 3. */ readonly policy?: string; + /** + * Which layer evaluates `policy`. `'pipeline'` — the default, and what a page route + * wants — means the `authz` stage decides through `ServerHooks.authorize`. `'handler'` + * means the handler is the one evaluation and the stage must not pre-judge. + * + * An action route says `'handler'` because `invoke` loads the row a row-level rule + * decides about, and the stage cannot: deciding here too would evaluate the same policy + * with `row: null`, deny the row's own author, and never reach the evaluation that had + * the row. Two authz systems is how every Meteor-like framework died — this field is + * which one is the system. + */ + readonly enforcedBy?: 'pipeline' | 'handler'; /** Validated in the body stage; also what the OpenAPI/MCP emitters read. */ readonly input?: Schema; readonly render?: RenderMode; @@ -216,6 +228,8 @@ export interface RouteDescription { readonly params: readonly string[]; readonly auth: 'public' | 'required'; readonly policy: string | null; + /** Named, not inferred: a policy with no stated evaluator reads as an unguarded one. */ + readonly enforcedBy: 'pipeline' | 'handler'; readonly render: RenderMode | null; readonly rateLimit: string | null; readonly tags: readonly string[]; @@ -240,6 +254,7 @@ export const describeRoutes = (table: RouteTable): readonly RouteDescription[] = params: paramsOf(route.path), auth: route.meta.auth, policy: route.meta.policy ?? null, + enforcedBy: route.meta.enforcedBy ?? 'pipeline', render: route.meta.render ?? null, rateLimit: route.meta.rateLimit ?? null, tags: route.meta.tags ?? [], From 0045ed5b3577715401c25fb8a330e3eb2eac18aa Mon Sep 17 00:00:00 2001 From: sebi Date: Mon, 10 Aug 2026 11:23:10 -0500 Subject: [PATCH 3/7] feat(dsl): ship the five abstractions the reference app assumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit defineApi, rpc, defineCatalogs, daysBetween — plus the seam the first of them needed. defineService was already shipped; defineMail's app-side signature was already fixed. - action: defineApi({ actions, mutators, queries, llm }) is the app's one boot call. Names come from export names; the returned value is also the type rpc() is shaped from, so there is no second list. - core: registrar.ts — the table one same-tier package reaches another through. action cannot import query sideways, so query announces registerQueries there and defineApi resolves it by kind. A missing registrar is X_REGISTRAR_MISSING, never a silent skip that drops every read; two different registrars for one kind is X_REGISTRAR_CONFLICT. - action: createClient renamed to rpc. One name, no alias. - i18n: defineCatalogs({ default, locales }) validates, flattens, registers under the framework catalog and configures the supported set in one call. currentTranslator renamed to useI18n(); Translator is generic and TranslationKey makes an unknown key a compile error. Bare Translator still defaults to string keys for ui and mail. - time: daysBetween(from, to, zone) counts local calendar-day boundaries, signed, DST-correct. Zone required — no ambient default, anywhere. - app: endOfBillingPeriod moves to @postly/core; a billing period is domain, not calendar time. - app: catalog plurals were authored as nested { one, other } branches the runtime never probes, so four strings rendered as a miss in production. Moved to _one/_other leaves; the parity test that should have caught it was vacuous and now asserts it found some. verify 17/17 green. +39 tests. Full suite failure set byte-identical to baseline. Reference app typecheck 307 -> 302, every one of the five symbols now resolving. Co-Authored-By: Claude --- examples/dummy/apps/web/api/index.ts | 40 +++--- examples/dummy/apps/web/app/orgs/service.ts | 14 +- examples/dummy/apps/web/shared/client.ts | 6 +- examples/dummy/packages/core/CLAUDE.md | 2 +- .../dummy/packages/core/src/billing.test.ts | 41 +++++- examples/dummy/packages/core/src/billing.ts | 23 +++- examples/dummy/packages/core/src/index.ts | 2 +- examples/dummy/packages/i18n/CLAUDE.md | 4 +- examples/dummy/packages/i18n/README.md | 3 +- examples/dummy/packages/i18n/catalogs/en.json | 15 ++- examples/dummy/packages/i18n/catalogs/es.json | 18 ++- .../dummy/packages/i18n/src/catalog.test.ts | 21 +-- examples/dummy/packages/i18n/src/index.ts | 10 +- packages/action/CLAUDE.md | 6 + packages/action/README.md | 54 +++++++- packages/action/src/client.test.ts | 8 +- packages/action/src/client.ts | 12 +- packages/action/src/define-api.test.ts | 120 ++++++++++++++++++ packages/action/src/define-api.ts | 97 ++++++++++++++ packages/action/src/facade.test.ts | 2 +- packages/action/src/index.ts | 4 +- packages/core/README.md | 2 + packages/core/src/error-codes.ts | 2 + packages/core/src/index.ts | 7 + packages/core/src/registrar.test.ts | 64 ++++++++++ packages/core/src/registrar.ts | 72 +++++++++++ packages/i18n/CLAUDE.md | 15 ++- packages/i18n/README.md | 54 +++++++- packages/i18n/src/context.test.ts | 4 +- packages/i18n/src/context.ts | 27 ++-- packages/i18n/src/define-catalogs.test.ts | 120 ++++++++++++++++++ packages/i18n/src/define-catalogs.ts | 56 ++++++++ packages/i18n/src/index.ts | 16 ++- packages/i18n/src/translator.test.ts | 67 +++++++++- packages/i18n/src/translator.ts | 33 ++++- packages/query/CLAUDE.md | 5 +- packages/query/src/registry.ts | 8 +- packages/time/README.md | 16 +++ packages/time/src/index.ts | 1 + packages/time/src/zoned.test.ts | 64 +++++++++- packages/time/src/zoned.ts | 16 +++ wiki/Error-Codes.md | 2 + wiki/Getting-Started.md | 24 +++- 43 files changed, 1060 insertions(+), 117 deletions(-) create mode 100644 packages/action/src/define-api.test.ts create mode 100644 packages/action/src/define-api.ts create mode 100644 packages/core/src/registrar.test.ts create mode 100644 packages/core/src/registrar.ts create mode 100644 packages/i18n/src/define-catalogs.test.ts create mode 100644 packages/i18n/src/define-catalogs.ts diff --git a/examples/dummy/apps/web/api/index.ts b/examples/dummy/apps/web/api/index.ts index fc5ebc749..633e073fd 100644 --- a/examples/dummy/apps/web/api/index.ts +++ b/examples/dummy/apps/web/api/index.ts @@ -1,16 +1,15 @@ /** - * The API surface: every action, mutator and query Postly exposes, registered in one place. + * The API surface: every action, mutator and query Postly exposes, registered in one call. * Nothing else lives here — no rendering, no logic, no request handling. From this list the * framework projects HTTP routes, `openapi.json`, the typed client, job handles, MCP tools and * test scaffolds. * - * Registration takes whole modules, so the export name IS the primitive's name: there is no + * `defineApi` takes whole modules, so the export name IS the primitive's name: there is no * second list of strings to keep in step with the declarations, and adding an action to a * feature is one edit rather than two. Two features exporting one name collide at registration - * with `X_ACTION_DUPLICATE`; the `Api` intersection below would merge them silently, so the - * runtime check is what enforces uniqueness, not the type. + * with `X_ACTION_DUPLICATE`. * - * Importing this module IS the boot — the calls below run on import, and nothing else registers + * Importing this module IS the boot — the call below runs on import, and nothing else registers * anything. The only importer today is the framework's own module scan, which dynamic-imports * every file under an app's surface directories; that is what backs `x manifest`, `x routes`, * `x dev` and `x verify`. No long-running process imports it, because the entry that would — @@ -18,34 +17,25 @@ * `dist/server.js` — has not been written yet. */ -import { registerActions } from '@ultimat3/action'; -import { registerQueries } from '@ultimat3/query'; +import { defineApi } from '@ultimat3/action'; import * as orgActions from '../app/orgs/actions'; +// `defineService('orgs', ...)` / `defineService('posts', ...)` run on import — the same +// "importing IS the boot" rule as the registration below, so `ctx.orgs` and `ctx.posts` are +// installed wherever this module has run, including tests. +import '../app/orgs/service'; import * as postActions from '../app/posts/actions'; import * as postQueries from '../app/posts/live'; // A mutator IS an action, so it registers as one: the optimistic local twin rides on the same // declaration instead of living in a parallel registry with a parallel authz path. import * as postMutators from '../app/posts/mutator'; -// `defineService('posts', ...)` runs on import — the same "importing IS the boot" rule as the -// registrations below, so `ctx.posts` is installed wherever this module has run, including -// tests. `orgs/service.ts` is NOT imported here yet: it still reaches `daysBetween`, which -// `@ultimat3/time` does not export — a pre-existing gap this task does not own, tracked -// separately from the posts feature this file already boots. import '../app/posts/service'; import * as settingsActions from '../app/settings-actions'; -registerActions(postActions); -registerActions(postMutators); -registerActions(orgActions); -registerActions(settingsActions); - -registerQueries(postQueries); +export const api = defineApi({ + actions: [postActions, orgActions, settingsActions], + mutators: [postMutators], + queries: [postQueries], +}); /** What the typed client is shaped from — imported as a TYPE only by `shared/client.ts`. */ -export type Api = { - readonly actions: typeof postActions & - typeof postMutators & - typeof orgActions & - typeof settingsActions; - readonly queries: typeof postQueries; -}; +export type Api = typeof api; diff --git a/examples/dummy/apps/web/app/orgs/service.ts b/examples/dummy/apps/web/app/orgs/service.ts index 4df8a52a9..83db97336 100644 --- a/examples/dummy/apps/web/app/orgs/service.ts +++ b/examples/dummy/apps/web/app/orgs/service.ts @@ -4,11 +4,11 @@ * tool call. */ -import { assertSeatsAvailable, quoteUpgrade } from '@postly/core'; +import { assertSeatsAvailable, endOfBillingPeriod, quoteUpgrade } from '@postly/core'; import type { AppLocale, AppTheme, AppZone } from '@postly/domain'; import { type MemberId, type OrgId, type PlanCode, seatLimit } from '@postly/domain'; import { type Ctx, defineService } from '@ultimat3/core'; -import { daysBetween, endOfBillingPeriod } from '@ultimat3/time'; +import { daysBetween, instant } from '@ultimat3/time'; import type { InviteInput, MemberView, OrgView, UpgradeReceipt } from './entity'; import { OrgNotFound } from './errors'; import { @@ -49,17 +49,21 @@ export const orgsService = defineService('orgs', (ctx: Ctx) => ({ /** * Prorated in integer minor units, in the org's own currency. Nothing is formatted here — the * receipt travels as `{ minor, currency }` and `` renders it once, at the edge. + * + * The period is a calendar month in `ctx.tz`, read once: three separate `ctx.now()` calls could + * straddle local midnight and quote a cycle the org was never on. */ async upgrade(plan: PlanCode): Promise { const org = await this.byId(ctx.actor.orgId); - const periodEnd = endOfBillingPeriod(ctx.now()); + const at = instant(ctx.now()); + const periodEnd = endOfBillingPeriod(at, ctx.tz); const quote = quoteUpgrade({ from: org.planCode, to: plan, currency: org.billingCurrency, - daysRemaining: daysBetween(ctx.now(), periodEnd), - daysInCycle: daysBetween(endOfBillingPeriod(ctx.now(), -1), periodEnd), + daysRemaining: daysBetween(at, periodEnd, ctx.tz), + daysInCycle: daysBetween(endOfBillingPeriod(at, ctx.tz, -1), periodEnd, ctx.tz), }); await ctx.billing.charge(quote.charge, { orgId: org.id, reason: `upgrade:${plan}` }); diff --git a/examples/dummy/apps/web/shared/client.ts b/examples/dummy/apps/web/shared/client.ts index 69afa9f3d..8bfba8bac 100644 --- a/examples/dummy/apps/web/shared/client.ts +++ b/examples/dummy/apps/web/shared/client.ts @@ -13,12 +13,12 @@ * client's own inputs. */ -import { createClient } from '@ultimat3/action'; +import { rpc } from '@ultimat3/action'; import { EnvMissingError } from '@ultimat3/core'; import type { Api } from '../api'; /** - * Resolved on access, never at import: `createClient` reads `baseUrl` only when a method is taken + * Resolved on access, never at import: `rpc` reads `baseUrl` only when a method is taken * off the client, so a missing `APP_URL` fails the call that needed an origin. Throwing at import * would take down every test that merely renders a component. An empty string is worse than * either — it makes relative requests that appear to work in a browser and fail to parse @@ -35,7 +35,7 @@ function appUrl(): string { return url; } -export const client = createClient({ +export const client = rpc({ get baseUrl() { return appUrl(); }, diff --git a/examples/dummy/packages/core/CLAUDE.md b/examples/dummy/packages/core/CLAUDE.md index 1aeaa2d21..543c4e705 100644 --- a/examples/dummy/packages/core/CLAUDE.md +++ b/examples/dummy/packages/core/CLAUDE.md @@ -15,7 +15,7 @@ Needs data? Take it as an argument. The caller's `repo.ts` does the loading. | File | Owns | |---|---| -| `src/billing.ts` | plan upgrade quotes + seat limits, minor-unit arithmetic | +| `src/billing.ts` | plan upgrade quotes + seat limits, minor-unit arithmetic, the billing period | | `src/digest-schedule.ts` | next 09:00 local per zone, DST-correct; local calendar date | | `src/membership.ts` | the authz predicates policies wrap | | `src/errors.ts` | `X_BILLING_SEATS_EXCEEDED`, `X_BILLING_NOT_AN_UPGRADE` | diff --git a/examples/dummy/packages/core/src/billing.test.ts b/examples/dummy/packages/core/src/billing.test.ts index 7f842bc6c..e0412233a 100644 --- a/examples/dummy/packages/core/src/billing.test.ts +++ b/examples/dummy/packages/core/src/billing.test.ts @@ -1,7 +1,8 @@ /** unit — no DB, no I/O. Every assertion is in minor units; nothing here formats money. */ import { expect, test } from 'bun:test'; -import { assertSeatsAvailable, quoteUpgrade, seatsRemaining } from './billing'; +import { addMs, daysBetween, fromIso, toIso } from '@ultimat3/time'; +import { assertSeatsAvailable, endOfBillingPeriod, quoteUpgrade, seatsRemaining } from './billing'; import type { CoreError } from './errors'; const codeOf = (run: () => unknown): string | undefined => { @@ -88,3 +89,41 @@ test('the seat limit is checked before the invite, and names the fix', () => { expect(codeOf(() => assertSeatsAvailable('free', 2))).toBeUndefined(); expect(codeOf(() => assertSeatsAvailable('free', 3))).toBe('X_BILLING_SEATS_EXCEEDED'); }); + +test('the period ends with the calendar month that contains the instant', () => { + const midJanuary = fromIso('2026-01-10T12:00:00Z'); + expect(toIso(endOfBillingPeriod(midJanuary, 'UTC'))).toBe('2026-01-31T23:59:59.999Z'); + expect(toIso(endOfBillingPeriod(midJanuary, 'UTC', -1))).toBe('2025-12-31T23:59:59.999Z'); +}); + +test('month length is read from the calendar, never assumed to be 30', () => { + const endOf = (iso: string): string => toIso(endOfBillingPeriod(fromIso(iso), 'UTC')); + expect(endOf('2026-01-15T00:00:00Z')).toBe('2026-01-31T23:59:59.999Z'); // 31 days + expect(endOf('2026-04-15T00:00:00Z')).toBe('2026-04-30T23:59:59.999Z'); // 30 days + expect(endOf('2026-02-15T00:00:00Z')).toBe('2026-02-28T23:59:59.999Z'); // 28 + expect(endOf('2024-02-15T00:00:00Z')).toBe('2024-02-29T23:59:59.999Z'); // 29, leap +}); + +test('it lands on the last instant of the period, not the first of the next', () => { + const end = endOfBillingPeriod(fromIso('2026-01-10T12:00:00Z'), 'UTC'); + expect(toIso(addMs(end, 1))).toBe('2026-02-01T00:00:00.000Z'); +}); + +test('the period boundary is local: New York ends January five hours after UTC does', () => { + const midJanuary = fromIso('2026-01-10T12:00:00Z'); + expect(toIso(endOfBillingPeriod(midJanuary, 'America/New_York'))).toBe( + '2026-02-01T04:59:59.999Z', + ); +}); + +test('the cycle length a quote prorates against is the real month, DST included', () => { + const cycleAt = (iso: string, zone: string): number => { + const at = fromIso(iso); + return daysBetween(endOfBillingPeriod(at, zone, -1), endOfBillingPeriod(at, zone), zone); + }; + + expect(cycleAt('2026-02-15T00:00:00Z', 'UTC')).toBe(28); + expect(cycleAt('2026-01-15T00:00:00Z', 'UTC')).toBe(31); + // March in New York contains a 23-hour day; it is still 31 days long. + expect(cycleAt('2026-03-15T00:00:00Z', 'America/New_York')).toBe(31); +}); diff --git a/examples/dummy/packages/core/src/billing.ts b/examples/dummy/packages/core/src/billing.ts index 051a513c0..ada0b6062 100644 --- a/examples/dummy/packages/core/src/billing.ts +++ b/examples/dummy/packages/core/src/billing.ts @@ -1,10 +1,12 @@ /** - * Plan upgrade arithmetic. Everything here is integer minor units with an attached currency; - * nothing in this file formats, and nothing converts between currencies. + * Plan upgrade arithmetic, and the billing period it prorates against. Everything here is integer + * minor units with an attached currency; nothing in this file formats, and nothing converts + * between currencies. */ import { type BillingCurrency, isUpgrade, type PlanCode, priceOf, seatLimit } from '@postly/domain'; import { isNegative, type Money, multiply, subtract, zero } from '@ultimat3/money'; +import { addMs, fromZoned, type Instant, type TimeZone, toZoned } from '@ultimat3/time'; import { NotAnUpgrade, SeatsExceeded } from './errors'; export type UpgradeQuoteInput = { @@ -49,6 +51,23 @@ export const quoteUpgrade = (input: UpgradeQuoteInput): UpgradeQuote => { }; }; +/** + * A billing period is one calendar month in the org's zone, and it ends at the last millisecond of + * its last local day — not at the first of the next, so `daysRemaining` never borrows a day from + * the following cycle. `periodOffset` walks whole periods: `-1` is the period before `at`. + * + * Built from local calendar fields and converted once, because a month is 28..31 days and a day is + * 23, 24 or 25 hours — neither is a constant to multiply by. + */ +export const endOfBillingPeriod = (at: Instant, zone: TimeZone, periodOffset = 0): Instant => { + const local = toZoned(at, zone); + const nextPeriodStart = fromZoned( + { year: local.year, month: local.month + periodOffset + 1, day: 1, hour: 0, minute: 0 }, + zone, + ); + return addMs(nextPeriodStart, -1); +}; + /** Called before an invite is written, not after — the seat limit is a plan promise, not advice. */ export const assertSeatsAvailable = (plan: PlanCode, currentMembers: number): void => { const limit = seatLimit(plan); diff --git a/examples/dummy/packages/core/src/index.ts b/examples/dummy/packages/core/src/index.ts index 6271dbe02..6f199ead9 100644 --- a/examples/dummy/packages/core/src/index.ts +++ b/examples/dummy/packages/core/src/index.ts @@ -1,7 +1,7 @@ /** The public surface of @postly/core. Explicit — never `export *`. */ export type { UpgradeQuote, UpgradeQuoteInput } from './billing'; -export { assertSeatsAvailable, quoteUpgrade, seatsRemaining } from './billing'; +export { assertSeatsAvailable, endOfBillingPeriod, quoteUpgrade, seatsRemaining } from './billing'; export { localDateIn, nextDigestAt, scheduleByZone } from './digest-schedule'; export { CoreError, NotAnUpgrade, SeatsExceeded } from './errors'; export type { Actor, ActorLike, OwnedRecord } from './membership'; diff --git a/examples/dummy/packages/i18n/CLAUDE.md b/examples/dummy/packages/i18n/CLAUDE.md index 4e4a80885..de2f53c45 100644 --- a/examples/dummy/packages/i18n/CLAUDE.md +++ b/examples/dummy/packages/i18n/CLAUDE.md @@ -28,7 +28,9 @@ ## Conventions - Namespace by feature: `site.*`, `app.*`, `posts.*`, `orgs.*`, `plans.*`, `digest.*`, `errors.*`. -- Plurals are `{ one, other }` objects; call sites pass `{ count }` and never branch themselves. +- Plurals are underscore suffixes on the leaf — `likes_one`, `likes_other` — never a nested + `{ one, other }` branch, which the runtime never probes. Call sites pass `t('app.post.likes', + { count })` and never branch themselves. - Interpolation slots are `{name}` and are typed — a missing slot fails typecheck. ## Gotchas diff --git a/examples/dummy/packages/i18n/README.md b/examples/dummy/packages/i18n/README.md index 3751f555d..079f272e4 100644 --- a/examples/dummy/packages/i18n/README.md +++ b/examples/dummy/packages/i18n/README.md @@ -34,7 +34,8 @@ errors.* user-facing text for the error codes a user can actually cause ## Plurals and interpolation ```json -"likes": { "one": "{count} like", "other": "{count} likes" } +"likes_one": "{count} like", +"likes_other": "{count} likes" ``` Call it as `t('app.post.likes', { count })`. The category is chosen by `Intl.PluralRules` for the diff --git a/examples/dummy/packages/i18n/catalogs/en.json b/examples/dummy/packages/i18n/catalogs/en.json index d038a290a..4a43aee78 100644 --- a/examples/dummy/packages/i18n/catalogs/en.json +++ b/examples/dummy/packages/i18n/catalogs/en.json @@ -39,7 +39,8 @@ "subheading": "Pick a currency at signup. We never guess an exchange rate on your behalf.", "currencyLabel": "Currency", "perMonth": "per month", - "seats": { "one": "{count} seat", "other": "{count} seats" }, + "seats_one": "{count} seat", + "seats_other": "{count} seats", "cta": "Choose {plan}", "included": "Included on every plan", "includedList": "Live feeds, offline likes, nightly digest, two languages, MCP access" @@ -59,10 +60,8 @@ "metaDescription": "You are offline. Postly keeps your feed readable and queues anything you write until the connection returns.", "heading": "You are offline", "body": "The feed you already loaded is still readable. Anything you write is queued and sent when the connection returns.", - "queued": { - "one": "{count} change waiting to send", - "other": "{count} changes waiting to send" - }, + "queued_one": "{count} change waiting to send", + "queued_other": "{count} changes waiting to send", "queuedNone": "Nothing is waiting to send." } }, @@ -84,7 +83,8 @@ "publishing": "Publishing", "like": "Like", "unlike": "Remove like", - "likes": { "one": "{count} like", "other": "{count} likes" }, + "likes_one": "{count} like", + "likes_other": "{count} likes", "commentsHeading": "Comments", "commentsEmpty": "No comments yet.", "commentPlaceholder": "Say something useful", @@ -169,7 +169,8 @@ "digest": { "subject": "Postly digest for {date}", "greeting": "Good morning, {name}.", - "intro": { "one": "{count} new post in {org}.", "other": "{count} new posts in {org}." }, + "intro_one": "{count} new post in {org}.", + "intro_other": "{count} new posts in {org}.", "readAction": "Read it on Postly", "footer": "Sent at 09:00 in {zone}. Change this in your settings.", "unsubscribe": "Turn off the digest" diff --git a/examples/dummy/packages/i18n/catalogs/es.json b/examples/dummy/packages/i18n/catalogs/es.json index a2d031263..b7ae24f76 100644 --- a/examples/dummy/packages/i18n/catalogs/es.json +++ b/examples/dummy/packages/i18n/catalogs/es.json @@ -39,7 +39,8 @@ "subheading": "Elige moneda al registrarte. Nunca adivinamos un tipo de cambio por ti.", "currencyLabel": "Moneda", "perMonth": "al mes", - "seats": { "one": "{count} asiento", "other": "{count} asientos" }, + "seats_one": "{count} asiento", + "seats_other": "{count} asientos", "cta": "Elegir {plan}", "included": "Incluido en todos los planes", "includedList": "Feeds en vivo, «me gusta» sin conexión, resumen diario, dos idiomas y acceso MCP" @@ -59,10 +60,8 @@ "metaDescription": "No hay conexión. Postly mantiene tu feed legible y encola lo que escribas hasta que vuelva la red.", "heading": "No tienes conexión", "body": "El feed que ya habías cargado se sigue leyendo. Lo que escribas queda en cola y se envía al volver la conexión.", - "queued": { - "one": "{count} cambio pendiente de enviar", - "other": "{count} cambios pendientes de enviar" - }, + "queued_one": "{count} cambio pendiente de enviar", + "queued_other": "{count} cambios pendientes de enviar", "queuedNone": "No hay nada pendiente de enviar." } }, @@ -84,7 +83,8 @@ "publishing": "Publicando", "like": "Me gusta", "unlike": "Quitar me gusta", - "likes": { "one": "{count} me gusta", "other": "{count} me gusta" }, + "likes_one": "{count} me gusta", + "likes_other": "{count} me gusta", "commentsHeading": "Comentarios", "commentsEmpty": "Aún no hay comentarios.", "commentPlaceholder": "Di algo útil", @@ -174,10 +174,8 @@ "digest": { "subject": "Resumen de Postly del {date}", "greeting": "Buenos días, {name}.", - "intro": { - "one": "{count} artículo nuevo en {org}.", - "other": "{count} artículos nuevos en {org}." - }, + "intro_one": "{count} artículo nuevo en {org}.", + "intro_other": "{count} artículos nuevos en {org}.", "readAction": "Leerlo en Postly", "footer": "Enviado a las 09:00 en {zone}. Puedes cambiarlo en tus ajustes.", "unsubscribe": "Desactivar el resumen" diff --git a/examples/dummy/packages/i18n/src/catalog.test.ts b/examples/dummy/packages/i18n/src/catalog.test.ts index 328ff6bdf..018249c11 100644 --- a/examples/dummy/packages/i18n/src/catalog.test.ts +++ b/examples/dummy/packages/i18n/src/catalog.test.ts @@ -57,14 +57,19 @@ test('interpolation slots match between locales', () => { }); test('every plural key carries both categories Spanish and English need', () => { - const pluralParents = [...flatEn.keys()] - .filter((key) => key.endsWith('.one') || key.endsWith('.other')) - .map((key) => key.slice(0, key.lastIndexOf('.'))); + // Underscore suffixes on the leaf, never a nested `{ one, other }` branch: `selectPluralKey` + // probes `_one` / `_other`, so a nested branch renders `⟦key⟧` at every call site. + const stems = [...flatEn.keys()] + .filter((key) => key.endsWith('_one') || key.endsWith('_other')) + .map((key) => key.slice(0, key.lastIndexOf('_'))); - for (const parent of new Set(pluralParents)) { - expect(flatEn.has(`${parent}.one`)).toBe(true); - expect(flatEn.has(`${parent}.other`)).toBe(true); - expect(flatEs.has(`${parent}.one`)).toBe(true); - expect(flatEs.has(`${parent}.other`)).toBe(true); + // Without this the loop below is vacuous: rename the convention and the test stays green. + expect(new Set(stems).size).toBeGreaterThan(0); + + for (const stem of new Set(stems)) { + expect(flatEn.has(`${stem}_one`)).toBe(true); + expect(flatEn.has(`${stem}_other`)).toBe(true); + expect(flatEs.has(`${stem}_one`)).toBe(true); + expect(flatEs.has(`${stem}_other`)).toBe(true); } }); diff --git a/examples/dummy/packages/i18n/src/index.ts b/examples/dummy/packages/i18n/src/index.ts index aa1ef2419..997388026 100644 --- a/examples/dummy/packages/i18n/src/index.ts +++ b/examples/dummy/packages/i18n/src/index.ts @@ -3,7 +3,12 @@ * admin, and the digest email in the worker — resolves strings through this module. */ -import { defineCatalogs, type Translator, useI18n } from '@ultimat3/i18n'; +import { + defineCatalogs, + type TranslationKey as KeyOf, + type Translator, + useI18n, +} from '@ultimat3/i18n'; import en from '../catalogs/en.json'; import es from '../catalogs/es.json'; @@ -15,7 +20,8 @@ export const catalogs = defineCatalogs({ /** English is the source of truth for the key space; `es` must match it or `x verify` fails. */ export type AppCatalog = typeof en; -export type TranslationKey = keyof Translator['keys']; +/** Every key this app's catalogs define — dot-paths, plus the stem of each plural family. */ +export type TranslationKey = KeyOf; /** * Use this, never `useI18n()` directly — the type parameter is what makes an unknown key a diff --git a/packages/action/CLAUDE.md b/packages/action/CLAUDE.md index c1f7b376f..a21b04bc3 100644 --- a/packages/action/CLAUDE.md +++ b/packages/action/CLAUDE.md @@ -17,6 +17,7 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3. | `facade.ts` | the fluent surface — binds each projection to the action, re-implements none | | `mutator.ts` | action + optimistic `.local` twin + authoritative `.server` + `.conflict` | | `registry.ts` | export-name registration, collisions, `describeActions()` | +| `define-api.ts` | `defineApi({ actions, mutators, queries, llm })` — the app's one boot call | | `http.ts` | route projection (`enforcedBy: 'handler'`) + OpenAPI operation | | `openapi.ts` | deterministic OpenAPI 3.1 document | | `client.ts` | typed RPC client (browser-safe: no server imports) | @@ -57,6 +58,11 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3. - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so an action file imports one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on every access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity. +- `defineApi` is the app's registration call; `registerActions` is what it composes. It reaches + `@ultimat3/query`'s registry through core's registrar table (`primitiveRegistrar('query')`), + never a sideways import — and throws `X_REGISTRAR_MISSING` rather than skipping a kind whose + registrar is absent, because a silent skip drops every read of that kind. +- `rpc` is the only name for the map-wide typed client. There is no `createClient` alias. - No policy at registration → `X_ACTION_POLICY_MISSING`. No exceptions, no flag. - `serializeOpenApi` output must be byte-stable: sorted keys, sorted registry, no clock. - `client.ts` stays free of server imports — it is bundled into the browser. diff --git a/packages/action/README.md b/packages/action/README.md index 0e73fc93f..eef1f2fa7 100644 --- a/packages/action/README.md +++ b/packages/action/README.md @@ -6,7 +6,7 @@ One declaration → six artifacts. |---|---|---|---| | 1 | HTTP route `POST /api//` | `toRoute(action)` — the server mounts it | policy + validation + idempotency + invalidation, non-optional | | 2 | OpenAPI 3.1 operation + document | `action.openapi()` / `buildOpenApi()` | byte-stable output, diffed by `x verify` | -| 3 | Typed RPC client | `action.client({ baseUrl })` / `createClient()` | server typo = compile error in Solid | +| 3 | Typed RPC client | `action.client({ baseUrl })` / `rpc()` | server typo = compile error in Solid | | 4 | MCP tool | `action.tool()` | *identical* policy evaluation to the route | | 5 | Job handle | `action.job()` | enqueue durable work, no rewrite | | 6 | Contract tests | `action.contract()` | garbage rejected, anonymous denied, spec present | @@ -56,11 +56,53 @@ export const publishPost = action({ }); ``` -Then, once, at boot: `registerActions(await import('./actions'))`. Names come from -**export names** — that is what makes the path, the tool name and the OpenAPI -`operationId` derivable everywhere without a second declaration. Registration stamps -the name onto the action the module exported, so the binding you imported is the one -that projects; a projection attempted before boot is `X_ACTION_UNREGISTERED`. +## Register — one call, at boot + +`apps/web/api/index.ts` is the whole API surface. Importing it IS the boot. + +```ts +import { defineApi } from '@ultimat3/action'; +import * as postActions from '../app/posts/actions'; +import * as postMutators from '../app/posts/mutator'; +import * as postQueries from '../app/posts/live'; + +export const api = defineApi({ + actions: [postActions], + mutators: [postMutators], + queries: [postQueries], +}); + +export type Api = typeof api; +``` + +| Key | Goes to | Why | +|---|---|---| +| `actions` | the action registry | the primitive | +| `mutators` | the action registry | a mutator IS an action, on the same authz path | +| `llm` | the action registry | `llm()` returns an action, not a ninth primitive | +| `queries` | `@ultimat3/query`'s registry, via core's registrar table | `query` is on this tier, so importing it here would be a build error | + +Names come from **export names** — that is what makes the path, the tool name and the +OpenAPI `operationId` derivable everywhere without a second declaration. Registration +stamps the name onto the action the module exported, so the binding you imported is the +one that projects; a projection attempted before boot is `X_ACTION_UNREGISTERED`. Two +features exporting one name collide with `X_ACTION_DUPLICATE` rather than merging. + +`registerActions` / `registerQueries` are what `defineApi` composes. An app calling them +directly is a second path. + +## Call it — `rpc` + +```ts +import { rpc } from '@ultimat3/action'; +import type { Api } from '../api'; + +export const client = rpc({ baseUrl: '/' }); +``` + +`Api['actions']` is the merged module type, so `client.publishPost` is typed from the +declaration with no codegen step. `Api` is imported as a **type only**, which is what keeps +a page's module graph free of any edge to a feature's implementation. ## Path derivation diff --git a/packages/action/src/client.test.ts b/packages/action/src/client.test.ts index d30519e21..ea0eeb587 100644 --- a/packages/action/src/client.test.ts +++ b/packages/action/src/client.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { can } from '@ultimat3/policy'; import { t } from '@ultimat3/schema'; import { action } from './action'; -import { createClient, type FetchLike } from './client'; +import { type FetchLike, rpc } from './client'; import { BUILD_ID_HEADER } from './http'; const Input = t.object({ postId: t.uuid }); @@ -32,7 +32,7 @@ describe('typed client', () => { return Response.json({ id: POST_ID, published: true }); }; - const api = createClient({ + const api = rpc({ baseUrl: 'https://app.test/', fetch: fetchStub, buildId: 'build-1', @@ -59,7 +59,7 @@ describe('typed client', () => { { status: 400, headers: { 'content-type': 'application/problem+json' } }, ); - const api = createClient({ baseUrl: 'https://app.test', fetch: fetchStub }); + const api = rpc({ baseUrl: 'https://app.test', fetch: fetchStub }); const failure = await api.publishPost({ postId: 'nope' }).catch((error: unknown) => error); expect((failure as { code?: string }).code).toBe('X_INPUT_INVALID'); expect((failure as { fix?: string }).fix).toBe('x actions describe publishPost --json'); @@ -72,7 +72,7 @@ describe('typed client', () => { { headers: { [BUILD_ID_HEADER]: 'build-2' } }, ); - const api = createClient({ + const api = rpc({ baseUrl: 'https://app.test', fetch: fetchStub, buildId: 'build-1', diff --git a/packages/action/src/client.ts b/packages/action/src/client.ts index 83cd72d50..739bb2656 100644 --- a/packages/action/src/client.ts +++ b/packages/action/src/client.ts @@ -49,7 +49,11 @@ export interface ClientOptions { readonly headers?: Readonly>; } -export function createClient(options: ClientOptions): Client { +/** + * The typed client for a whole action map: `rpc({ baseUrl })`. One blessed + * name — there is no `createClient` twin to choose between. + */ +export function rpc(options: ClientOptions): Client { const proxy = new Proxy( {}, { @@ -64,9 +68,9 @@ export function createClient(options: ClientOptions) } /** - * One action's method — what `createClient` proxies to and what `action.client()` - * returns. Both spellings are the same call, so a per-action client can never - * drift from the map-wide one. + * One action's method — what `rpc` proxies to and what `action.client()` returns. + * Both spellings are the same call, so a per-action client can never drift from the + * map-wide one. */ export function clientMethodFor( name: string, diff --git a/packages/action/src/define-api.test.ts b/packages/action/src/define-api.test.ts new file mode 100644 index 000000000..31a4c4dd3 --- /dev/null +++ b/packages/action/src/define-api.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { + type ModuleRegistrar, + registerPrimitiveRegistrar, + resetPrimitiveRegistrars, +} from '@ultimat3/core'; +import { can } from '@ultimat3/policy'; +import { t } from '@ultimat3/schema'; +import { action } from './action'; +import { defineApi } from './define-api'; +import { getAction, resetRegistry } from './registry'; + +const echo = () => + action({ + input: t.object({ id: t.uuid }), + output: t.object({ id: t.uuid }), + policy: can('post:publish'), + handle: ({ input }) => ({ id: input.id }), + }); + +/** Stands in for `@ultimat3/query`'s `registerQueries` — action cannot import it sideways. */ +function recordingQueryRegistrar(): { seen: string[]; registrar: ModuleRegistrar } { + const seen: string[] = []; + return { + seen, + registrar: (module) => { + const names = Object.keys(module).sort(); + seen.push(...names); + return names; + }, + }; +} + +beforeEach(() => { + resetRegistry(); + resetPrimitiveRegistrars(); +}); + +afterEach(() => { + resetRegistry(); + resetPrimitiveRegistrars(); +}); + +describe('defineApi', () => { + test('registers every action module under its export names', () => { + defineApi({ actions: [{ createPost: echo() }, { inviteMember: echo() }] }); + + expect(getAction('createPost')?.name).toBe('createPost'); + expect(getAction('inviteMember')?.name).toBe('inviteMember'); + }); + + test('accepts a single module as well as a list', () => { + defineApi({ actions: { createPost: echo() } }); + + expect(getAction('createPost')).toBeDefined(); + }); + + test('mutators and llm land in the action registry — they are the same primitive', () => { + defineApi({ mutators: { likePost: echo() }, llm: { summarizePost: echo() } }); + + expect(getAction('likePost')).toBeDefined(); + expect(getAction('summarizePost')).toBeDefined(); + }); + + test('the returned actions map holds the registered objects, not copies', () => { + const createPost = echo(); + const api = defineApi({ actions: { createPost } }); + + expect(api.actions.createPost).toBe(getAction('createPost')); + expect(api.actions.createPost.name).toBe('createPost'); + }); + + test('two features exporting one name collide with X_ACTION_DUPLICATE', () => { + let code = ''; + try { + defineApi({ actions: [{ createPost: echo() }, { createPost: echo() }] }); + } catch (error) { + code = (error as { code: string }).code; + } + expect(code).toBe('X_ACTION_DUPLICATE'); + }); + + test('queries route through core registrar table, not a sideways import', () => { + const { seen, registrar } = recordingQueryRegistrar(); + registerPrimitiveRegistrar('query', registrar); + + const api = defineApi({ queries: [{ liveFeed: {} }, { postById: {} }] }); + + expect(seen).toEqual(['liveFeed', 'postById']); + expect(Object.keys(api.queries)).toEqual(['liveFeed', 'postById']); + }); + + test('queries with no registrar loaded fail loudly instead of being dropped', () => { + let code = ''; + try { + defineApi({ queries: { liveFeed: {} } }); + } catch (error) { + code = (error as { code: string }).code; + } + expect(code).toBe('X_REGISTRAR_MISSING'); + }); + + test('no queries means the query registrar is never demanded', () => { + expect(() => defineApi({ actions: { createPost: echo() } })).not.toThrow(); + }); + + test('an empty definition registers nothing and returns empty maps', () => { + const api = defineApi({}); + + expect(Object.keys(api.actions)).toEqual([]); + expect(Object.keys(api.queries)).toEqual([]); + }); + + test('the returned surface is frozen — the API is declared once, not mutated later', () => { + const api = defineApi({ actions: { createPost: echo() } }); + + expect(Object.isFrozen(api)).toBe(true); + expect(Object.isFrozen(api.actions)).toBe(true); + }); +}); diff --git a/packages/action/src/define-api.ts b/packages/action/src/define-api.ts new file mode 100644 index 000000000..2249c59ce --- /dev/null +++ b/packages/action/src/define-api.ts @@ -0,0 +1,97 @@ +/** + * The API registry: one call that turns an app's primitive modules into the registered, + * projectable API surface. `apps/web/api/index.ts` calls it and nothing else, so importing + * that module IS the boot — and the value it returns is also the type the RPC client is + * shaped from, which is why there is no second list of names to keep in step. + */ + +import { primitiveRegistrar } from '@ultimat3/core'; +import { registerActions } from './registry'; + +/** A module namespace: `import * as postActions from './actions'`. */ +export type ApiModule = Readonly>; + +/** One module, or several — a feature per entry, never a list of name strings. */ +export type ApiModules = ApiModule | readonly ApiModule[]; + +export interface ApiDef { + readonly actions?: ApiModules; + /** A mutator IS an action; it registers as one, on the same authz path. */ + readonly mutators?: ApiModules; + readonly queries?: ApiModules; + /** `llm()` returns an action, so a model call registers exactly like every other one. */ + readonly llm?: ApiModules; +} + +type Get = TKey extends keyof TDef ? TDef[TKey] : undefined; + +/** + * Intersect a tuple of module namespaces. `{ createPost } & { inviteMember }` is the map the + * typed client indexes, so `rpc()` knows every action without a codegen step. + */ +type Merge = [TModules] extends [undefined] + ? EmptyModule + : TModules extends readonly [infer THead, ...infer TRest] + ? THead & Merge + : TModules extends readonly [] + ? EmptyModule + : TModules; + +type EmptyModule = Readonly>; + +/** What `defineApi` returns: the registered primitives, merged and keyed by export name. */ +export interface Api { + readonly actions: Merge> & + Merge> & + Merge>; + readonly queries: Merge>; +} + +/** + * Register every primitive the app exposes, in one call. + * + * ```ts + * export const api = defineApi({ + * actions: [postActions, orgActions], + * mutators: [postMutators], + * queries: [postQueries], + * }); + * ``` + * + * Names come from export names, so two features exporting one name collide here with + * `X_ACTION_DUPLICATE` instead of merging silently. Actions, mutators and `llm()` calls all + * land in the action registry — they are the same primitive. Queries reach their own registry + * through core's registrar table, because `@ultimat3/query` is on this tier and importing it + * sideways is a build error. + */ +export function defineApi(def: TDef): Api { + const actionModules = [ + ...moduleList(def.actions), + ...moduleList(def.mutators), + ...moduleList(def.llm), + ]; + const queryModules = moduleList(def.queries); + + for (const module of actionModules) registerActions(module); + // Resolved once, and only when there is something to register: a missing registrar with + // queries to register must be an error, never a silent skip that drops every read. + if (queryModules.length > 0) { + const registerQueries = primitiveRegistrar('query'); + for (const module of queryModules) registerQueries(module); + } + + return Object.freeze({ + actions: mergeModules(actionModules), + queries: mergeModules(queryModules), + }) as Api; +} + +function moduleList(modules: ApiModules | undefined): readonly ApiModule[] { + if (modules === undefined) return []; + return Array.isArray(modules) ? modules : [modules as ApiModule]; +} + +/** Registration names in place, so the merged object holds the registered primitives. */ +function mergeModules(modules: readonly ApiModule[]): ApiModule { + return Object.freeze(Object.assign({}, ...modules) as ApiModule); +} diff --git a/packages/action/src/facade.test.ts b/packages/action/src/facade.test.ts index 17cf775c1..ec32806f0 100644 --- a/packages/action/src/facade.test.ts +++ b/packages/action/src/facade.test.ts @@ -98,7 +98,7 @@ describe('the fluent surface', () => { expect(ran).toEqual({ id: POST_ID, published: true }); }); - test('.client() calls the derived path, same as createClient', async () => { + test('.client() calls the derived path, same as rpc', async () => { const { target } = definePublish(); let url: string | null = null; const fetchStub: FetchLike = async (input, init) => { diff --git a/packages/action/src/index.ts b/packages/action/src/index.ts index ac8c85ea3..b8ae3484f 100644 --- a/packages/action/src/index.ts +++ b/packages/action/src/index.ts @@ -34,9 +34,11 @@ export type { ClientOptions, FetchLike, } from './client'; -export { createClient } from './client'; +export { rpc } from './client'; export type { ContractTest, ContractTestOptions } from './contract-test'; export { anonymousCtx, contractTestsFor, policyTestStubFor } from './contract-test'; +export type { Api, ApiDef, ApiModule, ApiModules } from './define-api'; +export { defineApi } from './define-api'; export type { IdempotencyConflictReason } from './errors'; export { ActionDeniedError, diff --git a/packages/core/README.md b/packages/core/README.md index bfc990ad7..051f1b07c 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -19,6 +19,8 @@ Zero dependencies, zero `@ultimat3/*` imports. | OTel-shaped spans, always on, no-op by default | `telemetry.ts` | | graceful drain, `/healthz`, `/readyz` | `lifecycle.ts` | | the sockets this process opened, so a self-request is not egress | `listeners.ts` | +| `defineService('orgs', …)` → `ctx.orgs`, rebuilt per actor | `service.ts` | +| the registrar table one same-tier package reaches another through | `registrar.ts` | | decode → resize → encode, the one image pipeline | `image/` | | `assertNever`, `invariant` | `assert.ts` | diff --git a/packages/core/src/error-codes.ts b/packages/core/src/error-codes.ts index 13f31bd33..6a8ccc153 100644 --- a/packages/core/src/error-codes.ts +++ b/packages/core/src/error-codes.ts @@ -41,6 +41,8 @@ const CORE_CODE_TITLES = { X_INVARIANT: 'invariant violated', X_NO_CONTEXT: 'no request context is active', X_NOT_IMPLEMENTED: 'this driver does not implement the requested feature', + X_REGISTRAR_CONFLICT: 'two different registrars are loaded for one primitive kind', + X_REGISTRAR_MISSING: 'no registrar is loaded for a primitive kind', X_ROLE_INVALID: 'ROLE is not a known runtime role', X_SERVICE_DUPLICATE: 'a service name is registered twice', X_SERVICE_MISSING: 'service is not registered on the request context', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e858e4dfa..7d69d8f1b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -210,6 +210,13 @@ export { redactKeys, setLoggerContextFields, } from './logger'; +export type { ModuleRegistrar, PrimitiveKind } from './registrar'; +export { + hasPrimitiveRegistrar, + primitiveRegistrar, + registerPrimitiveRegistrar, + resetPrimitiveRegistrars, +} from './registrar'; export type { Err, Ok, Result } from './result'; export { err, isErr, isOk, map, mapErr, ok, tryCatch, unwrap, unwrapOr } from './result'; export type { ResolveRoleOptions, Role, RoleInfo, ScalingSignal } from './roles'; diff --git a/packages/core/src/registrar.test.ts b/packages/core/src/registrar.test.ts new file mode 100644 index 000000000..20ba5bc86 --- /dev/null +++ b/packages/core/src/registrar.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { + hasPrimitiveRegistrar, + type ModuleRegistrar, + primitiveRegistrar, + registerPrimitiveRegistrar, + resetPrimitiveRegistrars, +} from './registrar'; + +const noop: ModuleRegistrar = () => []; + +beforeEach(() => { + resetPrimitiveRegistrars(); +}); + +describe('registerPrimitiveRegistrar', () => { + test('a kind is unresolvable until its owner announces one', () => { + expect(hasPrimitiveRegistrar('query')).toBe(false); + registerPrimitiveRegistrar('query', noop); + expect(hasPrimitiveRegistrar('query')).toBe(true); + }); + + test('re-announcing the same function is a no-op', () => { + registerPrimitiveRegistrar('query', noop); + expect(() => registerPrimitiveRegistrar('query', noop)).not.toThrow(); + expect(primitiveRegistrar('query')).toBe(noop); + }); + + test('a second, different registrar for one kind is X_REGISTRAR_CONFLICT', () => { + registerPrimitiveRegistrar('query', noop); + const other: ModuleRegistrar = () => []; + let code = ''; + try { + registerPrimitiveRegistrar('query', other); + } catch (error) { + code = (error as { code: string }).code; + } + expect(code).toBe('X_REGISTRAR_CONFLICT'); + }); + + test('kinds are independent', () => { + registerPrimitiveRegistrar('query', noop); + expect(hasPrimitiveRegistrar('action')).toBe(false); + }); +}); + +describe('primitiveRegistrar', () => { + test('returns the announced registrar', () => { + const registrar: ModuleRegistrar = (module) => Object.keys(module); + registerPrimitiveRegistrar('action', registrar); + expect(primitiveRegistrar('action')({ a: 1, b: 2 })).toEqual(['a', 'b']); + }); + + test('throws X_REGISTRAR_MISSING with an installable fix rather than returning undefined', () => { + let thrown: { code: string; fix: string } | undefined; + try { + primitiveRegistrar('query'); + } catch (error) { + thrown = error as { code: string; fix: string }; + } + expect(thrown?.code).toBe('X_REGISTRAR_MISSING'); + expect(thrown?.fix).toBe('bun add @ultimat3/query'); + }); +}); diff --git a/packages/core/src/registrar.ts b/packages/core/src/registrar.ts new file mode 100644 index 000000000..a88b41576 --- /dev/null +++ b/packages/core/src/registrar.ts @@ -0,0 +1,72 @@ +// Single responsibility: the seam that lets one package hand a module of primitives to the +// package that owns them, without a sideways import. +// +// `action` and `query` sit on the same tier, so `defineApi` in `@ultimat3/action` cannot +// import `registerQueries` — the boundaries check is a build error, not a suggestion. Each +// owning package announces its registrar here at import time; a caller asks by kind. Same +// shape as `defineService` and `registerErrorCodes`: core holds the table, higher tiers fill +// it, and nobody imports sideways to reach it. + +import { UltimateError } from './errors'; + +/** The eight primitives. A registrar exists only for the kinds registered by module. */ +export type PrimitiveKind = + | 'action' + | 'entity' + | 'job' + | 'mutator' + | 'policy' + | 'query' + | 'route' + | 'task'; + +/** `registerActions` / `registerQueries`: export names become primitive names. */ +export type ModuleRegistrar = (module: Readonly>) => readonly unknown[]; + +const registrars = new Map(); + +/** + * Announce the registrar for `kind`, once per process. Re-announcing the same function is a + * no-op (a module re-evaluated under a different specifier); announcing a *different* one + * means two copies of the owning package are loaded, each with its own registry — half the + * primitives would register into a table nothing else reads. + */ +export function registerPrimitiveRegistrar(kind: PrimitiveKind, registrar: ModuleRegistrar): void { + const existing = registrars.get(kind); + if (existing !== undefined && existing !== registrar) { + throw new UltimateError({ + code: 'X_REGISTRAR_CONFLICT', + cause: `two different ${kind} registrars are loaded, so ${kind} primitives would split across two registries`, + fix: `bun pm ls | grep @ultimat3/${kind} — then dedupe it to one version in package.json`, + meta: { kind }, + }); + } + registrars.set(kind, registrar); +} + +export function hasPrimitiveRegistrar(kind: PrimitiveKind): boolean { + return registrars.has(kind); +} + +/** + * The registrar for `kind`. Throws rather than returning `undefined`: a caller that skipped a + * missing registrar would drop every primitive of that kind silently, which is exactly the + * failure this seam exists to make impossible. + */ +export function primitiveRegistrar(kind: PrimitiveKind): ModuleRegistrar { + const registrar = registrars.get(kind); + if (registrar === undefined) { + throw new UltimateError({ + code: 'X_REGISTRAR_MISSING', + cause: `no ${kind} registrar is loaded, so ${kind} primitives cannot be registered`, + fix: `bun add @ultimat3/${kind}`, + meta: { kind }, + }); + } + return registrar; +} + +/** Test-only. Production announces once at import and never withdraws. */ +export function resetPrimitiveRegistrars(): void { + registrars.clear(); +} diff --git a/packages/i18n/CLAUDE.md b/packages/i18n/CLAUDE.md index cde44766a..78b013062 100644 --- a/packages/i18n/CLAUDE.md +++ b/packages/i18n/CLAUDE.md @@ -7,18 +7,27 @@ Imported by every package that renders a string. | File | Single responsibility | |---|---| -| `translator.ts` | lookup + delegate interpolation. No locale logic. | +| `translator.ts` | lookup + delegate interpolation, and `TranslationKey`. No locale logic. | | `catalog.ts` | nested → flat, merge, validate. No lookup. | +| `define-catalogs.ts` | the app's one registration call. Boot only, composes the four below. | | `interpolate.ts` | `{var}` + CLDR plural selection. No catalog access (takes a `has` predicate). | | `locales.ts` | supported set, normalize, negotiate, RTL. No catalogs. | -| `context.ts` | request locale via ALS + the ambient `t`. | +| `context.ts` | request locale via ALS, the registry, the ambient `t` and `useI18n`. | | `extract.ts` | static scan + audit for `x verify`. | | `catalogs/en.json` | data, not code. | ## Rules +- An app registers catalogs with `defineCatalogs({ default, locales })`. One call, at boot. + `registerCatalog` / `configureLocales` are its internals — an app calling them is a second path. +- Reading: `t('ns.key')` for one string, `useI18n()` where the keys must be typed. + There is no `currentTranslator`; `useI18n` replaced it. - A miss renders `⟦key⟧`. Never add a fallback locale chain — it hides gaps. -- Plural selection is `Intl.PluralRules`. Never `count === 1`. +- Plural selection is `Intl.PluralRules`. Never `count === 1`. Variants are underscore suffixes + on the leaf (`n_one`, `n_plural`), never a nested `{ one, other }` branch — the runtime probes + `n_one`, and `TranslationKey` admits the stem `n` on exactly that basis. +- `Translator` must keep defaulting to `string` keys: `@ultimat3/ui` and + `@ultimat3/mail` take a bare `Translator`. `has()` and `raw()` stay `string` — they are probes. - Nothing here formats a number, date or money. That is `@ultimat3/money` / `@ultimat3/time`. - `Ctx` fields are read structurally (core cannot depend on `Locale`); one cast, in `context.ts`. - Adding a framework string: `catalogs/en.json`, feature-namespaced, then use `t('ns.key')`. diff --git a/packages/i18n/README.md b/packages/i18n/README.md index 3383dfcdb..c1f2f01cf 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -7,6 +7,7 @@ strings: format those with `Intl` via [@ultimat3/money](../money) and [@ultimat3 | Concern | Owner | Rule | |---|---|---| +| Registration | `define-catalogs.ts` | `defineCatalogs()` — one call, at boot, per app | | Lookup + interpolation | `translator.ts` | `t(key, vars?)`, miss → `⟦key⟧` | | Catalog shape | `catalog.ts` | nested authoring → flat dot-keys | | Placeholders + plurals | `interpolate.ts` | `{var}`, CLDR categories via `Intl.PluralRules` | @@ -14,12 +15,38 @@ strings: format those with `Intl` via [@ultimat3/money](../money) and [@ultimat3 | Request locale | `context.ts` | resolved once, read from ALS — never passed by hand | | Enforcement | `extract.ts` | `x verify` fails on a missing key in a shipped locale | -## Use +## Declare the catalogs, once + +`defineCatalogs` is the only way an app registers strings. Never call `registerCatalog` by hand. + +```ts +// packages/i18n/src/index.ts — the app's whole i18n module +import { defineCatalogs, type TranslationKey, type Translator, useI18n } from '@ultimat3/i18n'; +import en from '../catalogs/en.json'; +import es from '../catalogs/es.json'; + +export const catalogs = defineCatalogs({ default: 'en', locales: { en, es } }); + +export type AppCatalog = typeof en; +export type AppKey = TranslationKey; // 'nav.home' | 'posts.likes' | … +export const useT = (): Translator => useI18n(); +``` + +| One call | Because | +|---|---| +| validates + flattens every locale before registering any | a malformed catalog fails the boot whole, never half | +| registers the framework catalog first, the app's second | later wins, so app strings override framework strings | +| `configureLocales({ supported, fallback })` | the locale set is declared once, not twice | +| returns `{ default, locales, catalogs, keys() }` | the app's key space, for tests and tooling | + +A `default` outside `locales` is `X_LOCALE_UNSUPPORTED` — and a compile error before that. + +## Read a string ```ts -import { createTranslator, flattenCatalog, negotiateLocale, t } from '@ultimat3/i18n'; +import { negotiateLocale, t } from '@ultimat3/i18n'; -// per-request: the HTTP layer does this once +// per-request: the HTTP layer resolves the locale once const locale = negotiateLocale(request.headers.get('accept-language')); // 'de' // anywhere downstream — no locale argument, ever @@ -29,6 +56,16 @@ t('pagination.result', { count: 9 }); // "9 results" t('nav.settings'); // "⟦nav.settings⟧" — fix it or ship it broken, visibly ``` +| Call | Returns | Use for | +|---|---|---| +| `t(key, vars?)` | `string` | one string, framework or app, untyped keys | +| `useI18n()` | `Translator` | a component rendering several — unknown key is a build error | +| `translatorFor(locale)` | `Translator` | an explicit locale: mail, a worker tick, a preview | + +`Translator` with no type argument keeps taking any `string`, which is what `@ultimat3/ui` and +`@ultimat3/mail` are built against. `has()` and `raw()` stay permissive on purpose — a probe for +a key that may not exist is what they are for. + ## Plurals are CLDR, not `n === 1` Author two forms with `key` / `key_plural`, or all forms a locale needs with @@ -41,11 +78,14 @@ Author two forms with `key` / `key_plural`, or all forms a locale needs with `t('files.n', { count: 3 })` → `3 pliki`, `{ count: 5 }` → `5 plików`. Selection runs through `Intl.PluralRules`, so Polish, Russian and Arabic work without a special case. +Suffixes are underscores on the leaf, never a nested `{ one, other }` branch: the runtime probes +`files.n_few`, and `TranslationKey` admits the stem `files.n` for exactly the same reason. + ## Catalogs -Author nested and feature-namespaced; the loader flattens to dot-keys and rejects a -non-string leaf with `X_CATALOG_INVALID`. `mergeCatalogs(framework, app)` — later wins, -which is how an app overrides `errors.notFound.title` without forking the framework. +Author nested and feature-namespaced; `defineCatalogs` flattens to dot-keys and rejects a +non-string leaf with `X_CATALOG_INVALID`. Registration order is framework then app — later +wins, which is how an app overrides `errors.notFound.title` without forking the framework. `src/catalogs/en.json` ships the framework's own strings: `errors.*`, `auth.*`, `pagination.*`, `admin.*`, `validation.*`, `common.*`, `time.cron.*`. @@ -66,7 +106,7 @@ X_CATALOG_MISSING_KEYS: catalog is incomplete | Code | When | |---|---| -| `X_LOCALE_UNSUPPORTED` | a tag outside the supported set was asserted | +| `X_LOCALE_UNSUPPORTED` | a tag outside the supported set was asserted, or a `defineCatalogs` default that is not one of its locales | | `X_CATALOG_MISSING_KEYS` | a shipped locale lacks a key the source uses | | `X_CATALOG_INVALID` | non-string leaf, bad key segment, or a dotted/nested collision | diff --git a/packages/i18n/src/context.test.ts b/packages/i18n/src/context.test.ts index 7375a3836..9904d106a 100644 --- a/packages/i18n/src/context.test.ts +++ b/packages/i18n/src/context.test.ts @@ -2,12 +2,12 @@ import { beforeEach, describe, expect, test } from 'bun:test'; import { flattenCatalog } from './catalog'; import { currentLocale, - currentTranslator, localeCookieOf, registerCatalog, resetCatalogs, resolveLocale, t, + useI18n, } from './context'; const supported = ['en', 'es', 'de'] as const; @@ -65,6 +65,6 @@ describe('ambient translator', () => { test('app catalogs registered later override framework strings', () => { registerCatalog('en', flattenCatalog({ errors: { notFound: { title: 'Page not found' } } })); registerCatalog('en', flattenCatalog({ errors: { notFound: { title: 'Lost?' } } })); - expect(currentTranslator()('errors.notFound.title')).toBe('Lost?'); + expect(useI18n()('errors.notFound.title')).toBe('Lost?'); }); }); diff --git a/packages/i18n/src/context.ts b/packages/i18n/src/context.ts index 82f96910a..4f05d03f8 100644 --- a/packages/i18n/src/context.ts +++ b/packages/i18n/src/context.ts @@ -141,22 +141,29 @@ export function catalogFor(locale: Locale): Catalog { return registry.get(locale) ?? {}; } -/** Memoized per locale — building a translator per render is pure waste. */ -export function translatorFor(locale: Locale): Translator { - const cached = translators.get(locale); - if (cached !== undefined) return cached; - const translator = createTranslator(catalogFor(locale), locale); - translators.set(locale, translator); - return translator; +/** + * Memoized per locale — building a translator per render is pure waste. + * `TCatalog` narrows the key type only; the runtime catalog is whatever is registered. + */ +export function translatorFor(locale: Locale): Translator { + let translator = translators.get(locale); + if (translator === undefined) { + translator = createTranslator(catalogFor(locale), locale); + translators.set(locale, translator); + } + // One cast, one place. `TCatalog` narrows the key parameter and nothing else, so the memoized + // object already is the right value — the registry cannot be keyed by an app's catalog type. + return translator as unknown as Translator; } -export function currentTranslator(): Translator { - return translatorFor(currentLocale()); +/** The ambient translator for the in-flight request. Pass the app's catalog type to get typed keys. */ +export function useI18n(): Translator { + return translatorFor(currentLocale()); } /** The ambient `t` — what framework and app code calls. Never takes a locale. */ export function t(key: string, vars?: TranslateVars): string { - return currentTranslator()(key, vars); + return useI18n()(key, vars); } /** Test/CLI seam: drop every registered catalog. */ diff --git a/packages/i18n/src/define-catalogs.test.ts b/packages/i18n/src/define-catalogs.test.ts new file mode 100644 index 000000000..240681e38 --- /dev/null +++ b/packages/i18n/src/define-catalogs.test.ts @@ -0,0 +1,120 @@ +import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; +import { + configureLocales, + localeConfig, + registeredLocales, + resetCatalogs, + translatorFor, +} from './context'; +import { defineCatalogs } from './define-catalogs'; +import { I18nError } from './errors'; +import { FRAMEWORK_CATALOG } from './framework'; + +const en = { + nav: { home: 'Home', settings: 'Settings' }, + errors: { notFound: { title: 'Nothing here' } }, + files: { n_one: '{count} file', n_other: '{count} files' }, +}; + +const es = { + nav: { home: 'Inicio', settings: 'Ajustes' }, + errors: { notFound: { title: 'No hay nada' } }, + files: { n_one: '{count} archivo', n_other: '{count} archivos' }, +}; + +/** `configureLocales` is process-global; snapshot it before any test moves it. */ +const initialConfig = localeConfig(); + +/** Returns the X_* code a call throws, or `undefined` when it does not throw. */ +function codeOf(run: () => unknown): string | undefined { + try { + run(); + return undefined; + } catch (error) { + return error instanceof I18nError ? error.code : undefined; + } +} + +beforeEach(() => { + resetCatalogs(); + configureLocales(initialConfig); +}); + +afterAll(() => { + resetCatalogs(); + configureLocales(initialConfig); +}); + +describe('defineCatalogs', () => { + test('registers a catalog for every locale it is given', () => { + const set = defineCatalogs({ default: 'en', locales: { en, es } }); + + expect(registeredLocales()).toEqual(['en', 'es']); + expect(set.locales).toEqual(['en', 'es']); + expect(set.default).toBe('en'); + expect(translatorFor('en')('nav.home')).toBe('Home'); + expect(translatorFor('es')('nav.home')).toBe('Inicio'); + }); + + test('registers framework strings under the app, so an app key of the same name wins', () => { + // The framework ships this key; the fixture above deliberately redefines it. + expect(FRAMEWORK_CATALOG['errors.notFound.title']).toBe('Page not found'); + + defineCatalogs({ default: 'en', locales: { en, es } }); + const t = translatorFor('en'); + + expect(t('errors.notFound.title')).toBe('Nothing here'); + // A framework key the app never mentions is still there — the app did not replace the catalog. + expect(t('common.save')).toBe('Save'); + }); + + test('configures the supported set and the fallback from the same call', () => { + defineCatalogs({ default: 'es', locales: { en, es } }); + + expect(localeConfig().supported).toEqual(['en', 'es']); + expect(localeConfig().fallback).toBe('es'); + }); + + test('flattens nested authoring to dot-keys and reports the key space', () => { + const set = defineCatalogs({ default: 'en', locales: { en, es } }); + + expect(set.catalogs.en['nav.home']).toBe('Home'); + expect(set.catalogs.es['errors.notFound.title']).toBe('No hay nada'); + expect(set.keys()).toEqual([ + 'errors.notFound.title', + 'files.n_one', + 'files.n_other', + 'nav.home', + 'nav.settings', + ]); + // App keys only — the framework's own strings are registered, never copied into the set. + expect(set.keys()).not.toContain('common.save'); + }); + + test('refuses a default that is not one of the locales, and registers nothing', () => { + const code = codeOf(() => + // @ts-expect-error — the same mistake is a compile error before it is a runtime one + defineCatalogs({ default: 'fr', locales: { en, es } }), + ); + + expect(code).toBe('X_LOCALE_UNSUPPORTED'); + expect(registeredLocales()).toEqual([]); + }); + + test('rejects a non-string leaf with X_CATALOG_INVALID before registering any locale', () => { + const code = codeOf(() => + defineCatalogs({ default: 'en', locales: { en, es: { nav: { home: ['Inicio'] } } } }), + ); + + expect(code).toBe('X_CATALOG_INVALID'); + // `en` is loadable and comes first: a half-registered app is what the two-pass load prevents. + expect(registeredLocales()).toEqual([]); + }); + + test('plural families survive the round trip', () => { + defineCatalogs({ default: 'en', locales: { en, es } }); + + expect(translatorFor('en')('files.n', { count: 1 })).toBe('1 file'); + expect(translatorFor('es')('files.n', { count: 4 })).toBe('4 archivos'); + }); +}); diff --git a/packages/i18n/src/define-catalogs.ts b/packages/i18n/src/define-catalogs.ts new file mode 100644 index 000000000..b9c108156 --- /dev/null +++ b/packages/i18n/src/define-catalogs.ts @@ -0,0 +1,56 @@ +/** + * The one call an app makes to declare its catalogs: validate and flatten every locale, + * register it under the framework's own strings, and configure the supported set. + * Boot-time only — nothing here runs per request. + */ + +import { type Catalog, catalogKeys, loadCatalog, mergeCatalogs } from './catalog'; +import { configureLocales, registerCatalog } from './context'; +import { localeUnsupported } from './errors'; +import { registerFrameworkCatalog } from './framework'; + +/** Locale tag → the catalog file's parsed contents, nested exactly as authored. */ +export type CatalogSources = Readonly>; + +export interface DefineCatalogsInput { + /** The locale an unresolved request falls back to. Must be a key of `locales`. */ + readonly default: keyof TLocales & string; + /** One entry per shipped locale — `{ en, es }` after importing the JSON files. */ + readonly locales: TLocales; +} + +export interface CatalogSet { + readonly default: keyof TLocales & string; + readonly locales: readonly (keyof TLocales & string)[]; + /** Flattened app strings only. Framework strings are registered, never copied in here. */ + readonly catalogs: Readonly>; + /** Every dot-key across every locale, sorted — the app's key space. */ + keys(): string[]; +} + +export function defineCatalogs( + input: DefineCatalogsInput, +): CatalogSet { + const locales = Object.keys(input.locales) as (keyof TLocales & string)[]; + if (!locales.includes(input.default)) throw localeUnsupported(input.default, locales); + + // Load everything before registering anything: a malformed catalog must fail the boot + // whole, not leave half the locales live and the other half missing. + const loaded = locales.map((locale) => [locale, loadCatalog(input.locales[locale])] as const); + + for (const [locale, catalog] of loaded) { + // Framework first, app second. `registerCatalog` merges and the later call wins, which is + // how an app overrides `errors.notFound.title` without forking the framework catalog. + registerFrameworkCatalog(locale); + registerCatalog(locale, catalog); + } + configureLocales({ supported: locales, fallback: input.default }); + + const catalogs = Object.fromEntries(loaded) as Readonly>; + return { + default: input.default, + locales, + catalogs, + keys: () => catalogKeys(mergeCatalogs(...loaded.map(([, catalog]) => catalog))), + }; +} diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index e27167e15..43de55590 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -16,7 +16,6 @@ export { configureLocales, currentDirection, currentLocale, - currentTranslator, LOCALE_COOKIE, type LocaleConfig, type LocaleResolution, @@ -31,7 +30,14 @@ export { resolveLocale, t, translatorFor, + useI18n, } from './context'; +export { + type CatalogSet, + type CatalogSources, + type DefineCatalogsInput, + defineCatalogs, +} from './define-catalogs'; export { catalogInvalid, catalogMissingKeys, @@ -85,4 +91,10 @@ export { parseAcceptLanguage, SUPPORTED_LOCALES, } from './locales'; -export { createTranslator, isMiss, type TranslateVars, type Translator } from './translator'; +export { + createTranslator, + isMiss, + type TranslateVars, + type TranslationKey, + type Translator, +} from './translator'; diff --git a/packages/i18n/src/translator.test.ts b/packages/i18n/src/translator.test.ts index 4bcba0b60..1ec5e71ab 100644 --- a/packages/i18n/src/translator.test.ts +++ b/packages/i18n/src/translator.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; +import type { Catalog } from './catalog'; import { flattenCatalog } from './catalog'; -import { createTranslator, isMiss } from './translator'; +import { createTranslator, isMiss, type TranslationKey, type Translator } from './translator'; const en = flattenCatalog({ nav: { home: 'Home', billing: 'Billing' }, @@ -70,3 +71,67 @@ describe('createTranslator', () => { expect(t.keys()).toContain('approvals.empty'); }); }); + +/** + * These assertions fail at `tsc`, not at runtime: every `@ts-expect-error` below is a key an + * app must not be able to write. The `expect()` calls exist so the values are not dead code. + */ +const sample = { + nav: { home: 'Home', settings: { profile: 'Profile' } }, + files: { n_one: '{count} file', n_other: '{count} files' }, + rows: { n_one: '{count} row', n_few: '{count} rows', n_many: '{count} rows' }, + queue: { n: '{count} job waiting', n_plural: '{count} jobs waiting' }, + greeting: 'Hi {name}', +}; + +describe('TranslationKey', () => { + test('admits nested dot-paths, leaves and plural stems', () => { + const nested: TranslationKey = 'nav.settings.profile'; + const shallow: TranslationKey = 'nav.home'; + const root: TranslationKey = 'greeting'; + const variant: TranslationKey = 'files.n_one'; + // `t('files.n', { count })` is how plural selection is called — every stem must be a key. + const twoForm: TranslationKey = 'files.n'; + const cldr: TranslationKey = 'rows.n'; + const shortcut: TranslationKey = 'queue.n_plural'; + + expect([nested, shallow, root, variant, twoForm, cldr, shortcut]).toEqual([ + 'nav.settings.profile', + 'nav.home', + 'greeting', + 'files.n_one', + 'files.n', + 'rows.n', + 'queue.n_plural', + ]); + }); + + test('rejects a key the catalog does not define', () => { + // @ts-expect-error — a typo is a build error, not a ⟦key⟧ someone spots in production + const typo: TranslationKey = 'nav.hom'; + // @ts-expect-error — a branch is not a string leaf + const branch: TranslationKey = 'nav.settings'; + // @ts-expect-error — a plural stem exists, an invented suffix does not + const suffix: TranslationKey = 'files.n_lots'; + + // Joined, because the declared type is the narrow union the literals were rejected by. + expect([typo, branch, suffix].join(' ')).toBe('nav.hom nav.settings files.n_lots'); + }); + + test('degrades to string for a flat catalog, so untyped callers are unaffected', () => { + const flat: TranslationKey = 'anything.at.all'; + const indexed: TranslationKey> = 'anything.at.all'; + + expect([flat, indexed]).toEqual(['anything.at.all', 'anything.at.all']); + }); + + test('is the key type the Translator call signature carries', () => { + const typed: Parameters>[0] = 'files.n'; + // @ts-expect-error — the type argument reaches the call signature, so this call site fails + const untyped: Parameters>[0] = 'files.none'; + // No type argument ⇒ `string`, which is what @ultimat3/ui and @ultimat3/mail rely on. + const bare: Parameters[0] = 'whatever.a.caller.passes'; + + expect([typed, untyped, bare]).toEqual(['files.n', 'files.none', 'whatever.a.caller.passes']); + }); +}); diff --git a/packages/i18n/src/translator.ts b/packages/i18n/src/translator.ts index 3a0677ac1..f19749b90 100644 --- a/packages/i18n/src/translator.ts +++ b/packages/i18n/src/translator.ts @@ -13,8 +13,37 @@ export interface TranslateVars extends InterpolationVars { count?: number; } -export interface Translator { - (key: string, vars?: TranslateVars): string; +/** Suffixes `selectPluralKey` probes: the CLDR categories plus the two-form shortcut. */ +type PluralSuffix = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other' | 'plural'; + +/** `items_one` → `items`. A leaf with no plural suffix contributes nothing. */ +type PluralStem = TKey extends `${infer TStem}_${PluralSuffix}` + ? TStem + : never; + +/** Recursion budget — an unresolved generic catalog would otherwise never bottom out. */ +type Depth = [never, 0, 1, 2, 3, 4, 5, 6, 7]; + +/** Depth-first dot-paths of an authoring object — the type-level twin of `flattenCatalog`. */ +type CatalogPaths = [Depth[TDepth]] extends [never] + ? never + : { + [TKey in keyof TCatalog & string]: TCatalog[TKey] extends string + ? TKey | PluralStem + : `${TKey}.${CatalogPaths & string}`; + }[keyof TCatalog & string]; + +/** + * Dot-path keys of a nested catalog, plus the stem of every plural family — `items_one` + * also admits `items`, because `t('items', { count })` is how plural selection is called. + * An index-signature catalog (the untyped default) degrades to `string`. + */ +export type TranslationKey = string extends keyof TCatalog + ? string + : CatalogPaths; + +export interface Translator { + (key: TranslationKey, vars?: TranslateVars): string; /** Whether the key (or any of its plural variants) can be rendered. */ has(key: string): boolean; /** The raw template, for tooling that needs the placeholders. */ diff --git a/packages/query/CLAUDE.md b/packages/query/CLAUDE.md index 9d089ffc5..ac7b7c689 100644 --- a/packages/query/CLAUDE.md +++ b/packages/query/CLAUDE.md @@ -18,7 +18,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher. | `mcp-tool.ts` | MCP read descriptor, same `sourceFor` | | `client.ts` | typed read client (browser-safe: no server imports) | | `naming.ts` | export name → `/_x/query/` + snake_case tool name. Pure string math | -| `registry.ts` | export-name registration, `describeQueries()` | +| `registry.ts` | export-name registration, `describeQueries()`, and the `registerPrimitiveRegistrar('query', …)` announcement | | `live.ts` | `LiveQuery` descriptor + cursor arithmetic | | `matcher.ts` | change event → minimal patch, or `X_MATCHER_UNSUPPORTED` | | `pagination.ts` | `paginate()` over core's cursor codec — no offset, ever | @@ -48,6 +48,9 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher. the author said so. - `client.ts` stays free of server imports — it is bundled into the browser. `@ultimat3/action` is the same tier, so its naming is ported here, never imported. +- `registry.ts` announces `registerQueries` in core's registrar table at import. That is how + `defineApi({ queries })` in `@ultimat3/action` registers a read without importing this package + sideways. Never remove the announcement: `defineApi` would then throw `X_REGISTRAR_MISSING`. - Policy runs per subscriber for live queries. Never cache a decision across actors. - The matcher patches from `QueryShape`, never from SQL text. - `paginate` has no `offset` parameter and must never grow one. diff --git a/packages/query/src/registry.ts b/packages/query/src/registry.ts index 9f58045d9..4ee661880 100644 --- a/packages/query/src/registry.ts +++ b/packages/query/src/registry.ts @@ -3,6 +3,7 @@ * subscription protocol and the `/_x` dashboard all address a read by the same * identifier the source file uses. */ +import { registerPrimitiveRegistrar } from '@ultimat3/core'; import { QueryDuplicateError, QueryPolicyMissingError } from './errors'; import type { AnyQuery, QueryDescriptor } from './query'; import { isQuery, nameQuery } from './query'; @@ -25,7 +26,7 @@ export function registerQuery(name: string, target: Q): Q { } /** `registerQueries(await import('./live'))` — export names become query names. */ -export function registerQueries(module: Record): readonly AnyQuery[] { +export function registerQueries(module: Readonly>): readonly AnyQuery[] { const registered: AnyQuery[] = []; for (const name of Object.keys(module).sort()) { const value = module[name]; @@ -34,6 +35,11 @@ export function registerQueries(module: Record): readonly AnyQu return registered; } +// `defineApi` lives in `@ultimat3/action`, which sits on this tier and so cannot import this +// file. Announcing the registrar in core's table is what lets one `defineApi({ queries })` call +// register a read without a sideways import — importing the module you pass is what loads this. +registerPrimitiveRegistrar('query', registerQueries); + export function getQuery(name: string): AnyQuery | undefined { return registry.get(name); } diff --git a/packages/time/README.md b/packages/time/README.md index ade87a031..c0d255ae1 100644 --- a/packages/time/README.md +++ b/packages/time/README.md @@ -48,6 +48,22 @@ means gap. `fromZonedDetailed()` returns which case it was — log it when sched Half-hour and 45-minute zones (`Asia/Kolkata`, `Asia/Kathmandu`, `Australia/Adelaide`, `Pacific/Chatham`) are ordinary cases here, not special ones. +## Calendar days + +**Never cross a day boundary with `86_400_000`.** A local day is 23, 24 or 25 hours long. Every +one of these takes the zone explicitly, and none of them has a default. + +| Function | Answers | +|---|---| +| `startOfDay(at, zone)` / `endOfDay(at, zone)` | local midnight; the last millisecond of the day | +| `addDaysInZone(at, days, zone)` | the same wall-clock time, `days` calendar days away | +| `daysBetween(from, to, zone)` | local day boundaries crossed — signed, always integral | +| `isoDateInZone(at, zone)` | `2026-03-14` | +| `isSameLocalDay(left, right, zone)` | whether two instants share a local date | + +`daysBetween` counts boundaries, not milliseconds: 23 real hours across spring forward is `1`, +and 24 real hours inside a 25-hour fall-back day is `0`. + ## Cron `parseCron` handles 5 or 6 fields, `*/n`, ranges, lists, named months and days, `@daily` diff --git a/packages/time/src/index.ts b/packages/time/src/index.ts index 5cf85a93e..b5aadb1ab 100644 --- a/packages/time/src/index.ts +++ b/packages/time/src/index.ts @@ -107,6 +107,7 @@ export { } from './schedule'; export { addDaysInZone, + daysBetween, endOfDay, type FromZonedOptions, type FromZonedResult, diff --git a/packages/time/src/zoned.test.ts b/packages/time/src/zoned.test.ts index 797e3c78a..95c4181bc 100644 --- a/packages/time/src/zoned.test.ts +++ b/packages/time/src/zoned.test.ts @@ -1,12 +1,20 @@ import { describe, expect, test } from 'bun:test'; import { fromIso, toIso } from './instant'; -import { addDaysInZone, fromZoned, fromZonedDetailed, startOfDay, toZoned } from './zoned'; -import { offsetAt } from './zones'; +import { + addDaysInZone, + daysBetween, + fromZoned, + fromZonedDetailed, + startOfDay, + toZoned, +} from './zoned'; +import { offsetAt, UTC } from './zones'; const BERLIN = 'Europe/Berlin'; const NEW_YORK = 'America/New_York'; const KATHMANDU = 'Asia/Kathmandu'; const ADELAIDE = 'Australia/Adelaide'; +const TOKYO = 'Asia/Tokyo'; describe('toZoned', () => { test('renders the same instant differently per zone', () => { @@ -118,6 +126,58 @@ describe('calendar arithmetic in a zone', () => { }); }); +describe('daysBetween', () => { + test('counts local calendar days, signed, never fractional', () => { + const noon = fromIso('2026-03-14T12:00:00Z'); + expect(daysBetween(noon, fromIso('2026-03-14T23:59:59.999Z'), UTC)).toBe(0); + expect(daysBetween(noon, fromIso('2026-03-15T00:00:00Z'), UTC)).toBe(1); + expect(daysBetween(fromIso('2026-03-15T00:00:00Z'), noon, UTC)).toBe(-1); + }); + + test('a 23-hour spring-forward day still counts as one day', () => { + const before = fromZoned({ year: 2026, month: 3, day: 7, hour: 12, minute: 0 }, NEW_YORK); + const after = addDaysInZone(before, 1, NEW_YORK); + expect(after.getTime() - before.getTime()).toBe(23 * 3_600_000); + expect(daysBetween(before, after, NEW_YORK)).toBe(1); + }); + + test('a 25-hour fall-back day still counts as one day', () => { + const before = fromZoned({ year: 2026, month: 10, day: 31, hour: 12, minute: 0 }, NEW_YORK); + const after = addDaysInZone(before, 1, NEW_YORK); + expect(after.getTime() - before.getTime()).toBe(25 * 3_600_000); + expect(daysBetween(before, after, NEW_YORK)).toBe(1); + }); + + test('24 real hours inside one 25-hour local day is zero days', () => { + // 00:30 EDT and 23:30 EST are both 2026-11-01 in New York, and exactly 24h apart. + const from = fromIso('2026-11-01T04:30:00Z'); + const to = fromIso('2026-11-02T04:30:00Z'); + expect(to.getTime() - from.getTime()).toBe(86_400_000); + expect(daysBetween(from, to, NEW_YORK)).toBe(0); + }); + + test('the zone decides: 90 minutes across local midnight in Tokyo, none in UTC', () => { + const from = fromIso('2026-03-14T14:30:00Z'); // 23:30 Tokyo, 14 March + const to = fromIso('2026-03-14T16:00:00Z'); // 01:00 Tokyo, 15 March + expect(daysBetween(from, to, TOKYO)).toBe(1); + expect(daysBetween(from, to, UTC)).toBe(0); + }); + + test('a whole year is 365 days, and 366 in a leap year', () => { + expect(daysBetween(fromIso('2026-01-01T00:00:00Z'), fromIso('2027-01-01T00:00:00Z'), UTC)).toBe( + 365, + ); + expect(daysBetween(fromIso('2024-01-01T00:00:00Z'), fromIso('2025-01-01T00:00:00Z'), UTC)).toBe( + 366, + ); + }); + + test('an unknown zone raises X_TIMEZONE_INVALID', () => { + const at = fromIso('2026-03-14T08:00:00Z'); + expect(codeOf(() => daysBetween(at, at, 'Mars/Olympus'))).toBe('X_TIMEZONE_INVALID'); + }); +}); + function codeOf(run: () => unknown): string { try { run(); diff --git a/packages/time/src/zoned.ts b/packages/time/src/zoned.ts index 6e7ff54d7..c3f527a43 100644 --- a/packages/time/src/zoned.ts +++ b/packages/time/src/zoned.ts @@ -197,6 +197,22 @@ export function isSameLocalDay(left: Instant, right: Instant, zone: TimeZone): b return isoDateInZone(left, zone) === isoDateInZone(right, zone); } +/** + * Whole calendar days from `from` to `to`, counted as local day boundaries crossed in `zone`. + * Signed, and always integral. Not `differenceMs / 86_400_000`: a 23- or 25-hour DST day is + * still one day, and 24 real hours inside a 25-hour local day is still zero. + */ +export function daysBetween(from: Instant, to: Instant, zone: TimeZone): number { + // Both dates are reduced to a UTC midnight, so the subtraction carries no offset at all. + return (localDayEpoch(to, zone) - localDayEpoch(from, zone)) / 86_400_000; +} + +/** The instant's local calendar date, expressed as the UTC midnight of that date. */ +function localDayEpoch(at: Instant, zone: TimeZone): number { + const zoned = toZoned(at, zone); + return Date.UTC(zoned.year, zoned.month - 1, zoned.day); +} + function finish( epochMs: number, millisecond: number, diff --git a/wiki/Error-Codes.md b/wiki/Error-Codes.md index 109043148..6195a3a1a 100644 --- a/wiki/Error-Codes.md +++ b/wiki/Error-Codes.md @@ -41,6 +41,8 @@ X_DB_DRIFT: schema differs from migrations | `X_ID_INVALID` | value is not a valid id | a hand-built string passed where a typed id is required | generate ids with `uuid()` / `typedId<'post'>()` from `@ultimat3/core` | | `X_CURSOR_INVALID` | pagination cursor is malformed, tampered with or from another query | signature mismatch — an edited cursor, or `ULTIMATE_CURSOR_SECRET` rotated — or a cursor built for a different query, filter or sort order | drop the cursor and request the first page (`after: null`) | | `X_ERROR_CODE_DUPLICATE` | error code registered twice | two packages declared the same code | rename the colliding code in the registering package's `src/errors.ts` | +| `X_REGISTRAR_MISSING` | no registrar is loaded for a primitive kind | `defineApi({ queries })` ran without `@ultimat3/query` in the graph | `bun add @ultimat3/query` | +| `X_REGISTRAR_CONFLICT` | two different registrars are loaded for one primitive kind | two copies of one `@ultimat3/*` package in the dependency tree, each with its own registry | `bun pm ls \| grep @ultimat3/query` — then dedupe it to one version in `package.json` | ## Images diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md index ba415b1e2..de89524a1 100644 --- a/wiki/Getting-Started.md +++ b/wiki/Getting-Started.md @@ -61,16 +61,34 @@ export const publishPost = action({ That one declaration emits six artifacts — HTTP route, OpenAPI operation, typed client function, job handle, MCP tool, test scaffold. See [Actions](Actions). +Register it, with every other primitive, in one call. Importing `api/index.ts` IS the boot. + +```ts +// apps/web/api/index.ts +import { defineApi } from '@ultimat3/action'; +import * as postActions from '../app/posts/actions'; +import * as postMutators from '../app/posts/mutator'; +import * as postQueries from '../app/posts/live'; + +export const api = defineApi({ + actions: [postActions], + mutators: [postMutators], + queries: [postQueries], +}); + +export type Api = typeof api; +``` + ## 2. Call it from `app/` `app/` may import `api/` **types only**. The typed client is derived from those types; there is no codegen step to remember and no `fetch`. ```ts // apps/web/shared/client.ts -import { createClient } from '@ultimat3/action'; -import type * as actions from '../api/posts/actions'; +import { rpc } from '@ultimat3/action'; +import type { Api } from '../api'; -export const api = createClient({ baseUrl: '/' }); +export const api = rpc({ baseUrl: '/' }); ``` ```tsx From 52050319c884f71e893ff44985ec93ba773729ed Mon Sep 17 00:00:00 2001 From: sebi Date: Mon, 10 Aug 2026 11:43:48 -0500 Subject: [PATCH 4/7] =?UTF-8?q?docs(dsl):=20mirror=20the=20fa=C3=A7ade=20o?= =?UTF-8?q?n=20the=20other=20seven=20primitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code shipped earlier; the reference manual named the fluent surface for `action` and `query` only. Five primitives had a surface the manual never mentioned, so an agent reading the wiki could not find it. - job: `.enqueue()` `.as()` `.describe()` `.parse()` `.idempotencyKeyFor()`, and why `.as()` queues where an action's `.as()` runs — a job's execution surface IS the queue. `describe().steps` empty by design. - task: `.entries()` `.enqueue()` `.describe()`, the occurrence-scoped key a tick uses versus the plain key a manual fire uses, and `tz` now checked against the runtime's IANA database. Dropped two `Fields` rows that named declaration keys `TaskDefinition` does not have (`queue`, `enabled`) — both are type errors today, two lines above "Nothing else." - entity: the `$`-sigil members, and the type that explains them — `Entity = EntityCore & C`, so the entity IS its columns and `posts.name` must stay a column. Tenant resolution order written down. - mutator: `.local()` `.server()` `.conflict`, and that `.server()` routes through the action's own callable, so the offline half gets the same parse -> policy -> handle -> parse core because it IS that call. - route: the normalized descriptor — `meta()` always awaits, `budget` is always an object — plus the trap that normalization could have set, that `budget.js === undefined` still fails a hydrating `site/` route. - The-Eight-Primitives: the rule itself, once, with each primitive's surface. Two code fixes the docs pass surfaced: - `isMutator` was fail-open: it accepted any function carrying the brand, with no declaration check, so a hand-rolled look-alike counterfeited the one primitive `isAction` would have refused. Now structural, like its twin. - `packages/render/src/dsl.test.ts`: render was the only primitive package with a façade and no member-enumeration pin. Both new assertions were negative-proved — laundering the budget and adding a stray descriptor key each turn it red. Co-Authored-By: Claude --- packages/action/src/mutator.test.ts | 14 +++- packages/action/src/mutator.ts | 10 ++- packages/render/src/dsl.test.ts | 116 ++++++++++++++++++++++++++++ wiki/Entities-And-Migrations.md | 34 ++++++++ wiki/Jobs-And-Workflows.md | 20 +++++ wiki/Realtime.md | 21 +++++ wiki/Routes-And-Render-Modes.md | 26 +++++++ wiki/Scheduled-Tasks.md | 38 +++++++-- wiki/The-Eight-Primitives.md | 19 +++++ 9 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 packages/render/src/dsl.test.ts diff --git a/packages/action/src/mutator.test.ts b/packages/action/src/mutator.test.ts index 9a724c6c8..6ce9f7d28 100644 --- a/packages/action/src/mutator.test.ts +++ b/packages/action/src/mutator.test.ts @@ -4,7 +4,7 @@ import { can } from '@ultimat3/policy'; import { t } from '@ultimat3/schema'; import { invoke } from './invoke'; import type { LocalRow, LocalTable, LocalTx } from './mutator'; -import { custom, mutator, resolveConflict } from './mutator'; +import { custom, isMutator, mutator, resolveConflict } from './mutator'; const Input = t.object({ postId: t.uuid }); const Output = t.object({ id: t.uuid, likes: t.number }); @@ -132,6 +132,18 @@ describe('mutator', () => { expect(likePost.describe().name).toBe('likePost'); }); + test('isMutator is structural: the brand alone does not counterfeit one', () => { + expect(isMutator(likePost)).toBe(true); + // A look-alike carrying the brand has no declaration, so nothing can run it — + // the same refusal `isAction` gives a hand-rolled `kind: 'action'` object. + const counterfeit = Object.assign(() => Promise.resolve({}), { + kind: 'action' as const, + isMutator: true as const, + }); + expect(isMutator(counterfeit)).toBe(false); + expect(isMutator({ isMutator: true })).toBe(false); + }); + test('conflict strategies pick a winner', () => { const local = { id: POST_ID, likes: 4 }; const server = { id: POST_ID, likes: 7 }; diff --git a/packages/action/src/mutator.ts b/packages/action/src/mutator.ts index c28da7910..6562f6055 100644 --- a/packages/action/src/mutator.ts +++ b/packages/action/src/mutator.ts @@ -9,7 +9,7 @@ import type { Ctx } from '@ultimat3/core'; import { assertNever } from '@ultimat3/core'; import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema'; import type { Action, ActionCache, ActionDef, ActionDescriptor, ActionMcp } from './action'; -import { action } from './action'; +import { action, isAction } from './action'; import type { ActionPolicy } from './policy-gate'; /** Minimum shape of a locally-stored row: an id the local twin can address. */ @@ -123,8 +123,14 @@ export function mutator( diff --git a/packages/render/src/dsl.test.ts b/packages/render/src/dsl.test.ts new file mode 100644 index 000000000..3365ec321 --- /dev/null +++ b/packages/render/src/dsl.test.ts @@ -0,0 +1,116 @@ +/** + * Pins the `route` DSL surface. `route.test.ts` proves `defineRoute` validates and + * normalizes correctly; this file proves the *shape* cannot silently drift — the + * descriptor's exact member set, and that every consumer reads the descriptor rather + * than the declaration the author wrote. `route` is the one primitive whose façade is + * a normalized descriptor instead of projection methods, so what has to stay true is + * that all five readers see one shape and none of them branches. + */ +import { describe, expect, test } from 'bun:test'; +import { assertModeInvariants, assertModeShape } from './modes'; +import { clearRoutes, registerRoute } from './registry'; +import type { RouteDefinition } from './route'; +import { defineRoute, isRouteConfig, tagKeys } from './route'; + +// The exact contract: `RouteConfig` (route.ts). Kept in sync by hand on purpose — a +// silent drift here is exactly the regression this file exists to catch. Optional +// declaration keys are omitted rather than set to undefined, so they are not listed. +const DESCRIPTOR_MEMBERS = ['kind', 'render', 'offline', 'hydrate', 'meta', 'budget'] as const; + +const minimal: RouteDefinition = { + render: 'ssr', + offline: 'network-only', + hydrate: 'never', + meta: () => ({ title: 'Post', description: 'A post' }), +}; + +describe('the route DSL surface', () => { + test('a minimal route carries exactly the descriptor members, nothing more', () => { + const config = defineRoute(minimal); + expect(Object.keys(config).sort()).toEqual([...DESCRIPTOR_MEMBERS].sort()); + }); + + test('every declared key is carried onto the descriptor', () => { + const config = defineRoute({ + ...minimal, + render: 'isr', + revalidate: { ttl: '5m' }, + prerender: () => ['first-post'], + budget: { js: '40kb' }, + policy: { permission: 'post:read' }, + }); + expect(Object.keys(config).sort()).toEqual( + [...DESCRIPTOR_MEMBERS, 'revalidate', 'prerender', 'policy'].sort(), + ); + expect(config.revalidate).toEqual({ ttl: '5m' }); + expect(config.policy).toEqual({ permission: 'post:read' }); + }); + + test('the descriptor is branded and frozen — a declaration object is neither', () => { + const config = defineRoute(minimal); + expect(config.kind).toBe('route'); + expect(Object.isFrozen(config)).toBe(true); + expect(isRouteConfig(config)).toBe(true); + // The author's own object never counts: only `defineRoute` normalizes. + expect(isRouteConfig(minimal)).toBe(false); + expect(isRouteConfig({ kind: 'route' })).toBe(true); + }); + + test('`meta` is one shape — a sync declaration is not reachable as a sync member', () => { + const sync = defineRoute(minimal); + const async = defineRoute({ ...minimal, meta: async () => ({ title: 'A', description: 'B' }) }); + // Both are promises. No consumer branches on a thenable. + expect(sync.meta({})).toBeInstanceOf(Promise); + expect(async.meta({})).toBeInstanceOf(Promise); + // And the descriptor's `meta` is a wrapper, never the function the author passed. + expect(sync.meta).not.toBe(minimal.meta); + }); + + test('`budget` is always an object, so no consumer needs an undefined-check', () => { + expect(defineRoute(minimal).budget).toEqual({}); + expect(defineRoute({ ...minimal, budget: { js: '40kb' } }).budget).toEqual({ js: '40kb' }); + }); + + test('the always-present budget still leaves `budget.js` undeclared', () => { + // The normalization must not launder a missing JS budget into a satisfied one. + // `modes.ts` fails a hydrating `site/` route on exactly this, and that has to hold + // through the descriptor — otherwise the check silently stops engaging. + const hydrating = defineRoute({ ...minimal, render: 'static', hydrate: 'visible' }); + expect(hydrating.budget.js).toBeUndefined(); + const ctx = { + file: 'site/posts/page.tsx', + path: '/posts', + surface: 'site', + suspenseBoundaries: 0, + } as const; + expect(() => assertModeInvariants(hydrating, ctx)).toThrow(); + const budgeted = defineRoute({ ...hydrating, budget: { js: '40kb' } }); + expect(() => assertModeInvariants(budgeted, ctx)).not.toThrow(); + }); + + test('mode invariants are checked on the descriptor, at declaration time', () => { + // `defineRoute` runs `assertModeShape` itself, so a bad route fails at module + // evaluation — build time — not on the first request. + expect(() => defineRoute({ ...minimal, render: 'isr' })).toThrow(); + expect(() => assertModeShape(defineRoute(minimal))).not.toThrow(); + }); + + test('the registry accepts the descriptor and refuses a raw declaration', () => { + clearRoutes(); + const config = defineRoute(minimal); + const entry = registerRoute({ file: 'app/posts/page.tsx', config }); + expect(entry.config).toBe(config); + expect(entry.config.budget).toEqual({}); + clearRoutes(); + }); + + test('tags reach the descriptor in the cache wire form, never a private convention', () => { + const config = defineRoute({ + ...minimal, + render: 'isr', + revalidate: { tags: [{ entity: 'post', id: '123' }, { entity: 'feed' }] }, + }); + expect(tagKeys(config.revalidate?.tags)).toEqual(['post:123', 'feed']); + expect(tagKeys(undefined)).toEqual([]); + }); +}); diff --git a/wiki/Entities-And-Migrations.md b/wiki/Entities-And-Migrations.md index 46f1ca704..d0769815c 100644 --- a/wiki/Entities-And-Migrations.md +++ b/wiki/Entities-And-Migrations.md @@ -70,6 +70,29 @@ The table name is the first argument. Everything else is the init object: Presence of a `deletedAt` column is what makes an entity soft-deletable — not a flag. +## The fluent surface + +Every projection is a method on the entity — `posts.$view(['id', 'title'])`, never `view(posts, ['id', 'title'])` — and every declared field is lifted onto it under a `$`. An entity has no `.def`. + +| Member | Is | Rule | +|---|---|---| +| `posts.title` | a column reference | the columns are the entity's own properties, which is what forces the `$` sigil onto every framework member | +| `$row` | the phantom that carries the row type | `export type Post = typeof posts.$row`. Reading it as a value throws `X_INVARIANT_VIOLATED` — a type was meant | +| `$schema` | the Standard Schema the columns already describe | forms and actions hand input to it; the row shape is never declared a second time | +| `$parse(value)` | the parse boundary | fills declared defaults, then validates every column through the column that owns it. Throws on a bad value | +| `$view(keys)` | the row projection | `const PostView = posts.$view(['id', 'title'])` — what an action names as its `output`. An unknown key is a compile error, and `X_INVARIANT_VIOLATED` at declaration for a JS caller | +| `$assert(row)` | every invariant, run | called by the repo on insert and on update; reports **every** failing invariant at once, so one round trip fixes all | +| `$migration()` | the CHECK and UNIQUE statements this entity contributes | `ALTER TABLE … ADD CONSTRAINT … CHECK` per `check`, `CREATE UNIQUE INDEX` per `unique`. A JS-only rule is `kind: 'assert'` and emits nothing — never a pretend CHECK | +| `$describe()` | the manifest row | name, table, primary key, physical columns, invariants, index names, tags, `cacheTag`, `softDelete`, `orgScoped` | +| `$cacheTag` | `entity:` | the string `@ultimat3/cache` invalidates by | +| `$tagFor(id)` | `entity::` | row-level invalidation for live queries | +| `$tenantColumn` | the tenant column's property key, or `null` | presence is what turns tenancy on — resolution order under **Tenant column rule** below | +| `$name` `$table` `$columns` `$primaryKey` `$indexes` `$invariants` `$tags` `$softDelete` | the declaration, lifted | readable. `$table` is `$name` verbatim — the first argument to `entity()` is the physical table name, never pluralised or snake-cased for you | + +`entity` is the one primitive whose surface is `$`-prefixed, and the type says why: `type Entity = EntityCore & C` — the entity **is** its columns. `posts.title` is a column reference, so an unprefixed member would make `view`, `name` or `tenant` an illegal column name; with the sigil, a column called `name` and the entity's own name coexist as `posts.name` and `posts.$name`. `action`, `query` and `job` carry no sigil because nothing is merged into them. Reaching for `posts.view([…])` after reading [Actions](Actions) does not typecheck — there is no such member, only `$view`, and there is no free `view(entity, keys)` to import either. Nothing is hidden the way an action's `handle` is: an entity has no body to protect, so the whole declaration reads back, and the sigil protects the column namespace instead. Registration is not a second step — `entity()` registers itself as it is declared, which is why a duplicate name is `X_ENTITY_DUPLICATE` at import rather than a silent last-one-wins. + +`$view()` is what closes the type chain. `output: posts.$view(['id', 'title', 'excerpt'])` returns a Standard Schema over `Pick`, so renaming a column stops the key list itself from compiling, and the action's output type and every consumer of it fail in the same pass — not at runtime as a missing field. What it catches is the key list and the projected type; a value is still checked at runtime, by the column that owns it rather than by a second copy of its rule. A view never invents a default: it projects a row that already exists, so an absent required column is missing data. + ## Tenant column rule Every multi-tenant entity declares `tenant`. Every query against it is tenant-scoped or it fails: @@ -80,6 +103,17 @@ X_TENANCY_UNSCOPED: query is not scoped to a tenant fix: query through posts repo (ctx.posts.*) — it applies the tenant scope from ctx.actor ``` +Which column that is, in order — first match wins: + +| Order | Source | Detail | +|---|---|---| +| 1 | `tenant: 'workspaceId'` | the declaration, said out loud. It need not be named `orgId` and need not carry `.tenant()`. Declared, it wins outright — a `.tenant()` mark elsewhere is not a conflict, just not the winner | +| 2 | `.tenant()` on a column | the first column marked, in declaration order | +| 3 | a column named `orgId` | literal property name, no mark needed | +| 4 | none of those | `$tenantColumn` is `null` and the entity is not tenant-scoped | + +Omitting `tenant` keeps steps 2–4, so silence never means unscoped. A `tenant` key naming no column is a declaration error — `X_INVARIANT_VIOLATED`, listing the columns to pick from — because the alternative is a silently unscoped table. + | Consequence | Detail | |---|---| | Repo methods | inject the tenant predicate from `ctx.actor.orgId`; a raw `db.*` call in a service is a boundary error | diff --git a/wiki/Jobs-And-Workflows.md b/wiki/Jobs-And-Workflows.md index b9e6ab145..ab62e9a78 100644 --- a/wiki/Jobs-And-Workflows.md +++ b/wiki/Jobs-And-Workflows.md @@ -21,6 +21,26 @@ export const onboardOrg = job({ }); ``` +## The fluent surface + +Every projection is a method on the job — `onboardOrg.enqueue({ orgId })`, never `enqueueJob(onboardOrg, input)` — and every declared field is lifted onto it. A job has no `.def`. + +| Member | Is | Rule | +|---|---|---| +| `onboardOrg.enqueue(input, options?)` | the enqueue | resolves the ambient jobs facade, so it **joins the caller's transaction** when the app installed the outbox. One call site works in a request handler, a job, a script and a test | +| `.as(actor, input, options?)` | the same enqueue, on someone's behalf | fills `tenantId` from the actor's org, so per-tenant concurrency and rate limits apply. `null` — or an actor with no org — leaves `tenantId` unset: the limiter's own shared bucket, not a fake org id on the row. It **queues; it never runs inline** | +| `.run(args)` | the handler itself | the worker calls it. App code does not — see below | +| `.parse(raw)` | the payload check | a raw queue payload against the declared `input` | +| `.idempotencyKeyFor(input)` | the dedupe key | whatever the declared `idempotencyKey` returned. An empty string throws `X_INVARIANT` at the call — an empty key makes every enqueue look like a duplicate of every other | +| `.describe()` | the manifest row | `name`, `input` (vendor tag only), `queue`, `retry: { attempts, backoff }`, `steps` | +| `.kind` `.name` `.queue` `.retry` `.concurrency` `.timeoutMs` `.input` | the declaration, lifted | readable, and already **resolved**: `kind` is `'job'`, `queue` is `'default'` when undeclared, `retry` carries the framework defaults merged underneath, `timeoutMs` is `timeout` normalized to ms | + +**One enqueue implementation.** `.enqueue`, `.as` and a [task](Scheduled-Tasks)'s own `enqueue()` all resolve the same ambient facade; the only other calls into a driver's `enqueue` are the outbox relay and the scheduler's occurrence dispatch. So "does this join the transaction?" has one answer for every enqueue an app writes, and there is no second path to forget about. + +`run` is on the handle and is still not yours to call. The worker's `executeJob` is the one execution path, and it owns the attempt counter, the step store, the timeout and the lease — none of which a direct call carries. That is also why `.as()` queues: on an [action](Actions) `.as()` *runs* the mutation as that actor, on a job it *enqueues* as that actor. Same word, and the difference is the primitive's execution surface, not an inconsistency. + +`describe().steps` is **empty by design**. Step names are chosen inside `run()` at execution time, so they are not statically knowable — the steps a run actually recorded come from the run itself, via `x jobs show --json`. `x.manifest.json`, the `/_x` jobs panel and the MCP dev server read one list, and that list is a map over each handle's own `describe()` — so the list and a single job can never disagree. `name` is stamped from the export name by generated code; an unnamed handle enqueues fine and carries `anonymous-job-` on the row instead. + ## Transactional outbox by default `.enqueue` inside an [action](Actions) writes the job row in the **same transaction** as the business write — the handle resolves the ambient jobs facade, the one `ctx.jobs` names. Commit *is* the enqueue. diff --git a/wiki/Realtime.md b/wiki/Realtime.md index 57845617a..9814f8a00 100644 --- a/wiki/Realtime.md +++ b/wiki/Realtime.md @@ -40,6 +40,27 @@ export const likePost = mutator({ `local` is a pure function of `(tx, input)`, therefore replayable. Hence the rule: **no I/O, no `Date.now()`, no `Math.random()` inside `local`.** Same input, same patch, every replay. +## The fluent surface + +`interface Mutator extends Action` — a mutator **is** an action, so every action member is already on it (`likePost.tool()`, `.openapi()`, `.client({ baseUrl })`, `.job()`, `.contract()`, `.as()`, `.describe()`, `.named()`, and the lifted `.input` `.output` `.policy` `.mcp`). The three names it was authored with — `.local`, `.server`, `.conflict` — project unchanged, plus a brand and its own descriptor. Both halves are reached through the mutator itself, `likePost.local(tx, input)`, never a declaration object. A mutator has no `.def`. + +| Member | Is | Rule | +|---|---|---| +| `.local(tx, input)` | the optimistic twin | runs against the local store, synchronously, no I/O, returns `void`. Takes **already-parsed** input — nothing re-parses on this half | +| `.server(ctx, input)` | the authoritative half | takes **raw** input, like the callable and `.as()`, because this is where parsing happens | +| `.conflict` | the rebase strategy, lifted | `'server-wins'` \| `'last-write-wins'` \| `custom(merge)` — the declared value verbatim, merge function included | +| `.isMutator` | the brand | always `true`; `isMutator(value)` is the guard | +| `.describeMutator()` | the manifest row | the action descriptor plus `kind: 'mutator'` and the **resolved** strategy name — `custom(merge)` describes as `'custom'` | +| `.describe()` | the action descriptor | still reports `kind: 'action'`. `describeMutator()` is the one that says `mutator` | + +**`.server()` routes through the action's own callable, never the declared `server`.** It calls `base(input, { ctx })`, and that callable *is* `invoke` — so the authoritative half cannot skip the input parse, the policy or the output parse. Reaching the declaration from there would be a second execution path, which is the one thing `@ultimat3/action` exists to prevent. Hence the guarantee this page rests on: the offline/realtime half of a mutator gets exactly the same parse → policy → handle → parse core as an HTTP call, an MCP tool call and a job run, because it **is** that call. A denied `.server()` never reaches the declared half, and neither does one whose input fails the schema — `X_UNAUTHENTICATED`, `X_FORBIDDEN` and `X_INPUT_INVALID` come back from the same core that serves the HTTP route. + +The parsed-vs-raw asymmetry between `.local()` and `.server()` is deliberate, not an inconsistency. `local` replays on every rebase and must stay a pure function of `(tx, input)` — no I/O, no clock, no randomness — so re-parsing on each replay would be wasted work on input that was already validated once. + +`.local()` is the name. No alias: the old `.applyLocal` is gone, not deprecated. + +`.named()` rewraps rather than dropping the twin — a renamed mutator keeps both halves, its `conflict`, and every inherited action member. + ## Tier 2 → tier 3 is `persist: true` ```ts diff --git a/wiki/Routes-And-Render-Modes.md b/wiki/Routes-And-Render-Modes.md index 088968334..d4a198903 100644 --- a/wiki/Routes-And-Render-Modes.md +++ b/wiki/Routes-And-Render-Modes.md @@ -25,6 +25,32 @@ export const config = defineRoute({ | Owns | render mode, hydration timing, metadata, offline strategy | | Never | touch the DB directly, hold business logic, or omit `meta.description` in `site/` — that is a build error | +## The descriptor + +`route` is the one primitive whose façade is a **normalized descriptor** rather than a set of projection methods, because a route declares no behaviour to project. It is read by the router, the prerenderer, `sw.js` generation, the sitemap and the budget check, and the descriptor's job is to hand all five **one shape** so none of them branches. There is no `.describe()` on a route either — `describeRoutes()` is the one route list. + +| Member | Is | Rule | +|---|---|---| +| `kind: 'route'` | the brand | lets the registry reject a non-route export. `isRouteConfig(value)` is the guard | +| `meta(data)` | the `` producer | **always returns a promise.** A synchronous declared `meta` is wrapped, and a `meta` that throws synchronously becomes a rejection — so `await config.meta(data)` is the one way to fail as well as the one way to succeed, and no consumer branches on a thenable | +| `budget` | the per-route limits | **always an object**, `{}` when undeclared, rather than a second undefined-check at every call site | +| `render` `revalidate` `prerender` `offline` `hydrate` `policy` | the declaration, carried through | unchanged. Optional keys are omitted, never set to `undefined` | +| the whole object | `Object.freeze`d | no consumer can mutate the route another consumer is about to read | + +**The always-present `budget` does not weaken the `site/` JS-budget check.** `budget` is always an object, but its *fields* stay optional — so `budget.js === undefined` still means "this route declared no JS budget", and a `site/` route that opts into any `hydrate` other than `never` without one is `X_ROUTE_MODE_INVALID`, exactly as before the normalization. Measuring the declared numbers against the built output is the separate concern in **Budgets**, below. + +Validation runs at **module evaluation**. `defineRoute` checks the shape and the mode-local invariants immediately, so a bad route fails at build time rather than on the first request in production. + +| Checked | Where | Code | +|---|---|---| +| `offline` present and a known strategy | `defineRoute` | `X_ROUTE_OFFLINE_MISSING` | +| `meta` is a function | `defineRoute` | `X_ROUTE_META_MISSING` | +| mode-local: known `render` and `hydrate`; `static` with a `policy` or a `revalidate`; `isr` with no trigger; `ssr` with a `prerender`; `spa` with no `policy` | `defineRoute` | `X_ROUTE_MODE_INVALID` | +| surface-dependent: mode allowed on the surface; `site/` hydration without `budget.js`; `stream` with no ``; `prerender` on a non-prerenderable mode | `registerRoute` | `X_ROUTE_MODE_INVALID` | +| two files claiming one URL | `registerRoute` | `X_ROUTE_DUPLICATE` | + +The split is about what is knowable, not about strictness: everything decidable from the config alone is decided at import; the rest needs the file's surface, which only the route table knows. + ## Five render modes | Mode | Behavior | Use | diff --git a/wiki/Scheduled-Tasks.md b/wiki/Scheduled-Tasks.md index 8433e1dfc..98af02a1d 100644 --- a/wiki/Scheduled-Tasks.md +++ b/wiki/Scheduled-Tasks.md @@ -22,7 +22,7 @@ If it has a handler body, it is a [job](Jobs-And-Workflows), not a task. | Concern | Where it lives | |---|---| | When to fire | `task.cron` + `task.tz` | -| What to enqueue | `task.enqueue` — returns `[[jobRef, input], …]` | +| What to enqueue | the `enqueue` field — `(occurrenceMs) => [[jobRef, input], …]` | | The work | the `job`'s `run` | | Retries, steps, concurrency, rate limits | the `job` | | Idempotency | the `job`'s `idempotencyKey` — this is what absorbs a double fire | @@ -38,17 +38,33 @@ export const rotateApiKeys = task({ }); ``` +## The fluent surface + +Every projection is a method on the task — `nightlyDigest.enqueue()`, never `runTask(nightlyDigest)` — and every declared field is lifted onto it. A task has no `.def`. + +| Member | Is | Rule | +|---|---|---| +| `nightlyDigest.entries(occurrenceMs?)` | the `[[jobRef, input], …]` pairs this task fires — the handle's read of the declared `enqueue` field | built **for that occurrence**, not for "now" — a catch-up dispatch runs long after the instant it fires for, and a payload derived from the wall clock there is silently for the wrong day. Defaults to now, the honest answer for the two callers that have no occurrence: a manual `enqueue()` and `describe()` | +| `.enqueue(options?)` | fire the declared entries immediately | the backfill and "run it again" path — no scheduler, no leader, no tick. Goes through the **same facade `.enqueue` uses**, so it joins the caller's transaction on the same terms. Returns one `{ job, result }` per entry | +| `.describe()` | the manifest row | `kind`, `name`, `cron`, `tz`, `catchUp`, `maxCatchUp`, `jobs` — the job names, in declaration order, because a task's entries are a sequence and not a set | +| `.kind` `.name` `.cron` `.tz` `.catchUp` `.maxCatchUp` | the declaration, lifted | readable, and already resolved: `kind` is `'task'`, `catchUp` defaults to `'skip'`, `maxCatchUp` to `10` | + +A task reaches its jobs only through its own entries, and those entries carry job **handles**, never job names. So the scheduler dispatches with `.idempotencyKeyFor(input)` and `.retry.attempts` read off the handle — a task never restates a job's retry policy and never resolves a job by string. + +Manual `enqueue()` and a scheduled tick differ in exactly one place: the key. A tick's is occurrence-scoped, `::`, so two schedulers or a retried tick cannot double-fire that occurrence. `enqueue()` uses the job's plain key, because a manual run has no occurrence to scope to — and scoping it to whichever occurrence it happened to land in is the one thing that would make a backfill dedupe against a real tick. + ## Fields | Field | Required | Rule | |---|---|---| | `cron` | yes | standard 5-field expression. No seconds field, no `@hourly` aliases — one way to write it | | `tz` | yes | explicit IANA zone (`'UTC'`, `'Europe/Berlin'`, `'America/New_York'`). Omitting it is a compile error | -| `enqueue` | yes | `() => [[jobRef, input], …]`. Zero or more pairs; an empty list is a valid no-op tick | -| `queue` | no | overrides the target queue for everything this task enqueues | -| `enabled` | no — default `true` | a disabled task is still listed by `x tasks list --json`, with `nextRun: null` | +| `enqueue` | yes | `(occurrenceMs) => [[jobRef, input], …]`. Zero or more pairs; an empty list is a valid no-op tick. Read back through the handle as `entries()`, never as `.enqueue` — that name on the handle is the *fire* method | +| `catchUp` | no — default `'skip'` | what to do when the scheduler was down across one or more occurrences. `'skip'` waits for the next one, `'run-once'` fires a single catch-up, `'run-all'` fires one per missed occurrence | +| `maxCatchUp` | no — default `10` | how many occurrences one tick walks forward from the last fire. Bounds `'run-all'` directly, and caps the lookback for every policy | +| `name` | no | the export name, stamped by generated code. An unnamed task carries `anonymous-task-` | -Nothing else. There is no `timeout`, no `retry`, no `concurrency` on a task — those belong to the job it enqueues. +Nothing else. There is no `timeout`, no `retry`, no `concurrency` on a task — those belong to the job it enqueues. No `queue` either: the queue is the job's, and a per-call override rides on the fire, as `.enqueue({ queue })`. ## `tz` is required for a reason @@ -59,6 +75,16 @@ Nothing else. There is no `timeout`, no `retry`, no `concurrency` on a task — | DST spring-forward gap | a `2:30` local tick has no instant — the framework fires it at the zone's next valid instant | | DST fall-back overlap | a `1:30` local tick occurs twice — the framework fires it **once**, on the first occurrence | +**`tz` is checked against the runtime's own IANA database, not merely for non-emptiness** `As of 2026-08`. The declaration asks `Intl.DateTimeFormat` to resolve the zone, and `Intl` carries the runtime's copy of the tz database — the only check that can tell `America/Bogota` from `Bogota`. So a non-empty string is not a timezone: + +``` +X_INVARIANT: task "nightlyDigest" has tz "Bogota", which is not a zone in the IANA tz database +fix: use the full zone id on task("nightlyDigest"), e.g. tz: 'America/Bogota' — list the valid + ones with: bun -e "console.log(Intl.supportedValuesOf('timeZone').join('\n'))" +``` + +It fails at declaration, not at the first tick. The alternative is what an abbreviation used to do: resolve every occurrence in UTC and run the cron five hours off, silently, forever. + Store UTC, schedule against an explicit zone, format at the edge. See [Timezones and dates](Timezones-And-Dates). ## The `scheduler` role @@ -90,6 +116,8 @@ Role table and drain sequence: [Deployment](Deployment). | MCP `tasks.list` | same content as `x tasks list --json`, same authz | | `/_x` dev panel | schedule table with next-run countdown and last-tick outcome | | `x.manifest.json` | generated `tasks` section — the build-time source of truth | +| `.describe()` | in process: `kind`, `name`, `cron`, `tz`, `catchUp`, `maxCatchUp`, and the job names it enqueues | +| `.entries(occurrenceMs?)` | in process: the exact pairs that occurrence fires, inputs included — which `describe()` drops | ``` $ x tasks list --json diff --git a/wiki/The-Eight-Primitives.md b/wiki/The-Eight-Primitives.md index e760b5c28..2f2dcc040 100644 --- a/wiki/The-Eight-Primitives.md +++ b/wiki/The-Eight-Primitives.md @@ -24,6 +24,25 @@ task — a scheduled trigger (cron) that enqueues jobs | `route` | a route folder's `config` export | `x g route ` | [Routes and render modes](Routes-And-Render-Modes) | | `task` | `/jobs.ts` | `x g task ` | [Scheduled tasks](Scheduled-Tasks) | +## Every primitive projects itself + +A primitive's surfaces are methods **on the primitive**, never free functions taking it. `publishPost.tool()`, not `toMcpTool(publishPost)`. Every declared field is lifted onto it, and the declaration object — the `handle`, the `sql`, the `server` — is not reachable from app code at all. + +| Primitive | Its surface | +|---|---| +| `entity` | `$`-sigil members: `posts.$view([...])`, `posts.$parse(row)`, `posts.$assert(row)`, `posts.$migration()`. An entity *is* its columns, so the sigil keeps the namespace clear — `posts.name` is a column, `posts.$name` is the entity | +| `policy` | one signature, `({ actor, input, row, ctx })`, identical on every surface. `row` is required and nullable, never smuggled through `input` | +| `action` | `.as()` `.tool()` `.openapi()` `.client()` `.job()` `.contract()` `.describe()`, plus `.input` `.output` `.policy` `.mcp`. `handle` is not among them | +| `mutator` | everything `action` has, plus `.local()` `.server()` `.conflict` `.describeMutator()` | +| `query` | `.as()` `.live()` `.tool()` `.client()` `.describe()`, plus `.input` `.policy` `.cache` `.mcp` `.isLive`. `sql` is not among them | +| `job` | `.enqueue()` `.as()` `.describe()`, plus `.parse()` and `.idempotencyKeyFor()` | +| `route` | a normalized descriptor rather than methods — `meta()` always awaits, `budget` is always an object. A route declares no behaviour to project; `describeRoutes()` is the one route list | +| `task` | `.entries()` `.enqueue()` `.describe()` | + +The declaration lives in a private store inside the package that runs it, and nothing exports a reader for it. So there is one execution path and one authz path structurally, not by convention: a hand-rolled look-alike carrying the right `kind` is `X_ACTION_FOREIGN` / `X_QUERY_FOREIGN`, never a registered primitive. Registration stamps the export name **in place**, so `import { publishPost }` is the object that projects once the app has booted — there is no second, differently-named twin to remember. + +Each primitive's deep page carries the full member table. + ## `entity` A table + its domain type + invariants. The single source of the DB schema, the TS type, and the parse boundary. From 89a59018dee8fb8623656a3933063f14d88f0a48 Mon Sep 17 00:00:00 2001 From: sebi Date: Mon, 10 Aug 2026 11:54:39 -0500 Subject: [PATCH 5/7] feat(dsl): re-export t from every declaration package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four primitive packages (action/query/jobs/entity) already re-exported `t`; the rule stopped there. A `defineMail` template, a `defineAppMcp` tool and an `llm()` call each declare a schema too, and each had to reach past its package to `@ultimat3/schema` — a second import, and the ambiguity tax axiom 1 exists to remove. - mail/mcp/ai re-export `t` + `Infer` verbatim from `@ultimat3/schema`. All three already declared the dep, so no boundary changes (tier 4 → 0). - index.test.ts per package asserts `t` is the SAME object, not a copy: a spread would typecheck and silently freeze `schemaProvider()` at import. Negative-proved — swapping the export for `{ ...schemaT }` turns it red. - 5 reference-app files now take `t` from the package they declare in. `apps/web/**/entity.ts` deliberately stays on `@ultimat3/schema`: those declare no primitive, so schema already IS their one import. - `@postly/mcp` drops its now-unused `@ultimat3/schema` dependency. - `packages/{ai,mcp}/README.md` used `t` with no import at all — fixed. - Rule stated once in `wiki/The-Eight-Primitives.md`, with why `policy` and `route` re-export nothing: neither primitive declares a schema. Co-Authored-By: Claude --- examples/dummy/CLAUDE.md | 9 ++++-- examples/dummy/apps/web/app/digest/mail.ts | 5 +-- examples/dummy/apps/web/app/orgs/entity.ts | 3 ++ examples/dummy/apps/web/app/posts/entity.ts | 3 ++ examples/dummy/apps/web/app/posts/mail.ts | 5 +-- .../apps/web/app/posts/prompts/summarize.ts | 5 +-- examples/dummy/packages/mcp/CLAUDE.md | 2 +- examples/dummy/packages/mcp/package.json | 1 - examples/dummy/packages/mcp/src/tools.test.ts | 3 +- examples/dummy/packages/mcp/src/tools.ts | 5 +-- packages/ai/CLAUDE.md | 3 ++ packages/ai/README.md | 2 +- packages/ai/src/index.test.ts | 20 ++++++++++++ packages/ai/src/index.ts | 3 ++ packages/mail/CLAUDE.md | 3 ++ packages/mail/README.md | 3 +- packages/mail/src/index.test.ts | 26 ++++++++++++++++ packages/mail/src/index.ts | 3 ++ packages/mcp/CLAUDE.md | 3 ++ packages/mcp/README.md | 2 +- packages/mcp/src/index.test.ts | 31 +++++++++++++++++++ packages/mcp/src/index.ts | 4 ++- wiki/The-Eight-Primitives.md | 17 ++++++++++ 23 files changed, 142 insertions(+), 19 deletions(-) create mode 100644 packages/ai/src/index.test.ts create mode 100644 packages/mail/src/index.test.ts create mode 100644 packages/mcp/src/index.test.ts diff --git a/examples/dummy/CLAUDE.md b/examples/dummy/CLAUDE.md index c92bcf934..5af984371 100644 --- a/examples/dummy/CLAUDE.md +++ b/examples/dummy/CLAUDE.md @@ -65,8 +65,13 @@ 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: the schema namespace (`@ultimat3/schema`) in schema/entity/action - files, the i18n translator (`useI18n()`) in components. Never both in one file. +- `t` is two different things by file kind: the schema namespace in declaration files, the i18n + translator (`useI18n()`) in components. Never both in one file. A declaration file imports the + schema `t` from the package it declares in — `@ultimat3/action` in `actions.ts`/`mutator.ts`, + `@ultimat3/query` in `live.ts`, `@ultimat3/jobs` in `jobs.ts`, `@ultimat3/mail` in `mail.ts`, + `@ultimat3/mcp` in the MCP package — never reaching past it to `@ultimat3/schema`. It is the same + object either way. A module that declares no primitive (a feature's `entity.ts` view schemas) + imports `@ultimat3/schema` directly, because that already is its one import. - Money is `{ minor, currency }`. Arithmetic in `packages/core/src/billing.ts`; formatting only in `` at the edge. - Dates are UTC instants in the DB, rendered only through ``. diff --git a/examples/dummy/apps/web/app/digest/mail.ts b/examples/dummy/apps/web/app/digest/mail.ts index 348d0e149..a5fada05e 100644 --- a/examples/dummy/apps/web/app/digest/mail.ts +++ b/examples/dummy/apps/web/app/digest/mail.ts @@ -3,10 +3,11 @@ * usually wrong — the member's timezone. `localDate` is computed once by the job, in the member's * zone, and travels as a string: nothing here reaches for a clock, so "yesterday" means yesterday * where the reader is rather than where the worker runs. + * + * `t` comes from @ultimat3/mail, not @ultimat3/schema: a mail file imports one package. */ -import { blocks, defineMail } from '@ultimat3/mail'; -import { type Infer, t } from '@ultimat3/schema'; +import { blocks, defineMail, type Infer, t } from '@ultimat3/mail'; import { MemberView } from '../orgs/entity'; import { PostSummary } from '../posts/entity'; diff --git a/examples/dummy/apps/web/app/orgs/entity.ts b/examples/dummy/apps/web/app/orgs/entity.ts index 02838891d..6767cdc1d 100644 --- a/examples/dummy/apps/web/app/orgs/entity.ts +++ b/examples/dummy/apps/web/app/orgs/entity.ts @@ -2,6 +2,9 @@ * The orgs feature's view schemas: what an org, a membership and a billing receipt look like on * the wire. Money crosses the wire as minor units plus a currency — never as a formatted string, * because a client that receives "€18,00" cannot do arithmetic with it. + * + * `t` comes from @ultimat3/schema here, not from a primitive package: this file declares no + * primitive, so schema *is* its one import. Every primitive package re-exports the same object. */ import { diff --git a/examples/dummy/apps/web/app/posts/entity.ts b/examples/dummy/apps/web/app/posts/entity.ts index 93f559b4e..c1668035e 100644 --- a/examples/dummy/apps/web/app/posts/entity.ts +++ b/examples/dummy/apps/web/app/posts/entity.ts @@ -1,6 +1,9 @@ /** * The posts feature's view schemas. The table lives in `@postly/db` because admin and the worker * need it too; what a *post looks like on the wire* is this feature's business, so it lives here. + * + * `t` comes from @ultimat3/schema here, not from a primitive package: this file declares no + * primitive, so schema *is* its one import. Every primitive package re-exports the same object. */ import type { Post } from '@postly/db'; diff --git a/examples/dummy/apps/web/app/posts/mail.ts b/examples/dummy/apps/web/app/posts/mail.ts index 63d210f0e..6862f60f0 100644 --- a/examples/dummy/apps/web/app/posts/mail.ts +++ b/examples/dummy/apps/web/app/posts/mail.ts @@ -1,10 +1,11 @@ /** * Mail the posts feature sends. A template is a declaration: the runtime renders it in the * recipient's locale, in the `worker` role, with no DOM and no request in scope. + * + * `t` comes from @ultimat3/mail, not @ultimat3/schema: a mail file imports one package. */ -import { blocks, defineMail } from '@ultimat3/mail'; -import { type Infer, t } from '@ultimat3/schema'; +import { blocks, defineMail, type Infer, t } from '@ultimat3/mail'; import { MemberView } from '../orgs/entity'; import { PostView } from './entity'; diff --git a/examples/dummy/apps/web/app/posts/prompts/summarize.ts b/examples/dummy/apps/web/app/posts/prompts/summarize.ts index f0c44cd2a..8cfa731e0 100644 --- a/examples/dummy/apps/web/app/posts/prompts/summarize.ts +++ b/examples/dummy/apps/web/app/posts/prompts/summarize.ts @@ -9,13 +9,14 @@ * The prompt itself is a versioned artifact. Bumping `version` changes its content hash, which * keys the semantic cache and appears in traces, so a summary can always be attributed to the * exact text that produced it. + * + * `t` comes from @ultimat3/action, not @ultimat3/schema: an action file imports one package. */ import { tag } from '@postly/db'; -import { action } from '@ultimat3/action'; +import { action, t } from '@ultimat3/action'; import { definePrompt } from '@ultimat3/ai'; import { can } from '@ultimat3/policy'; -import { t } from '@ultimat3/schema'; import { summarizeTemplate } from './summarize-template'; /** Editing the markdown requires bumping this version — it keys the cache and the traces. */ diff --git a/examples/dummy/packages/mcp/CLAUDE.md b/examples/dummy/packages/mcp/CLAUDE.md index ba54ad591..830fd453c 100644 --- a/examples/dummy/packages/mcp/CLAUDE.md +++ b/examples/dummy/packages/mcp/CLAUDE.md @@ -6,7 +6,7 @@ The app's own MCP surface. Wired in by `app.config.ts` (`mcp.server`). | May import | Must never | |---|---| -| `@ultimat3/mcp`, `@ultimat3/schema`, `@postly/core`, `@postly/domain` | `apps/*` at runtime, `@postly/db` | +| `@ultimat3/mcp` (`t` included — never `@ultimat3/schema` past it), `@postly/core`, `@postly/domain` | `apps/*` at runtime, `@postly/db` | | `import type` from the app's `Api` for tool typing | a second authz path, a service account, a "trusted" mode | Actions reach this surface because they declare `mcp: { expose: true }`, not because this package diff --git a/examples/dummy/packages/mcp/package.json b/examples/dummy/packages/mcp/package.json index 5da22b8b6..70630f411 100644 --- a/examples/dummy/packages/mcp/package.json +++ b/examples/dummy/packages/mcp/package.json @@ -22,7 +22,6 @@ "@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/mcp/src/tools.test.ts b/examples/dummy/packages/mcp/src/tools.test.ts index bfdebea39..538fc7b6a 100644 --- a/examples/dummy/packages/mcp/src/tools.test.ts +++ b/examples/dummy/packages/mcp/src/tools.test.ts @@ -9,8 +9,7 @@ */ import { describeActions } from '@ultimat3/action'; -import { defineAppMcp } from '@ultimat3/mcp'; -import { t } from '@ultimat3/schema'; +import { defineAppMcp, t } from '@ultimat3/mcp'; import { expect, test } from '@ultimat3/testing'; import { mcp } from './tools'; diff --git a/examples/dummy/packages/mcp/src/tools.ts b/examples/dummy/packages/mcp/src/tools.ts index 9b57de280..90ef3a9cd 100644 --- a/examples/dummy/packages/mcp/src/tools.ts +++ b/examples/dummy/packages/mcp/src/tools.ts @@ -10,12 +10,13 @@ * `packages/admin/src/mcp.ts` (the framework's own admin package) is the shape to follow once * they do — it wraps `resolveToken` around the same `actor({ token })` hook its HTTP surface * already uses. + * + * `t` comes from @ultimat3/mcp, not @ultimat3/schema: an MCP file imports one package. */ import { localDateIn, nextDigestAt, quoteUpgrade, seatsRemaining } from '@postly/core'; import { PLAN_CODES, seatLimit } from '@postly/domain'; -import { defineAppMcp } from '@ultimat3/mcp'; -import { t } from '@ultimat3/schema'; +import { defineAppMcp, t } from '@ultimat3/mcp'; export const mcp = defineAppMcp({ name: 'postly', diff --git a/packages/ai/CLAUDE.md b/packages/ai/CLAUDE.md index 8bff8b2a2..cf8731d63 100644 --- a/packages/ai/CLAUDE.md +++ b/packages/ai/CLAUDE.md @@ -37,6 +37,9 @@ rather than imported. Same contract, two wire formats. - **`llm()` returns an `action`. It is not a ninth primitive** (root `CLAUDE.md`, 2026-08). It never re-implements parse, authz or invoke — `action()` owns those and `invoke` runs them. +- `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so an `llm` file imports one + package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on every + access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity. - The model half is the only thing `llm.ts` adds: render the prompt, project `output` into the one tool the model may answer through, reserve the budget, consult the semantic cache. - One repair turn, then `X_LLM_OUTPUT_INVALID`. Two schema failures is a prompt/schema diff --git a/packages/ai/README.md b/packages/ai/README.md index aa66ab365..cba9bfbe2 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -86,7 +86,7 @@ Not a ninth primitive. A model call has an input schema, an output schema and a is an `action` — so `llm()` returns one, and everything an action projects, it projects. ```ts -import { llm } from '@ultimat3/ai'; +import { llm, t } from '@ultimat3/ai'; import { can } from '@ultimat3/policy'; export const summarize = llm({ diff --git a/packages/ai/src/index.test.ts b/packages/ai/src/index.test.ts new file mode 100644 index 000000000..b35a2a2c0 --- /dev/null +++ b/packages/ai/src/index.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'bun:test'; +import { t as schemaT } from '@ultimat3/schema'; +import { t } from './index'; + +describe('@ultimat3/ai public surface', () => { + test('re-exports the one `t`, not a copy of it', () => { + // A spread or a re-implementation would still typecheck but would stop tracking + // `configureSchemaProvider()`. Identity is the only assertion that catches that. + expect(t).toBe(schemaT); + }); + + test('the re-exported `t` builds a working `llm` output schema', () => { + // `llm()` projects its `output` into the tool the model must answer through, so the + // schema an author writes here is the one that rejects a malformed completion. + const schema = t.object({ summary: t.string, tags: t.array(t.string) }); + + expect(schema.parse({ summary: 'ok', tags: ['a'] })).toEqual({ summary: 'ok', tags: ['a'] }); + expect(() => schema.parse({ summary: 'ok', tags: 'a' })).toThrow(); + }); +}); diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 86986dee5..aae6a5cc4 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,6 +1,9 @@ // Public API of @ultimat3/ai. Explicit — a wildcard barrel would leak internals an app // could depend on, and the gateway's guarantees only hold if every call goes through it. +/** Re-exported so an `llm` file needs one import, not two. Same object as schema's. */ +export type { Infer } from '@ultimat3/schema'; +export { t } from '@ultimat3/schema'; export type { BudgetLedgerInput, BudgetLimits, diff --git a/packages/mail/CLAUDE.md b/packages/mail/CLAUDE.md index 8f7b3d9df..6f4b64ad5 100644 --- a/packages/mail/CLAUDE.md +++ b/packages/mail/CLAUDE.md @@ -26,6 +26,9 @@ ## Rules +- `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so a `defineMail` file + imports one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on + every access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity. - `SendOptions.locale` is non-optional in the type. Never relax it, never default it. - Text part is derived from blocks and must be non-empty (`X_MAIL_TEXT_MISSING`). - No literal user-facing string in `templates/` or `layout.ts` — keys only. diff --git a/packages/mail/README.md b/packages/mail/README.md index 45733d1b6..01c2d8453 100644 --- a/packages/mail/README.md +++ b/packages/mail/README.md @@ -4,8 +4,7 @@ Transactional email as data. One template renders **both** an HTML part and a pl every string is an i18n key, every colour is a design token, and delivery is a job. ```ts -import { defineMail, send, blocks } from '@ultimat3/mail'; -import { t } from '@ultimat3/schema'; +import { defineMail, send, blocks, t } from '@ultimat3/mail'; export const receiptMail = defineMail({ id: 'receipt', diff --git a/packages/mail/src/index.test.ts b/packages/mail/src/index.test.ts new file mode 100644 index 000000000..41f9fcb78 --- /dev/null +++ b/packages/mail/src/index.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test'; +import { t as schemaT } from '@ultimat3/schema'; +import { blocks, defineMail, t } from './index'; + +describe('@ultimat3/mail public surface', () => { + test('re-exports the one `t`, not a copy of it', () => { + // A spread or a re-implementation would still typecheck but would stop tracking + // `configureSchemaProvider()`. Identity is the only assertion that catches that. + expect(t).toBe(schemaT); + }); + + test('the re-exported `t` builds a working `defineMail` input', () => { + const receipt = defineMail({ + id: 'index.test.receipt', + subject: 'mail.receipt.subject', + input: t.object({ name: t.string, url: t.url }), + template: ({ data }) => [blocks.heading('mail.receipt.heading', { name: data.name })], + }); + + expect(receipt.input?.parse({ name: 'Ada', url: 'https://example.com' })).toEqual({ + name: 'Ada', + url: 'https://example.com', + }); + expect(() => receipt.input?.parse({ name: 'Ada', url: 'not-a-url' })).toThrow(); + }); +}); diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index 091544be8..55d0f0174 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -1,6 +1,9 @@ // Single responsibility: the public API of @ultimat3/mail. Explicit named exports only — // other packages call `defineMail`, `send` and the driver seam, and nothing else. +/** Re-exported so a `defineMail` file needs one import, not two. Same object as schema's. */ +export type { Infer } from '@ultimat3/schema'; +export { t } from '@ultimat3/schema'; export type { CalloutTone, MailBlock, MailTemplate, TemplateArgs } from './blocks'; export { blocks } from './blocks'; diff --git a/packages/mcp/CLAUDE.md b/packages/mcp/CLAUDE.md index d162fb555..da8c13869 100644 --- a/packages/mcp/CLAUDE.md +++ b/packages/mcp/CLAUDE.md @@ -30,6 +30,9 @@ import. The CLI wires it. ## Invariants +- `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so a `defineAppMcp` file + imports one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on + every access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity. - Three outcomes, never blurred: role-hidden → `-32601` ToolNotFound with no `data`; scope → `-32600` `X_MCP_SCOPE_DENIED` naming the scope; policy → an `isError` result carrying `X_POLICY_DENIED`. Swapping any two is an enumeration oracle. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 6b410a631..b2f14c13a 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -85,7 +85,7 @@ user's app: ```ts // apps/admin/src/mcp.ts -import { defineAppMcp } from '@ultimat3/mcp'; +import { defineAppMcp, t } from '@ultimat3/mcp'; export const mcp = defineAppMcp({ name: 'acme-admin', diff --git a/packages/mcp/src/index.test.ts b/packages/mcp/src/index.test.ts new file mode 100644 index 000000000..a12bf28aa --- /dev/null +++ b/packages/mcp/src/index.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test'; +import { t as schemaT } from '@ultimat3/schema'; +import { defineAppMcp, t } from './index'; + +describe('@ultimat3/mcp public surface', () => { + test('re-exports the one `t`, not a copy of it', () => { + // A spread or a re-implementation would still typecheck but would stop tracking + // `configureSchemaProvider()`. Identity is the only assertion that catches that. + expect(t).toBe(schemaT); + }); + + test('the re-exported `t` builds a working hand-written tool input', () => { + const mcp = defineAppMcp({ + name: 'index-test', + tools: { + seatReport: { + description: 'Seats used, remaining and the plan limit. Read-only.', + input: t.object({ orgId: t.string }), + policy: 'org:administer', + destructive: false, + handle: () => ({ used: 3 }), + }, + }, + }); + + // The published JSON Schema is derived from the author's `t` — a copied `t` would still + // produce one, so the assertion is that this one describes the declared field. + const tool = mcp.tools.find((candidate) => candidate.name === 'seatReport'); + expect(tool?.inputSchema).toMatchObject({ type: 'object', required: ['orgId'] }); + }); +}); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 5a9373943..833acc06c 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,7 +1,9 @@ // Public API of @ultimat3/mcp. Explicit — nothing is re-exported by wildcard, so the // surface an app or an agent can reach is exactly this list. -export { formatIssues } from '@ultimat3/schema'; +/** Re-exported so a `defineAppMcp` file needs one import, not two. Same object as schema's. */ +export type { Infer } from '@ultimat3/schema'; +export { formatIssues, t } from '@ultimat3/schema'; export type { AnyAppToolDefinition, AppToolArgs, diff --git a/wiki/The-Eight-Primitives.md b/wiki/The-Eight-Primitives.md index 2f2dcc040..cf540ec8c 100644 --- a/wiki/The-Eight-Primitives.md +++ b/wiki/The-Eight-Primitives.md @@ -43,6 +43,23 @@ The declaration lives in a private store inside the package that runs it, and no Each primitive's deep page carries the full member table. +## One import per authoring file + +`t` is re-exported from every package that hosts a declaration factory. Import it from the package you declare in; never reach past that package to `@ultimat3/schema`. It is the **same object**, not a copy — `configureSchemaProvider()` still swaps the provider under it — and each package pins that with an identity assertion in its `index.test.ts`. + +| Import `t` from | When the file declares | +|---|---| +| `@ultimat3/entity` | an `entity` | +| `@ultimat3/action` | an `action` or a `mutator` | +| `@ultimat3/query` | a `query` | +| `@ultimat3/jobs` | a `job` or a `task` | +| `@ultimat3/mail` | a `defineMail` template | +| `@ultimat3/mcp` | a `defineAppMcp` hand-written tool | +| `@ultimat3/ai` | an `llm` — a factory over `action`, so it carries `t` too | +| `@ultimat3/schema` | nothing — a plain view-schema module, where schema *is* the one import | + +`policy` and `route` declare no schema, so neither package re-exports `t`: a policy decides from `({ actor, input, row, ctx })` and a route from its path. + ## `entity` A table + its domain type + invariants. The single source of the DB schema, the TS type, and the parse boundary. From 5e957a95af501ffe2f55717a3ba1865a56d1b951 Mon Sep 17 00:00:00 2001 From: sebi Date: Mon, 10 Aug 2026 11:58:24 -0500 Subject: [PATCH 6/7] test(dsl): pin the mutator DSL surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mutator was the one primitive whose fluent façade the dsl.test.ts pattern never enumerated — action/query/jobs/entity/policy/render all had a shape+delegation pin, mutator only had behavioral tests. - Adds it to packages/action/src/dsl.test.ts (mutator lives in the same package, built on action()): exact member set (action façade + isMutator/conflict/local/server/describeMutator), .local()/.server() delegation proofs, and a.tool().policy === a.policy for a mutator instance specifically. Co-Authored-By: Claude --- packages/action/src/dsl.test.ts | 97 +++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/packages/action/src/dsl.test.ts b/packages/action/src/dsl.test.ts index a32a93781..b638a7719 100644 --- a/packages/action/src/dsl.test.ts +++ b/packages/action/src/dsl.test.ts @@ -7,12 +7,15 @@ * fails this test, not just a downstream consumer. */ import { describe, expect, test } from 'bun:test'; +import { createContext, userActor } from '@ultimat3/core'; import { can } from '@ultimat3/policy'; import { t } from '@ultimat3/schema'; import { action } from './action'; import { toOpenApiOperation } from './http'; import { toJobHandle } from './job-handle'; import { toMcpTool } from './mcp-tool'; +import type { LocalRow, LocalTable, LocalTx } from './mutator'; +import { mutator } from './mutator'; const Input = t.object({ postId: t.uuid }); const Output = t.object({ id: t.uuid, published: t.boolean }); @@ -119,3 +122,97 @@ describe('the action DSL surface', () => { } }); }); + +// A mutator IS an action (`mutator.ts` builds it on `action()`, not beside it), so it +// carries the entire action façade above plus the three members it was authored with. +// Kept in sync by hand on purpose, same as `FACADE_MEMBERS` — a silent drift here is +// exactly the regression this file exists to catch. +const MUTATOR_MEMBERS = ['isMutator', 'conflict', 'local', 'server', 'describeMutator'] as const; + +interface PostRow extends LocalRow { + readonly likes: number; +} + +function fakeTx(rows: Map): LocalTx { + const table: LocalTable = { + insert: (row) => { + rows.set(row.id, row); + }, + update: (id, patch) => { + const current = rows.get(id); + if (current === undefined) return; + rows.set(id, { ...current, ...(typeof patch === 'function' ? patch(current) : patch) }); + }, + delete: (id) => { + rows.delete(id); + }, + }; + return { table: () => table } as unknown as LocalTx; +} + +const likerActor = { ...userActor({ id: 'u1' }), permissions: ['post:like'] }; + +function defineMutatorTarget() { + return mutator({ + input: Input, + output: Output, + policy: can('post:like'), + mcp: { expose: true, description: 'dsl pin' }, + local: (tx, { postId }) => + tx.table('posts').update(postId, (post) => ({ likes: post.likes + 1 })), + server: (_ctx, { postId }) => ({ id: postId, published: true }), + conflict: 'server-wins', + }).named('dslLikePost'); +} + +describe('the mutator DSL surface', () => { + test('a built mutator carries the whole action façade plus its own three members', () => { + const target = defineMutatorTarget(); + expect(typeof target).toBe('function'); + for (const member of [...BASE_MEMBERS, ...FACADE_MEMBERS, ...MUTATOR_MEMBERS]) { + expect(target).toHaveProperty(member); + } + }); + + test('.local() delegates to the declared local, verbatim — no wrapping in between', () => { + const target = defineMutatorTarget(); + const rows = new Map([[POST_ID, { id: POST_ID, likes: 3 }]]); + target.local(fakeTx(rows), { postId: POST_ID }); + expect(rows.get(POST_ID)?.likes).toBe(4); + }); + + test('.server() delegates through the action own callable — never the declared half directly', async () => { + const target = defineMutatorTarget(); + const ctx = createContext({ actor: likerActor }); + const viaServer = await target.server(ctx, { postId: POST_ID }); + const viaCallable = await target({ postId: POST_ID }, { ctx }); + expect(viaServer).toEqual(viaCallable); + }); + + test('.tool() delegates to toMcpTool() — same data, same policy reference', () => { + const target = defineMutatorTarget(); + const direct = toMcpTool(target); + const viaFacade = target.tool(); + expect(viaFacade.name).toBe(direct.name); + expect(viaFacade.policy).toBe(direct.policy); + }); + + // Same central claim as the action façade above, pinned again for the mutator + // instance specifically: wrapping (`mutator.ts`'s `wrap()`) must never fork the + // policy the action itself carries — `.tool()` and `.server()` decide from one object. + test('a.tool().policy === a.policy — one authz object across every surface', () => { + const target = defineMutatorTarget(); + expect(target.tool().policy).toBe(target.policy); + }); + + test('a named twin carries the same façade — naming never rebuilds it', () => { + const target = defineMutatorTarget(); + const twin = target.named('dslLikePostTwin'); + expect(twin.policy).toBe(target.policy); + expect(twin.conflict).toBe(target.conflict); + expect(twin.tool().policy).toBe(twin.policy); + for (const member of [...BASE_MEMBERS, ...FACADE_MEMBERS, ...MUTATOR_MEMBERS]) { + expect(twin).toHaveProperty(member); + } + }); +}); From 931275ebdb134cb1ce7fcb873433f9a77891b4c7 Mon Sep 17 00:00:00 2001 From: sebi Date: Mon, 10 Aug 2026 12:33:21 -0500 Subject: [PATCH 7/7] fix: address PR #27 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - defineApi builds its maps from the registrars' own results, not from every module export — a feature helper could otherwise sit in Api['actions'] as a client method nothing serves, and two modules' same-named helpers overwrote each other with no X_ACTION_DUPLICATE. ModuleRegistrar now returns RegisteredPrimitive; the type filters too. - Date.UTC remaps years 0-99 onto 1900-1999, so daysBetween across the 0099/0100 boundary returned -693959. One utcEpoch() in zones.ts now builds every calendar epoch in the package: localDayEpoch, toZoned's weekday, offsetAt, fromZoned's target, normalizeWall, cron occurrence. fromZoned({ year: 50 }) resolved to 1950 'exact' before this. - registerRoute refuses a raw declaration with the new X_ROUTE_UNNORMALIZED instead of registering it and crashing later in describeRoutes() on config.budget.js. - X_REGISTRAR_CONFLICT's fix was `bun pm ls | grep ... - then dedupe it`, which hands grep the prose as file arguments. Now one runnable command. - Invalid `tenant:` now carries the edit that repairs it, not `x entity explain`, which describes invariants the author never wrote. Tests that could not fail: - .server() denial asserted through invoke (X_UNAUTHENTICATED); the two existing calls agreed even if it reached the declared half directly. - Projections checked by property access, not Object.keys — a non-enumerable handle/def passed the old assertion. - Route mode failures assert X_ROUTE_MODE_INVALID, not any throw; the "refuses a raw declaration" test now has that negative case. Docs: - action README + wiki reach projections through publishPost, not the action factory; typed client is rpc(). - Lifted-field contract made exhaustive (four members; the rest is describe()-only). i18n plural rule names the real CLDR suffix set. - Scheduled tasks show occurrenceMs; `enabled` dropped — no such field. - Four headers trimmed to the 1-4 line rule, four added. Co-Authored-By: Claude --- packages/action/CLAUDE.md | 5 ++ packages/action/README.md | 12 ++--- packages/action/src/define-api.test.ts | 56 ++++++++++++++++++++--- packages/action/src/define-api.ts | 50 ++++++++++++++------ packages/action/src/dsl.test.ts | 12 +++++ packages/action/src/http.test.ts | 12 ++--- packages/action/src/index.test.ts | 9 ++-- packages/ai/src/index.test.ts | 7 +++ packages/cli/src/dev-hooks.ts | 12 ++--- packages/core/src/index.ts | 2 +- packages/core/src/registrar.test.ts | 13 ++++-- packages/core/src/registrar.ts | 31 +++++++++---- packages/entity/src/tenancy.ts | 16 ++++--- packages/i18n/CLAUDE.md | 8 ++-- packages/i18n/README.md | 2 +- packages/i18n/src/define-catalogs.test.ts | 5 ++ packages/mail/src/index.test.ts | 5 ++ packages/render/CLAUDE.md | 1 + packages/render/src/dsl.test.ts | 34 ++++++++++---- packages/render/src/errors.ts | 19 ++++++++ packages/render/src/registry.ts | 15 +++++- packages/time/src/cron-occurrence.ts | 6 +-- packages/time/src/zoned.test.ts | 54 ++++++++++++++++++++++ packages/time/src/zoned.ts | 12 ++--- packages/time/src/zones.ts | 24 +++++++++- wiki/Actions.md | 10 ++-- wiki/Entities-And-Migrations.md | 10 +++- wiki/Error-Codes.md | 5 +- wiki/Routes-And-Render-Modes.md | 1 + wiki/Scheduled-Tasks.md | 8 ++-- 30 files changed, 354 insertions(+), 102 deletions(-) diff --git a/packages/action/CLAUDE.md b/packages/action/CLAUDE.md index a21b04bc3..f2b2b0fd9 100644 --- a/packages/action/CLAUDE.md +++ b/packages/action/CLAUDE.md @@ -62,6 +62,11 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3. `@ultimat3/query`'s registry through core's registrar table (`primitiveRegistrar('query')`), never a sideways import — and throws `X_REGISTRAR_MISSING` rather than skipping a kind whose registrar is absent, because a silent skip drops every read of that kind. +- `defineApi`'s returned maps are built from the **registrar's own results**, never from the + modules' exports. A feature module exports helpers next to its primitives; copying every export + would seat one in `Api['actions']` as a client method nothing serves, and let two modules' + same-named helpers overwrite each other with no `X_ACTION_DUPLICATE` to raise. The type does the + same filter, so `rpc()` offers only what registered. - `rpc` is the only name for the map-wide typed client. There is no `createClient` alias. - No policy at registration → `X_ACTION_POLICY_MISSING`. No exceptions, no flag. - `serializeOpenApi` output must be byte-stable: sorted keys, sorted registry, no clock. diff --git a/packages/action/README.md b/packages/action/README.md index eef1f2fa7..c936bc3ad 100644 --- a/packages/action/README.md +++ b/packages/action/README.md @@ -4,12 +4,12 @@ One declaration → six artifacts. | # | Artifact | Reach it with | Guarantees | |---|---|---|---| -| 1 | HTTP route `POST /api//` | `toRoute(action)` — the server mounts it | policy + validation + idempotency + invalidation, non-optional | -| 2 | OpenAPI 3.1 operation + document | `action.openapi()` / `buildOpenApi()` | byte-stable output, diffed by `x verify` | -| 3 | Typed RPC client | `action.client({ baseUrl })` / `rpc()` | server typo = compile error in Solid | -| 4 | MCP tool | `action.tool()` | *identical* policy evaluation to the route | -| 5 | Job handle | `action.job()` | enqueue durable work, no rewrite | -| 6 | Contract tests | `action.contract()` | garbage rejected, anonymous denied, spec present | +| 1 | HTTP route `POST /api//` | `toRoute(publishPost)` — the server mounts it | policy + validation + idempotency + invalidation, non-optional | +| 2 | OpenAPI 3.1 operation + document | `publishPost.openapi()` / `buildOpenApi()` | byte-stable output, diffed by `x verify` | +| 3 | Typed RPC client | `publishPost.client({ baseUrl })` / `rpc()` | server typo = compile error in Solid | +| 4 | MCP tool | `publishPost.tool()` | *identical* policy evaluation to the route | +| 5 | Job handle | `publishPost.job()` | enqueue durable work, no rewrite | +| 6 | Contract tests | `publishPost.contract()` | garbage rejected, anonymous denied, spec present | ## The fluent surface diff --git a/packages/action/src/define-api.test.ts b/packages/action/src/define-api.test.ts index 31a4c4dd3..7a3c61721 100644 --- a/packages/action/src/define-api.test.ts +++ b/packages/action/src/define-api.test.ts @@ -1,6 +1,11 @@ +// `defineApi` is the app's whole boot: one call decides what registered, and its return value is +// the type the RPC client is shaped from. A drop, a silent overwrite or an unregistered export +// slipping into the map would all ship as a client method the server never mounts. + import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { type ModuleRegistrar, + type RegisteredPrimitive, registerPrimitiveRegistrar, resetPrimitiveRegistrars, } from '@ultimat3/core'; @@ -18,15 +23,25 @@ const echo = () => handle: ({ input }) => ({ id: input.id }), }); -/** Stands in for `@ultimat3/query`'s `registerQueries` — action cannot import it sideways. */ +/** A query, as far as the registrar seam can see one. */ +const fakeQuery = () => ({ kind: 'query' as const, name: '' }); + +/** + * Stands in for `@ultimat3/query`'s `registerQueries` — action cannot import it sideways. Like + * the real one it takes only the branded exports and hands back what it took, named. + */ function recordingQueryRegistrar(): { seen: string[]; registrar: ModuleRegistrar } { const seen: string[] = []; return { seen, registrar: (module) => { - const names = Object.keys(module).sort(); - seen.push(...names); - return names; + const registered: RegisteredPrimitive[] = []; + for (const name of Object.keys(module).sort()) { + if ((module[name] as { kind?: unknown } | undefined)?.kind !== 'query') continue; + seen.push(name); + registered.push({ kind: 'query', name }); + } + return registered; }, }; } @@ -84,7 +99,7 @@ describe('defineApi', () => { const { seen, registrar } = recordingQueryRegistrar(); registerPrimitiveRegistrar('query', registrar); - const api = defineApi({ queries: [{ liveFeed: {} }, { postById: {} }] }); + const api = defineApi({ queries: [{ liveFeed: fakeQuery() }, { postById: fakeQuery() }] }); expect(seen).toEqual(['liveFeed', 'postById']); expect(Object.keys(api.queries)).toEqual(['liveFeed', 'postById']); @@ -93,7 +108,7 @@ describe('defineApi', () => { test('queries with no registrar loaded fail loudly instead of being dropped', () => { let code = ''; try { - defineApi({ queries: { liveFeed: {} } }); + defineApi({ queries: { liveFeed: fakeQuery() } }); } catch (error) { code = (error as { code: string }).code; } @@ -111,6 +126,35 @@ describe('defineApi', () => { expect(Object.keys(api.queries)).toEqual([]); }); + test('a module helper never reaches the API — the map is what registered, not what exported', () => { + const helper = (id: string): string => id; + const { registrar } = recordingQueryRegistrar(); + registerPrimitiveRegistrar('query', registrar); + + const api = defineApi({ + actions: { createPost: echo(), slugFor: helper }, + queries: { liveFeed: fakeQuery(), feedKey: helper }, + }); + + expect(Object.keys(api.actions)).toEqual(['createPost']); + expect(Object.keys(api.queries)).toEqual(['liveFeed']); + // The clients this shapes would otherwise offer `slugFor` as a method nothing serves. + expect(api.actions).not.toHaveProperty('slugFor'); + expect(api.queries).not.toHaveProperty('feedKey'); + }); + + test('two modules exporting one helper name cannot overwrite each other in silence', () => { + // Same name, no collision to raise — because neither is a primitive, so neither registers. + const api = defineApi({ + actions: [ + { createPost: echo(), toRow: () => 'a' }, + { inviteMember: echo(), toRow: () => 'b' }, + ], + }); + + expect(Object.keys(api.actions).sort()).toEqual(['createPost', 'inviteMember']); + }); + test('the returned surface is frozen — the API is declared once, not mutated later', () => { const api = defineApi({ actions: { createPost: echo() } }); diff --git a/packages/action/src/define-api.ts b/packages/action/src/define-api.ts index 2249c59ce..3ce1e7b20 100644 --- a/packages/action/src/define-api.ts +++ b/packages/action/src/define-api.ts @@ -5,7 +5,7 @@ * shaped from, which is why there is no second list of names to keep in step. */ -import { primitiveRegistrar } from '@ultimat3/core'; +import { primitiveRegistrar, type RegisteredPrimitive } from '@ultimat3/core'; import { registerActions } from './registry'; /** A module namespace: `import * as postActions from './actions'`. */ @@ -25,26 +25,37 @@ export interface ApiDef { type Get = TKey extends keyof TDef ? TDef[TKey] : undefined; +/** + * The exports a registrar would take, and only those. A feature module legitimately exports its + * own helpers next to its primitives; carrying one into `Api['actions']` would offer the client a + * method the server never registered and no surface can project. + */ +type Registered = { + readonly [K in keyof TModule as TModule[K] extends { readonly kind: TKind } + ? K + : never]: TModule[K]; +}; + /** * Intersect a tuple of module namespaces. `{ createPost } & { inviteMember }` is the map the * typed client indexes, so `rpc()` knows every action without a codegen step. */ -type Merge = [TModules] extends [undefined] +type Merge = [TModules] extends [undefined] ? EmptyModule : TModules extends readonly [infer THead, ...infer TRest] - ? THead & Merge + ? Registered & Merge : TModules extends readonly [] ? EmptyModule - : TModules; + : Registered; type EmptyModule = Readonly>; /** What `defineApi` returns: the registered primitives, merged and keyed by export name. */ export interface Api { - readonly actions: Merge> & - Merge> & - Merge>; - readonly queries: Merge>; + readonly actions: Merge, 'action'> & + Merge, 'action'> & + Merge, 'action'>; + readonly queries: Merge, 'query'>; } /** @@ -72,17 +83,20 @@ export function defineApi(def: TDef): Api { ]; const queryModules = moduleList(def.queries); - for (const module of actionModules) registerActions(module); + const actions: RegisteredPrimitive[] = []; + for (const module of actionModules) actions.push(...registerActions(module)); + // Resolved once, and only when there is something to register: a missing registrar with // queries to register must be an error, never a silent skip that drops every read. + const queries: RegisteredPrimitive[] = []; if (queryModules.length > 0) { const registerQueries = primitiveRegistrar('query'); - for (const module of queryModules) registerQueries(module); + for (const module of queryModules) queries.push(...registerQueries(module)); } return Object.freeze({ - actions: mergeModules(actionModules), - queries: mergeModules(queryModules), + actions: byRegisteredName(actions), + queries: byRegisteredName(queries), }) as Api; } @@ -91,7 +105,13 @@ function moduleList(modules: ApiModules | undefined): readonly ApiModule[] { return Array.isArray(modules) ? modules : [modules as ApiModule]; } -/** Registration names in place, so the merged object holds the registered primitives. */ -function mergeModules(modules: readonly ApiModule[]): ApiModule { - return Object.freeze(Object.assign({}, ...modules) as ApiModule); +/** + * Keyed by the name registration stamped, read back off the registrar's own results — never off + * the module's exports. Copying every export would seat a feature's helper in `api.actions` under + * a name no surface serves, and the last module exporting that name would win in silence. + */ +function byRegisteredName(primitives: readonly RegisteredPrimitive[]): ApiModule { + const map: Record = {}; + for (const primitive of primitives) map[primitive.name] = primitive; + return Object.freeze(map); } diff --git a/packages/action/src/dsl.test.ts b/packages/action/src/dsl.test.ts index b638a7719..c6f9f7d6a 100644 --- a/packages/action/src/dsl.test.ts +++ b/packages/action/src/dsl.test.ts @@ -11,6 +11,7 @@ import { createContext, userActor } from '@ultimat3/core'; import { can } from '@ultimat3/policy'; import { t } from '@ultimat3/schema'; import { action } from './action'; +import { ActionDeniedError } from './errors'; import { toOpenApiOperation } from './http'; import { toJobHandle } from './job-handle'; import { toMcpTool } from './mcp-tool'; @@ -187,6 +188,17 @@ describe('the mutator DSL surface', () => { const viaServer = await target.server(ctx, { postId: POST_ID }); const viaCallable = await target({ postId: POST_ID }, { ctx }); expect(viaServer).toEqual(viaCallable); + + // The load-bearing half: the two calls above agree even if `.server()` reached the declared + // half directly, because both return the same value. The declared `server` ignores its ctx + // entirely, so it would answer an anonymous caller happily — only a call routed through + // `invoke` reaches the policy. This denial is what proves the route taken. + const denied = await target.server(createContext(), { postId: POST_ID }).then( + (value) => value, + (error: unknown) => error, + ); + expect(denied).toBeInstanceOf(ActionDeniedError); + expect((denied as ActionDeniedError).code).toBe('X_UNAUTHENTICATED'); }); test('.tool() delegates to toMcpTool() — same data, same policy reference', () => { diff --git a/packages/action/src/http.test.ts b/packages/action/src/http.test.ts index b30b98d44..0bb5b06e7 100644 --- a/packages/action/src/http.test.ts +++ b/packages/action/src/http.test.ts @@ -1,8 +1,6 @@ -// The route projection, proven over the real pipeline rather than by calling `route.handler`. -// What it pins is the task's whole point: an action route has ONE authz evaluation, it happens -// inside `invoke`, and it happens after `row` has loaded. A pipeline that decided first would -// decide from `row: null` — a denial for the row's own author, issued by an authz system that -// never saw the row. That is the bug these tests exist to keep fixed. +// The route projection, proven over the real pipeline rather than by calling `route.handler`: +// an action route has ONE authz evaluation, it happens inside `invoke`, and it happens after +// `row` has loaded. import { describe, expect, test } from 'bun:test'; import type { Actor } from '@ultimat3/core'; @@ -76,7 +74,9 @@ describe('an action route over the pipeline', () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ id: POST_ID, published: true }); - // Twice would mean the pipeline decided too — with `row: null`, so it would have denied. + // Twice would mean the pipeline decided too, and it decides from `row: null` — a denial for + // the row's own author, issued by an authz system that never saw the row. That is the bug + // these tests exist to keep fixed. expect(evaluations.count).toBe(1); }); diff --git a/packages/action/src/index.test.ts b/packages/action/src/index.test.ts index 9f88aae01..3ce93b8e8 100644 --- a/packages/action/src/index.test.ts +++ b/packages/action/src/index.test.ts @@ -52,12 +52,13 @@ describe('@ultimat3/action public surface', () => { ['route', surface.toRoute(publishPost)], ]; - for (const [label, projection] of projections) { + for (const [, projection] of projections) { // `route` legitimately carries a `handler` — a closure over `invoke`. A `handle` or a // `def` would be the declaration itself, reachable from whoever holds the projection. - expect({ [label]: Object.keys(projection) }).toEqual({ - [label]: expect.not.arrayContaining(['handle', 'def']), - }); + // Asked by property access, not `Object.keys`: a non-enumerable or inherited `def` is + // still reachable by whoever holds the projection, and a key list would not see it. + expect(projection).not.toHaveProperty('handle'); + expect(projection).not.toHaveProperty('def'); } }); }); diff --git a/packages/ai/src/index.test.ts b/packages/ai/src/index.test.ts index b35a2a2c0..879c8197c 100644 --- a/packages/ai/src/index.test.ts +++ b/packages/ai/src/index.test.ts @@ -1,3 +1,10 @@ +/** + * The barrel must re-export the ONE `t` from `@ultimat3/schema` by identity, never a copy — `t` + * delegates to `schemaProvider()` on every access, so a spread or a re-declaration would freeze + * the provider at import time and still typecheck, still build an `llm` output schema. Identity + * is the only assertion that catches that, which is why this file exists. + */ + import { describe, expect, test } from 'bun:test'; import { t as schemaT } from '@ultimat3/schema'; import { t } from './index'; diff --git a/packages/cli/src/dev-hooks.ts b/packages/cli/src/dev-hooks.ts index 715946e5a..d8f79aa34 100644 --- a/packages/cli/src/dev-hooks.ts +++ b/packages/cli/src/dev-hooks.ts @@ -1,11 +1,6 @@ // The two seams `@ultimat3/http` leaves open, bound to the packages that own them. `authorize` -// evaluates the SAME `Policy` object every other surface evaluates — the route table's declared -// permission — so a denial in `x dev` is the denial production produces, not a dev-only -// approximation. -// -// It decides for pages only. An action route carries `enforcedBy: 'handler'`, so the pipeline -// never asks: `invoke` is its one evaluation, and the only one holding the row a row-level rule -// reads. Reconstructing that decision here would be the second authz system, one row short. +// decides for pages only, from the SAME `Policy` object every other surface evaluates — the route +// table's declared permission — so a denial in `x dev` is the one production produces. import { actorOf } from '@ultimat3/action'; import type { AuthzDecision, ServerHooks } from '@ultimat3/http'; @@ -30,6 +25,9 @@ function policyFor(path: string): Policy | undefined { export function devHooks(): ServerHooks { return { authorize: (route, _request, ctx): AuthzDecision => { + // An action route never arrives here: it carries `enforcedBy: 'handler'`, so the pipeline + // never asks. `invoke` is its one evaluation, and the only one holding the row a row-level + // rule reads — reconstructing it here would be a second authz system, one row short. const policy = policyFor(route.path); if (policy === undefined) { return { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7d69d8f1b..d2ed96a9b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -210,7 +210,7 @@ export { redactKeys, setLoggerContextFields, } from './logger'; -export type { ModuleRegistrar, PrimitiveKind } from './registrar'; +export type { ModuleRegistrar, PrimitiveKind, RegisteredPrimitive } from './registrar'; export { hasPrimitiveRegistrar, primitiveRegistrar, diff --git a/packages/core/src/registrar.test.ts b/packages/core/src/registrar.test.ts index 20ba5bc86..f14e49625 100644 --- a/packages/core/src/registrar.test.ts +++ b/packages/core/src/registrar.test.ts @@ -1,14 +1,21 @@ +// Guards the one seam that keeps same-tier packages from importing each other: a registrar that +// resolved to `undefined`, or a second copy quietly winning the table, would split a kind's +// primitives across two registries and drop half of them with nothing raised. + import { beforeEach, describe, expect, test } from 'bun:test'; import { hasPrimitiveRegistrar, type ModuleRegistrar, primitiveRegistrar, + type RegisteredPrimitive, registerPrimitiveRegistrar, resetPrimitiveRegistrars, } from './registrar'; const noop: ModuleRegistrar = () => []; +const asQuery = (name: string): RegisteredPrimitive => ({ kind: 'query', name }); + beforeEach(() => { resetPrimitiveRegistrars(); }); @@ -45,10 +52,10 @@ describe('registerPrimitiveRegistrar', () => { }); describe('primitiveRegistrar', () => { - test('returns the announced registrar', () => { - const registrar: ModuleRegistrar = (module) => Object.keys(module); + test('returns the announced registrar, and its results carry the registered names', () => { + const registrar: ModuleRegistrar = (module) => Object.keys(module).map(asQuery); registerPrimitiveRegistrar('action', registrar); - expect(primitiveRegistrar('action')({ a: 1, b: 2 })).toEqual(['a', 'b']); + expect(primitiveRegistrar('action')({ a: 1, b: 2 })).toEqual([asQuery('a'), asQuery('b')]); }); test('throws X_REGISTRAR_MISSING with an installable fix rather than returning undefined', () => { diff --git a/packages/core/src/registrar.ts b/packages/core/src/registrar.ts index a88b41576..ac90d3f22 100644 --- a/packages/core/src/registrar.ts +++ b/packages/core/src/registrar.ts @@ -1,11 +1,7 @@ -// Single responsibility: the seam that lets one package hand a module of primitives to the -// package that owns them, without a sideways import. -// -// `action` and `query` sit on the same tier, so `defineApi` in `@ultimat3/action` cannot -// import `registerQueries` — the boundaries check is a build error, not a suggestion. Each -// owning package announces its registrar here at import time; a caller asks by kind. Same -// shape as `defineService` and `registerErrorCodes`: core holds the table, higher tiers fill -// it, and nobody imports sideways to reach it. +// Hands a module of primitives to the package that owns them, without a sideways import: +// `defineApi` in `@ultimat3/action` cannot import `@ultimat3/query`'s `registerQueries` on +// the same tier, so each owner announces its registrar here at import time and callers ask +// by kind. Same shape as `defineService` and `registerErrorCodes`. import { UltimateError } from './errors'; @@ -20,8 +16,20 @@ export type PrimitiveKind = | 'route' | 'task'; +/** + * What a registrar hands back: the primitives it actually took, each carrying the name + * registration stamped on it. Returning the registered set — rather than nothing — is what lets + * a caller build its API map from what registered instead of from what a module exported. + */ +export interface RegisteredPrimitive { + readonly kind: PrimitiveKind; + readonly name: string; +} + /** `registerActions` / `registerQueries`: export names become primitive names. */ -export type ModuleRegistrar = (module: Readonly>) => readonly unknown[]; +export type ModuleRegistrar = ( + module: Readonly>, +) => readonly RegisteredPrimitive[]; const registrars = new Map(); @@ -37,7 +45,10 @@ export function registerPrimitiveRegistrar(kind: PrimitiveKind, registrar: Modul throw new UltimateError({ code: 'X_REGISTRAR_CONFLICT', cause: `two different ${kind} registrars are loaded, so ${kind} primitives would split across two registries`, - fix: `bun pm ls | grep @ultimat3/${kind} — then dedupe it to one version in package.json`, + // One command, because a `fix:` is pasted verbatim: collapsing every range on the package + // to one resolved version is the repair. `bun pm why @ultimat3/` names the dependents + // when a range genuinely disagrees and the update cannot converge on its own. + fix: `bun update @ultimat3/${kind}`, meta: { kind }, }); } diff --git a/packages/entity/src/tenancy.ts b/packages/entity/src/tenancy.ts index 360f3dd6f..b64abf84b 100644 --- a/packages/entity/src/tenancy.ts +++ b/packages/entity/src/tenancy.ts @@ -2,7 +2,7 @@ // through a plan that carries an org predicate; building one without it throws // `X_TENANCY_UNSCOPED` at the seam instead of leaking another tenant's rows. -import { invariantViolated, tenancyUnscoped } from './errors'; +import { EntityError, tenancyUnscoped } from './errors'; import type { ColumnMap } from './types'; export type Operator = @@ -69,11 +69,15 @@ export const resolveTenantColumn = ( ): string | null => { if (declared === undefined) return tenantColumnOf(columns); if (!Object.hasOwn(columns, declared)) { - throw invariantViolated( - entityName, - 'tenant', - `tenant: '${declared}' names no column — pick from: ${Object.keys(columns).join(', ')}`, - ); + const available = Object.keys(columns).join(', '); + // Not `invariantViolated`: its fix points at `x entity explain`, which describes invariants + // the author never wrote. What repairs this is one edit to the declaration, so the error + // carries that edit and both ways out of it. + throw new EntityError({ + code: 'X_INVARIANT_VIOLATED', + cause: `${entityName}.tenant: tenant: '${declared}' names no column — pick from: ${available}`, + fix: `set tenant to one of ${available} in entity('${entityName}'), or remove the tenant key — inference then takes the .tenant() column, else one named ${ORG_COLUMN}`, + }); } return declared; }; diff --git a/packages/i18n/CLAUDE.md b/packages/i18n/CLAUDE.md index 78b013062..ede15e6b3 100644 --- a/packages/i18n/CLAUDE.md +++ b/packages/i18n/CLAUDE.md @@ -23,9 +23,11 @@ Imported by every package that renders a string. - Reading: `t('ns.key')` for one string, `useI18n()` where the keys must be typed. There is no `currentTranslator`; `useI18n` replaced it. - A miss renders `⟦key⟧`. Never add a fallback locale chain — it hides gaps. -- Plural selection is `Intl.PluralRules`. Never `count === 1`. Variants are underscore suffixes - on the leaf (`n_one`, `n_plural`), never a nested `{ one, other }` branch — the runtime probes - `n_one`, and `TranslationKey` admits the stem `n` on exactly that basis. +- Plural selection is `Intl.PluralRules`. Never `count === 1`. Variants are underscore suffixes on + the leaf — a CLDR category (`_zero _one _two _few _many _other`), or `n` / `n_plural` as the + two-form shortcut; pair `n_one` with `n_other`, never with `n_plural`. Never a nested + `{ one, other }` branch — the runtime probes the variant leaf (`n_other`), and `TranslationKey` + admits the stem `n` on exactly that basis. - `Translator` must keep defaulting to `string` keys: `@ultimat3/ui` and `@ultimat3/mail` take a bare `Translator`. `has()` and `raw()` stay `string` — they are probes. - Nothing here formats a number, date or money. That is `@ultimat3/money` / `@ultimat3/time`. diff --git a/packages/i18n/README.md b/packages/i18n/README.md index c1f2f01cf..3f7344c98 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -20,7 +20,7 @@ strings: format those with `Intl` via [@ultimat3/money](../money) and [@ultimat3 `defineCatalogs` is the only way an app registers strings. Never call `registerCatalog` by hand. ```ts -// packages/i18n/src/index.ts — the app's whole i18n module +// the app's own packages/i18n/src/index.ts — its whole i18n module import { defineCatalogs, type TranslationKey, type Translator, useI18n } from '@ultimat3/i18n'; import en from '../catalogs/en.json'; import es from '../catalogs/es.json'; diff --git a/packages/i18n/src/define-catalogs.test.ts b/packages/i18n/src/define-catalogs.test.ts index 240681e38..980bc33d9 100644 --- a/packages/i18n/src/define-catalogs.test.ts +++ b/packages/i18n/src/define-catalogs.test.ts @@ -1,3 +1,8 @@ +// `defineCatalogs` is the app's one registration call and it runs once, at boot — so a regression +// here is not a failing render but a locale that registered with no strings, or with half of them. +// These tests hold that line: all-or-nothing registration across every locale, and the app winning +// over the framework on a shared key. + import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; import { configureLocales, diff --git a/packages/mail/src/index.test.ts b/packages/mail/src/index.test.ts index 41f9fcb78..f43f48021 100644 --- a/packages/mail/src/index.test.ts +++ b/packages/mail/src/index.test.ts @@ -1,3 +1,8 @@ +// The barrel must re-export the ONE `t` from `@ultimat3/schema` by identity, never a copy — `t` +// delegates to `schemaProvider()` on every access, so a spread or a re-declaration would freeze +// the provider at import time and still typecheck, still build a `defineMail` input. Identity is +// the only assertion that catches that, which is why this file exists. + import { describe, expect, test } from 'bun:test'; import { t as schemaT } from '@ultimat3/schema'; import { blocks, defineMail, t } from './index'; diff --git a/packages/render/CLAUDE.md b/packages/render/CLAUDE.md index c903321b4..0389276a4 100644 --- a/packages/render/CLAUDE.md +++ b/packages/render/CLAUDE.md @@ -16,6 +16,7 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach | No `describe()` on a route | `describeRoutes()` is the one route list. A per-route projection would be a second one. | | Mode invariants | `modes.ts` only. Never inline a mode check in a render-\* file. | | Route truth | `registry.ts`. Never keep a second route list anywhere. | +| Registry input | descriptors only. `registerRoute` refuses a raw declaration with `X_ROUTE_UNNORMALIZED` — `defineRoute` is the one normalizer, and every reader downstream assumes it ran. | | Descriptors | `describeRoutes()` must stay JSON-safe, sorted by path, deterministic. | | Boundary | `surfaces.ts` throws; it never warns. Type-only edges are not violations. | | Errors | `errors.ts` subclasses only. Never a bare `Error`, never a bare `TODO`. | diff --git a/packages/render/src/dsl.test.ts b/packages/render/src/dsl.test.ts index 3365ec321..5d6e04707 100644 --- a/packages/render/src/dsl.test.ts +++ b/packages/render/src/dsl.test.ts @@ -1,17 +1,24 @@ /** - * Pins the `route` DSL surface. `route.test.ts` proves `defineRoute` validates and - * normalizes correctly; this file proves the *shape* cannot silently drift — the - * descriptor's exact member set, and that every consumer reads the descriptor rather - * than the declaration the author wrote. `route` is the one primitive whose façade is - * a normalized descriptor instead of projection methods, so what has to stay true is - * that all five readers see one shape and none of them branches. + * Pins the `route` DSL surface's *shape*, which `route.test.ts` (validation) does not: + * `route` is the one primitive whose façade is a normalized descriptor rather than + * projection methods, so every reader must see one shape and none may branch. */ import { describe, expect, test } from 'bun:test'; import { assertModeInvariants, assertModeShape } from './modes'; -import { clearRoutes, registerRoute } from './registry'; -import type { RouteDefinition } from './route'; +import { clearRoutes, registerRoute, routeFor } from './registry'; +import type { RouteConfig, RouteDefinition } from './route'; import { defineRoute, isRouteConfig, tagKeys } from './route'; +/** The stable code every route-shape refusal carries; a bare `toThrow()` would accept any. */ +const codeOf = (run: () => unknown): string => { + try { + run(); + } catch (error) { + return (error as { code?: string }).code ?? ''; + } + return ''; +}; + // The exact contract: `RouteConfig` (route.ts). Kept in sync by hand on purpose — a // silent drift here is exactly the regression this file exists to catch. Optional // declaration keys are omitted rather than set to undefined, so they are not listed. @@ -83,7 +90,7 @@ describe('the route DSL surface', () => { surface: 'site', suspenseBoundaries: 0, } as const; - expect(() => assertModeInvariants(hydrating, ctx)).toThrow(); + expect(codeOf(() => assertModeInvariants(hydrating, ctx))).toBe('X_ROUTE_MODE_INVALID'); const budgeted = defineRoute({ ...hydrating, budget: { js: '40kb' } }); expect(() => assertModeInvariants(budgeted, ctx)).not.toThrow(); }); @@ -91,7 +98,7 @@ describe('the route DSL surface', () => { test('mode invariants are checked on the descriptor, at declaration time', () => { // `defineRoute` runs `assertModeShape` itself, so a bad route fails at module // evaluation — build time — not on the first request. - expect(() => defineRoute({ ...minimal, render: 'isr' })).toThrow(); + expect(codeOf(() => defineRoute({ ...minimal, render: 'isr' }))).toBe('X_ROUTE_MODE_INVALID'); expect(() => assertModeShape(defineRoute(minimal))).not.toThrow(); }); @@ -101,6 +108,13 @@ describe('the route DSL surface', () => { const entry = registerRoute({ file: 'app/posts/page.tsx', config }); expect(entry.config).toBe(config); expect(entry.config.budget).toEqual({}); + // The author's own object carries no normalized `budget`, so registering it would seat a + // route the descriptor's readers cannot read — `describeRoutes()` reaches `budget.js`. + const raw = minimal as unknown as RouteConfig; + expect(codeOf(() => registerRoute({ file: 'app/drafts/page.tsx', config: raw }))).toBe( + 'X_ROUTE_UNNORMALIZED', + ); + expect(routeFor('/drafts')).toBeUndefined(); clearRoutes(); }); diff --git a/packages/render/src/errors.ts b/packages/render/src/errors.ts index 5eefa8e67..ef5a634f4 100644 --- a/packages/render/src/errors.ts +++ b/packages/render/src/errors.ts @@ -9,6 +9,7 @@ export const RENDER_ERROR_CODES = [ 'X_ROUTE_MODE_INVALID', 'X_ROUTE_OFFLINE_MISSING', 'X_ROUTE_META_MISSING', + 'X_ROUTE_UNNORMALIZED', 'X_ROUTE_DUPLICATE', 'X_SURFACE_BOUNDARY', 'X_BUDGET_EXCEEDED', @@ -21,6 +22,7 @@ export const RENDER_ERROR_TITLES: Readonly> = { X_ROUTE_MODE_INVALID: 'render mode not allowed on this surface', X_ROUTE_OFFLINE_MISSING: "the route's offline strategy is missing or contradictory", X_ROUTE_META_MISSING: 'required metadata missing', + X_ROUTE_UNNORMALIZED: 'a route was registered without defineRoute', X_ROUTE_DUPLICATE: 'two route files resolve to one URL', X_SURFACE_BOUNDARY: 'a surface imported across the hard boundary', X_BUDGET_EXCEEDED: 'a route blew its JS or LCP budget', @@ -75,6 +77,23 @@ export class RouteMetaMissingError extends UltimateError { } } +/** + * The route table holds descriptors, never declarations. `defineRoute` is the one normalizer, so + * a raw declaration reaching the registry means `budget` and `meta` are whatever the author wrote + * — and every reader downstream (`describeRoutes`, `sw.js`, the sitemap) assumes they are not. + */ +export class RouteUnnormalizedError extends UltimateError { + static readonly code = 'X_ROUTE_UNNORMALIZED' as const; + constructor(cause: string, fix: string) { + super({ + code: RouteUnnormalizedError.code, + cause, + fix, + docs: docsFor(RouteUnnormalizedError.code), + }); + } +} + /** Two files claim the same URL — the route table must stay a function of the path. */ export class RouteDuplicateError extends UltimateError { static readonly code = 'X_ROUTE_DUPLICATE' as const; diff --git a/packages/render/src/registry.ts b/packages/render/src/registry.ts index 230d75599..ffd7e4e6b 100644 --- a/packages/render/src/registry.ts +++ b/packages/render/src/registry.ts @@ -5,7 +5,7 @@ * from. Nothing downstream may keep its own list of routes. */ -import { RouteDuplicateError, SurfaceBoundaryError } from './errors'; +import { RouteDuplicateError, RouteUnnormalizedError, SurfaceBoundaryError } from './errors'; import { assertModeInvariants } from './modes'; import type { HydrateStrategy, @@ -15,7 +15,7 @@ import type { RouteData, RouteParams, } from './route'; -import { tagKeys } from './route'; +import { isRouteConfig, tagKeys } from './route'; import type { Surface } from './surfaces'; import { surfaceOf } from './surfaces'; @@ -149,6 +149,17 @@ export interface RegisterRouteInput { export function registerRoute( input: RegisterRouteInput, ): RouteEntry { + // The type already refuses a declaration; this catches the JS caller and the cast. Without it a + // raw declaration registers, and `describeRoutes()` is where it surfaces — as a bare TypeError + // on `config.budget.js`, one build step away from the file that caused it. + if (!isRouteConfig(input.config)) { + throw new RouteUnnormalizedError( + `${input.file} registered a route declaration, not a descriptor: defineRoute normalizes ` + + '`meta` and `budget`, and the route table has no other normalizer', + `wrap the declaration in ${input.file}: registerRoute({ file, config: defineRoute({ … }) })`, + ); + } + const derived = routePathFromFile(input.file); const path = input.path ?? derived.path; const suspenseBoundaries = input.suspenseBoundaries ?? 0; diff --git a/packages/time/src/cron-occurrence.ts b/packages/time/src/cron-occurrence.ts index 73b1e3fc6..6e973e675 100644 --- a/packages/time/src/cron-occurrence.ts +++ b/packages/time/src/cron-occurrence.ts @@ -7,7 +7,7 @@ import { type CronExpression, matchesDay, parseCronOnce } from './cron-parse'; import { cronInvalid } from './errors'; import { fromEpochMs, type Instant } from './instant'; import { fromZoned, toZoned } from './zoned'; -import { assertTimeZone, type TimeZone } from './zones'; +import { assertTimeZone, type TimeZone, utcEpoch } from './zones'; /** True when `at` matches the expression in `zone`, to the second. */ export function matchesCron( @@ -160,12 +160,12 @@ interface Cursor { } function isoWeekday(cursor: Cursor): number { - const day = new Date(Date.UTC(cursor.year, cursor.month - 1, cursor.day)).getUTCDay(); + const day = new Date(utcEpoch(cursor.year, cursor.month, cursor.day)).getUTCDay(); return ((day + 6) % 7) + 1; } function daysInMonth(year: number, month: number): number { - return new Date(Date.UTC(year, month, 0)).getUTCDate(); + return new Date(utcEpoch(year, month + 1, 0)).getUTCDate(); } function resetTime(cursor: Cursor): void { diff --git a/packages/time/src/zoned.test.ts b/packages/time/src/zoned.test.ts index 95c4181bc..12c93db56 100644 --- a/packages/time/src/zoned.test.ts +++ b/packages/time/src/zoned.test.ts @@ -178,6 +178,60 @@ describe('daysBetween', () => { }); }); +// `Date.UTC(99, …)` does not mean year 99 — the legacy constructor remaps years 0–99 onto +// 1900–1999, so any date arithmetic that rebuilds an epoch from zoned fields silently jumps +// nineteen centuries for a first-century date. These fixtures come from ISO strings, which are +// parsed literally, and the assertions are the values the proleptic Gregorian calendar gives. +describe('years before 100', () => { + test('the 0099 → 0100 boundary is one day, not -693959', () => { + const from = fromIso('0099-12-31T12:00:00Z'); + const to = fromIso('0100-01-01T12:00:00Z'); + // Pins the fixtures: a remapped `from` would read 1999-12-31 and the test would prove nothing. + expect(toIso(from)).toBe('0099-12-31T12:00:00.000Z'); + expect(toZoned(from, UTC).year).toBe(99); + expect(daysBetween(from, to, UTC)).toBe(1); + expect(daysBetween(to, from, UTC)).toBe(-1); + }); + + test('a whole local day inside year 0050 is one day', () => { + const from = fromIso('0050-06-10T00:00:00Z'); + const to = fromIso('0050-06-11T00:00:00Z'); + expect(daysBetween(from, to, UTC)).toBe(1); + expect(daysBetween(from, fromIso('0050-06-10T23:59:59.999Z'), UTC)).toBe(0); + }); + + test('a year spanning the boundary is 365 days — 0100 is not a leap year', () => { + const from = fromIso('0099-06-15T00:00:00Z'); + const to = fromIso('0100-06-15T00:00:00Z'); + expect(daysBetween(from, to, UTC)).toBe(365); + }); + + test('toZoned reports the real weekday: 0099-12-31 is a Thursday, not 1999-12-31 Friday', () => { + expect(toZoned(fromIso('0099-12-31T12:00:00Z'), UTC)).toMatchObject({ + year: 99, + month: 12, + day: 31, + weekday: 4, + }); + expect(toZoned(fromIso('0050-06-10T12:00:00Z'), UTC).weekday).toBe(5); + }); + + test('fromZoned resolves the year it was asked for, not 1900 + it', () => { + // The other half of the same remap: `offsetAt` rebuilds an epoch from wall-clock fields too, + // so before the fix this returned 1950 with `resolution: 'exact'` — or, once the caller was + // fixed alone, a ~1.9-million-year-off instant reported as a DST gap. + const resolved = fromZonedDetailed({ year: 50, month: 6, day: 10, hour: 12, minute: 0 }, UTC); + expect(resolved.resolution).toBe('exact'); + expect(resolved.offsetMinutes).toBe(0); + expect(toIso(resolved.instant)).toBe('0050-06-10T12:00:00.000Z'); + // And through a real zone, where the offset is what the remap corrupted: Berlin had no + // standard time in year 99, so `Intl` answers with its LMT, +00:53. + const berlin = fromZoned({ year: 99, month: 12, day: 31, hour: 0, minute: 0 }, BERLIN); + expect(offsetAt(BERLIN, berlin)).toBe(53); + expect(toIso(berlin)).toBe('0099-12-30T23:07:00.000Z'); + }); +}); + function codeOf(run: () => unknown): string { try { run(); diff --git a/packages/time/src/zoned.ts b/packages/time/src/zoned.ts index c3f527a43..a1248fa98 100644 --- a/packages/time/src/zoned.ts +++ b/packages/time/src/zoned.ts @@ -5,7 +5,7 @@ import { dstAmbiguous, dstNonexistent } from './errors'; import { addMs, type Instant } from './instant'; -import { assertTimeZone, offsetAt, type TimeZone, zonePartsAt } from './zones'; +import { assertTimeZone, offsetAt, type TimeZone, utcEpoch, zonePartsAt } from './zones'; /** A local date and time with no zone attached — meaningless until paired with one. */ export interface WallClock { @@ -59,7 +59,7 @@ export function toZoned(at: Instant, zone: TimeZone): ZonedDateTime { assertTimeZone(zone); const parts = zonePartsAt(zone, at); const offsetMinutes = offsetAt(zone, at); - const asIfUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute); + const asIfUtc = utcEpoch(parts.year, parts.month, parts.day); return { ...parts, millisecond: at.getTime() - Math.floor(at.getTime() / 1000) * 1000, @@ -97,9 +97,9 @@ export function fromZonedDetailed( // Normalize overflow first (day 32, hour 24, month 13) so callers can do naive calendar // arithmetic — `addDaysInZone` relies on it — and so verification compares real fields. const normal = normalizeWall(wall); - const target = Date.UTC( + const target = utcEpoch( normal.year, - normal.month - 1, + normal.month, normal.day, normal.hour, normal.minute, @@ -210,7 +210,7 @@ export function daysBetween(from: Instant, to: Instant, zone: TimeZone): number /** The instant's local calendar date, expressed as the UTC midnight of that date. */ function localDayEpoch(at: Instant, zone: TimeZone): number { const zoned = toZoned(at, zone); - return Date.UTC(zoned.year, zoned.month - 1, zoned.day); + return utcEpoch(zoned.year, zoned.month, zoned.day); } function finish( @@ -235,7 +235,7 @@ interface NormalWall { function normalizeWall(wall: WallClock): NormalWall { const asUtc = new Date( - Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second ?? 0), + utcEpoch(wall.year, wall.month, wall.day, wall.hour, wall.minute, wall.second ?? 0), ); return { year: asUtc.getUTCFullYear(), diff --git a/packages/time/src/zones.ts b/packages/time/src/zones.ts index c8cee0ade..abc1046ea 100644 --- a/packages/time/src/zones.ts +++ b/packages/time/src/zones.ts @@ -15,6 +15,26 @@ export const UTC: TimeZone = 'UTC'; /** ES2024 `Intl` accepts `+01:00` as a zone; we do not — a fixed offset has no DST rules. */ const NUMERIC_OFFSET = /^[+-]/; +/** + * The package's only builder of a UTC epoch from calendar fields, because `Date.UTC` remaps + * years 0–99 onto 1900–1999 — silently, so a first-century wall clock resolves 1900 years off + * and every derived answer (offset, weekday, day count) is wrong without ever throwing. + * Overflow still carries exactly as `Date.UTC` does: day 0, day 32 and hour 24 all roll. + */ +export function utcEpoch( + year: number, + month: number, + day: number, + hour = 0, + minute = 0, + second = 0, +): number { + const at = new Date(0); + at.setUTCFullYear(year, month - 1, day); + at.setUTCHours(hour, minute, second, 0); + return at.getTime(); +} + export function isValidTimeZone(zone: string): boolean { if (zone === '' || NUMERIC_OFFSET.test(zone)) return false; try { @@ -70,9 +90,9 @@ export function zonePartsAt(zone: TimeZone, at: Instant): ZoneParts { */ export function offsetAt(zone: TimeZone, at: Instant): number { const parts = zonePartsAt(zone, at); - const asIfUtc = Date.UTC( + const asIfUtc = utcEpoch( parts.year, - parts.month - 1, + parts.month, parts.day, parts.hour, parts.minute, diff --git a/wiki/Actions.md b/wiki/Actions.md index 5c9c8ab1c..95d869b57 100644 --- a/wiki/Actions.md +++ b/wiki/Actions.md @@ -49,7 +49,7 @@ Declared in `api/` or a feature's `actions.ts`. Named export, never default. The ## The fluent surface -Every projection is a method on the action — `publishPost.tool()`, never `toMcpTool(publishPost)` — and every declared field is lifted onto it. An action has no `.def`. +Every projection is a method on the action — `publishPost.tool()`, never `toMcpTool(publishPost)`. Exactly four declared fields are lifted onto it as readable properties: `input`, `output`, `policy`, `mcp`. The rest of the declaration is structured metadata, reachable through `describe()` and nowhere else. An action has no `.def`. | Member | Is | Rule | |---|---|---| @@ -60,10 +60,10 @@ Every projection is a method on the action — `publishPost.tool()`, never `toMc | `.client({ baseUrl })` | the typed RPC method | derives `POST /api/posts/publish` by string math, so the browser imports no server code | | `.job()` | the durable-work handle | the same handler, run through the queue as `action:publishPost` | | `.contract()` | the generated assertions | garbage rejected, anonymous denied, spec present | -| `.describe()` | the manifest row | name, verb, resource, path, capability, schemas, invalidated tags, `mcp`, `rateLimit` | -| `.input` `.output` `.policy` `.mcp` | the declaration, lifted | readable. `handle` is not among them | +| `.describe()` | the manifest row | `kind`, `name`, `verb`, `resource`, `method`, `path`, `capability`, `input`, `output`, `invalidates`, `idempotent`, `mcp`, `rateLimit` — and the only reader for the declared metadata that is not lifted | +| `.input` `.output` `.policy` `.mcp` | the declaration, lifted | the whole lifted set — readable, and `.mcp` present only when declared. `cache.invalidates`, `rateLimit` and `idempotent` are not properties: read them off `describe()`, where `cache.invalidates` flattens to `invalidates` | -`handle` is unreachable by design. The declaration lives in a private store inside `invoke.ts` and `@ultimat3/action` exports no reader for it, so `invoke` is the only thing that can run it — one execution path and one authz path, structurally rather than by convention. A hand-rolled object with `kind: 'action'` is `X_ACTION_FOREIGN`, never a registered action. +`handle` and `row` — the declaration's two functions — are unreachable by design: neither lifted nor described. The declaration lives in a private store inside `invoke.ts` and `@ultimat3/action` exports no reader for it, so `invoke` is the only thing that can run them — one execution path and one authz path, structurally rather than by convention. A hand-rolled object with `kind: 'action'` is `X_ACTION_FOREIGN`, never a registered action. Every projection needs the name `registerActions()` stamps on. It names the export in place, so `import { publishPost }` is the action that projects once the app has booted — there is no second, differently-named twin to remember. @@ -73,7 +73,7 @@ Every projection needs the name `registerActions()` stamps on. It names the expo |---|---|---|---| | 1 | **HTTP route** | name + `input` | `POST /api/posts/publish` — first word is the verb, the rest is the pluralized resource. Body parsed by `input`, errors are `UltimateError` JSON | | 2 | **OpenAPI operation** | `input` + `output` + `mcp.description` | `publishPost.openapi()`, emitted into `x.manifest.json` and `openapi.json`; contract diff runs in `x verify` | -| 3 | **Typed client function** | `input` + `output` | `await api.publishPost({ postId })` in `app/`, or `publishPost.client({ baseUrl })` for one method — no fetch, no codegen step to remember | +| 3 | **Typed client function** | `input` + `output` | one map-wide client, `export const client = rpc({ baseUrl })`, then `await client.publishPost({ postId })` in `app/`; or `publishPost.client({ baseUrl })` for a single method. `rpc` is the only name for the map-wide client — no `createClient` alias, 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` | `publishPost.tool()` — one `publish_post` per exposed action, JSON Schema from `input`, authz unchanged | | 6 | **Test** | `input` + `policy` | `publishPost.contract()` — schema round-trip plus a denial test per policy branch, generated green, not as a `TODO` | diff --git a/wiki/Entities-And-Migrations.md b/wiki/Entities-And-Migrations.md index d0769815c..7a2db58f8 100644 --- a/wiki/Entities-And-Migrations.md +++ b/wiki/Entities-And-Migrations.md @@ -112,7 +112,15 @@ Which column that is, in order — first match wins: | 3 | a column named `orgId` | literal property name, no mark needed | | 4 | none of those | `$tenantColumn` is `null` and the entity is not tenant-scoped | -Omitting `tenant` keeps steps 2–4, so silence never means unscoped. A `tenant` key naming no column is a declaration error — `X_INVARIANT_VIOLATED`, listing the columns to pick from — because the alternative is a silently unscoped table. +Omitting `tenant` keeps steps 2–4, so silence never means unscoped. A `tenant` key naming no column is a declaration error — because the alternative is a silently unscoped table. The error lists the columns to pick from and both edits that resolve it, so the declaration is repaired without opening the entity file: + +``` +X_INVARIANT_VIOLATED: a domain invariant rejected this row + cause: posts.tenant: tenant: 'workspaceId' names no column — pick from: id, title, body, orgId + fix: set tenant to one of id, title, body, orgId in entity('posts'), or remove the tenant key — inference then takes the .tenant() column, else one named orgId +``` + +Removing the key is a real option, not a hedge: inference (steps 2–4) still applies, so the table stays scoped whenever a column is marked `.tenant()` or named `orgId`. | Consequence | Detail | |---|---| diff --git a/wiki/Error-Codes.md b/wiki/Error-Codes.md index 6195a3a1a..470bd2bc2 100644 --- a/wiki/Error-Codes.md +++ b/wiki/Error-Codes.md @@ -41,8 +41,8 @@ X_DB_DRIFT: schema differs from migrations | `X_ID_INVALID` | value is not a valid id | a hand-built string passed where a typed id is required | generate ids with `uuid()` / `typedId<'post'>()` from `@ultimat3/core` | | `X_CURSOR_INVALID` | pagination cursor is malformed, tampered with or from another query | signature mismatch — an edited cursor, or `ULTIMATE_CURSOR_SECRET` rotated — or a cursor built for a different query, filter or sort order | drop the cursor and request the first page (`after: null`) | | `X_ERROR_CODE_DUPLICATE` | error code registered twice | two packages declared the same code | rename the colliding code in the registering package's `src/errors.ts` | -| `X_REGISTRAR_MISSING` | no registrar is loaded for a primitive kind | `defineApi({ queries })` ran without `@ultimat3/query` in the graph | `bun add @ultimat3/query` | -| `X_REGISTRAR_CONFLICT` | two different registrars are loaded for one primitive kind | two copies of one `@ultimat3/*` package in the dependency tree, each with its own registry | `bun pm ls \| grep @ultimat3/query` — then dedupe it to one version in `package.json` | +| `X_REGISTRAR_MISSING` | no registrar is loaded for a primitive kind | the owning package is absent from the graph, so nothing announced a registrar — `defineApi({ queries })` without `@ultimat3/query`. `meta.kind` names the kind, and the owner is `@ultimat3/`; importing it is what announces | `bun add @ultimat3/` | +| `X_REGISTRAR_CONFLICT` | two different registrars are loaded for one primitive kind | two copies of `@ultimat3/` in the dependency tree, each with its own registry, so half the primitives register where nothing reads them. `bun pm why @ultimat3/` names the dependents when ranges genuinely disagree | `bun update @ultimat3/` | ## Images @@ -204,6 +204,7 @@ One pipeline in `@ultimat3/core` serves `storage`, `seo` and `pwa`. It **decodes | `X_ROUTE_MODE_INVALID` | render mode not allowed on this surface | `stream` on a `site/` route | use `static`, `isr` or `ssr` in `site/`; `stream`, `spa` or `ssr` in `app/` | | `X_ROUTE_OFFLINE_MISSING` | the route's offline strategy is missing or contradictory | `precache` on an `ssr` route | set a compatible `offline`, or change the render mode | | `X_ROUTE_META_MISSING` | required metadata missing | no `meta.title`, or no `description` on a `site/` route | add it to `meta` in the route file | +| `X_ROUTE_UNNORMALIZED` | a route was registered without `defineRoute` | `registerRoute({ config })` was handed the author's own object, so `meta` and `budget` were never normalized and every descriptor reader would read them wrong | wrap it: `registerRoute({ file, config: defineRoute({ … }) })` | | `X_ROUTE_DUPLICATE` | two route files resolve to one URL | a copied page directory | delete or rename one | | `X_SURFACE_BOUNDARY` | a surface imported across the hard boundary | `site/` reached `app/`, transitively | `x fix boundary `, or move the shared module out of `shared/ui` | | `X_BOUNDARY_SITE_TO_APP` | `site/` imported `app/` | the classic transitive import | the chain is printed in `cause`; break it at the named hop | diff --git a/wiki/Routes-And-Render-Modes.md b/wiki/Routes-And-Render-Modes.md index d4a198903..859bd865a 100644 --- a/wiki/Routes-And-Render-Modes.md +++ b/wiki/Routes-And-Render-Modes.md @@ -47,6 +47,7 @@ Validation runs at **module evaluation**. `defineRoute` checks the shape and the | `meta` is a function | `defineRoute` | `X_ROUTE_META_MISSING` | | mode-local: known `render` and `hydrate`; `static` with a `policy` or a `revalidate`; `isr` with no trigger; `ssr` with a `prerender`; `spa` with no `policy` | `defineRoute` | `X_ROUTE_MODE_INVALID` | | surface-dependent: mode allowed on the surface; `site/` hydration without `budget.js`; `stream` with no ``; `prerender` on a non-prerenderable mode | `registerRoute` | `X_ROUTE_MODE_INVALID` | +| the config came from `defineRoute` and not straight from the author | `registerRoute` | `X_ROUTE_UNNORMALIZED` | | two files claiming one URL | `registerRoute` | `X_ROUTE_DUPLICATE` | The split is about what is knowable, not about strictness: everything decidable from the config alone is decided at import; the rest needs the file's surface, which only the route table knows. diff --git a/wiki/Scheduled-Tasks.md b/wiki/Scheduled-Tasks.md index 98af02a1d..f2c37c77c 100644 --- a/wiki/Scheduled-Tasks.md +++ b/wiki/Scheduled-Tasks.md @@ -11,7 +11,9 @@ v1.0.0 `As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)) export const nightlyDigest = task({ cron: '0 3 * * *', tz: 'UTC', - enqueue: () => [[sendDigest, {}]], + // The occurrence, never the wall clock: a catch-up tick runs long after the instant it fires + // for, and a payload read off `Date.now()` there is silently for the wrong day. + enqueue: (occurrenceMs) => [[sendDigest, { occurrenceMs }]], }); ``` @@ -110,7 +112,7 @@ Role table and drain sequence: [Deployment](Deployment). | Command / tool | Output | |---|---| -| `x tasks list --json` | name, `cron`, `tz`, `enabled`, `lastRun`, `lastStatus`, **`nextRun`** (ISO 8601, UTC + the zone-local rendering) | +| `x tasks list --json` | `name`, `cron`, `tz` and `enqueues` off the descriptor, plus the scheduler's last-tick state — `lastRun`, `lastStatus`, **`nextRun`** (ISO 8601, UTC + the zone-local rendering). Those three are run state, read from the scheduler's Postgres row and its next-occurrence resolution, never declared fields | | `x tasks show --json` | the next N fire times, the jobs it enqueues, the resolved queue | | `x tasks run ` | fires one tick immediately, out of band, for verification. Dispatch only — the job still runs on a worker | | MCP `tasks.list` | same content as `x tasks list --json`, same authz | @@ -122,7 +124,7 @@ Role table and drain sequence: [Deployment](Deployment). ``` $ x tasks list --json {"tasks":[{"name":"nightlyDigest","cron":"0 3 * * *","tz":"UTC", - "enabled":true,"lastRun":"2026-07-26T03:00:00Z","lastStatus":"enqueued", + "lastRun":"2026-07-26T03:00:00Z","lastStatus":"enqueued", "nextRun":"2026-07-27T03:00:00Z","enqueues":["sendDigest"]}]} ```