Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 86 additions & 26 deletions src/lib/Session.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {
property_type as doc_property_type,
kind as doc_kind,
inspect as doc_inspect,
create_document_draft,
apply_op_to_draft,
apply_ops,
op_target_node_id,
count_references as doc_count_references,
validate_document_schema,
validate_document,
Expand Down Expand Up @@ -57,7 +57,7 @@ function transform_offset_for_splice(offset: number, op: DocumentOperation): num
return start;
}

type HistoryEntry = {
export type HistoryEntry = {
ops: Transaction['ops'];
inverse_ops: Transaction['inverse_ops'];
selection_before: Selection | null;
Expand Down Expand Up @@ -176,6 +176,11 @@ export default class Session<S extends DocumentSchema = DocumentSchema> {
// state: notification happens explicitly in _commit().
#change_listeners: Array<(change: ChangeEvent) => void> = [];

// History spill configuration (see constructor options). Plain fields —
// may be reconfigured at runtime.
history_limit = Infinity;
on_history_spill: ((entries: HistoryEntry[]) => void) | null = null;

// Reactive helpers for UI state
can_undo = $derived(this.history_index >= 0);
can_redo = $derived(this.history_index < this.history.length - 1);
Expand Down Expand Up @@ -203,7 +208,21 @@ export default class Session<S extends DocumentSchema = DocumentSchema> {
schema: S,
doc: Document,
config: SessionConfig,
options: { selection?: Selection | null } = {}
options: {
selection?: Selection | null;
/**
* Maximum number of history entries kept in memory (default
* Infinity). When exceeded, the oldest entries are handed to
* on_history_spill and dropped; undo depth is bounded accordingly.
*/
history_limit?: number;
/**
* Called with the oldest history entries when history_limit trims
* them. Entries are plain JSON; persist them to replay later (see
* apply_ops in doc_utils.ts).
*/
on_history_spill?: (entries: HistoryEntry[]) => void;
} = {}
) {
// Validate the schema first
validate_document_schema(schema);
Expand All @@ -216,6 +235,9 @@ export default class Session<S extends DocumentSchema = DocumentSchema> {

// Set selection after doc is initialized so validation can work properly
this.selection = options.selection ?? null;

this.history_limit = options.history_limit ?? Infinity;
this.on_history_spill = options.on_history_spill ?? null;
}

/**
Expand Down Expand Up @@ -498,6 +520,24 @@ export default class Session<S extends DocumentSchema = DocumentSchema> {
} else {
this.last_batch_started = undefined;
}

// Spill the oldest entries when the in-memory history exceeds its
// limit. Spilled entries are plain JSON — hosts persist them and
// can materialize any version by replay (apply_ops in doc_utils.ts).
if (this.history.length > this.history_limit) {
const spill_count = this.history.length - this.history_limit;
const spilled = this.history.slice(0, spill_count);
this.history = this.history.slice(spill_count);
this.history_index = this.history_index - spill_count;
if (this.on_history_spill) {
// A throwing host callback must not abort apply().
try {
this.on_history_spill(spilled);
} catch (error) {
console.error('on_history_spill callback failed', error);
}
}
}
}

return this;
Expand All @@ -508,13 +548,8 @@ export default class Session<S extends DocumentSchema = DocumentSchema> {
return;
}
const change = this.history[this.history_index];
// One draft for the whole change set — ops mutate the draft with
// node-level copy-on-write instead of copying the nodes map per op.
const doc = create_document_draft(this.doc);
const undo_ops = change.inverse_ops.slice().reverse();
undo_ops.forEach((op) => {
apply_op_to_draft(doc, op);
});
const doc = apply_ops(this.doc, undo_ops);
// Reversing the original ops yields the inverse of this undo commit:
// applying them in reverse order (= original order) redoes the change.
this._commit(doc, {
Expand All @@ -536,10 +571,7 @@ export default class Session<S extends DocumentSchema = DocumentSchema> {
}
this.history_index = this.history_index + 1;
const change = this.history[this.history_index];
const doc = create_document_draft(this.doc);
change.ops.forEach((op) => {
apply_op_to_draft(doc, op);
});
const doc = apply_ops(this.doc, change.ops);
this._commit(doc, {
ops: change.ops,
inverse_ops: change.inverse_ops,
Expand All @@ -551,6 +583,35 @@ export default class Session<S extends DocumentSchema = DocumentSchema> {
return this;
}

/**
* Returns the history entries whose ops touch any of the given nodes,
* with their indices in session.history.
*
* Ops are node-scoped, so history can be filtered per node: time travel
* through one node's versions, or scoped undo ("undo my last change to
* THIS node") built on top by the host. Pass a node id plus its owned
* subgraph ids (see get_referenced_nodes) to scope to a node including
* its mark and annotation nodes.
*
* Returned entries are live references into session.history: a later
* batched apply may still append ops to the newest entry.
*
* @param node_ids - One node id or a list of ids
* @returns Matching entries, oldest first
*/
history_for(node_ids: NodeId | NodeId[]): Array<{ entry: HistoryEntry; index: number }> {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive local lookup set.
const ids = new Set(Array.isArray(node_ids) ? node_ids : [node_ids]);
const result: Array<{ entry: HistoryEntry; index: number }> = [];
for (let index = 0; index < this.history.length; index++) {
const entry = this.history[index];
if (entry.ops.some((op) => ids.has(op_target_node_id(op) as NodeId))) {
result.push({ entry, index });
}
}
return result;
}

/**
* Applies ops from an external writer (another tab, a sync layer, an
* agent, a version restore) to the live session.
Expand Down Expand Up @@ -589,24 +650,23 @@ export default class Session<S extends DocumentSchema = DocumentSchema> {
let changed_node_types = false;
for (const op of ops) {
const [type] = op;
// op_target_node_id owns the op-shape knowledge; only the
// create/delete/modify classification lives here.
const target_node_id = op_target_node_id(op);
if (target_node_id === null) {
throw new Error(`Cannot ingest op of unknown type: ${JSON.stringify(op)}`);
}
if (type === 'create') {
created_node_ids.push(op[1].id);
created_node_ids.push(target_node_id);
} else if (type === 'delete') {
deleted_node_ids.push(op[1]);
} else if (type === 'set') {
modified_node_ids.push(op[1][0]);
if (op[1][1] === 'type') changed_node_types = true;
} else if (type === 'splice') {
modified_node_ids.push(op[1][0]);
deleted_node_ids.push(target_node_id);
} else {
throw new Error(`Cannot ingest op of unknown type: ${JSON.stringify(op)}`);
modified_node_ids.push(target_node_id);
if (type === 'set' && op[1][1] === 'type') changed_node_types = true;
}
}

const draft = create_document_draft(this.doc);
for (const op of ops) {
apply_op_to_draft(draft, op);
}
const draft = apply_ops(this.doc, ops);

// Validate before committing — the current doc stays untouched when
// the result is invalid.
Expand Down
38 changes: 38 additions & 0 deletions src/lib/doc_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,44 @@ export function apply_op(doc: Document, op: DocumentOperation): Document {
return apply_op_to_draft(create_document_draft(doc), op);
}

/**
* Applies a sequence of ops to a document and returns the new document.
*
* This is the replay primitive for host-side history features:
* materialize a document at any version from a snapshot plus the ops
* recorded after it (e.g. entries spilled from Session history, or a
* persisted op log).
*
* @param doc - The document to start from (not mutated)
* @param ops - Ops to apply, in order
* @returns The new document with all ops applied
*/
export function apply_ops(doc: Document, ops: DocumentOperation[]): Document {
const draft = create_document_draft(doc);
for (const op of ops) {
apply_op_to_draft(draft, op);
}
return draft;
}

/**
* Returns the id of the node an op addresses.
*
* Ops are node-scoped by construction: set/splice target
* [node_id, property], create carries the node, delete carries the id.
* This is what makes node-scoped history filtering possible (see
* Session.history_for).
*
* @returns The addressed node id, or null for unknown ops
*/
export function op_target_node_id(op: DocumentOperation): NodeId | null {
const [type] = op;
if (type === 'set' || type === 'splice') return op[1][0];
if (type === 'create') return op[1].id;
if (type === 'delete') return op[1];
return null;
}

/**
* Counts how many times a node is referenced in the document.
*/
Expand Down
6 changes: 4 additions & 2 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export type {
} from './types.js';
export type { SelectedAttachment } from './doc_utils.js';
export type { Keymap } from './KeyMapper.svelte.js';
export type { ChangeEvent } from './Session.svelte.js';
export type { ChangeEvent, HistoryEntry } from './Session.svelte.js';

// Document utilities
export {
Expand All @@ -72,7 +72,9 @@ export {
validate_document_schema,
validate_document,
validate_node,
get_referencing_node_ids
get_referencing_node_ids,
apply_ops,
op_target_node_id
} from './doc_utils.js';

// Command system
Expand Down
101 changes: 101 additions & 0 deletions src/test/history.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import create_test_session from './create_test_session.js';
import { apply_ops } from '../lib/doc_utils.js';
import { set_button_content } from './op_engine_helpers.js';

describe('history spill', () => {
it('spills the oldest entries when history_limit is exceeded', () => {
const session = create_test_session();
const spilled: unknown[] = [];
session.history_limit = 2;
session.on_history_spill = (entries) => spilled.push(...entries);

set_button_content(session, 'One');
set_button_content(session, 'Two');
set_button_content(session, 'Three');

expect(session.history.length).toBe(2);
expect(spilled.length).toBe(1);
expect((spilled[0] as { ops: any[] }).ops[0][2].content).toBe('One');

// Undo depth is bounded by the retained window.
session.undo();
session.undo();
expect(session.can_undo).toBe(false);
expect(session.get<{ content: string }>(['button_1', 'content']).content).toBe('One');
});

it('isolates spill callback exceptions from apply()', () => {
const session = create_test_session();
session.history_limit = 1;
session.on_history_spill = () => {
throw new Error('spill boom');
};

expect(() => {
set_button_content(session, 'One');
set_button_content(session, 'Two');
}).not.toThrow();

expect(session.history.length).toBe(1);
expect(session.get<{ content: string }>(['button_1', 'content']).content).toBe('Two');
});

it('replays spilled entries onto a snapshot to materialize old versions', () => {
const session = create_test_session();
const base = session.to_json();
const spilled: { ops: any[] }[] = [];
session.history_limit = 2;
session.on_history_spill = (entries) => spilled.push(...entries);

set_button_content(session, 'One');
set_button_content(session, 'Two');
set_button_content(session, 'Three');

const at_version_1 = apply_ops(base, spilled[0].ops);
expect(at_version_1.nodes.button_1.content.content).toBe('One');

const all_ops = [...spilled, ...session.history].flatMap((entry) => entry.ops);
const at_head = apply_ops(base, all_ops);
expect(at_head.nodes.button_1.content.content).toBe('Three');

// Replay never mutates the snapshot.
expect(base.nodes.button_1.content.content).toBe('Get started');
});
});

describe('history_for (node-scoped history)', () => {
it('filters history entries by the node their ops address', () => {
const session = create_test_session();

set_button_content(session, 'One');
session.apply(session.tr.splice(['story_1', 'title'], 0, 0, 'X'));

const button_history = session.history_for('button_1');
expect(button_history.length).toBe(1);
expect(button_history[0].index).toBe(0);

const story_history = session.history_for('story_1');
expect(story_history.length).toBe(1);
expect(story_history[0].index).toBe(1);

expect(session.history_for(['button_1', 'story_1']).length).toBe(2);
expect(session.history_for('list_1').length).toBe(0);
});

it('finds entries that created a node', () => {
const session = create_test_session();

const tr = session.tr;
tr.create({
id: 'paragraph_new',
type: 'paragraph',
content: { content: 'Hello', marks: [], annotations: [] }
});
session.apply(tr);

const entries = session.history_for('paragraph_new');
expect(entries.length).toBe(1);
expect(entries[0].entry.ops[0][0]).toBe('create');
});
});