Primate 0.40:路由页面、存储枚举、异步模式与事件
Primate 0.40: Route pages, store enums, async schemas and events

原始链接: https://primate.run/blog/primate-040

Primate 0.40 带来了多项易用性改进与架构优化: * **路由页面同构化:** 路由及其对应的各种前端组件(React、Vue、Svelte 等)现可存放在同一位置。数据加载逻辑与 UI 组件可直接共享类型定义,无需手动同步接口。此结构同样适用于布局文件。 * **增强的数据处理:** `p.enum` 类型现可直接集成到存储中,无需手动定义常量即可提供完整的验证、类型检查和反向查找。此外,`pema` 模式现支持 `.derive()` 进行同步转换,并支持 `p.async()` 在路由和存储中进行 I/O 相关的规范化处理。 * **精简的服务器推送事件 (SSE):** `response.sse` 现采用更简洁的设置与清理模式。结合新增的 `primate/events` 内存通道辅助工具,管理键控订阅变得更加轻松。 * **清理与优化:** * 导入现使用统一的 `@/*` 别名。 * HTML 外壳文件已移至 `templates/` 目录,以明确术语。 * **生产环境自动迁移:** 生产构建现在可以自动执行数据库迁移,简化了部署流程,消除了对生产环境包管理器的依赖。 如需查看完整的变更列表,请访问 Primate 文档。

```Hacker News最新 | 过往 | 评论 | 提问 | 展示 | 招聘 | 提交登录Primate 0.40:路由页面、存储枚举、异步架构与事件 (primate.run)6 点,由 terrablue 于 1 小时前发布 | 隐藏 | 过往 | 收藏 | 2 条评论帮助 happyPersonR 28 分钟前 [–] 哈哈哈,我喜欢这种风格,发一个随机的软件框架链接……什么描述都没有。回复bilaxi70 8 分钟前 | 父评论 [–] 懂的自然懂吧。回复 考虑申请 YC 2026 年秋季班!申请截止日期为 7 月 27 日。 准则 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系方式 搜索: ```
相关文章

原文

Today we're announcing the availability of the Primate 0.40 preview release.

If you're new to Primate, we recommend reading the quickstart page to get started.

Collocated route pages

Primate 0.40 supports collocated route pages. Instead of importing a component from views and passing it to response.view, a route can render the frontend file next to it with response.page.

TypeScriptroutes/post/[id].ts
import response from "primate/response";
import route from "primate/route";

export default route({
  get(request) {
    return response.page({ id: request.path.get("id") });
  },
});

The frontend file has the same basename as the route, only a different extension.

React Angular Vue Svelte Solid Markoroutes/post/[id].tsxroutes/post/[id].component.tsroutes/post/[id].vueroutes/post/[id].svelteroutes/post/[id].tsxroutes/post/[id].marko
import type route from "./[id]";

export default function Post(props: typeof route.get.Page) {
  return <h1>Post {props.id}</h1>;
}

The route handles data loading, validation, redirects and status codes; the page handles rendering.

Typed props without declarations

The component examples above use typeof route.get.Page for their props. That type comes from response.page(...), so the route response is the source of truth and there's no separate interface to keep in sync.

Layout pages

Layouts can be collocated too.

TypeScriptroutes/admin/+layout.ts
import response from "primate/response";
import route from "primate/route";

export default route({
  get() {
    return response.page({ section: "Admin" });
  },
});

As with normal routes, add a component alongside the layout with the same basename.

React Angular Vue Svelte Solid Markoroutes/admin/+layout.tsxroutes/admin/+layout.component.tsroutes/admin/+layout.vueroutes/admin/+layout.svelteroutes/admin/+layout.tsxroutes/admin/+layout.marko
import type route from "./+layout";

export default function Layout(
  props: typeof route.get.Page & { children: React.ReactNode },
) {
  return <main data-section={props.section}>{props.children}</main>;
}

The matching layout page receives those props plus its children. This keeps route-specific UI close to the route while preserving the existing views directory for shared components and explicitly named views.

Store enums

In Primate 0.40, p.enum participates in stores. No more juggling a p.u8 column and a separate hand-rolled constants object that you hope stays in sync. One declaration is fully validated, fully typed, and carries the enum names with the schema.

// stores/Account.ts
import db from "@/config/db";
import p from "pema";
import store from "primate/store";

const Status = p.enum({
  UNCONFIRMED: 0,
  CONFIRMED: 1,
});

export default store({
  table: "account",
  db,
  schema: {
    id: store.key.primary(p.u32),
    email: p.string,
    status: Status,
    created_at: p.date.default(() => new Date()),
  },
}).extend(_Account => ({
  Status,
}));

Then anywhere else in your app:

import Account from "@/stores/Account";

const account = await Account.insert({
  email: "[email protected]",
  status: Account.Status.UNCONFIRMED,
});

if (account.status === Account.Status.CONFIRMED) {
  // ...
}

This removes a few common sources of drift.

Plain storage

p.enum is backed by p.u8 under the hood, so it stores as a plain integer from 0 to 255. There are no dialect-specific enum types and no CHECK constraints to babysit across SQLite, Postgres and MySQL — it rides on u8's existing column mapping.

Membership validation

p.u8 alone happily accepts 7 for a two-value enum. p.enum validates membership too: only declared values pass.

One declared value

The enum is the field, not a sibling constant you remember to update. Account.Status.CONFIRMED is generated straight from the schema, not duplicated by hand.

Reverse lookup

Account.Status.nameOf(account.status) gets you "CONFIRMED" back when you need the name, not just the number.

Derived and async schemas

Pema schemas can now derive a parsed value into a different value. Use .derive(...) when parsed input should be normalized before the handler sees it.

import p from "pema";

const FullName = p({
  first: p.string,
  last: p.string,
}).derive(({ first, last }) => `${first} ${last}`);

const name = FullName.parse({ first: "John", last: "Adams" });
// name is "John Adams"

This works anywhere a Pema schema is accepted. For request bodies, the derived type flows into the route handler.

import p from "pema";
import route from "primate/route";

const Body = p({
  name: p.string,
}).derive(({ name }) => name.toUpperCase());

export default route({
  post: route.with(
    { body: Body, contentType: "application/json" },
    async request => {
      const name = await request.body.json();
      // name is string
      return name;
    },
  ),
});

Primate 0.40 also supports p.async(...) for object schemas that need async post-processing. Its parse(...) method always returns a promise, and async derives compose just like sync derives.

import p from "pema";

const User = p.async({
  id: p.string,
}).derive(async ({ id }) => {
  return await loadUser(id);
});

Async schemas are supported in route.with(...) for bodies and path parameters. For path schemas, p.async(...) keeps the object shape visible so Primate can still check that route parameters and schema properties match at build time.

// routes/user/[id].ts
import p from "pema";
import route from "primate/route";

const Path = p.async({
  id: p.string,
}).derive(async ({ id }) => ({
  id: await resolveUserId(id),
}));

export default route({
  get: route.with({ path: Path }, request => {
    return request.path.get("id");
  }),
});

Use regular .derive(...) for synchronous transformations. Reach for p.async(...) when the schema has to await I/O, lookup data, or perform another async normalization step before the handler sees the value.

Events and simpler SSE cleanup

response.sse now uses a single setup function instead of separate open and close callbacks. Return a cleanup function to stop timers or unsubscribe when the browser disconnects.

import response from "primate/response";
import route from "primate/route";

export default route({
  get() {
    return response.sse(source => {
      const timer = setInterval(() => {
        source.send("tick", Date.now());
      }, 1000);

      return () => clearInterval(timer);
    });
  },
});

Primate 0.40 also includes primate/events, an in-memory channel helper for keyed subscriptions.

// services/DeploymentEvents.ts
import events from "primate/events";

type DeploymentEvent = {
  type: "step";
  key: number;
  status: "running" | "done" | "error";
};

export default events.channel<number, DeploymentEvent>();

Emit with a key, subscribe with the same key, and return the unsubscribe function from response.sse.

return response.sse(source => {
  return DeploymentEvents.subscribe(deployment.id, event => {
    source.send(event.type, event);
  });
});

Channels are intentionally local to one server process. They do not persist events or broadcast across multiple running instances.

Minor improvements

App imports move to @/

New Primate apps now use a single @/* TypeScript path alias for application imports instead of the previous family of # aliases. This avoids collisions with package-internal import maps and makes app imports read like regular project-root imports.

{
  "compilerOptions": {
    "baseUrl": "${configDir}",
    "paths": {
      "@/*": ["*"]
    }
  }
}

Existing imports map directly to their project folders:

import app from "@/config/app";
import db from "@/config/db";
import View from "@/views/Post";
import route from "@/routes/post";
import User from "@/stores/User";

This replaces app-level imports like #app, #db, #lib/*, #view/*, #route/*, and #store/*.

HTML templates move to templates

Primate's HTML shell files now live in templates instead of pages. This frees up the term "page" for collocated route pages while making the existing concept clearer: these files are document templates, not route pages.

templates/app.html
templates/error.html

The render option has been renamed in the same spirit:

return response.view(View, props, { template: "admin.html" });
return response.error({ template: "error.html" });

New apps generated with npx primate init use templates/app.html, and the old pages directory is no longer part of the app structure.

Autoapplying migrations

Primate can now autoapply migrations in production. To activate this, add autoapply: true to your configuration:

import config from "primate/config";
import db from "@/config/db";

export default config({
  db: {
    migrations: {
      table: "migration",
      db,
      autoapply: true, // default is false
    },
  },
});

Migrations are then automatically applied when you run node build/server.js in production. There's no need to run npx primate migrate:apply before running the server.

This removes the need to rely on node_modules or a package manager in production. You can copy the contents of build to your deployment server and run your app with Node, Deno or Bun. If you're crossbuilding, consider also setting --target=node, --target=deno or --target=bun; see Crossbuilding in the Primate 0.39 release notes.

Fin

If you like Primate, consider joining our Discord server or starring us on GitHub.

联系我们 contact @ memedata.com