Sign In

@ultimat3/action

Package Overview
Dependencies
Maintainers
1
Versions
17
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/action

The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests

Source
npmnpm
Version
11.0.0
Version published
Maintainers
1
Created
Source

@ultimat3/action ⚡

One declaration → six artifacts.

#ArtifactReach it withGuarantees
1HTTP route POST /api/<resource>/<verb>toRoute(publishPost) — the server mounts itpolicy + validation + idempotency + invalidation, non-optional
2OpenAPI 3.1 operation + documentpublishPost.openapi() / buildOpenApi()byte-stable output, diffed by x verify
3Typed RPC clientpublishPost.client({ baseUrl }) / rpc<Api['actions']>()server typo = compile error in Solid
4MCP toolpublishPost.tool()identical policy evaluation to the route
5Job handlepublishPost.job()enqueue durable work, no rewrite
6Contract testspublishPost.contract()garbage rejected, anonymous denied, spec present

The fluent surface

An action carries its own projections, so app code never reaches through .def and never imports a projection function:

publishPost.input                              // the declared input schema
publishPost.output                             // the declared output schema
publishPost.policy                             // the one policy object
publishPost.mcp                                // { expose, description }, as declared
await publishPost.as(actor, { postId })        // run as someone, one execution path
publishPost.tool()                             // MCP descriptor
publishPost.openapi()                          // OpenAPI operation
publishPost.client({ baseUrl })                // typed RPC method
publishPost.job()                              // durable-work handle
publishPost.contract()                         // the three generated assertions

publishPost.tool().policy === publishPost.policy — the same object, so an MCP call cannot reach a different authz path. .as() keeps the surrounding context whole and swaps only the actor: impersonation, not a second context.

Declare

t is re-exported here — the same object @ultimat3/schema exports, so an action file imports one package for the primitive and its schemas, never two.

import { action, t } from '@ultimat3/action';

export const publishPost = action({
  input:  t.object({ postId: t.uuid, orgId: t.uuid, notify: t.boolean.default(true) }),
  output: PostView,
  policy: can('post:publish', ({ input, actor }) => ownsPost(actor, input.postId)),
  cache:  { invalidates: [tag.post, tag.feed] },
  mcp:    { expose: true, description: 'Publish a draft post' },
  idempotent: true,
  async handle({ input, ctx }) {
    const post = await ctx.posts.publish(input.postId);
    if (input.notify) await notifySubscribers.enqueue({ postId: post.id, orgId: input.orgId });
    return post;
  },
});

Register — one call, at boot

apps/web/api/index.ts is the whole API surface. Importing it IS the boot.

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;

Six keys, all optional:

KeyGoes toWhy
actionsthe action registrythe primitive
mutatorsthe action registrya mutator IS an action, on the same authz path
llmthe action registryllm() returns an action, not a ninth primitive
queries@ultimat3/query's registry, via core's registrar tablequery is on this tier, so importing it here would be a build error
jobs@ultimat3/jobs' registry, the same waythe export name becomes the durable queue key — a job row names the handle, not a counter
tasks@ultimat3/jobs' registry, the same wayhanding a task over is what names its cron after its export

Jobs register before tasks: a task's descriptor lists the jobs it enqueues by name, so the other order would read the queue keys one boot step before they were assigned. Api carries all four maps back — api.actions, api.queries, api.jobs, api.tasks — keyed by the name registration stamped.

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, and two names deriving one route collide with X_ACTION_PATH_DUPLICATEpluralize leaves a trailing s alone, so archiveOrder and archiveOrders are two exports and one POST /api/orders/archive.

registerActions is what defineApi composes for the three action-shaped keys; the other three go through core's primitiveRegistrar(kind), because this package may not import @ultimat3/query or @ultimat3/jobs sideways. An app calling either directly is a second path.

Call it — rpc

import { rpc } from '@ultimat3/action';
import type { Api } from '../api';

export const client = rpc<Api['actions']>({ 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

First camelCase word is the verb; the rest is the resource, last word pluralized, kebab-cased. The MCP tool name is not derived at all — it is the export name verbatim, because that is what defineAppMcp's scopes: and a tools/call have to spell.

ActionRouteMCP tool
publishPostPOST /api/posts/publishpublishPost
updateUserProfilePOST /api/user-profiles/updateupdateUserProfile
likePostPOST /api/posts/likelikePost
checkout (single word)POST /api/checkouts/invokecheckout

One name, four surfaces.tool().name, openapi.json's x-ultimate.mcpTool, describeAction().mcp.tool (what x actions describe --json, x actions list --json, the actions.describe dev MCP tool and the /_x Routes panel show) and the catalog @ultimat3/mcp serves. It was two until 2026-08: a toToolName() here snake_cased the first three to publish_post while the server answered only publishPost, so an agent that read the published contract called a tool that does not exist. toToolName is deleted, not deprecated — a second derivation is a second name. mcp-tool.test.ts's "one name per action, on every surface" is what keeps it that way.

x.manifest.json is not one of the four: ActionFact.mcp is { expose, description? }, so the manifest never carried a tool name and was never wrong about one.

One invocation core

invoke() is the only execution path: parse input → evaluate policy → handle → parse output. HTTP, MCP, jobs and direct server calls differ only in the surface they hand to enforce() from @ultimat3/policy, which selects how a denial renders (problem+json / tool error / failed job) — never whether authz runs.

Enforced structurally, not by convention: the declaration is held in a private store inside invoke.ts, so handle is reachable from nowhere else. An action has no .def. A second authz path cannot be written without deleting that store.

StageFailure
parse inputX_INPUT_INVALID
evaluate policythe policy's own code — X_UNAUTHENTICATED (401), X_FORBIDDEN (403)
handlewhatever the handler throws
parse outputX_OUTPUT_INVALID — and fields the schema never declared are dropped

cache: { invalidates } fans out after the handler commits, so it never fails it: a fan-out that refuses — an undeclared tag, X_CACHE_TAG_UNKNOWN — is one action.invalidate.failed log line and the entries expire by TTL. A replayed idempotent call busts nothing; the first call already did.

Registering an action without policy: throws X_ACTION_POLICY_MISSING; there is no bypass flag. A look-alike that never came out of action() is X_ACTION_FOREIGN.

mutator = action + local twin

mutator() is built on top of action(). A mutator IS an action, so it gets all six projections; it adds local(tx, input) for the optimistic write and a conflict strategy for the rebase.

export const likePost = mutator({
  input: t.object({ postId: t.uuid }),
  output: PostLikes,
  policy: can('post:like'),
  // Convergent, not incremental: `local` replays on every rebase, so applying it N times has to
  // equal applying it once — `likedByMe` is what makes the second application a no-op.
  local(tx, { postId }) {
    tx.posts.update(postId, (p) =>
      p.likedByMe ? {} : { likedByMe: true, likeCount: p.likeCount + 1 });
  },
  async server(ctx, { postId }) { return ctx.posts.like(postId); },
  conflict: 'server-wins', // | 'last-write-wins' | custom(merge)
});

The projected surface carries the same three names the declaration used, on top of every action member above:

likePost.local(tx, { postId })            // the optimistic write, replayed on rebase
await likePost.server(ctx, { postId })    // the authoritative write
likePost.conflict                         // the declared strategy

.server() is not a shortcut past invoke — it calls the action's own callable, so the input parse, the policy and the output parse all still run: an actor the policy denies is denied there exactly as over HTTP. .local() is the only half that skips the core, because it never leaves the client; keep it a pure function of (tx, input) — no I/O, no clock, no randomness — since every rebase replays it.

LocalTx is the client write surface (@ultimat3/realtime implements it over OPFS SQLite). Type your tables once: declare module '@ultimat3/action' { interface LocalTables { posts: PostRow } }.

Determinism + idempotency

serializeOpenApi(buildOpenApi()) sorts keys at every depth, iterates the registry name-sorted, and reads no clock, env or random source — same registry ⇒ same bytes ⇒ x verify can diff the spec and fail on X_CONTRACT_DRIFT.

rateLimit: is the enforced limit

rateLimit: { limit: 5, windowMs: 600_000 },   // 5 held, one back every two minutes

One declaration, three places it lands: the bucket the limiter runs on (named after the action, registered by @ultimat3/http's withRouteBuckets when the route is mounted), the ratelimit-limit header the caller reads, and x-ultimate.rateLimit in the OpenAPI operation. toBucket is the only conversion — capacity: limit, refillPerSecond: limit / (windowMs / 1000) — so the published numbers and the enforced ones cannot differ. It lives in @ultimat3/http, beside Bucket and the limiter maths, and is re-exported here: @ultimat3/query needs the same conversion and is the same tier, so a copy in either package would be a second answer for the other. A pair the limiter cannot run on is X_RATE_LIMIT_INVALID, at projection. An action that declares nothing stays on the default bucket. An app that also configures http.rateLimit.buckets.<actionName> with different numbers is X_RATE_LIMIT_BUCKET_CONFLICT at boot: neither source wins, because the loser would go on being read as enforced.

idempotent: — and where its records live

idempotent: true + an Idempotency-Key header replays the first outcome (x-ultimate-replayed: 1); a duplicate still in flight, or a reused key with a new payload, is X_IDEMPOTENCY_CONFLICT.

A record belongs to one caller. The key is namespaced by action and by actor (idempotencyKeyFor), so two callers sending the same header value hold two records — the same value under one action used to be one shared record, which replayed one caller's response to another. A blank Idempotency-Key: is X_IDEMPOTENCY_KEY_INVALID, never read as "no key": Headers.get() answers '' and not null, so a blank header was itself a shared key, and the quiet reading — run without idempotency — loses the retry protection exactly when a client's key interpolation broke. Omit the header to run un-keyed; the published maxLength: 255 is enforced by the same refusal. An anonymous caller has no identity to narrow to, so anonymous callers of a public idempotent action still share a key space: a UUID key is what keeps them apart.

A failed first attempt is replayed too, not re-run. guard() and the input parse both happen before the idempotency gate, so everything it can see throw is post-authorization and possibly post-commit: a handler that took the money and then failed its own output: schema is the case. The reservation is settled as a FAILURE and the retry re-throws it under the first attempt's own code. Releasing it there is what made idempotency the cause of a double charge.

Where the records live is declared, and refused at registration. The default store is process memory — bounded, swept on a 24h window, and scope: 'process'. An app on more than one replica must say so and bring a store that can keep it, or the retry that lands on another replica finds no record and runs the handler again:

// boot, before registerActions()
import {
  configureIdempotency,
  postgresIdempotencyStore,
  setIdempotencyStore,
} from '@ultimat3/action';

// NOT `executor: Bun.sql` — `Bun.sql.query` is `undefined` `As of 2026-08` (it is a tagged
// template whose positional form is `unsafe`), so that line compiles and throws on the first
// reservation.
// The framework boot installs this store for you; reach for it by hand only from a host that
// boots the framework itself, and wrap the client that host already opened.
setIdempotencyStore(
  postgresIdempotencyStore({
    executor: { query: (text, values) => client.query({ text, values }) },
  }),
);
configureIdempotency({ scope: 'shared' });

configureIdempotency({ scope: 'shared' }) over a per-process store — or over a store that declares no scope at all — is X_IDEMPOTENCY_NOT_SHARED at registerAction, before the socket opens. The table is SQL_IDEMPOTENCY_TABLE, applied the way SQL_JOBS_TABLE is: x db up in development, the release-phase ROLE=migrate in production. postgresIdempotencyStore(...) .purgeExpired() is the sweep — Postgres forgets nothing on its own, so run it from a task.

A plain mutating route can use the same gate. withIdempotency, IDEMPOTENCY_HEADER, idempotencyKeyFor and getIdempotencyStore are all public, so a route that is not an action reserves and replays through the one implementation rather than growing a second:

const key = req.header(IDEMPOTENCY_HEADER);
// No header is the caller declining idempotency; a BLANK one is not, and
// `idempotencyKeyFor` refuses it below rather than filing a record everyone shares.
if (key === null) return json(await refund(input));
const outcome = await withIdempotency(
  getIdempotencyStore(),
  // Namespaced by action AND actor: otherwise two routes share one caller's key, and two
  // callers share one record.
  idempotencyKeyFor('refundCharge', key, req.ctx.actor),
  input,
  () => refund(input),
);

settle and fail take the reservation's own id — outcome's reservation, never the key alone. Both stores fence on it AND on in-flight As of 2026-08, the way @ultimat3/jobs' SQL_ACK fences on id = $1 and state = 'running': a reservation whose window lapsed is reclaimed by the next caller, so a straggler from the first attempt satisfied a status-only fence exactly and overwrote a live reservation.

A query has none and never will: a read has nothing to be idempotent about.

deprecated: — a compat window, not a version

deprecated: { since: '2026-08-01T00:00:00Z', sunset: '2026-12-31T23:59:59Z', replacedBy: 'searchOrders' },

Four things at once: Deprecation: @1754006400 (RFC 9745) and Sunset: Wed, 31 Dec 2026 … (RFC 8594) on every response including the failures, link: </api/orders/search>; rel="successor-version", deprecated: true plus x-ultimate.deprecation in the OpenAPI operation, and a deprecated_calls_total{primitive,name} counter — which is the only way to answer "is anyone still calling it?" before deleting it. A date that cannot be rendered is X_ACTION_DEPRECATION_INVALID at projection, not on the first request.

Versioning itself is deliberately absent, and will stay absent. Running v1 and v2 of one action side by side is two deployments behind one ingress — axiom 7's answer, costing this package no router feature, no path prefix and no second registry. What ships is the window: a date, a successor, and a number.

Audit — the seam, not the row

audit: true on any action or mutator sends every attempt — allowed, denied and failed — to the installed AuditSink. Opt-in per declaration, never a global switch: a login and a price change are not the same event, and the framework is not the thing that knows which of them your business has to keep.

import { setAuditSink } from '@ultimat3/action';

setAuditSink({
  async write(record) { await record.ctx.db.auditRows.insert(myRow(record)); },
});

What the framework supplies is what it genuinely knows:

Field
atwhen the attempt began, from ctx.now() — an instant, never a rendering
action / mutatorthe registered name, and which primitive it was
surfaceserver | http | mcp | job — the same price change over MCP is not the same event
ctxthe whole context: actor, requestId, traceId, locale, and the services a sink needs to write a row
inputthe parsed input, or undefined when the parse is what failed — never the raw payload
idempotencyKey / replayedthe namespaced key, and whether this was a call rather than a write
outcomeallowed | denied | failed
failurethe X_* code and the thrown value, on every outcome but allowed

What it does not supply: an audit entity, a schema, a retention policy, a storage backend, a hash chain, a subject index, or an opinion on what "who" means under impersonation. Four apps model those four ways; shipping one would make three of them wrong.

A denial is recorded because invoke wraps the whole path — guard throws before handle, so nothing you could write around your own handler would ever see one. That is the reason this lives in the framework and the row does not.

Failure is loud, both ways. audit: true with no sink installed is X_AUDIT_SINK_MISSING, raised before the input parse — the one audit failure with no committed write behind it. A sink that refuses a successful record is X_AUDIT_SINK_FAILED: the deliberate opposite of the cache tier's bestEffort, because a dropped cache entry expires by TTL and the stack heals itself while nothing ever re-derives an audit row that was never written. It is post-commit all the same, and the error says so.

Its fix: branches, because only one of the two is ever true. Retrying is safe exactly when this invocation went through the idempotency store — then the settled record replays and the audit row is re-attempted without re-running the handler. It did not when the action is not idempotent, and it did not when the action is idempotent but the caller sent no Idempotency-Key: invoke reads def.idempotent === true ? (options.idempotencyKey ?? null) : null, so both collapse to the same null. In that case the error says do not retry and names the edit — telling a caller to re-run a committed mutator is worse than saying nothing. meta.replayable carries the same fact to --json.

A sink that refuses a denied or failed record is logged as audit.sink.failed and the original error still reaches the caller: answering X_AUDIT_SINK_FAILED there would hide the X_FORBIDDEN from whoever has to act on it.

Your house rule goes in a wrapper, not in a config option — the same shape as tenantEntity():

// apps/web/shared/base/audited-mutator.ts — the app's convention, written once
import { mutator, type MutatorDef } from '@ultimat3/action';
import type { StandardSchemaV1 } from '@ultimat3/schema';

/** Every write in this app is recorded and retryable — declared once, not at forty call sites. */
export const auditedMutator = <I extends StandardSchemaV1, O extends StandardSchemaV1>(
  def: MutatorDef<I, O>,
) => mutator({ ...def, audit: true, idempotent: true });

Nothing downstream can tell the difference: isMutator() is structural and registerActions names the object in place, so every projection, the manifest and admin CRUD work on it exactly as on a hand-written one.

The row it produces is the app's, and so is every question the framework refused to answer — which fields, whose tenant, chained or not, kept how long:

setAuditSink({
  async write(record) {
    const { ctx } = record;                             // the services a sink needs to write a row
    const prev = await chainHead(ctx);                  // hash-chained: the app's choice
    await ctx.db.auditRows.insert({
      orgId: orgOf(ctx.actor),                          // tenancy: derived from the actor
      subjectId: subjectOf(record.action, record.input),// queryable by subject: the app's index
      actorId: impersonatorOf(ctx.actor) ?? ctx.actor.id,
      at: record.at, outcome: record.outcome, code: record.failure?.code ?? null,
      prevHash: prev, hash: await sha256(prev, record),
    });
  },
});

Contract tests

publishPost.contract() returns three assertions. Run them; they throw X_CONTRACT_DRIFT.

AssertionHolds when
input schema rejects garbagethe invocation fails X_INPUT_INVALID — that code, not any failure
policy denies an anonymous actorthe invocation fails with an ActionDeniedError
OpenAPI document contains its operationthe derived path is in buildOpenApi()

The denial assertion sends an input synthesized from input:'s own schema — required keys only, formats included — because a payload the schema rejects never reaches a policy. It asserts the denial, not X_FORBIDDEN: a denial carries the policy decision's own code, and can() answers a null actor with X_UNAUTHENTICATED.

publishPost.contract({
  garbage: 42,                                 // what the input schema must reject
  input: { postId, orgId },                    // when the synthesized one cannot fit
  ctx: myCtx,                                  // default: an anonymous context
})

Pass input: when the schema carries a constraint the IR cannot invert (a bare pattern) or when row: needs an id that resolves. Anything thrown before the policy decides is drift, never a pass — the assertion says which code got in the way and names input: as the fix.

Errors

CodeWhenFix
X_ACTION_DUPLICATEtwo actions registered under one namerename one export
X_ACTION_PATH_DUPLICATEtwo actions derive one HTTP path (archiveOrder / archiveOrders)rename one export
X_ACTION_POLICY_MISSINGregistration without policy:add policy: can('…')
X_RATE_LIMIT_INVALIDrateLimit: with a non-positive or non-finite half — windowMs: 0 refills infinitely. Owned by @ultimat3/http, which owns the conversionmake both positive, or delete the block
X_ACTION_DEPRECATION_INVALIDdeprecated: with a since/sunset that is not a dateuse an ISO-8601 instant
X_INPUT_INVALIDinput failed the Standard Schemax actions describe <name> --json
X_IDEMPOTENCY_CONFLICTkey reused with a new payload / still in flightnew key, or retry later
X_IDEMPOTENCY_KEY_INVALIDIdempotency-Key: sent blank (Headers.get() answers '', not null) or past 255 characterssend one unique value per request, or omit the header
X_IDEMPOTENCY_NOT_SHAREDconfigureIdempotency({ scope: 'shared' }) over a per-process (or scope-less) storeinstall postgresIdempotencyStore({ executor }) at boot
X_IDEMPOTENCY_REPLAYED_FAILUREa retried key replays a first attempt that failed and carried no framework code of its ownread the first attempt, then send a fresh key
X_IDEMPOTENCY_STATUS_UNKNOWNx_idempotency.status holds a word this build has no branch for — written by a newer deployfinish the rollout onto the build that writes it, then reconcile those requests — never DELETE the rows, which frees the key to run an already-committed action a second time
X_CONTRACT_DRIFTclient/server build skew, missing spec entryreload / x verify --contract
X_RPC_FAILEDnon-problem+json failure, or a body naming no X_ codecheck the gateway
X_ACTION_UNREGISTEREDprojected before registerActions() ranregister at boot
X_AUDIT_SINK_MISSINGaudit: true and no sink installed — raised before the input parsesetAuditSink(yourSink) at boot
X_AUDIT_SINK_FAILEDthe sink refused the record for an attempt that succeededfix the sink — then retry the same Idempotency-Key if this call carried one, else reconcile by hand

Denials re-throw the policy layer's own codes (X_FORBIDDEN, X_UNAUTHENTICATED) — this package never invents an authz code.

The client does the same with the server's: a problem+json failure comes back as a RemoteActionError keeping the code the server sent, marked meta.origin: 'remote' because the browser bundle may never have registered it, and linked only to a page that exists — the server's own docs/type when it sent an http(s) one, this build's registered link when it knows the code, otherwise the error index. A per-code URL is never synthesized for a code nothing here declares.

Boundaries

Tier 3. Imports @ultimat3/core, schema, cache, policy, http. Never imports query, jobs, realtime (same tier) or anything above it — those import this.

FAQs

Package last updated on 23 Aug 2026

Related posts