Primate 0.43: diamond migrations, deferred navigation and direct SQL
Today we're announcing Primate 0.43.
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 ──┴── cf57a103Two 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 applyCreation 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.jsThe 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} | objectThis 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.
Navigation that says what it does
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:
- Incoming headers, cookies and request-context values remain server-only instead of entering hydration or client-navigation payloads.
- String redirects are same-origin unless a trusted external destination uses
allowExternal: true; malformed, credential-bearing and alternate-port URLs cannot bypass the check. - Named server-sent events reject empty names and protocol control characters.
- Responses preserve every
Set-Cookieline instead of losing all but the last. - React
<Head>delegates server serialization to React, so quotes, closing tags and other HTML-significant metadata remain values instead of creating additional attributes or active elements in the rendered document. - Ruby and Go integrations pass application paths through argument-safe process APIs rather than interpolating them into shell commands.
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 upgradeThis 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 --confirmPrimate 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
- Svelte SSR now emits hydration metadata for client-enabled pages, and Svelte form actions use the shared enhanced-form lifecycle instead of losing their post-hydration behavior.
- Eventless
client.form().submit()calls now emit a clear, nonfatal diagnostic when their matching form is missing or is not a form, instead of silently skipping the submission with no explanation. - Native fragment navigation on a freshly loaded page no longer throws from
Primate's
popstatehandler when client-navigation history is still empty. - Cross-frontend navigation now hands document-body ownership off cleanly when React, Vue, Marko and Svelte replace one another in CSR or after hydration.
- Solid source-workspace builds select the browser
<Head>implementation in the browser, avoiding a CSR crash from treating DOM children as SSR records. - Marko flushes mount-time reactive updates before a deferred value queues its
render, so a native
<await>boundary cannot remain on its placeholder after client navigation. - Marko's server-side
<Head>validation now distinguishes quoted attribute values from markup, so metadata containing angle brackets, such asList<String>, no longer causes a 500 response. This correction begins with 0.43 and is not backported to the 0.42 maintenance line. - Marko SSR now removes
<Head>content from its body-side hydration templates after copying it into the document head. Empty grouped anchors let the client adopt the server-rendered elements, so titles, metadata and links occur only once in the raw HTML while navigation updates retain correct ownership. - Marko applications configured with
csr: falserender<Head>through a lifecycle-free server tag. Head-only static HTML therefore omits both its hydration anchors and the inline Marko walker. Other interactive Marko features can still cause the walker to be emitted independently. - The website's Marko pages once again mount correctly when moving between lazy route groups; the corresponding website fix was applied to both development lines.
Documentation website
- Documentation navigation remains available at tablet and mobile widths through an accessible drawer button aligned beside the Primate logo. The drawer manages focus and close interactions, while responsive content gutters and local table and code scrolling prevent text from touching or overflowing the viewport.
Build and package resolution
- Client builds and generated server templates honor the
@primate/sourceexport condition, including source-only files that are absent from compiled package output. - The Primate website now exercises that path in production: its container
builds directly from workspace TypeScript sources without first compiling
Core, Pema, Primate, or its frontend modules into package
libdirectories. - Generated React, Solid and Svelte roots and server route-client shims no
longer leak internal
@rcompat/isimports into applications that did not declare that package.
Database adapters
- Generated UUID primary keys in MemoryDB and JSONDB now honor the declared
UUID version for both single and batch insertion.
p.uuid.v4()generates UUIDv4, whilep.uuid.v7()and the preferred genericp.uuidform generate UUIDv7; explicitly supplied keys remain unchanged.
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.