Skip to content

Data and schemas

The Rust document owner (on Loro), shared by the hitSlop 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.

schema.ts
import { defineDocument, s } from "hitslop";
export default defineDocument({
title: s.text(),
archived: s.boolean(),
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(), due: s.optional(s.string()) })),
visits: s.counter(),
});

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
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. Inserts and initial values may leave optional fields out. An optional object is created or replaced with set, unless it holds lists, counters or text; edit those fields instead. An unset optional text shows as empty in a bound field, and typing creates it. Scalar lists are addressed by position: insert(value, index?), set(index, value), preview(index, value), remove(index, count?) and replace(values). Records use put(key, value), delete(key) and entry(key); keys are 1–256 characters and can’t be $id, __proto__, constructor or prototype. Replacing an object entry that holds text or lists is refused; edit its fields instead. 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 slop.ts’s initial, which must fit the schema:

initial: {
title: "Weekend",
archived: false,
budget: 120,
mood: 4,
lane: "todo",
tasks: [{ text: "Buy groceries", done: false }],
visits: 0,
},

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 slop schema prints it. Initial values seed a new document only; rebuilding a template does not rewrite an existing document. Schema evolution is deferred.

schema.ts’s default export is also the live document. Any component imports it:

<script lang="ts">
import { bindText } from "hitslop/svelte";
import doc from "./schema";
</script>
<input aria-label="Title" use:bindText={doc.fields.title} />
<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} />
<input aria-label="Task" use:bindText={row.text} />
{/each}

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.

bindText keeps what the person types in the field 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. 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. 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 hitSlop 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.

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.

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", done: false }, { after: task.$id });
await doc.fields.tasks.move(id, { before: anotherTask.$id });
await doc.fields.tasks.item(id).text.set("Book train tickets");
await doc.fields.tasks.remove(id);
await doc.fields.visits.increment();

item(id) is a row’s handle by ID. increment(-1) subtracts; a counter has no reset. If a counter’s merged contributions are invalid, its snapshot is null: show it as unavailable and disable its buttons. A deleted row is no longer a valid write target. Do not add $id manually to creation-only initial values.

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", done: false });
tx.fields.tasks.move(id, { before: finished[0].$id });
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.

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:

Terminal window
bunx hitslop schema My.slop
bunx hitslop get My.slop
bunx hitslop apply My.slop --op '{"type":"set","path":["title"],"value":"Ready"}'
bunx hitslop apply My.slop --op '{"type":"set","path":["tasks",{"id":"<$id>"},"done"],"value":true}'

Edits print {ids} and 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.

Every kind’s merge rules, error codes and CLI paths are in the document types reference.

Use isRejected(error) from hitslop when catching a semantic refusal; its reason and opIndex identify the core rule and operation. 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.