feat(workspace): implement Yjs CRDT real-time collaborative editing (resolves FLAW-01)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 29s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 29s
This commit is contained in:
1 parent
0bdeb5f320
commit
ef8cf8f022
13 files changed
+1028
-85
No files matched your search
@@ -232,6 +232,25 @@ func (s *Store) migrate() error {
|
||||
PRIMARY KEY(user_id, tag)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_crdt_state (
|
||||
doc_id TEXT PRIMARY KEY,
|
||||
state_blob BLOB NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_crdt_updates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doc_id TEXT NOT NULL,
|
||||
update_blob BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_crdt_updates_doc ON document_crdt_updates(doc_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -1559,6 +1578,59 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
return doc, mergedOrder, nil
|
||||
}
|
||||
|
||||
// GetDocumentCRDTState returns the compacted CRDT state blob and any pending updates for a document.
|
||||
func (s *Store) GetDocumentCRDTState(docID string) ([]byte, [][]byte, error) {
|
||||
var stateBlob []byte
|
||||
err := s.db.QueryRow(`SELECT state_blob FROM document_crdt_state WHERE doc_id = ?`, docID).Scan(&stateBlob)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`SELECT update_blob FROM document_crdt_updates WHERE doc_id = ? ORDER BY id ASC`, docID)
|
||||
if err != nil {
|
||||
return stateBlob, nil, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var updates [][]byte
|
||||
for rows.Next() {
|
||||
var u []byte
|
||||
if err := rows.Scan(&u); err == nil && len(u) > 0 {
|
||||
updates = append(updates, u)
|
||||
}
|
||||
}
|
||||
return stateBlob, updates, nil
|
||||
}
|
||||
|
||||
// AppendDocumentCRDTUpdate appends a new CRDT update delta for a document.
|
||||
func (s *Store) AppendDocumentCRDTUpdate(docID string, update []byte, userID string) error {
|
||||
now := domain.NowISO()
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO document_crdt_updates (doc_id, update_blob, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, docID, update, now, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveDocumentCRDTState saves a compacted snapshot of the document CRDT state and prunes older incremental updates.
|
||||
func (s *Store) SaveDocumentCRDTState(docID string, stateBlob []byte, userID string) error {
|
||||
now := domain.NowISO()
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO document_crdt_state (doc_id, state_blob, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(doc_id) DO UPDATE SET
|
||||
state_blob = excluded.state_blob,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by
|
||||
`, docID, stateBlob, now, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Prune incremental updates older than the snapshot
|
||||
_, _ = s.db.Exec(`DELETE FROM document_crdt_updates WHERE doc_id = ?`, docID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID.
|
||||
func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) {
|
||||
userSet := make(map[string]bool)
|
||||
|
||||
@@ -683,3 +683,62 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentCRDTState(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "crdt_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
user, err := store.CreateUser("crdt@example.com", "hash", "CRDT User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
|
||||
doc, err := store.CreateDocument(user.ID, "", "CRDT Test Doc", "📝", "", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Initial CRDT state is empty
|
||||
state, updates, err := store.GetDocumentCRDTState(doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentCRDTState failed: %v", err)
|
||||
}
|
||||
if len(state) != 0 || len(updates) != 0 {
|
||||
t.Fatalf("expected empty CRDT state, got state=%d updates=%d", len(state), len(updates))
|
||||
}
|
||||
|
||||
// 2. Append incremental updates
|
||||
u1 := []byte{1, 2, 3, 4}
|
||||
u2 := []byte{5, 6, 7, 8}
|
||||
if err := store.AppendDocumentCRDTUpdate(doc.ID, u1, user.ID); err != nil {
|
||||
t.Fatalf("AppendDocumentCRDTUpdate 1 failed: %v", err)
|
||||
}
|
||||
if err := store.AppendDocumentCRDTUpdate(doc.ID, u2, user.ID); err != nil {
|
||||
t.Fatalf("AppendDocumentCRDTUpdate 2 failed: %v", err)
|
||||
}
|
||||
|
||||
_, updatesAfter, err := store.GetDocumentCRDTState(doc.ID)
|
||||
if err != nil || len(updatesAfter) != 2 {
|
||||
t.Fatalf("expected 2 updates, got %d (err: %v)", len(updatesAfter), err)
|
||||
}
|
||||
|
||||
// 3. Compact snapshot state
|
||||
snapshot := []byte{99, 100, 101}
|
||||
if err := store.SaveDocumentCRDTState(doc.ID, snapshot, user.ID); err != nil {
|
||||
t.Fatalf("SaveDocumentCRDTState failed: %v", err)
|
||||
}
|
||||
|
||||
stateAfter, updatesAfterCompact, err := store.GetDocumentCRDTState(doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentCRDTState after compact failed: %v", err)
|
||||
}
|
||||
if len(stateAfter) != 3 || len(updatesAfterCompact) != 0 {
|
||||
t.Fatalf("expected compacted state of length 3 and 0 updates, got state=%d updates=%d", len(stateAfter), len(updatesAfterCompact))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user