Materialization
A materializer turns the latest stored schema into the public type of a pulse. It is where you rename, hide and derive, so the rest of the codebase never sees migration scars.
Why not delete fields?
In a CRDT, removing a field is bad practice. Its operations remain in every replica, an offline client may still write it, and a later migration may need to read it. Store what you must; expose what you want.
Aliases with bind
bind("storedField") creates a typed two-way alias. The stored field disappears from enumeration, snapshots and the public type. Reads and writes go straight to the CRDT register, so aliases cost nothing.
import { createPulse, definePulse, object, type PulseProperties } from "pulsify-core";
const userV1 = object({ name: "string", email: "string" });
const userV2 = object({ name: "string", emailV2: "string" });
// The type the rest of the codebase sees.
type User = PulseProperties<"User"> & { name: string; email: string };
export const User = definePulse("User", userV1)
.migrateTo(userV2, (before) => ({ emailV2: before.email.toLowerCase() }))
.materialize<User>(({ bind }) => ({
email: bind("emailV2"), // two-way alias; emailV2 is hidden
}));
const user = createPulse(User, { name: "Ada", emailV2: "ada@x.io" });
user.email = "grace@x.io"; // writes emailV2
"emailV2" in user; // false
Custom bindings
A binding is any object with get(data) and set(data, value). Derived values do not consume a stored field.
type Invoice = PulseProperties<"Invoice"> & {
lines: { label: string; cents: number }[];
total: number; // read-only, derived
currency: string; // stored as "cur"
};
export const Invoice = definePulse("Invoice", invoiceSchema)
.materialize<Invoice>(({ bind }) => ({
currency: bind("cur"),
total: {
get: (data) => data.lines.reduce((sum, line) => sum + line.cents, 0),
set: () => { throw new Error("total is derived"); },
},
}));
One shape everywhere
Every consumer, from UI components to server jobs, imports the same definition and gets the same end type. When you ship V7 of a schema, nothing outside the definition file changes. Snapshots taken with snapshotPulse contain materialized fields too, so API responses and tests stay consistent.