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.

user.ts
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.

invoice.ts
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.