Files
BadNote/lib/editor/persistence/editor_repository.dart

150 lines
5.6 KiB
Dart
Raw Permalink Normal View History

// 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:<pageIndex>"` 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<String, Set<String>> _persistedIds = {};
// ── Factory ────────────────────────────────────────────────────────────
/// Convenience constructor that initialises from [DatabaseService].
static Future<EditorRepository> 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:<i>"`.
Future<Map<String, List<EditorStroke>>> 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:<documentId>:page:<pageIndex>"
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 = <String, List<EditorStroke>>{};
for (final row in rows) {
final hostId = row['host_id'] as String;
final strokeJson =
jsonDecode(row['stroke_json'] as String) as Map<String, dynamic>;
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<void> saveHost(
String hostKind,
String hostId,
List<EditorStroke> 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<String>.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';
}