feat(notes): operation log, block leases and sync API for collaborative editing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 50s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 50s
Phase 1 of the collaboration plan (server side):
- internal/collab: block tree with parent IDs and fractional order keys,
operations with conflict rules (version check, block leases, conflict
copies instead of lost text, restore on late edits, cycle checks),
in-memory leases/sessions and long-poll notifier.
- Storage: per-note lock + transaction, append-only effect log (doc_ops),
idempotent results per opId (doc_op_results), snapshot, log paging,
central DocRole. Old flat block data is converted to the tree.
- The existing whole-document save (PUT/PATCH) runs as an adapter through
the same engine, so the current editor keeps working and writes to the log.
- API: GET /docs/{id}/snapshot, POST /docs/{id}/sync, GET /docs/{id}/changes
(long poll), POST /docs/{id}/leave; DOC_SEQ poke for WebSocket/SSE rooms.
- Backend accepts editor (write) shares for notes and folders; the forced
read-only migration is gone. The share dialog still offers read only.
- Tests: conflict matrix, order keys, leases, storage adapter round trip,
idempotency, API protocol incl. long poll, and a randomized simulation
(4 clients, lost/duplicated requests and responses, offline phases,
lease expiry) checking convergence, no lost text and exactly-once results.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bcc06da3ee
commit
1ea0c78b41
23 files changed
+3266
-287
No files matched your search
+74
-270
@@ -415,11 +415,6 @@ func (s *Store) migrate() error {
|
||||
// Backfill username for existing accounts where username is empty
|
||||
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
|
||||
|
||||
// Ensure all existing document & folder shares are strictly read-only
|
||||
_, _ = s.db.Exec(`UPDATE doc_shares SET permission = 'read' WHERE permission != 'read'`)
|
||||
_, _ = s.db.Exec(`UPDATE doc_team_shares SET permission = 'read' WHERE permission != 'read'`)
|
||||
_, _ = s.db.Exec(`UPDATE workspace_folder_shares SET permission = 'read' WHERE permission != 'read'`)
|
||||
|
||||
// Default system settings (public registration disabled by default; invite or admin creation required)
|
||||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
|
||||
('sharing_mode', 'teams_only'),
|
||||
@@ -463,6 +458,9 @@ func (s *Store) migrate() error {
|
||||
_, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`)
|
||||
_, _ = s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`)
|
||||
|
||||
// Block tree, operation log and idempotency tables for collaborative editing
|
||||
s.migrateDocSync()
|
||||
|
||||
// Migrate workspace documents, blocks, folders, and strip legacy folder:* tags into pure DB tables
|
||||
s.migrateWorkspaceToPureDB()
|
||||
|
||||
@@ -1449,86 +1447,22 @@ func (s *Store) migrateWorkspaceToPureDB() {
|
||||
}
|
||||
|
||||
func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, document_id, type, content, level, position, checked, collapsed, lang
|
||||
FROM blocks
|
||||
WHERE document_id = ?
|
||||
ORDER BY position ASC, rowid ASC
|
||||
`, docID)
|
||||
if err == nil {
|
||||
blocks := make([]domain.DocumentBlock, 0)
|
||||
for rows.Next() {
|
||||
var b domain.DocumentBlock
|
||||
var chk, col int
|
||||
if scanErr := rows.Scan(&b.ID, &b.DocumentID, &b.Type, &b.Content, &b.Level, &b.Position, &chk, &col, &b.Lang); scanErr == nil {
|
||||
b.Checked = chk != 0
|
||||
b.Collapsed = col != 0
|
||||
blocks = append(blocks, b)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
if len(blocks) > 0 {
|
||||
return blocks
|
||||
}
|
||||
if d, _, err := loadCollabDoc(s.db, docID); err == nil && len(d.Blocks) > 0 {
|
||||
return flatDocumentBlocks(docID, d)
|
||||
}
|
||||
|
||||
// Very old notes only stored blocks_json; convert them once
|
||||
blocks := make([]domain.DocumentBlock, 0)
|
||||
if fallbackJSON != "" && fallbackJSON != "[]" {
|
||||
_ = json.Unmarshal([]byte(fallbackJSON), &blocks)
|
||||
if len(blocks) > 0 {
|
||||
s.replaceDocBlocksInDB(docID, "", blocks, domain.NowISO())
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func (s *Store) replaceDocBlocksInDB(docID, _ string, blocks []domain.DocumentBlock, now string) {
|
||||
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, docID)
|
||||
if len(blocks) > 0 {
|
||||
stmt, err := s.db.Prepare(`
|
||||
INSERT INTO blocks (id, document_id, content, level, position, type, checked, collapsed, lang)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
document_id = excluded.document_id,
|
||||
content = excluded.content,
|
||||
level = excluded.level,
|
||||
position = excluded.position,
|
||||
type = excluded.type,
|
||||
checked = excluded.checked,
|
||||
collapsed = excluded.collapsed,
|
||||
lang = excluded.lang
|
||||
`)
|
||||
if err == nil {
|
||||
defer stmt.Close()
|
||||
for i, b := range blocks {
|
||||
bID := strings.TrimSpace(b.ID)
|
||||
if bID == "" {
|
||||
bID = domain.NewID("blk")
|
||||
}
|
||||
bType := b.Type
|
||||
if bType == "" {
|
||||
bType = "bullet"
|
||||
}
|
||||
chk := 0
|
||||
if b.Checked {
|
||||
chk = 1
|
||||
}
|
||||
col := 0
|
||||
if b.Collapsed {
|
||||
col = 1
|
||||
}
|
||||
pos := b.Position
|
||||
if pos <= 0 {
|
||||
pos = float64((i + 1) * 1000)
|
||||
}
|
||||
_, _ = stmt.Exec(bID, docID, b.Content, b.Level, pos, bType, chk, col, b.Lang)
|
||||
if d, _, err := loadCollabDoc(s.db, docID); err == nil {
|
||||
return flatDocumentBlocks(docID, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update markdown_cache on documents
|
||||
mdCache := domain.CompileBlocksToMarkdown(blocks)
|
||||
_, _ = s.db.Exec(`UPDATE documents SET markdown_cache = ?, updated_at = ? WHERE id = ?`, mdCache, now, docID)
|
||||
return blocks
|
||||
}
|
||||
|
||||
func (s *Store) LoadDocumentState(docID string) (string, []domain.DocumentBlock, error) {
|
||||
@@ -1541,222 +1475,85 @@ func (s *Store) LoadDocumentState(docID string) (string, []domain.DocumentBlock,
|
||||
return title, blocks, nil
|
||||
}
|
||||
|
||||
// FlushDocumentState persists the in-memory blocks into `blocks`, extracts all #tags into `block_tags`,
|
||||
// and compiles the hierarchical Markdown into `documents.markdown_cache`.
|
||||
// FlushDocumentState stores a complete block list (and optionally the title) through the operation log.
|
||||
func (s *Store) FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error {
|
||||
now := domain.NowISO()
|
||||
|
||||
// Normalize positions sequentially
|
||||
normalized := make([]domain.DocumentBlock, len(blocks))
|
||||
var plainBuilder strings.Builder
|
||||
for i, b := range blocks {
|
||||
if strings.TrimSpace(b.ID) == "" {
|
||||
b.ID = domain.NewID("blk")
|
||||
}
|
||||
if b.Type == "" {
|
||||
b.Type = "bullet"
|
||||
}
|
||||
b.DocumentID = docID
|
||||
b.Position = float64((i + 1) * 1000)
|
||||
normalized[i] = b
|
||||
if b.Content != "" {
|
||||
plainBuilder.WriteString(b.Content)
|
||||
plainBuilder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
s.replaceDocBlocksInDB(docID, "", normalized, now)
|
||||
_ = s.indexDocBlockTags(docID, "", normalized)
|
||||
|
||||
mdCache := domain.CompileBlocksToMarkdown(normalized)
|
||||
plainContent := plainBuilder.String()
|
||||
blocksJSON, _ := json.Marshal(normalized)
|
||||
|
||||
s.replaceDocBlocksInDB(docID, "", blocks, domain.NowISO())
|
||||
if strings.TrimSpace(title) != "" {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE documents
|
||||
SET title = ?, markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, title, mdCache, plainContent, string(blocksJSON), now, docID)
|
||||
return err
|
||||
s.applyLegacyTitle(docID, "", title)
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE documents
|
||||
SET markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, mdCache, plainContent, string(blocksJSON), now, docID)
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// PatchDocumentBlocks applies atomic block-level upserts, deletions, and order reconciliation without overwriting concurrent edits from other sessions.
|
||||
// PatchDocumentBlocks applies block upserts, deletions and an optional order to a note through the
|
||||
// operation log. Blocks not mentioned keep their position relative to each other.
|
||||
func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPatchRequest) (*domain.Document, []string, error) {
|
||||
if !CanEditDoc(s.DocRole(userID, docID)) {
|
||||
return nil, nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||||
}
|
||||
doc, err := s.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !doc.IsOwner {
|
||||
return nil, nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||||
|
||||
deleted := make(map[string]bool, len(req.DeletedIDs))
|
||||
for _, id := range req.DeletedIDs {
|
||||
deleted[strings.TrimSpace(id)] = true
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
|
||||
// Ensure blocks table is seeded if previously only in blocks_json
|
||||
var existingCount int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM blocks WHERE document_id = ?`, docID).Scan(&existingCount)
|
||||
if existingCount == 0 && len(doc.Blocks) > 0 {
|
||||
s.replaceDocBlocksInDB(docID, doc.UserID, doc.Blocks, doc.UpdatedAt)
|
||||
}
|
||||
|
||||
// Read current ordered block IDs from DB before patch
|
||||
var existingOrder []string
|
||||
ordRows, err := s.db.Query(`SELECT id FROM blocks WHERE document_id = ? ORDER BY position ASC, rowid ASC`, docID)
|
||||
if err == nil {
|
||||
for ordRows.Next() {
|
||||
var bid string
|
||||
if err := ordRows.Scan(&bid); err == nil {
|
||||
existingOrder = append(existingOrder, bid)
|
||||
}
|
||||
upserts := make(map[string]domain.DocumentBlock, len(req.Upserted))
|
||||
for _, b := range req.Upserted {
|
||||
if id := strings.TrimSpace(b.ID); id != "" && !deleted[id] {
|
||||
upserts[id] = b
|
||||
}
|
||||
_ = ordRows.Close()
|
||||
}
|
||||
|
||||
// 1. Delete explicitly deleted block IDs
|
||||
deletedSet := make(map[string]bool, len(req.DeletedIDs))
|
||||
for _, delID := range req.DeletedIDs {
|
||||
delID = strings.TrimSpace(delID)
|
||||
if delID == "" {
|
||||
merged := make([]domain.DocumentBlock, 0, len(doc.Blocks)+len(upserts))
|
||||
used := make(map[string]bool, len(upserts))
|
||||
for _, b := range doc.Blocks {
|
||||
if deleted[b.ID] {
|
||||
continue
|
||||
}
|
||||
deletedSet[delID] = true
|
||||
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ? AND id = ?`, docID, delID)
|
||||
}
|
||||
|
||||
// Filter existingOrder to exclude deleted blocks
|
||||
survivingExisting := make([]string, 0, len(existingOrder))
|
||||
survivingSet := make(map[string]bool, len(existingOrder))
|
||||
for _, bid := range existingOrder {
|
||||
if !deletedSet[bid] {
|
||||
survivingExisting = append(survivingExisting, bid)
|
||||
survivingSet[bid] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Upsert modified or newly created blocks
|
||||
upsertedIDs := make([]string, 0, len(req.Upserted))
|
||||
for idx, b := range req.Upserted {
|
||||
bID := strings.TrimSpace(b.ID)
|
||||
if bID == "" || deletedSet[bID] {
|
||||
if up, ok := upserts[b.ID]; ok {
|
||||
merged = append(merged, up)
|
||||
used[b.ID] = true
|
||||
continue
|
||||
}
|
||||
bType := b.Type
|
||||
if bType == "" {
|
||||
bType = "bullet"
|
||||
merged = append(merged, b)
|
||||
}
|
||||
for _, b := range req.Upserted {
|
||||
if id := strings.TrimSpace(b.ID); id != "" && !used[id] && !deleted[id] {
|
||||
merged = append(merged, b)
|
||||
used[id] = true
|
||||
}
|
||||
chk := 0
|
||||
if b.Checked {
|
||||
chk = 1
|
||||
}
|
||||
col := 0
|
||||
if b.Collapsed {
|
||||
col = 1
|
||||
}
|
||||
defaultSort := float64((len(survivingExisting) + idx + 1) * 1000)
|
||||
_, _ = s.db.Exec(`
|
||||
INSERT INTO blocks (id, document_id, content, level, position, type, checked, collapsed, lang)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
content = excluded.content,
|
||||
level = excluded.level,
|
||||
checked = excluded.checked,
|
||||
collapsed = excluded.collapsed,
|
||||
lang = excluded.lang
|
||||
`, bID, docID, b.Content, b.Level, defaultSort, bType, chk, col, b.Lang)
|
||||
upsertedIDs = append(upsertedIDs, bID)
|
||||
survivingSet[bID] = true
|
||||
}
|
||||
|
||||
// 3. Reconcile block ordering without dropping blocks concurrently created by other users
|
||||
var mergedOrder []string
|
||||
inMerged := make(map[string]bool)
|
||||
|
||||
if len(req.BlockOrder) > 0 {
|
||||
for _, bid := range req.BlockOrder {
|
||||
if !deletedSet[bid] && survivingSet[bid] && !inMerged[bid] {
|
||||
mergedOrder = append(mergedOrder, bid)
|
||||
inMerged[bid] = true
|
||||
}
|
||||
rank := make(map[string]int, len(req.BlockOrder))
|
||||
for i, id := range req.BlockOrder {
|
||||
rank[id] = i
|
||||
}
|
||||
for i, bid := range survivingExisting {
|
||||
if inMerged[bid] {
|
||||
continue
|
||||
sort.SliceStable(merged, func(i, j int) bool {
|
||||
ri, okI := rank[merged[i].ID]
|
||||
rj, okJ := rank[merged[j].ID]
|
||||
if okI && okJ {
|
||||
return ri < rj
|
||||
}
|
||||
insertPos := 0
|
||||
for j := i - 1; j >= 0; j-- {
|
||||
predID := survivingExisting[j]
|
||||
if inMerged[predID] {
|
||||
for k, mID := range mergedOrder {
|
||||
if mID == predID {
|
||||
insertPos = k + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
mergedOrder = append(mergedOrder[:insertPos], append([]string{bid}, mergedOrder[insertPos:]...)...)
|
||||
inMerged[bid] = true
|
||||
}
|
||||
for _, bid := range upsertedIDs {
|
||||
if !inMerged[bid] {
|
||||
mergedOrder = append(mergedOrder, bid)
|
||||
inMerged[bid] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, bid := range survivingExisting {
|
||||
if !inMerged[bid] {
|
||||
mergedOrder = append(mergedOrder, bid)
|
||||
inMerged[bid] = true
|
||||
}
|
||||
}
|
||||
for _, bid := range upsertedIDs {
|
||||
if !inMerged[bid] {
|
||||
mergedOrder = append(mergedOrder, bid)
|
||||
inMerged[bid] = true
|
||||
}
|
||||
}
|
||||
return okI && !okJ
|
||||
})
|
||||
}
|
||||
|
||||
// Update position in blocks
|
||||
for idx, bid := range mergedOrder {
|
||||
_, _ = s.db.Exec(`UPDATE blocks SET position = ? WHERE document_id = ? AND id = ?`, float64((idx+1)*1000), docID, bid)
|
||||
s.replaceDocBlocksInDB(docID, userID, merged, domain.NowISO())
|
||||
if req.Title != nil {
|
||||
s.applyLegacyTitle(docID, userID, strings.TrimSpace(*req.Title))
|
||||
}
|
||||
|
||||
// Reload authoritative block list and update document snapshot
|
||||
finalBlocks := s.loadDocBlocks(docID, "")
|
||||
var plainBuilder strings.Builder
|
||||
for _, b := range finalBlocks {
|
||||
if b.Content != "" {
|
||||
plainBuilder.WriteString(b.Content)
|
||||
plainBuilder.WriteString("\n")
|
||||
}
|
||||
updated, err := s.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
plainContent := plainBuilder.String()
|
||||
mdCache := domain.CompileBlocksToMarkdown(finalBlocks)
|
||||
blocksJSON, _ := json.Marshal(finalBlocks)
|
||||
|
||||
_, _ = s.db.Exec(`UPDATE documents SET markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`,
|
||||
mdCache, plainContent, string(blocksJSON), now, docID)
|
||||
|
||||
doc.Blocks = finalBlocks
|
||||
doc.MarkdownCache = mdCache
|
||||
doc.Content = plainContent
|
||||
doc.UpdatedAt = now
|
||||
_ = s.indexDocBlockTags(doc.ID, doc.UserID, finalBlocks)
|
||||
|
||||
return doc, mergedOrder, nil
|
||||
order := make([]string, len(updated.Blocks))
|
||||
for i, b := range updated.Blocks {
|
||||
order[i] = b.ID
|
||||
}
|
||||
return updated, order, nil
|
||||
}
|
||||
|
||||
// GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID.
|
||||
@@ -2383,6 +2180,7 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
||||
|
||||
if title != nil {
|
||||
doc.Title = strings.TrimSpace(*title)
|
||||
s.applyLegacyTitle(docID, userID, doc.Title)
|
||||
}
|
||||
if icon != nil {
|
||||
doc.Icon = *icon
|
||||
@@ -3445,8 +3243,7 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
|
||||
}
|
||||
}
|
||||
|
||||
// Geteilte Ordner können ausschließlich mit Leserberechtigung geteilt werden (wie Dokumente)
|
||||
permission = "read"
|
||||
permission = normalizeSharePermission(permission)
|
||||
|
||||
var targetID, targetName, email, avatarURL string
|
||||
if cleanType == "team" {
|
||||
@@ -3671,8 +3468,7 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri
|
||||
return nil, fmt.Errorf("team nicht gefunden")
|
||||
}
|
||||
|
||||
// Document sharing is strictly read-only
|
||||
permission = "read"
|
||||
permission = normalizeSharePermission(permission)
|
||||
|
||||
now := domain.NowISO()
|
||||
shareID := domain.NewID("share")
|
||||
@@ -3699,6 +3495,15 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normalizeSharePermission maps share roles from the API to the stored values: "write" (editor) or "read" (viewer).
|
||||
func normalizeSharePermission(p string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(p)) {
|
||||
case "write", "editor":
|
||||
return "write"
|
||||
}
|
||||
return "read"
|
||||
}
|
||||
|
||||
func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permission string) (*domain.DocShare, error) {
|
||||
doc, err := s.GetDocumentByID(currentUserID, docID)
|
||||
if err != nil {
|
||||
@@ -3712,11 +3517,10 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
|
||||
|
||||
// If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare
|
||||
if strings.HasPrefix(cleanTarget, "team_") {
|
||||
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, "read")
|
||||
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
|
||||
}
|
||||
|
||||
// Document sharing is strictly read-only
|
||||
permission = "read"
|
||||
permission = normalizeSharePermission(permission)
|
||||
|
||||
// Resolve target user by ID, username, or Email
|
||||
var targetUser *domain.User
|
||||
|
||||
Reference in new issue
Block a user