Values

Everything the codec can describe, Pulsify can synchronize. Fields are last-writer-wins registers, sequences are RGAs, and nested containers keep their identity across remote edits.

Primitives

settings.ts
import { definePulse, literal, object, optional, union } from "pulsify-core";

const Settings = definePulse("Settings", object({
  name: "string",          // atomic: replacing it is one write
  count: "int",            // also uint, int32, uint32, float32, float64
  big: "bigint",
  enabled: "boolean",
  updated: "date",
  avatar: "uint8Array",    // copied binary leaf
  nickname: optional("string"),
  theme: union([literal("light"), literal("dark")]),
})).materialize();

const s = createPulse(Settings, {
  name: "Ada", count: 1, big: 10n, enabled: true,
  updated: new Date(), avatar: new Uint8Array(), theme: "dark",
});
s.count += 1;
s.nickname = "ada";
s.nickname = undefined;

Plain strings are atomic: assigning one replaces the whole value. Use richText() for character-level collaboration. Float32 fields read back their normalized wire value.

Objects and maps

Each object field is its own register, so two peers editing different fields of the same object both win. Assigning a whole object replaces the container and invalidates references to the old one.

profile.ts
const Profile = definePulse("Profile", object({
  address: object({ city: "string", zip: "string" }),
  prefs: map("boolean"),  // Record<string, boolean>
})).materialize();

const p = createPulse(Profile, { address: { city: "Paris", zip: "75001" }, prefs: {} });
p.address.city = "Lyon";            // one field register, concurrent zip edits survive
p.address = { city: "Nice", zip: "06000" }; // replaces the whole container
p.prefs["dark"] = true;
delete p.prefs["dark"];

Arrays

A pulse array is an array: same methods, same types, same iteration. arrayOperations adds what native arrays lack, most importantly move, which keeps element identity and merges with concurrent edits to the moved element.

list.ts
import { arrayOperations } from "pulsify-core";

const List = definePulse("List", object({
  items: array(object({ label: "string", done: "boolean" })),
})).materialize();

const list = createPulse(List, { items: [] });

// Everything a normal array does.
list.items.push({ label: "A", done: false }, { label: "B", done: false });
list.items[0]!.done = true;
list.items.splice(1, 1);
list.items.unshift({ label: "Z", done: false });
list.items.filter((item) => item.done).length;

// Plus explicit collection operations.
const ops = arrayOperations(list.items);
ops.insert(1, [{ label: "C", done: false }]);
ops.delete(0, 1);
ops.move(1, 0);       // [A, C] → [C, A], element identity preserved
ops.move(0, 2, 2);    // move a block of 2, `to` is its first final index
ops.replaceAll([{ label: "fresh", done: false }]);
  • Removal methods return detached values.
  • Holes, extending length and delete arr[i] are rejected; use splice.
  • sort, reverse and replaceAll rebuild elements. Prefer move for reordering.
  • Deletion wins over any concurrent move of the same element.

Tuples

geo.ts
const Geo = definePulse("Geo", object({
  point: tuple("float64", "float64"),
  labeled: tuple("string", "int", object({ ok: "boolean" })),
})).materialize();

const g = createPulse(Geo, { point: [48.85, 2.35], labeled: ["x", 1, { ok: true }] });
g.point[0] = 48.86;       // typed per slot
g.point.push(1);          // rejected: tuples keep their length

Optionals and unions

optional(schema) allows undefined. Tagged unions narrow in TypeScript and switch variants by assignment.

event.ts
const Event = definePulse("Event", object({
  payload: union({
    tagName: "kind",
    tagType: "string",
    variants: {
      created: { title: "string" },
      deleted: { reason: optional("string") },
    },
  }),
})).materialize();

const e = createPulse(Event, { payload: { kind: "created", title: "Hi" } });
if (e.payload.kind === "created") e.payload.title = "Hello"; // narrowed
e.payload = { kind: "deleted" };                              // switch variant

Constants and frozen values

constant(schema) marks a value that never changes after creation. frozen(schema) marks a value that can only be replaced as a whole: its contents are deeply readonly, and concurrent replacements never interleave them. Both are readonly in TypeScript.

article.ts
import { constant, frozen } from "pulsify-core";

const Article = definePulse("Article", object({
  origin: constant(object({ source: "string" })),
  cover: frozen(tuple("uint", "uint")),
  items: array(object({ id: constant("string"), label: "string" })),
})).materialize();

article.origin = { source: "x" }; // TypeScript error and runtime rejection
article.cover = [800, 600];       // ok: replaced as a whole
article.cover[0] = 800;           // TypeScript error and runtime rejection
// literal(...) fields are constant too.

Rich text

richText() is a string on the wire and a collaborative text in memory. Indices count Unicode code points. Formatting is per character; concurrent edits to different attributes merge.

doc.ts
import { richText } from "pulsify-core";

const Doc = definePulse("Doc", object({
  body: richText(),
  comments: array(object({ text: richText() })),
})).materialize();

const doc = createPulse(Doc, { body: "Hello", comments: [] });
doc.body.insert(5, " world");
doc.body.format(0, 5, { bold: true });
doc.body.delete(0, 1);
doc.body.text;        // "ello world"
doc.body.length;      // code points
doc.body.toDelta();   // [{ insert: "ello", attributes: { bold: true } }, { insert: " world" }]

doc.comments.push({ text: "First" }); // plain strings initialize rich text
doc.comments[0]!.text.insert(5, "!");

For a real editor, bind it to ProseMirror or Lexical.

Trees

tree(schema) is an ordered, movable hierarchy. Each node carries a typed payload in data. Moves keep the subtree and its proxies; concurrent moves resolve deterministically and cycles are cut predictably.

outline.ts
import { tree } from "pulsify-core";

const Outline = definePulse("Outline", object({
  outline: tree(object({ title: "string", body: richText() })),
})).materialize();

const doc = createPulse(Outline, { outline: {} });
const chapter = doc.outline.createNode({ title: "Chapter", body: "" });
const section = chapter.createNode({ title: "Section", body: "Text" });
const appendix = doc.outline.createNode({ title: "Appendix", body: "" });

section.data.title = "Renamed";     // payload is a normal typed value
section.move(appendix, 0);          // reparent, keeps subtree and proxies
section.moveBefore(chapter);        // sibling moves adopt the parent
section.move();                     // back to the roots
appendix.delete();                  // hides the node and its descendants

doc.outline.roots();                // TreeNode[]
chapter.children();
doc.outline.getNode(section.id);
doc.outline.toJSON();               // { id, data, children }[]
  • Indices refer to the final sibling list, after the moved node is removed. Omit the index to append.
  • snapshot() returns the flat stored form, including tombstones. toJSON() returns the visible hierarchy.
  • Moving a node into its own subtree throws before committing.