// lib/editor/persistence/editor_repository.dart // // MF3 diff-write contract: per-host diff of stroke ids against the last // persisted set. Only changed/new rows are upserted; only removed rows are // deleted. All mutations run in ONE transaction per saveHost call. // The in-memory _persistedIds map is updated only after the transaction // commits successfully. import 'dart:convert'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import '../engine/stroke_model.dart'; import '../../services/database_service.dart'; /// Repository for persisting [EditorStroke]s to the `ink` table. /// /// Host-id scheme: `"page:"` where pageIndex is the zero-based /// index of the page within its document. For example, page 0 of a document /// uses host_id `"page:0"`. /// /// [loadDocument] uses a single batched query over all ink rows whose /// host_id begins with `"page:"` for the document, grouped by host_id. /// [saveHost] implements the MF3 diff-write contract. class EditorRepository { EditorRepository(this._db); final Database _db; /// Per host_id, the set of stroke ids that were last persisted to the DB. /// Updated only after a successful transaction commit. final Map> _persistedIds = {}; // ── Factory ──────────────────────────────────────────────────────────── /// Convenience constructor that initialises from [DatabaseService]. static Future fromService(DatabaseService service) async { return EditorRepository(service.database); } // ── Load ─────────────────────────────────────────────────────────────── /// Load all ink rows for [documentId] in a single batched query. /// /// Returns a map keyed by host_id (e.g. `"page:0"`) whose values are /// the strokes for that host in ascending ordinal order. /// /// The host_id scheme is: host_kind = `"page"`, host_id = `"page:"`. Future>> loadDocument( String documentId, ) async { // All page hosts for a document share the prefix "page:" inside host_id. // We tag them with document_id via the host_id prefix convention: // host_id = "doc::page:" final rows = await _db.query( 'ink', where: 'host_kind = ? AND host_id LIKE ?', whereArgs: ['page', 'doc:$documentId:page:%'], orderBy: 'host_id ASC, ordinal ASC', ); final result = >{}; for (final row in rows) { final hostId = row['host_id'] as String; final strokeJson = jsonDecode(row['stroke_json'] as String) as Map; final stroke = EditorStroke.fromJson(strokeJson); result.putIfAbsent(hostId, () => []).add(stroke); } // Populate _persistedIds from what we just read so that subsequent // saveHost calls can diff correctly even on a fresh repository instance. for (final entry in result.entries) { _persistedIds[entry.key] = entry.value.map((s) => s.id).toSet(); } return result; } // ── Save (MF3 diff-write contract) ──────────────────────────────────── /// Persist [strokes] for the given host ([hostKind], [hostId]). /// /// Diff against the last-known persisted id-set: /// - NEW / CHANGED rows → INSERT OR REPLACE (upsert) /// - REMOVED rows → DELETE /// /// All mutations execute in a single transaction. [_persistedIds] is /// updated only after the transaction commits. Future saveHost( String hostKind, String hostId, List strokes, ) async { final incoming = strokes; final incomingIds = incoming.map((s) => s.id).toSet(); final persisted = _persistedIds[hostId] ?? {}; final toDelete = persisted.difference(incomingIds); final toUpsert = incoming.where((s) => !persisted.contains(s.id)).toList(); // Fast path: nothing to do. if (toDelete.isEmpty && toUpsert.isEmpty) return; final now = DateTime.now().millisecondsSinceEpoch; await _db.transaction((txn) async { // Upsert new/changed rows. for (var i = 0; i < incoming.length; i++) { final stroke = incoming[i]; if (!persisted.contains(stroke.id)) { await txn.rawInsert( '''INSERT INTO ink (id, host_kind, host_id, stroke_json, ordinal, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET stroke_json = excluded.stroke_json, ordinal = excluded.ordinal, updated_at = excluded.updated_at''', [ stroke.id, hostKind, hostId, jsonEncode(stroke.toJson()), i, now, ], ); } } // Delete removed rows. for (final id in toDelete) { await txn.delete('ink', where: 'id = ?', whereArgs: [id]); } }); // Update persisted id-set only after successful commit. _persistedIds[hostId] = Set.from(incomingIds); } // ── Host-id helpers ─────────────────────────────────────────────────── /// Build the canonical host_id for a document page. static String pageHostId(String documentId, int pageIndex) => 'doc:$documentId:page:$pageIndex'; }