Features / Size and limits
Size and limits
A note holds up to 2 MB of Yjs state, because a note is one SQLite row and Cloudflare caps a row at 2 MB. At 1.6 MB the page says the note is almost full; when an edit no longer fits, the note goes read only.
Measuring
A note's size is the length of Y.encodeStateAsUpdate(doc): the whole state, as stored. The relay reports it in a message of jot's own (type 64). After each save, and when it refuses an update, the figure is measured. On connect it's an upper bound: the size at the last measure plus the bytes applied since, which can run ahead of the truth during the two seconds before a save. The client also measures its own copy a second after changes and when the server confirms its edits, which covers edits the server hasn't seen yet, and goes by the larger of the two (session.ts).
The status pill shows it ("Synced · 238 KB"), and ⋯ → Note info shows it against the cap with a meter. Both turn amber at 1.6 MB and red when the note is full.
Checking every update cheaply
Measuring the merged state on every keystroke would be wasteful, so the relay keeps an upper bound: the size at the last measure, plus the bytes applied since, plus the update and 64 bytes of overhead.
export function fitsUnderCap(stateBytes: number, updateBytes: number): boolean {
return stateBytes + updateBytes + UPDATE_OVERHEAD_BYTES <= NOTE_MAX_BYTES;
}Only when the bound fails does it measure exactly, by merging the update into the state. An update that would pass 2 MB isn't applied, and its sender is told.
/**
* Applies a client's update unless it would push the stored state past the
* cap (D05). The cheap bound (`size + pending + update`) decides almost
* every update; near the cap the merged size is measured exactly.
*/
private receiveUpdate(ws: WebSocket, update: Uint8Array): void {
if (update.byteLength === 0) {
throw new MalformedMessage('empty update');
}
if (!fitsUnderCap(this.size + this.pending, update.byteLength)) {
const state = Y.encodeStateAsUpdate(this.doc);
const merged = Y.mergeUpdates([state, update as Uint8Array<ArrayBuffer>]);
this.size = state.byteLength;
this.pending = 0;
if (merged.byteLength > NOTE_MAX_BYTES) {
ws.send(encodeNoteStatus(this.size, NOTE_STATUS_REJECTED));
return;
}
}
Y.applyUpdate(this.doc, update, ws);
this.pending += update.byteLength;
this.scheduleSave();
}Compaction
The relay's document has garbage collection on, and every save writes encodeStateAsUpdate of it, so deleted text never piles up in storage: a note typed and erased all day stays small.
/**
* Writes the whole document as one row. The state comes from
* `encodeStateAsUpdate` of a gc-on document, so every save is also a
* compaction: deleted content does not accumulate in storage (D05).
*/
save(): void {
if (this.saveTimer !== null) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
if (!this.dirty) {
return;
}
const state = Y.encodeStateAsUpdate(this.doc);
this.ensureSchema();
this.ctx.storage.sql.exec(
`INSERT INTO note (id, name, state, saved_at) VALUES (0, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET name = excluded.name, state = excluded.state,
saved_at = excluded.saved_at`,
this.noteId ?? '',
toArrayBuffer(state),
Date.now(),
);
this.dirty = false;
this.size = state.byteLength;
this.pending = 0;
this.broadcast(encodeNoteStatus(this.size, 0));
}Expiry
Every visit sets the note's alarm to 90 days out. If it fires while someone is connected, it reschedules; otherwise the note's storage is deleted.