Primate LogoPrimate

Primate 0.43: diamond migrations, deferred navigation and direct SQL

Today we're announcing Primate 0.43.

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

Diamond migration histories

Migrations are no longer numbered steps in one global sequence. They are now a directed acyclic graph of one-time application state transitions, identified by immutable random eight-character revision IDs.

a81f3c20
├── 23f24df1 ──┐
└── 9b127ed0 ──┴── cf57a103

Two branches can create 23f24df1 and 9b127ed0 independently. Neither file needs to be renamed when the branches meet. A later merge revision names both heads as parents and brings the history back to one head.

Migration files now export typed declarations through app.migrate:

import app from "@/app";

export default app.migrate({
  id: "23f24df1",
  parents: ["a81f3c20"],

  async run(db) {
    await db.schema.alter("post", {
      add: { published: "boolean" },
      drop: [],
      rename: [],
    });
  },
});

The ID is the migration's identity; its descriptive filename no longer controls ordering. parents records the heads that existed when the migration was authored. Migration bodies remain general application upgrade code even though generated migrations commonly update Store schemas.

Migration configuration consequently moves out of the database namespace and into the application configuration:

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

The database stores the durable execution ledger but does not restrict what a migration may do. JSONDB is no longer accepted as the migration-ledger database because it cannot atomically exclude concurrent migration processes; it remains available for ordinary Stores. The CLI commands now share the migration namespace:

npx primate migration create --name "add posts"
npx primate migration status
npx primate migration apply

Creation uses every current graph head as a parent. When merged branches leave multiple heads but there is no schema difference, it automatically writes a merge-only declaration without a run function. This also works for applications without Store files.

Before anything runs, Primate validates the complete graph and ledger. It rejects malformed or duplicate IDs, missing parents, cycles, multiple or disconnected roots, unknown applied IDs, and applied children whose parents are missing. Pending revisions run in deterministic topological order, with every parent applied before its children.

Production builds contain a self-contained build/migrate.js entrypoint with the exact migration graph, configuration and database adapter bundled into the server release. Deployments can inspect or apply that artifact without the source tree or Primate CLI:

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

The server still refuses to start when migrations are pending or the ledger is inconsistent. See Migrations for authoring, branch and deployment workflows, and the upgrade notes below when moving an existing 0.42 history.

Stream page data during navigation

Page and layout handlers can now return their critical props immediately while independent work continues. Wrap a direct prop with response.defer and Primate streams its result after client navigation has begun.

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

export default route({
  async get() {
    const project = await loadProject();

    return response.page({
      project,
      activity: response.defer(loadActivity(project)),
      changes: response.defer(loadChanges(project)),
    });
  },
});

The destination page and its immediate data can mount without waiting for activity feeds, recommendations, secondary reports or other work outside the critical path. Each deferred value arrives in the frontend as a Promise, so the page uses its framework's native asynchronous rendering primitive rather than a Primate-specific component.

{#await activity}
  <p>Loading activity…</p>
{:then items}
  <Activity items={items} />
{:catch error}
  <p>Could not load activity: {error.message}</p>
{/await}

React 19 can unwrap the Promise inside Suspense; Vue, Solid, Angular, Marko, Voby and Web Components can use their normal Promise or resource boundaries. The inferred prop type is T | Promise<T>.

Deferred values are deliberately scoped. They must be direct props of an explicit page or layout response; nested values are not separate boundaries. Initial document requests still resolve them before SSR, so 0.43 improves client navigation without changing initial HTML streaming. A rejected operation reaches the frontend boundary, and a superseding navigation cancels the old stream.

See Deferred props.

Safer and more concise schemas

Pema object schemas can now reject undeclared input properties and require a minimum number of supplied properties. Together with p.partial, this gives update bodies a concise validation boundary:

import p from "pema";

const User = {
  name: p.string,
  email: p.string.email(),
};

const UpdateUser = p.partial(User).closed().min(1);

UpdateUser.parse({ name: "Ada" }); // { name: "Ada" }
UpdateUser.parse({}); // too_small
UpdateUser.parse({ emali: "ada@example.com" }); // unknown_property at /emali

.closed() is opt-in: ordinary object schemas continue to discard undeclared properties. It produces a dedicated unknown_property issue whose JSON Pointer path identifies the offending key, including nested and escaped names. Closing an object does not implicitly close its nested object schemas.

Object .min(n) counts supplied, own enumerable, declared properties that have a value other than undefined and parse successfully. Unknown properties and values inserted only by defaults do not satisfy the minimum. The methods are also available on ordinary p({...}) and p.object({...}) schemas, and may be chained in either order. See Object validation.

Pema also applies one bound contract to discrete cardinalities. String and array .min(), .max() and .length(), plus object .min(), now require finite, nonnegative integers. Numeric .min() and .max() remain value thresholds, so negative and fractional numeric limits are still valid.

Date schemas now provide p.date.now() for the common dynamic default:

const Event = p({
  created_at: p.date.now(),
});

This is shorthand for p.date.default(() => new Date()). Each missing or undefined value receives a fresh Date, while a supplied Date is preserved. It is a creation-time default, not a timestamp that updates after parsing or persistence. See Built-in values.

The website now has a dedicated Pema reference for schema families, composition, structured errors and Store persistence compatibility.

Insert records in one atomic batch

Stores can now insert an array of independent records with the same schema, default and serialization behavior as a single-record insert.

import Post from "@/stores/Post";

const posts = await Post.insert([
  { title: "Introducing Primate 0.43", body: "..." },
  { title: "Migration guide", body: "..." },
]);

Primate validates and prepares the entire array before writing. The returned records remain in input order and include their generated primary keys; the input records are not mutated. Passing an empty array returns [] without contacting the database.

Bulk insertion has one portable all-or-nothing contract across MemoryDB, JSONDB, SQLite, PostgreSQL, MySQL, OracleDB and MongoDB. A validation error, duplicate key or other database constraint failure leaves the batch uninserted. Concurrent batches also receive distinct generated keys, although failed and concurrent writes may leave gaps in a generated sequence.

The database adapters use their native multi-row or bulk operations rather than repeating single-record inserts. Primate does not silently split an oversized array because separate statements could weaken atomicity. Database statement and parameter limits therefore bound one batch and produce an error before execution where the database exposes a fixed limit.

MongoDB provides the same guarantee through a transaction, so array insertion requires a replica-set member or mongos. Existing single-record Store.insert(record) calls keep their previous behavior and do not gain that deployment requirement. See Stores.

PostgreSQL stores native JSONB values

PostgreSQL Store fields declared with p.json() now preserve their native JSONB shape. Primate delegates serialization to the PostgreSQL driver instead of serializing a value before the driver sees it. Objects, arrays and string, number and boolean scalars are consequently encoded exactly once, so JSONB constraints, operators and indexes observe the intended structure.

SELECT detail, jsonb_typeof(detail) FROM record;
-- {"enabled": true} | object

This correction begins in 0.43 and is not backported to 0.42.

Direct, immediate raw SQL

Raw SQL now follows the same direct execution model as other database calls. Pass a query, parameters and optional output schema in one object and await the result.

import db from "@/config/db";
import p from "pema";

const users = await db.sql({
  query: "SELECT name FROM users WHERE age > :age",
  params: { age: 18 },
  output: p.array({ name: p.string }),
});

Named placeholders remain portable across SQLite, MySQL, PostgreSQL and OracleDB. Primate checks that parameter keys and placeholders match, then hands the values to the driver's binding mechanism. PostgreSQL parsing also accounts for quoted strings, escaped quotes and dollar-quoted bodies when finding placeholders.

An output schema validates the database result and supplies its inferred type. Without output, the call executes the statement and returns void, which makes writes and DDL straightforward.

await db.sql({
  query: "INSERT INTO users (name, age) VALUES (:name, :age)",
  params: { name: "Ada", age: 37 },
});

await db.sql("CREATE INDEX idx_users_age ON users (age)");

Parameters are raw driver values. Use SQL conversion functions or db.client when a database needs a driver-specific representation. See Raw SQL.

Faster, more precise client startup

Primate now writes the application entrypoint's static JavaScript imports into the initial document. Browsers can fetch the app and its static chunks in parallel instead of discovering each chunk through another request in a serial chain. Dynamic route-group chunks remain lazy.

Hydrated pages also preload only chunks reachable from the active application and route group. Unrelated named entrypoints and lazy groups no longer appear in the initial asset set merely because they were emitted by the same build.

The client route registry now contains generated request stubs and route metadata rather than importing the original server route modules. Handler code and its transitive server-only dependencies therefore stay out of browser bundles, even when a client calls the route through Primate's typed route API.

Transitions between virtual route groups reuse styles that are already active on both sides. Shared CSS is no longer removed and reinserted during the swap, avoiding an unnecessary style recalculation and visible unstyled frame.

Shared browser navigation now has a callable primate/navigate entrypoint:

import navigate from "primate/navigate";

await navigate("/projects/42");
await navigate.refresh();

const unsubscribe = navigate.after(({ source, url }) => {
  analytics.page(url, { source });
});

navigate.after() runs after a successful navigation has mounted or updated the destination and returns an unsubscribe function. Ordinary same-origin anchors remain the preferred path for links and are enhanced automatically.

This also removes an ambiguity between shared navigation and framework clients. @primate/svelte/client, @primate/react/client and their peers remain the home of framework-reactive form() and field() helpers; they do not expose a second navigation API. See Client navigation.

Safer server and browser boundaries

0.43 tightens several boundaries uncovered during the release security review:

Native desktop launchers and sidecars

Native builds now embed the application server, client bundles and static files in a relocatable Bun executable. A coordinated parent and child process keeps the WebView event loop on the main thread as required by macOS AppKit, waits for the embedded server before navigating and propagates startup failures, exit codes and shutdown signals instead of leaving a broken window behind.

Modules can also declare validated runtime sidecars for Native to copy beside the executable. Python applications use this generic contract to ship their required Pyodide runtime files. Applications without sidecar declarations need no additional runtime sidecar files beside the executable.

The Native target now uses the GTK 4, WebKitGTK 6.0 and libsoup 3 generation of the Linux WebView sidecar. This aligns native Primate applications with current Linux desktop libraries and the @rcompat/webview 0.30 runtime.

Linux development machines, CI images and runtime containers must provide GTK 4 and WebKitGTK 6.0. GTK 3, WebKitGTK 4.0 and libsoup 2 are not ABI-compatible substitutes. Windows continues to use WebView2 and macOS uses the system WebKit framework. See Native prerequisites.

A Flog-native workspace

Primate's own workspace now uses Flog as its sole JavaScript package manager. The repository now uses the standard package.json#workspaces field, declares the compatible Flog 0.1 release line through devEngines, records allowed install scripts in allowScripts, and commits a deterministic flog.lock.

This primarily affects contributors and source-workspace consumers; published applications install as before. When configured, client and server builds also resolve packages that expose @primate/source directly, without requiring a separate package build first.

Upgrade notes

Inspect PostgreSQL JSONB strings

Primate 0.42 double-serialized p.json() Store values in PostgreSQL. Reads parsed the extra layer and could hide that objects and arrays were physically stored as JSONB strings. In 0.43, new writes use native JSONB shapes and reads preserve genuine JSON strings.

Existing rows are not rewritten automatically because a legacy encoded value cannot always be distinguished from an intentional JSON string. Before upgrading, inspect p.json() columns where the application expects an object or array:

SELECT id, detail, jsonb_typeof(detail)
FROM record
WHERE jsonb_typeof(detail) = 'string';

Back up affected tables and migrate only rows known to contain the old extra encoding. If a column's application contract excludes genuine JSON strings, the extra layer can be removed explicitly:

UPDATE record
SET detail = (detail #>> '{}')::jsonb
WHERE jsonb_typeof(detail) = 'string';

Do not apply this update indiscriminately to columns that intentionally contain JSON string values.

Update TypeScript path aliases

primate/tsconfig now uses module: "preserve" with moduleResolution: "bundler" and works with TypeScript 7, which removed baseUrl. Remove baseUrl from application configs and make path targets explicitly relative:

{
  "extends": "primate/tsconfig",
  "compilerOptions": {
    "paths": {
      "@/*": ["./*"]
    }
  }
}

Import browser navigation from primate/navigate

The primate/client application entrypoint has been removed. Replace its navigation methods with the callable API:

// 0.42
import client from "primate/client";

await client.navigate("/projects/42");
await client.refresh();
const unsubscribe = client.onNavigation(listener);

// 0.43
import navigate from "primate/navigate";

await navigate("/projects/42");
await navigate.refresh();
const unsubscribe = navigate.after(listener);

Framework-specific imports such as @primate/svelte/client are unchanged. They continue to provide forms and validated fields rather than navigation.

Use integer bounds for cardinalities

Fractional bounds on strings and arrays never described a meaningful number of characters or items. They now fail when the schema is created, as do fractional object minimums:

// 0.42: accepted; 0.43: schema error
p.string.min(1.5);
p.array(p.string).max(2.5);

// Still valid: this is a numeric value threshold, not a cardinality.
p.number.min(-1.5);

Replace fractional cardinality bounds with an explicit integer chosen for the application. NaN, infinities and negative cardinalities are also rejected; zero remains valid.

Keep p.json() values losslessly representable

Following the stricter JSON predicate in @rcompat/is, p.json() now rejects NaN, infinities, maps, sets and class instances. Cyclic structures now produce the normal invalid-type result rather than overflowing during Primate's undefined-property handling. Shared references that are not cyclic remain valid. Existing behavior is unchanged for bigint values, dates and undefined array entries (rejected), and undefined object properties (omitted). Sparse arrays are also rejected instead of silently converting their holes to null.

Replace lossy inputs with explicit JSON representations before validation. For example, encode a date as a string and convert a map to a plain object or array of entries.

Implement parseable objects with p.parseable

Pema now owns its object-unwrapping protocol. Implement computed methods with the symbol exposed on its default aggregate:

import p from "pema";

const value = {
  [p.parseable]() {
    return { name: "Pema" };
  },
};

The legacy Symbol.for("std:symbol/parse") key is no longer recognized. The new p.parseable value uses Symbol.for("pema:parseable"), preserving identity across separately loaded Pema and consumer copies. Primate request bags already implement the new protocol, so passing them directly to schemas is unchanged.

db.sql executes immediately

The 0.42 db.sql API created a reusable function from input, query and output schemas. In 0.43, object calls execute immediately and use params instead of an input schema.

// 0.42
const findByAge = db.sql({
  input: p({ age: p.u8 }),
  query: "SELECT name FROM users WHERE age > :age",
  output: p.array({ name: p.string }),
});
const users = await findByAge({ age: 18 });

// 0.43
const users = await db.sql({
  query: "SELECT name FROM users WHERE age > :age",
  params: { age: 18 },
  output: p.array({ name: p.string }),
});

There is no db.bind compatibility layer. Move reusable operations into an ordinary function in application code. String calls such as db.sql("VACUUM") now execute immediately and return Promise<void>.

Input schemas no longer validate parameter values. Parameters are driver-level values, while output remains the opt-in validation boundary for results.

Split HTTP timeout policy by network clock

The numeric http.timeout setting now uses a strict object that distinguishes consecutive inactivity from total request and header receipt time:

// 0.42
http: { timeout: 30_000 }

// 0.43
http: {
  timeout: {
    inactivity: 30_000,
    request: 30 * 60_000,
    headers: 60_000,
  },
}

request allows a slow upload to run longer than Node's five-minute default without becoming a route-handler deadline. headers limits slow header receipt, while inactivity retains the old setting's stalled-upload and quiet-response behavior. Each value is in whole milliseconds, and 0 disables a supported clock.

Node supports all three clocks. Bun supports inactivity; Deno exposes none of them. Configuring an unsupported clock now fails at startup instead of silently doing nothing. Reverse proxies retain their own independent deadlines, and increasing or disabling receive limits reduces slow-client protection. See application configuration for exact defaults, ranges, runtime support, SSE guidance, and Node's timeout-checking cadence.

Linux native prerequisites

Update Linux development and deployment images from GTK 3/WebKitGTK 4.0 to GTK 4/WebKitGTK 6.0 before rebuilding native applications. Do not use compatibility symlinks between the library generations.

Repository contributors use Flog

Run workspace installs, scripts, packing and release preparation with the Flog version declared by the root package. Nub and pnpm configuration and lockfiles are no longer part of the repository.

Convert 0.42 migration histories

0.43 moves migration configuration from db.migrations to top-level migrations, stores string revision IDs instead of sequence numbers, and requires every migration to export an app.migrate() declaration. Existing numbered filenames may remain unchanged.

These conversion steps do not support 0.42 ledgers stored in JSONDB. There is no automatic JSONDB-ledger transfer in 0.43, and switching to an empty ledger can rerun completed migration bodies. Applications using that configuration must remove migrations or remain on 0.42 while adopting a separately verified ledger strategy.

Back up the migration database, move the configuration, and generate a stable conversion plan:

npx primate migration upgrade

This writes migrations/.primate-042-upgrade.json, assigning each legacy file a random revision ID and its predecessor as parent. Keep the file: preparation reuses it instead of silently changing the map. Wrap each existing body with the recorded declaration, without changing its behavior, then rerun the command to validate the edited files without touching the ledger.

After taking a fresh backup, explicitly confirm the ledger conversion:

npx primate migration upgrade --confirm

Primate snapshots the mapped ledger into the plan, recreates it with string IDs, restores applied timestamps and verifies the result without rerunning migration bodies. If interrupted, retain the plan and rerun the confirmed command; its snapshot and the database backup provide the recovery path. Read the complete 0.42 migration upgrade before deploying 0.43.

Bug fixes

Client rendering and navigation

Documentation website

Build and package resolution

Database adapters

Changes also shipped in 0.42 patches

The 0.42 maintenance line received the fixes that were safe and relevant to release there: server-only request credentials, route-group CSS reuse, website navigation restoration, narrower hydration asset preloads and repeated cookie headers. Applications on the latest 0.42 patch already have those corrections. Deferred navigation, the callable navigation API, safer Pema update schemas, normalized cardinality bounds, diamond migration histories, immediate raw SQL, atomic bulk insertion, server-handler-free client route bundles, the Linux native runtime change, the Svelte and Marko rendering fixes, and other new or breaking work above remain part of 0.43.

Fin

If you like Primate, consider joining our Discord server, visiting us in #primate on irc.libera.chat, or starring us on GitHub.