diff --git a/src/lib/Session.svelte.ts b/src/lib/Session.svelte.ts index bb196ca4..c1fcf779 100644 --- a/src/lib/Session.svelte.ts +++ b/src/lib/Session.svelte.ts @@ -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, @@ -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; @@ -176,6 +176,11 @@ export default class Session { // 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); @@ -203,7 +208,21 @@ export default class Session { 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); @@ -216,6 +235,9 @@ export default class Session { // 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; } /** @@ -498,6 +520,24 @@ export default class Session { } 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; @@ -508,13 +548,8 @@ export default class Session { 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, { @@ -536,10 +571,7 @@ export default class Session { } 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, @@ -551,6 +583,35 @@ export default class Session { 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. @@ -589,24 +650,23 @@ export default class Session { 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. diff --git a/src/lib/doc_utils.ts b/src/lib/doc_utils.ts index 60105af1..d47453a7 100644 --- a/src/lib/doc_utils.ts +++ b/src/lib/doc_utils.ts @@ -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. */ diff --git a/src/lib/index.ts b/src/lib/index.ts index e77cae52..3981b489 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -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 { @@ -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 diff --git a/src/test/history.svelte.test.ts b/src/test/history.svelte.test.ts new file mode 100644 index 00000000..b7a64eba --- /dev/null +++ b/src/test/history.svelte.test.ts @@ -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'); + }); +});