feat(engine): P0 stroke engine + persistence
Per the full-refactor plan §9 (input-independent half of P0): - engine: canonical EditorStroke (lossless InkStroke round-trip) + stroke_geometry (single getStroke outline) + revision-gated StrokeStore - render: static/live ink painters + ink_picture_cache (revision-keyed) + annotation_layer (RepaintBoundary) - persistence: DB v6 (ink, notebook_pages) + editor_repository diff-write (UPSERT changed / DELETE removed in one txn; id-set after commit) + save_scheduler - pdf_service export now FILLS the getStroke outline (R7 hairline fix) Not yet wired into the live editor (input relocation pending pen-pressure diagnostic). 28 new tests pass.
This commit is contained in:
149
lib/editor/persistence/editor_repository.dart
Normal file
149
lib/editor/persistence/editor_repository.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
// 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';
|
||||
}
|
||||
Reference in New Issue
Block a user