150 lines
5.6 KiB
Dart
150 lines
5.6 KiB
Dart
|
|
// 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';
|
||
|
|
}
|