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
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.
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.
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
lengthanddelete arr[i]are rejected; usesplice. sort,reverseandreplaceAllrebuild elements. Prefermovefor reordering.- Deletion wins over any concurrent move of the same element.
Tuples
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.
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.
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.
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.
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.