Data and schemas
The Rust document owner, shared by the pop app and CLI, merges and validates every edit and stores the bytes locally. Your app reads immutable snapshots and writes through typed handles; it never touches storage.
Define the shape
Section titled “Define the shape”import { defineDocument, s } from "@hitslop/pop";export default defineDocument({ title: s.text(), archived: s.boolean({ default: false }), budget: s.number({ min: 0 }), mood: s.integer({ min: 1, max: 5 }), lane: s.enum(["todo", "doing", "done"]), note: s.optional(s.string({ maxLength: 280 })), tasks: s.list(s.object({ text: s.text(), done: s.boolean({ default: false }), due: s.optional(s.string()) })), visits: s.counter(),}, { initial: { title: "Weekend", budget: 120, mood: 4, lane: "todo", tasks: [{ text: "Buy groceries" }] } });Choose each field by how edits from several places should merge:
| Builder | Use for | Merge behavior |
|---|---|---|
s.text() |
Anything a person types | Character edits merge |
s.string({ minLength?, maxLength? }), s.number({ min?, max? }), s.integer({ min?, max? }), s.boolean(), s.enum([...]) |
Settings, amounts, dates, states | Last writer wins |
s.optional(scalar, text or object) |
Values that may be absent | set gives a value, clear() removes it (an optional object uses $set and $clear()) |
s.object({...}) |
Fixed groups of fields | Per field |
s.list(s.object({...})) |
Rows with identity | Insert, remove and move keep $id |
s.list(s.string()) (any scalar) |
Small lists of plain values: tags, a pixel grid | Inserts are kept; one element: last writer wins |
s.record(value) |
Scalars or objects by key: check-ins by date, cells by "A1" |
Per key; object entries per field |
s.counter() |
Tallies | Increments add up |
minLength and maxLength count characters (Unicode code points, so 😀 is one), bounds are inclusive, and integers are safe integers; a write outside the rules is refused and changes nothing.
A field that inserts and initial values leave out takes its default: text starts empty, lists and records start empty, counters start at 0, and a scalar takes the default it declares, such as s.boolean({ default: false }) or s.enum(["low", "high"], { default: "low" }). A scalar without a default must be given. Optional fields are left absent. An unset optional text shows as empty in a bound field, and typing creates it.
An optional object uses $set(value) and $clear(), because its handle also holds the object’s fields (any field name is free except constructor, prototype and the Object.prototype method names); a record uses put(key, value), delete(key) and entry(key). Creating an absent object or entry applies creation defaults. Replacing an existing one requires a complete value, including required fields with defaults, and preserves surviving text, lists and row identities. Omitted optional fields are removed. Record keys are 1–256 UTF-16 units and can’t be $id, __proto__, constructor or prototype.
Scalar lists are addressed by position: insert(value, index?), set(index, value), preview(index, value), remove(index, count?) and replace(values). Trees and rich text are not available. Keep transient view state (selection, hover, flipped cards) in component state; anything an export or icon shows must be a field, because they render from the saved document.
A new document starts from the initial option of defineDocument in schema.ts.
It must fit the schema; fields with defaults or optional fields may be left out.
The builder validates initial values and stores the schema’s descriptor and the initial values in the template. The descriptor is not JSON Schema, and pop schema prints it. Initial values seed a new document only; rebuilding a template does not rewrite an existing document. Schema evolution is deferred.
Read snapshots; write through handles
Section titled “Read snapshots; write through handles”defineDocument returns definition data. In an editor component, import that definition as schema and call getDocument(schema) while the component initializes: at the top of its script, or in a store factory called there. Keep the result; event handlers and timers use it rather than calling getDocument again. Do not call it at module scope. Each mount has its own typed, reactive document.
<script lang="ts"> import { EditableText, getDocument } from "@hitslop/pop/svelte"; import schema from "./schema"; const doc = getDocument(schema);</script> <EditableText class="title" field={doc.fields.title} label="Title" placeholder="Untitled" /> <input type="checkbox" bind:checked={doc.fields.archived.value} /> <input type="range" min="1" max="5" bind:value={doc.fields.mood.value} /> <select bind:value={doc.fields.lane.value}> <option value="todo">To do</option><option value="doing">Doing</option><option value="done">Done</option> </select> <input aria-label="Note" bind:value={doc.fields.note.value} /> {#each doc.current.tasks as task (task.$id)} {@const row = doc.at(task)} <input type="checkbox" bind:checked={row.done.value} /> <EditableText class="task" field={row.text} label="Task" /> {/each}Use Value<typeof schema.descriptor> from pop for the saved data type and LiveDocument<typeof schema.descriptor> for an acquired document’s type. Static views receive capture props.
doc.current is a read-only snapshot, and unchanged rows keep their object identity between edits. doc.at(value) returns the typed write handle for the root, a row or a nested object taken directly from doc.current; do not pass clones.
EditableText shows a text field and turns into a growing textarea where the person clicks. It keeps what they type and sends each change for the app to merge, so edits made elsewhere at the same time (another window, or an agent writing from the version it read) are kept, and the caret stays put, including during IME composition. Style it through its one class, and [data-placeholder] inside it for the empty text; onenter handles Enter, which otherwise adds a line. To bind your own <input> or <textarea> instead, use use:bindText={handle}. A text handle’s value reads the shown text, and text.set(value) replaces a whole field.
Scalar fields (booleans, strings, numbers, integers and enums) have a value for Svelte’s bind:, which works with native inputs and with component libraries such as bits-ui. Assigning shows the value at once and saves it once the person pauses, so dragging a range or typing a date writes once. An emptied control clears an optional field. Svelte binds only to a name or a property path, so give a row’s handle a name with {@const row = doc.at(task)} before binding its fields.
Writes are asynchronous. set on a scalar shows the new value in doc.current at once; every write’s promise resolves once the app has accepted it and doc.current shows it, so read row inserts and moves after awaiting. To react to a change (play a sound, show a celebration), watch the state in a $derived or $effect rather than reading doc.current again in the handler; that also fires for edits that come from an agent or an undo. A resolved write is not yet on disk or rendered: use await doc.flush() when it must be saved and Svelte’s tick() when the next step depends on the DOM.
A refused write leaves the saved value in doc.current, and pop shows the person what went wrong. You don’t need to catch it: await doc.fields.tasks.insert(...) followed by clearing a composer keeps the composer’s text when the insert is refused. Catch a rejection only to show your own message; one you catch is not reported again.
Composers, motion and notices
Section titled “Composers, motion and notices”@hitslop/pop/svelte handles behavior most pop repeat, and leaves the markup to you:
<script lang="ts"> import { draft, motion, notify, getDocument } from "@hitslop/pop/svelte"; import schema from "./schema"; const doc = getDocument(schema); const task = draft(text => doc.fields.tasks.insert({ text })); async function finishAll() { const count = await doc.change(tx => { const unfinished = doc.current.tasks.filter(task => !task.done); for (const row of unfinished) tx.at(row).done.set(true); return unfinished.length; }); notify(`${count} finished.`); } const ratio = $derived(doc.current.tasks.filter((t) => t.done).length / Math.max(1, doc.current.tasks.length)); const fill = motion(() => ratio);</script><form onsubmit={task.submit}> <input aria-label="New task" bind:value={task.value} /> <button disabled={!task.ready}>Add</button></form><div class="bar" style:transform={`scaleX(${fill.current})`}></div><button onclick={finishAll}>Finish all</button>draft trims the text, ignores a second submit while one runs, and clears the input once the write is accepted, unless the person typed more meanwhile. motion eases a number toward its target, jumps when the person prefers reduced motion, and starts at its target. notify shows a message in the window’s notice region, [data-pop-notice], which holds the current message as a <p>; restyle it from your CSS. A page handler can call refuse("Enter a task.") to show a refusal there too.
Previews for drags and drawing
Section titled “Previews for drags and drawing”While a person drags or draws, call handle.preview(value) on each frame: doc.current shows the value immediately, but nothing is written. Then set the final value. Pending previews are saved by flush, close and export.
Keep row identity stable
Section titled “Keep row identity stable”Every row has $id. Key Svelte loops and selection state by that ID, not by index:
const { id } = await doc.fields.tasks.insert({ text: "Book tickets" }, { after: task });await doc.fields.tasks.move(id, { before: anotherTask });await doc.fields.tasks.item(id).text.set("Book train tickets");await doc.fields.tasks.remove(id);await doc.fields.visits.increment();item(row), move(row, destination) and remove(row) accept a row snapshot or its $id. The before and after destinations accept either form too, for both live and transaction handles. increment(-1) subtracts; a counter has no reset. A deleted row is no longer a valid write target. Do not add $id manually to creation-only initial values.
Group changes
Section titled “Group changes”await doc.change(tx => { for (const task of finished) tx.at(task).done.set(true); const { id } = tx.fields.tasks.insert({ text: "Plan next weekend" }); tx.fields.tasks.move(id, { before: finished[0] }); tx.fields.archived.set(false);});A change is one synchronous, all-or-nothing commit. Inside the callback, insert returns its id immediately so later writes can address the new row. Do not await inside the callback, nest changes, keep tx handles after it returns, or write through doc there, and remember doc.current still shows the state from before the change until the returned promise resolves. A rejected operation discards the whole change.
Prefer a change, or increment on a counter, over reading a value and writing it back: the snapshot you read may be a moment old. Order rows by their place in the list and reorder them with move, rather than storing positions in a number field that two edits can both rewrite.
Share an edit with agents
Section titled “Share an edit with agents”Commands in commands.ts are for agents, and the page cannot import that file. When a button and an agent should do the same thing, write the edit once as a plain function over Tx<typeof schema>, the tx that both doc.change and a command’s run receive:
import { defineDocument, refuse, s, type Tx } from "@hitslop/pop";const schema = defineDocument({ tasks: s.list(s.object({ text: s.text() })) });export default schema;
export function addTask(tx: Tx<typeof schema>, text: string) { if (!text.trim()) refuse("Enter a task."); return tx.fields.tasks.insert({ text: text.trim() });}The page runs it with doc.change(tx => addTask(tx, text)), and the command wraps the same function:
import { defineCommands } from "@hitslop/pop/commands";import { s } from "@hitslop/pop";import schema, { addTask } from "./schema";
export default defineCommands(schema, { addTask: { description: "Add a task and return its row ID.", args: { text: s.string() }, run: ({ tx }, { text }) => addTask(tx, text), },});Both are one atomic edit and one undo step, and a refuse reaches the person in the window or the agent in pop call. Pass time and randomness in as arguments: the page has Date.now(), while a command must use ctx.now and ctx.random(). Helpers can live in schema.ts or any plain module.
Save and recover
Section titled “Save and recover”Edit ▸ Undo and Redo (⌘Z and ⇧⌘Z, including inside text fields) step back through the changes made since the document opened, the person’s and an agent’s. A typing run in one field is one step, each doc.change is one step, and an agent’s consecutive edits are one step, so write a gesture once when it ends rather than on every move. Call doc.undo() and doc.redo() for your own buttons.
The host autosaves, and await doc.flush() sends any unsent text, waits for pending writes, and saves. Close, quit and export do the same first. Failed saves keep every edit and offer native retry. Application errors are reported separately from save failures. A document keeps its edit history until it grows past 4 MiB. Then, when it closes, it keeps only the history of the session that just ended, or none if even that is large.
CLI reads and edits go through the same app-owned core:
bunx @hitslop/pop schema My.popbunx @hitslop/pop get My.popbunx @hitslop/pop apply My.pop --op '{"type":"set","path":["title"],"value":"Ready"}'bunx @hitslop/pop apply My.pop --op '{"type":"set","path":["tasks",{"id":"<$id>"},"done"],"value":true}'Example responses (schema and value excerpts; other fields are omitted):
| Command | Response |
|---|---|
schema My.pop |
The title descriptor: {"kind":"text"}. |
get My.pop |
A task row: {"$id":"example-task-id","text":"Book train","done":false}. |
Either apply above |
{"ids":[]} — no rows inserted. Use the real row ID in place of <$id>. |
Edits are saved when the command succeeds; get prints the data. After an unknown outcome, inspect with get before another edit; edits are never replayed automatically. Theme overrides are separate host metadata; see styling. The numbered command walkthrough includes full discovery and read responses.
Each kind’s merge behavior is in the table above, and the operation shapes are in the CLI guide.
Handling refused edits
Section titled “Handling refused edits”Use isRejected(error) from pop when catching a semantic refusal;
its reason and opIndex identify the core rule and operation. isRefused(error)
recognizes a command’s refuse(message). Use
isDocumentError(error) to distinguish other document outcomes from application
errors. Use these guards rather than instanceof. An unknown outcome does not prove
an edit was refused; read current state before another edit.
Previews and bound .value assignments belong to live handles. Inside change,
use transaction write methods; previews are not part of the collector.