@btravstack/contract

中文文档
0.58.0发布于 2026/11/08MITTypeScriptESM + CJS零依赖构建来源已验证近 30 天下载 9699

The contract tier: markers and normed shapes a client and the server that implements it both need — the authenticated() marker, and a cursor page with its schema

原始使用文档
文档目录5 节
  1. Usage
  2. The marker is a WeakMap, not a property
  3. Paging a listing
  4. Sorting a listing
  5. License

@btravstack/contract

The contract tier: what a client and the server that implements it both need, and no transport owns. Declare which security schemes a procedure accepts and which scopes each must grant, and nothing about who the caller is; and describe one page of a listing once, as the type a port speaks and the schema a contract publishes. The root has zero dependencies — a client can take a contract without the server, and any transport's contract can use the same marker.

📖 Documentation · API Reference

pnpm add @btravstack/contract

Nothing to install beside it, unless you use the page schemas under @btravstack/contract/zod, whose zod is an optional peer. The optional @btravstack/contract/orpc entry also needs @orpc/contract: its contractInputSchema and contractOutputSchema retrieve the procedure's original Zod schemas with their exact types. A package that ships a marked contract takes this one as a peerDependency rather than an ordinary one — the marker is identity-based, so one copy per application is the point; see Why a peer below. Node >=22.

Usage

import { authenticated } from "@btravstack/contract";

export const contract = {
  orders: authenticated({ user: [] })({
    place,
    find,
    // Overrides the group default for itself: a `user` token needs the scope,
    // or a `service` token needs nothing.
    export: authenticated(
      { user: ["orders:export"] },
      { service: [] },
    )(oc.output(z.object({ csv: z.string() }))),
  }),
  customers: { find },
};

authenticated is curried: it takes one or more OpenAPI security requirements — a scheme name mapped to the scopes it must grant — and hands back the function that marks a node. Several requirements are ORed, tried in the order given. A marked record is the default for every procedure beneath it; a marked procedure replaces that default for itself. Nearest mark wins, which is OpenAPI's own rule. Apply it after a builder chain is finished, never inside one.

The contract says which schemes protect a route; the application's defineHttp({ authenticators }) says what each one resolves to. No identity type is named here, so nothing about the server's own view of a caller reaches a client, and enriching it is never a contract change.

The marker is a WeakMap, not a property

The marker is identity-based — a WeakMap, no property on the node — which is why a package shipping a marked contract takes this one as a peer dependency rather than an ordinary one: two copies would mean two registries, and a contract marked by one reading unmarked to the other is a protected route served open. The registry is copy-proof against that anyway (it hangs off globalThis under Symbol.for("@btravstack/contract/requirements"), so every copy shares one WeakMap), so a stray second copy costs a compile error on the mismatched marker symbol, not an open route.

isAuthenticated(node) reads back the Requirements a node was marked with, or undefined when nobody marked it.

Paging a listing

One page, described once: the type a port speaks and the schema the contract publishes are the same shape, and a test pins that they cannot drift apart.

import { pageOf, pageRequestOf } from "@btravstack/contract/zod";

export const orders = oc.router({
  list: oc
    .input(pageRequestOf({ minQuantity: z.number().int().min(1).optional() }))
    .output(pageOf(orderView)),
});

A flag and its cursor are one fact. hasNextPage: true carries the nextCursor that continues the listing, and hasNextPage: false has no such field — so "there is more, and nothing to follow it with" is unrepresentable, and a client that checked the flag holds the cursor with no null to widen it. after and before are a union in the type and refused as a pair by the schema: a page runs in one direction.

An adapter pages a listing with keyset(request), which answers one object holding both halves of a keyset page — take (the page plus the one extra row that proves the next one), backward, cursor, and a page that folds what the store answered:

import { keyset } from "@btravstack/contract";

const keys = keyset(request);
const listed = keys.page(store.seek(keys), (row) => String(row.id));

The over-fetch and the trim agree by construction, which is the whole reason they are one object. keyset does not run the query — that is the one thing this tier cannot express — and where a store reports both cursors itself, page(items, { previous, next }) takes them directly. A controller turns a validated input into the port's PageRequest with pageRequest(input).

Sorting a listing

A listing declares its own sortable vocabulary, checked against the item it already publishes, and a required default — an implicit one would be a listing sorted by something nobody chose:

import { sortableBy } from "@btravstack/contract/zod";

export const sortedOrders = oc.router({
  list: oc
    .input(
      pageRequestOf(
        { minQuantity: z.number().int().min(1).optional() },
        {
          sortableBy: sortableBy(orderView, ["quantity"]),
          defaultSort: { field: "quantity", direction: "desc" },
        },
      ),
    )
    .output(pageOf(orderView)),
});

The cursor carries the sort verbatim, not hashed — field:direction, the field half encodeURIComponentd, like the sort value and the tiebreak beside it — because the vocabulary is already public in the emitted OpenAPI document. A cursor is valid only for the sort it was issued under: sorted, keyset(request) answers SortedKeyset<F> | CursorRefused, a union the adapter must branch on rather than an unfiltered page served from the wrong side:

import { keyset } from "@btravstack/contract";

const sortedKeys = keyset(sortedRequest);
const sortedListed = sortedKeys.resumable
  ? sortedKeys.page(sortedRows, (row) => [String(row.quantity), String(row.id)])
  : sortedKeys.reason; // "malformed" or "sort-mismatch"

SortedKeyset<F>.page's cursorOf returns both values a sorted seek needs — the sort key's and the tiebreak's — so a forgotten tiebreak is a compile error rather than a keyset that silently skips rows that tie.

License

MIT © Benoit TRAVERS

版本与发布记录

@btravstack/contract 共发布 62 个版本,首次发布于 2026/08/26;过去 12 个月发布了 62 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.58.0latest2026/11/08今天
0.57.02026/11/071 天前
0.56.12026/11/062 天前
0.56.02026/11/062 天前
0.55.12026/11/035 天前
0.55.02026/11/035 天前

关于 @btravstack/contract 的常见问题

@btravstack/contract 是什么?

The contract tier: markers and normed shapes a client and the server that implements it both need — the authenticated() marker, and a cursor page with its schema

如何安装 @btravstack/contract?

在项目目录运行 npm install @btravstack/contract,或使用 pnpm add / yarn add / bun add。

@btravstack/contract 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@btravstack/contract 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@btravstack/contract 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@btravstack/contract 可以商用吗?

@btravstack/contract 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@btravstack/contract 的下载量有多少?

根据 npm 官方下载统计,@btravstack/contract 在 2026/10/09 – 2026/11/07 这 30 天内被下载 9,699 次,日均约 323 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@btravstack/contract 还在维护吗?

最新版本 0.58.0 发布于 2026/11/08(今天);至今共发布 62 个版本,其中过去 12 个月发布了 62 个;首次发布于 2026/08/26。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。