Web開発
Primate 0.40: ルートページ、enumの保存、非同期スキーマとイベント
Primate 0.40: Route pages, store enums, async schemas and events (primate.run)
要約
WebフレームワークPrimateのバージョン0.40プレリリース版が発表されました。今回のアップデートでは、ルーティングとフロントエンドファイルの連携を強化する「コローケーションルートページ」機能が導入され、React、Vue、Svelteなどの主要フレームワークに対応しています。また、データベースストアでenum型を直接扱えるようになり、スキーマ定義の同期管理が容易になりました。
全文翻訳
Primate 0.40: ルートページ、enumの保存、非同期スキーマとイベント
2026年7月1日 by terrablue
本日、Primate 0.40プレビューリリースを発表します。Primateを初めて使用される方は、クイックスタートページをお読みになることをお勧めします。
コローケーションルートページ
Primate 0.40は、コローケーションルートページをサポートしています。response.viewにコンポーネントをインポートして渡す代わりに、ルートは隣接するフロントエンドファイルをresponse.pageでレンダリングできます。
TypeScript
routes/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")
});
},
});
フロントエンドファイルは、ルートと同じベース名を持ち、拡張子のみが異なります。
React
routes/post/[id].tsx
import type route from "./[id]";
export default function Post(props: typeof route.get.Page) {
return <h1>Post {props.id}</h1>;
}
Angular
routes/post/[id].component.ts
import type route from "./[id]";
import { Component, input } from "@angular/core";
type Props = typeof route.get.Page;
@Component({
template: `<h1>Post {{ id() }}</h1>`,
})
export default class Post {
id = input.required<Props["id"]>();
}
Vue
routes/post/[id].vue
<script setup lang="ts">
import type route from "./[id]";
type Props = typeof route.get.Page;
const props = defineProps<{ id: Props["id"] }>();
</script>
<template>
<h1>Post {{ props.id }}</h1>
</template>
Svelte
routes/post/[id].svelte
<script lang="ts">
import type route from "./[id]";
const props: typeof route.get.Page = $props();
</script>
<h1>Post {props.id}</h1>
Solid
routes/post/[id].tsx
import type route from "./[id]";
import type { JSX } from "solid-js";
export default function Post(props: typeof route.get.Page) {
return <h1>Post {props.id}</h1>;
}
Marko
routes/post/[id].marko
import type route from "./[id]";
export interface Input {
id: typeof route.get.Page["id"];
}
<h1>Post ${input.id}</h1>
ルートはデータロード、検証、リダイレクト、ステータスコードを処理し、ページはレンダリングを処理します。
宣言なしの型付きプロップス
上記のコンポーネント例では、プロップスにtypeof route.get.Pageを使用しています。この型はresponse.page(...)から取得されるため、ルートのレスポンスが真実の源であり、同期を保つための個別のインターフェースは不要です。
レイアウトページ
レイアウトもコローケーションできます。
TypeScript
routes/admin/+layout.ts
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.page({
section: "Admin"
});
},
});
通常のルートと同様に、同じベース名を持つコンポーネントをレイアウトの隣に追加します。
React
routes/admin/+layout.tsx
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>;
}
Angular
routes/admin/+layout.component.ts
import type route from "./+layout";
import { Component, input } from "@angular/core";
type Props = typeof route.get.Page;
@Component({
template: `<main [attr.data-section]="section()"> <ng-content /> </main>`,
})
export default class Layout {
section = input.required<Props["section"]>();
}
Vue
routes/admin/+layout.vue
<script setup lang="ts">
import type route from "./+layout";
type Props = typeof route.get.Page;
const props = defineProps<{ section: Props["section"] }>();
</script>
<template>
<main :data-section="props.section">
<slot />
</main>
</template>
Svelte
routes/admin/+layout.svelte
<script lang="ts">
import type route from "./+layout";
const props: typeof route.get.Page & { children: any } = $props();
</script>
<main data-section={props.section}>
{@render props.children()}
</main>
Solid
routes/admin/+layout.tsx
import type route from "./+layout";
import type { JSX } from "solid-js";
export default function Layout(
props: typeof route.get.Page & { children: JSX.Element },
) {
return <main data-section={props.section}>{props.children}</main>;
}
Marko
routes/admin/+layout.marko
import type route from "./+layout";
export interface Input {
section: typeof route.get.Page["section"];
renderBody: Marko.Body;
}
<main data-section=input.section>
<${input.renderBody}/>
</main>
マッチするレイアウトページは、これらのプロップスに加えて子を受け取ります。これにより、ルート固有のUIをルートの近くに配置しつつ、既存のviewsディレクトリを共有コンポーネントや明示的に名前が付けられたビューのために保持します。
ストアのenum
Primate 0.40では、p.enumがストアに参加します。p.u8カラムと、同期が保たれることを願う手作りの定数オブジェクトを使い分ける必要はもうありません。1つの宣言で完全に検証され、完全に型付けされ、スキーマと共にenum名が保持されます。
// 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,
}));
その後、アプリの他の場所で:
import Account from "@/stores/Account";
const account = await Account.insert({
email: "john@example.com",
status: Account.Status.UNCONFIRMED,
});
if (account.status === Account.Status.CONFIRMED) {
// ...
}
これにより、一般的なドリフトのソースがいくつか削除されます。
プレーンストレージ
p.enumは内部的にp.u8でバックアップされているため、0から255までの整数として格納されます。方言固有のenum型や、SQLite、Postgres、MySQL間で管理する必要のあるCHECK制約はありません。u8の既存のカラムマッピングを利用します。
メンバーシップ検証
p.u8だけでは、2値のenumに対して7を受け入れてしまいます。p.enumはメンバーシップも検証します。宣言された値のみが通過します。
宣言された値
enumはフィールドであり、更新を忘れる可能性のある兄弟定数ではありません。Account.Status.CONFIRMEDは、手作業で重複されるのではなく、スキーマから直接生成されます。
逆引き
Account.Status.nameOf(account.status)は、数値だけでなく名前が必要な場合に「CONFIRMED」を返します。
派生および非同期スキーマ
Pemaスキーマは、解析された値を別の値に派生できるようになりました。.derive(...)は、ハンドラが値を見る前に解析された入力が正規化される必要がある場合に使用します。
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"
これは、Pemaスキーマが受け入れられる場所ならどこでも機能します。リクエストボディの場合、派生型はルートハンドラに流れます。
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は、非同期後処理が必要なオブジェクトスキーマのためにp.async(...)もサポートしています。そのparse(...)メソッドは常にPromiseを返し、非同期派生は同期派生と同様に構成されます。
import p from "pema";
const User = p.async({
id: p.string,
}).derive(async ({ id }) => {
return await loadUser(id);
});
非同期スキーマは、ボディとパスパラメータのroute.with(...)でサポートされています。パススキーマの場合、p.async(...)はオブジェクト形状を可視状態に保つため、Primateはビルド時にルートパラメータとスキーマプロパティが一致することを確認できます。
// 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");
}),
});
同期変換には通常の.derive(...)を使用します。スキーマが値を見る前にI/O、データ検索、またはその他の非同期正規化ステップを待機する必要がある場合は、p.async(...)を使用します。
イベントと簡単なSSEクリーンアップ
response.sseは、個別のopenおよびcloseコールバックの代わりに、単一のsetup関数を使用するようになりました。ブラウザが切断されたときにタイマーを停止したり、サブスクライブを解除したりするために、クリーンアップ関数を返します。
import response f