Primate LogoPrimate

Primate 0.41: Isolated client bundles, programmable navigation and production migrations

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

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

Route-group client bundles

Primate applications can now split their browser code into isolated route groups. Each group gets its own JavaScript, CSS, dynamic imports, named entrypoints and file assets.

// config/app.ts
import config from "primate/config";

export default config({
  client: {
    groups: {
      public: "/app/public/",
      account: "/app/account/",
      admin: "/admin/app/",
    },
    routes: {
      "/": "public",
      "/account": "account",
      "/admin": "admin",
    },
  },
});

Routes are matched by longest URL prefix. The / mapping is required and acts as the fallback. Every route value must name a declared group, and Primate validates prefixes when the app starts.

Independent build branches

Each route group is an independent build branch, compiled into its own output file tree. A public visitor does not need to download the client pages, styles or dynamic imports used, for example, by an admin area. An admin deployment can use a different asset prefix and a specific cache policy.

Dependencies used by more than one group are deliberately emitted into each output tree. This makes the boundary complete: groups do not share generated chunks or imported file assets. Put assets that are intentionally public and shared in the static directory.

Client bundle separation is not an authorization system. Routes, data and assets that require authentication must still be protected by the server or a reverse proxy.

Same-document transitions

Cross-route-group navigation stays in the current document. Primate loads the destination assets and inactive styles, then waits for the destination frontend to become ready while the outgoing frontend and styles remain active. Once the destination is ready, Primate disposes the outgoing frontend, replaces its styles with the prepared destination styles, and mounts the destination.

Links, enhanced forms, redirects, Back and Forward all use the same transition path. React, Svelte, Solid, Vue, Angular and Marko can be used on either side of the boundary. A failed or denied destination leaves the current URL, frontend, styles and history state intact, so a login or network failure cannot produce a page whose address belongs to another app.

See Client groups for configuration details and deployment guidance.

Browser code can now start Primate navigation directly with primate/client. This is useful for notification clicks, command palettes and other interactions that are not ordinary anchors.

import client from "primate/client";

await client.navigate("/projects/42?tab=activity");

Same-origin URLs use Primate's client router. External URLs fall back to normal browser navigation, so application code does not need to branch first.

await client.navigate("https://example.com");

Primate also supports navigating between pages rendered by different frontend modules. The outgoing adapter is disposed and the destination adapter mounts in the same document.

Refreshing the current route

When server data changes without a new destination, refresh the current route in place:

const refreshed = await client.refresh();

Primate fetches and applies fresh route data without changing the URL, query, hash, history or scroll position. The promise resolves to true after the update, or false if the request fails, is superseded, redirects or does not return a successful Primate route response. A failed refresh leaves the current view and location intact.

Shallow query updates

Use shallow navigation when a query string represents local UI state and the current page does not need to refetch or remount.

await client.navigate("/search?q=primate", { shallow: true });

The public request store updates immediately. Back and Forward restore the query state without fetching while history remains within the same rendered view. Add replace when filters should update the current history entry.

await client.navigate("/search?q=framework", {
  replace: true,
  shallow: true,
});

See Client navigation for refresh, destination and event handling.

Typed query parameters and cancellation

Route clients now accept a typed query option. When a route declares a query schema, its input type flows to every client call.

// routes/search.ts
import p from "pema";
import route from "primate/route";

export default route({
  get: route.with({
    query: p({ filter: p.string, page: p.loose.u32 }),
  }, request => ({
    filter: request.query.get("filter"),
    page: request.query.get("page"),
  })),
});
import search from "@/routes/search";

const controller = new AbortController();
const pending = search.get({
  query: { filter: "active", page: "2" },
  signal: controller.signal,
});

// Calling controller.abort() while pending cancels the request.
const response = await pending;

Client stubs serialize objects or URLSearchParams and forward an optional AbortSignal. The server validates the query before the handler runs.

Production migrations

primate build now emits a self-contained migration program beside the server:

build/
├── migrate.js
└── server.js

It contains the exact database configuration, adapter and migration files from that build. Migrations can be applied as a standalone deployment step without the source tree, the Primate CLI or a package manager.

node build/migrate.js status --json
node build/migrate.js apply --json
node build/server.js

status exits with 0 when the database is current, 2 when migrations are pending or the database is ahead, and 1 for invalid commands or operational errors. This makes it suitable for release jobs, init containers and deployment health checks.

Migration IDs are now checked as an append-only sequence. primate migrate commands and the production artifact reject duplicate IDs and any unapplied migration whose ID is below the highest already applied ID.

Autoapply is deprecated

Automatic migration application remains functional in 0.41 for compatibility, but db.migrations.autoapply is deprecated and will be removed in 0.42. When it is enabled, Primate emits one warning.

Apply migrations explicitly during development:

npx primate migrate:apply

In production, run the artifact before the server:

node build/migrate.js apply
node build/server.js

See Migrations for the full workflow.

Pema string normalization and custom checks

Pema strings can normalize input before validating it. Transforms run in the order they are declared, followed by validators.

import p from "pema";

const Username = p.string
  .trim()
  .lowercase()
  .regex(/^[a-z][a-z0-9]{2,31}$/);

Username.parse("  Primate41  "); // "primate41"

Available string transforms are trim(), lowercase() and uppercase(). As the parsed value carries the normalized result, the same schema can be used directly in route bodies, query parameters and stores.

Use check for a predicate that is clearer as code than as a built-in validator.

const EvenCode = p.u32.check(
  value => value % 2 === 0,
  "Code must be even",
);

String validators also accept custom messages.

const Email = p.string.email({ message: "Enter a valid email address" });

Read more in Input validation.

Store query ergonomics

Use Store.first() when a query needs at most one record. It accepts the same filtering, sorting, projection, offset and relation options as find, adds a limit of one internally, and returns undefined when nothing matches.

const latest = await Post.first({
  where: { published: true },
  sort: { created: "desc" },
  select: ["id", "title"],
});

Stores also expose their inferred insertion type as Store.Insert.

import Post from "@/stores/Post";

type NewPost = typeof Post.Insert;

const draft: NewPost = {
  title: "Primate 0.41",
  body: "...",
};

await Post.insert(draft);

Default and generated fields remain optional in the insertion type. See Stores for query and schema details.

Persistent session cookies

Session cookies can now declare maxAge in seconds. Without it, the cookie lasts for the browser session; with it, Primate emits Max-Age for persistent login or preference sessions.

// config/session.ts
import session from "primate/session";
import Session from "@/stores/Session";

export default session({
  cookie: {
    maxAge: 60 * 60 * 24 * 30,
  },
  store: Session,
});

The browser lifetime and server-side record retention are separate policies, so configure the backing store accordingly. See Sessions.

Submitter-aware enhanced forms

Enhanced forms now include the button that initiated submission. Multiple submit buttons can therefore share one form and communicate their intent using ordinary HTML names and values.

<form method="post">
  <input name="title" />
  <button name="intent" value="draft">Save draft</button>
  <button name="intent" value="publish">Publish</button>
</form>

The clicked button is included in request.body.form() as intent. This works for Primate's automatic enhanced forms and client.form in every reactive frontend.

Server-controlled SSE completion

An SSE producer can now end its own response with source.close().

return response.sse(source => {
  const timer = setInterval(() => {
    source.send("progress", nextProgress());

    if (complete()) source.close();
  }, 1000);

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

Closing from the server completes the response cleanly and runs the setup function's cleanup exactly once, just like a client disconnect. See Server-sent events.

Fin

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