Migrations

Schemas change. Documents already exist on thousands of devices, some of them offline. Pulsify is the only CRDT framework that handles this for you.

Why migrations matter

With a central database you run one migration script and every row changes at once. A CRDT document has no single place to run it: copies live in browsers, in local stores and in the server log, and they must all converge. Rewriting history is not an option, since history is what makes merging work.

Lazy migration

Pulsify migrates pulse by pulse, when a pulse is loaded. A document records its version. When newer code opens it, the missing steps run in order, each producing an ordinary patch that synchronizes like any other edit. Peers already on the new version simply apply it. Documents that are never opened are never touched.

user.ts
import { definePulse, object, optional } from "pulsify-core";

const userV1 = object({ name: "string", email: "string" });
const userV2 = object({ name: "string", email: "string", displayName: "string" });
const userV3 = object({
  name: "string", email: "string", displayName: "string", avatar: optional("string"),
});

export const User = definePulse("User", userV1)
  // V1 → V2: derive the new required field.
  .migrateTo(userV2, (before) => ({ displayName: before.name }))
  // V2 → V3: optional fields need no value, but the step records the version.
  .migrateTo(userV3, () => ({}))
  .materialize();

// Creation always takes the latest shape.
const user = createPulse(User, { name: "Ada", email: "ada@x.io", displayName: "Ada" });
  • migrateTo(schema, fn) appends a version. The callback receives a detached copy of the previous version and returns only the fields that are new or whose type changed.
  • Migration callbacks must be deterministic and side-effect free.
  • Rich text arrives as a plain string in before; the target schema restores collaborative semantics.
  • A document already at the latest version does no extra work.

Never edit a step

A document that already reached V2 will not rerun the V1 → V2 callback. Changing that callback gives two documents of the same version different data. Append a new step instead, even when the schema stays the same.

person.ts
export const Person = definePulse("Person", personV1)
  // Published. Never edit this step again.
  .migrateTo(personV2, (before) => ({ displayName: before.name }))
  // Wrong casing shipped? Append a correction with the same schema.
  .migrateTo(personV2, (before) => ({ displayName: before.displayName.trim() }))
  .materialize();

Drift detection

Each version has a fingerprint chained from the previous one: Fn = H(Fn-1, n, schemaHash). When a peer's chain disagrees, the incoming update is rejected with MigrationDriftError before it touches the document, and a loud warning describes both chains. A peer that merely lacks newer versions is reported as outdated, not as drift.

load.ts
import { MigrationDriftError } from "pulsify-core";

try {
  await loadPulse(User, id, { connectors: [remote] });
} catch (error) {
  if (error instanceof MigrationDriftError) {
    // Another client is running a different V1→V2 chain. Nothing was written.
    console.error(error.details);
  }
}

Removing fields

You can drop a field from the schema, but its history stays in every replica and old clients may still write it. The clean way to retire a field is to keep it stored and hide it from the public type. That is materialization.