Primate 0.40: Route pages, store enums, async schemas and events
Today we're announcing the availability of the Primate 0.40 preview release.
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.
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.
import type route from "./[id]";
export default function Post(props: typeof route.get.Page) {
return <h1>Post {props.id}</h1>;
}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"]>();
}<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><script lang="ts">
import type route from "./[id]";
const props: typeof route.get.Page = $props();
</script>
<h1>Post {props.id}</h1>import type route from "./[id]";
export default function Post(props: typeof route.get.Page) {
return <h1>Post {props.id}</h1>;
}import type route from "./[id]";
export interface Input {
id: typeof route.get.Page["id"];
}
<h1>Post ${input.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.
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.
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>;
}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"]>();
}<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><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>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>;
}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>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: "john@example.com",
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.htmlThe 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.