Primate 0.37: Migrations, Typed Env, and a Cleaner Module API

Frameworks that manage their own complexity are rare. Most accumulate convenience features until the magic outweighs the clarity. Primate 0.37 goes the other direction - stripping implicit behavior in favor of explicit contracts, and the result is a release worth paying attention to.

Primate bills itself as a universal web framework: rather than tying you to a single frontend or backend stack, it lets you compose the combination that fits your project. On the frontend side, that means React, Svelte, Vue, Angular, Solid, and others can coexist in the same application or be swapped out without rewriting the backend.

On the server side, Primate uses WebAssembly to support languages beyond TypeScript and JavaScript - Go, Python, and Ruby are all viable backend choices today, with the compiled Wasm binary running in place of plain JS. The runtime layer is similarly flexible: NodeJS, Deno, and Bun are all supported through native execution paths rather than compatibility shims.

The practical pitch is stability across ecosystem churn. Instead of coupling your application to a meta-framework's opinionated stack (Next for React, Nuxt for Vue, SvelteKit for Svelte), Primate gives you a common foundation that doesn't force you to rewrite when your frontend preferences change or a new runtime emerges.

The 0.37 preview lands several significant changes: a revised module API, database migrations, typed environment access, explicit portable stores, and a cleaner split between transport and validation in the request pipeline.

Modules Are Now Plain Objects

The old module API required extending an abstract class and overriding lifecycle methods - a pattern that carries all the usual class-inheritance friction. In 0.37, a module is a plain object with a name string and a setup function that receives the lifecycle hooks:

import type { Module } from "primate";

export default (): Module => ({
  name: "my-module",
  setup({ onServe, onHandle }) {
    onServe(app => {
      console.log(`serving, secure: ${app.secure}`);
    });
  },
});

The five hooks (onInit, onBuild, onServe, onHandle, onRoute) do the same things they always did. What changed is how you register them: you call registrar functions passed into setup rather than overriding class methods. State that used to live as class fields now lives as closure variables in the factory function - shared naturally across hooks without this gymnastics.

This is the right direction. The abstract-class approach was an abstraction without a payoff; the factory-closure pattern is both more composable and more readable.

Note

This is a pattern Primate replicates across its entire design: it's been pretty responsive to developer concerns, while being fairly opinionated about working applications. It does a lot of things well, when it comes to "you should do things the right way."

Database Migrations

Primate 0.37 adds an opt-in migration system. The core idea is familiar: compare your store schemas to the live database, generate numbered migration files, apply them in order. What's notable is how Primate handles the edge cases.

Enable migrations in config/app.ts:

import config from "primate/config";
import db from "./db/index.ts";

export default config({
  db: {
    migrations: {
      table: "migration",
      db,
    },
  },
});

The workflow is three commands:

npx primate migrate:create --name="add posts"
npx primate migrate:status
npx primate migrate:apply

migrate:create inspects the live schema against your stores, writes a numbered file, and pauses to ask you when a change looks like a rename rather than a drop-and-add. migrate:status shows applied vs. pending. migrate:apply runs them in order and records the result.

Two behaviors stand out as particularly thoughtful. First, Primate refuses to generate a new migration if there are unapplied ones — no stacking migrations on top of unresolved state. Second, when serving a built app, Primate verifies at startup that the database matches the migration version captured at build time, a function that isn't unique but is still quite welcome. Misconfiguration fails fast rather than manifesting as runtime schema confusion.

If you don't configure db.migrations, none of this activates. Existing apps are unaffected.

Typed Environment Access

Server-side configuration usually involves a scattered collection of process.env or Deno.env.get() calls with no validation until something breaks in production. Primate 0.37 introduces app.env() as a single, optionally typed access point:

import app from "../config/app.ts";
const token = app.env("API_TOKEN");

Add a schema in config/app.ts and the access becomes validated and type-aware:

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

export default config({
  env: {
    schema: p({
      API_TOKEN: p.string,
      PORT: p.u16,
    }),
  },
});

With a schema in place, missing keys and validation failures propagate at startup. If app.env("PORT") returns a number, the type system knows and validates the values. The method is intentionally server-only - calling it in a frontend bundle throws an error.

This is a small API surface with meaningful guarantees.

Explicit, Portable Stores

Stores - meaning data access mechanisms - previously relied on two implicit behaviors: name was derived from the filename, and db was inferred from the app's default database. The convenience came at a cost; stores weren't self-contained modules. Importing one outside of a running Primate context could fail silently.

In 0.37, both name and db are required, and schema moves into the single options object:

// stores/Post.ts
export default store({
  name: "post",
  db,
  schema: {
    id: key.primary(p.uuid),
    title: p.string.max(100),
    body: p.string,
    created: p.date.default(() => new Date()),
  },
});

The migration from the old two-argument form is mechanical. The payoff is that a store file now works anywhere - migration scripts, test suites, REPLs - without framework initialization.

This also connects to another 0.37 addition: p.uuid as a first-class Pema type. key.primary(p.string) is no longer valid; UUID primary keys now use key.primary(p.uuid). Each database driver stores UUIDs in its most efficient native format (PostgreSQL's UUID, MySQL's BINARY(16), SQLite's TEXT, MongoDB's BinData(4)), with bind/unbind handled transparently.

This does mean that strings are not valid keys. This is a deliberate decision, another reflection of opinionated design on the part of Primate. There are costs to the decision, but those costs mainly serve to push developers and users into "proper design," treating unique values as indexable contents (where an index can be unique) rather than as a key. This makes sense, because using strings as keys can be rather brittle in practice.

Request Body and Coerce Cleanup

Two smaller API changes in 0.37 sharpen the separation of concerns in the request pipeline.

request.body methods no longer accept a schema. They now decode to transport-level representations only:

request.body.json()   // returns raw parsed JSON
request.body.form()   // returns form data

Validation happens on the schema side:

// before
const body = request.body.form(LoginSchema);

// after
const body = LoginSchema.parse(request.body.form());

Separately, coerce in Pema was a getter producing a modified schema. It's now a method:

// before
p.u32.coerce.parse(x)

// after
p.u32.coerce(x)

Neither change is large, but both reduce the number of places where the framework needs to know about validation policy.

Frontend Request Access

One more notable change: the implicit request prop that Primate previously injected into every component is gone. In its place, each supported frontend gets its own app:FRONTEND virtual module exposing the current request as a native reactive primitive — a writable store for Svelte, a hook for React, a composable for Vue, a signal for Angular and Solid. Opt-in, explicitly imported, fully typed.

Bottom Line

Primate 0.37 is a coherent release. The changes share a theme: replace implicit behavior with explicit contracts, and move validation concerns to where they belong. The migration system in particular is well-designed - opt-in, strict about ordering, and fail-fast at both generation and startup time.

If you're evaluating TypeScript web frameworks with a serious ORM story and multi-frontend flexibility, 0.37 is worth a look. The quickstart is a reasonable entry point.

Comments (0)

Sign in to comment

No comments yet.