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
@@ -0,0 +1,663 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/collab"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// Collaborative note editing: every change to a note's blocks or title runs through collab.Apply under a
|
||||
// per-document lock, is appended to doc_ops (the log) and recorded in doc_op_results (idempotency).
|
||||
|
||||
// ErrDocNotFound is returned when a note does not exist or the user has no access to it.
|
||||
var ErrDocNotFound = errors.New("Notiz nicht gefunden oder kein Zugriff")
|
||||
|
||||
// Roles a user can have on a note.
|
||||
const (
|
||||
DocRoleOwner = "owner"
|
||||
DocRoleEditor = "editor"
|
||||
DocRoleViewer = "viewer"
|
||||
)
|
||||
|
||||
// DocLogEntry is one effect from the log, as sent to clients.
|
||||
type DocLogEntry struct {
|
||||
Seq int64 `json:"seq"`
|
||||
OpID string `json:"opId,omitempty"`
|
||||
UserID string `json:"userId"`
|
||||
ClientID string `json:"clientId"`
|
||||
Effect collab.Effect `json:"effect"`
|
||||
}
|
||||
|
||||
// DocSnapshot is the complete state of a note including tombstones, needed to replay later effects.
|
||||
type DocSnapshot struct {
|
||||
DocID string `json:"docId"`
|
||||
Title string `json:"title"`
|
||||
TitleVersion int `json:"titleVersion"`
|
||||
HeadSeq int64 `json:"headSeq"`
|
||||
Blocks []collab.Block `json:"blocks"`
|
||||
}
|
||||
|
||||
var docLocks sync.Map // docID -> *sync.Mutex
|
||||
|
||||
func lockDoc(docID string) func() {
|
||||
m, _ := docLocks.LoadOrStore(docID, &sync.Mutex{})
|
||||
mu := m.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
type docQueryer interface {
|
||||
QueryRow(query string, args ...interface{}) *sql.Row
|
||||
Query(query string, args ...interface{}) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
// migrateDocSync adds the columns and tables of the collaboration model and converts old flat block lists.
|
||||
func (s *Store) migrateDocSync() {
|
||||
stmts := []string{
|
||||
"ALTER TABLE blocks ADD COLUMN parent_id TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE blocks ADD COLUMN order_key TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE blocks ADD COLUMN version INTEGER NOT NULL DEFAULT 1;",
|
||||
"ALTER TABLE blocks ADD COLUMN conflict_of TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE blocks ADD COLUMN conflict_author TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE blocks ADD COLUMN updated_by TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE blocks ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;",
|
||||
"CREATE INDEX IF NOT EXISTS idx_blocks_doc_parent ON blocks(document_id, parent_id);",
|
||||
"ALTER TABLE documents ADD COLUMN head_seq INTEGER NOT NULL DEFAULT 0;",
|
||||
"ALTER TABLE documents ADD COLUMN title_version INTEGER NOT NULL DEFAULT 0;",
|
||||
`CREATE TABLE IF NOT EXISTS doc_ops (
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL,
|
||||
op_id TEXT NOT NULL DEFAULT '',
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
client_id TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL,
|
||||
block_id TEXT NOT NULL DEFAULT '',
|
||||
payload TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (doc_id, seq)
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS doc_op_results (
|
||||
op_id TEXT PRIMARY KEY,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
result TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);`,
|
||||
"CREATE INDEX IF NOT EXISTS idx_doc_op_results_doc ON doc_op_results(doc_id);",
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := s.db.Exec(q); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||
slog.Error("Migration der Notiz-Synchronisation fehlgeschlagen", "error", err, "statement", q)
|
||||
}
|
||||
}
|
||||
s.backfillBlockTree()
|
||||
}
|
||||
|
||||
// backfillBlockTree converts blocks stored as a flat list (level + position) into parent_id + order_key.
|
||||
func (s *Store) backfillBlockTree() {
|
||||
rows, err := s.db.Query(`SELECT DISTINCT document_id FROM blocks WHERE order_key = ''`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var docIDs []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
docIDs = append(docIDs, id)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for _, docID := range docIDs {
|
||||
bRows, err := s.db.Query(`SELECT id, level FROM blocks WHERE document_id = ? ORDER BY position ASC, rowid ASC`, docID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
type flat struct {
|
||||
id string
|
||||
level int
|
||||
}
|
||||
var list []flat
|
||||
for bRows.Next() {
|
||||
var f flat
|
||||
if bRows.Scan(&f.id, &f.level) == nil {
|
||||
list = append(list, f)
|
||||
}
|
||||
}
|
||||
_ = bRows.Close()
|
||||
|
||||
parents, keys := treeFromLevels(len(list), func(i int) int { return list[i].level })
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for i, f := range list {
|
||||
parent := ""
|
||||
if parents[i] >= 0 {
|
||||
parent = list[parents[i]].id
|
||||
}
|
||||
_, _ = tx.Exec(`UPDATE blocks SET parent_id = ?, order_key = ? WHERE id = ?`, parent, keys[i], f.id)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
slog.Error("Umwandlung der Blockstruktur fehlgeschlagen", "doc_id", docID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// treeFromLevels turns a flat list with nesting levels into parent indices (-1 = top level) and
|
||||
// sequential order keys per parent. Levels deeper than possible are clamped.
|
||||
func treeFromLevels(n int, level func(int) int) ([]int, []string) {
|
||||
parents := make([]int, n)
|
||||
keys := make([]string, n)
|
||||
lastKey := map[int]string{}
|
||||
var stack []int // stack[l] = index of the last block at level l
|
||||
for i := 0; i < n; i++ {
|
||||
lvl := level(i)
|
||||
if lvl < 0 {
|
||||
lvl = 0
|
||||
}
|
||||
if lvl > len(stack) {
|
||||
lvl = len(stack)
|
||||
}
|
||||
stack = stack[:lvl]
|
||||
parent := -1
|
||||
if lvl > 0 {
|
||||
parent = stack[lvl-1]
|
||||
}
|
||||
stack = append(stack, i)
|
||||
parents[i] = parent
|
||||
k, _ := collab.KeyBetween(lastKey[parent], "")
|
||||
lastKey[parent] = k
|
||||
keys[i] = k
|
||||
}
|
||||
return parents, keys
|
||||
}
|
||||
|
||||
func loadCollabDoc(q docQueryer, docID string) (*collab.Doc, int64, error) {
|
||||
d := collab.NewDoc()
|
||||
var head int64
|
||||
err := q.QueryRow(`SELECT title, title_version, head_seq FROM documents WHERE id = ?`, docID).Scan(&d.Title, &d.TitleVersion, &head)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := q.Query(
|
||||
`SELECT id, parent_id, order_key, type, content, checked, collapsed, lang, conflict_of, conflict_author, version, deleted, updated_by
|
||||
FROM blocks WHERE document_id = ?`, docID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
b := &collab.Block{}
|
||||
var chk, col, del int
|
||||
if err := rows.Scan(&b.ID, &b.ParentID, &b.OrderKey, &b.Type, &b.Content, &chk, &col, &b.Props.Lang,
|
||||
&b.Props.ConflictOf, &b.Props.ConflictAuthor, &b.Version, &del, &b.UpdatedBy); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
b.Props.Checked, b.Props.Collapsed, b.Deleted = chk != 0, col != 0, del != 0
|
||||
d.Blocks[b.ID] = b
|
||||
}
|
||||
return d, head, rows.Err()
|
||||
}
|
||||
|
||||
// flatDocumentBlocks converts the live tree into the flat list (level + position) the existing API returns.
|
||||
func flatDocumentBlocks(docID string, d *collab.Doc) []domain.DocumentBlock {
|
||||
flat := d.Flatten()
|
||||
out := make([]domain.DocumentBlock, len(flat))
|
||||
for i, f := range flat {
|
||||
out[i] = domain.DocumentBlock{
|
||||
ID: f.ID, DocumentID: docID, Type: f.Type, Content: f.Content, Checked: f.Props.Checked,
|
||||
Lang: f.Props.Lang, Level: f.Level, Position: float64((i + 1) * 1000), Collapsed: f.Props.Collapsed,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ApplyDocOps applies client operations to a note. Operations whose opId was already processed return
|
||||
// the stored result without being applied again, so clients can safely resend after lost responses.
|
||||
func (s *Store) ApplyDocOps(docID string, a collab.Actor, ops []collab.Op, leases collab.LeaseView) ([]collab.Result, int64, error) {
|
||||
return s.applyDocChange(docID, a, leases, func(*collab.Doc) []collab.Op { return ops })
|
||||
}
|
||||
|
||||
// applyDocChange builds operations from the current state (under the document lock) and applies them.
|
||||
func (s *Store) applyDocChange(docID string, a collab.Actor, leases collab.LeaseView, build func(*collab.Doc) []collab.Op) ([]collab.Result, int64, error) {
|
||||
unlock := lockDoc(docID)
|
||||
defer unlock()
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
doc, head, err := loadCollabDoc(tx, docID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, 0, ErrDocNotFound
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
before := doc.Clone()
|
||||
startHead := head
|
||||
env := collab.Env{
|
||||
Leases: leases,
|
||||
IDTaken: func(id string) bool {
|
||||
var owner string
|
||||
return tx.QueryRow(`SELECT document_id FROM blocks WHERE id = ?`, id).Scan(&owner) == nil && owner != docID
|
||||
},
|
||||
}
|
||||
now := domain.NowISO()
|
||||
ops := build(doc)
|
||||
results := make([]collab.Result, 0, len(ops))
|
||||
|
||||
for _, op := range ops {
|
||||
if op.OpID == "" || len(op.OpID) > 100 {
|
||||
results = append(results, collab.Result{OpID: op.OpID, Status: collab.StatusRejected, Reason: "invalid_op_id"})
|
||||
continue
|
||||
}
|
||||
var stored, storedDoc string
|
||||
err := tx.QueryRow(`SELECT result, doc_id FROM doc_op_results WHERE op_id = ?`, op.OpID).Scan(&stored, &storedDoc)
|
||||
if err == nil {
|
||||
var res collab.Result
|
||||
if storedDoc != docID || json.Unmarshal([]byte(stored), &res) != nil {
|
||||
res = collab.Result{OpID: op.OpID, Status: collab.StatusRejected, Reason: "op_id_reused"}
|
||||
}
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
effects, res := collab.Apply(doc, op, a, env)
|
||||
for _, e := range effects {
|
||||
head++
|
||||
payload, _ := json.Marshal(e)
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO doc_ops (doc_id, seq, op_id, user_id, client_id, kind, block_id, payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
docID, head, op.OpID, a.UserID, a.ClientID, e.Kind, e.BlockID, string(payload), now,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
if len(effects) > 0 {
|
||||
res.Seq = head
|
||||
}
|
||||
resJSON, _ := json.Marshal(res)
|
||||
if _, err := tx.Exec(`INSERT INTO doc_op_results (op_id, doc_id, user_id, result, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
op.OpID, docID, a.UserID, string(resJSON), now); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
|
||||
if head != startHead {
|
||||
if err := persistDocState(tx, docID, before, doc, head, now); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return results, head, nil
|
||||
}
|
||||
|
||||
// persistDocState writes changed blocks and refreshes data derived from the note (markdown, search text, tags).
|
||||
func persistDocState(tx *sql.Tx, docID string, before, after *collab.Doc, head int64, now string) error {
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO blocks (id, document_id, parent_id, order_key, type, content, checked, collapsed, lang,
|
||||
conflict_of, conflict_author, version, deleted, updated_by, level, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
parent_id = excluded.parent_id, order_key = excluded.order_key, type = excluded.type,
|
||||
content = excluded.content, checked = excluded.checked, collapsed = excluded.collapsed,
|
||||
lang = excluded.lang, conflict_of = excluded.conflict_of, conflict_author = excluded.conflict_author,
|
||||
version = excluded.version, deleted = excluded.deleted, updated_by = excluded.updated_by
|
||||
WHERE blocks.document_id = excluded.document_id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
tagsChanged := false
|
||||
for id, b := range after.Blocks {
|
||||
if old, ok := before.Blocks[id]; ok && *old == *b {
|
||||
continue
|
||||
}
|
||||
tagsChanged = true
|
||||
if _, err := stmt.Exec(b.ID, docID, b.ParentID, b.OrderKey, b.Type, b.Content, boolInt(b.Props.Checked),
|
||||
boolInt(b.Props.Collapsed), b.Props.Lang, b.Props.ConflictOf, b.Props.ConflictAuthor, b.Version,
|
||||
boolInt(b.Deleted), b.UpdatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
flat := flatDocumentBlocks(docID, after)
|
||||
var plain strings.Builder
|
||||
for _, b := range flat {
|
||||
if b.Content != "" {
|
||||
plain.WriteString(b.Content)
|
||||
plain.WriteString("\n")
|
||||
}
|
||||
}
|
||||
blocksJSON, _ := json.Marshal(flat)
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE documents SET title = ?, title_version = ?, head_seq = ?, markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`,
|
||||
after.Title, after.TitleVersion, head, domain.CompileBlocksToMarkdown(flat), plain.String(), string(blocksJSON), now, docID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if tagsChanged {
|
||||
if _, err := tx.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range flat {
|
||||
for _, tag := range domain.ExtractTags(b.Content) {
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO block_tags (tag, document_id, block_id) VALUES (?, ?, ?)`, tag, docID, b.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDocOpsSince returns up to limit log entries after seq, plus the current head.
|
||||
func (s *Store) GetDocOpsSince(docID string, since int64, limit int) ([]DocLogEntry, int64, error) {
|
||||
var head int64
|
||||
if err := s.db.QueryRow(`SELECT head_seq FROM documents WHERE id = ?`, docID).Scan(&head); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.db.Query(
|
||||
`SELECT seq, op_id, user_id, client_id, payload FROM doc_ops WHERE doc_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?`,
|
||||
docID, since, limit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]DocLogEntry, 0)
|
||||
for rows.Next() {
|
||||
var e DocLogEntry
|
||||
var payload string
|
||||
if err := rows.Scan(&e.Seq, &e.OpID, &e.UserID, &e.ClientID, &payload); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(payload), &e.Effect); err != nil {
|
||||
return nil, 0, fmt.Errorf("log entry %d unreadable: %w", e.Seq, err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, head, rows.Err()
|
||||
}
|
||||
|
||||
// GetDocSnapshot returns the full block tree (including tombstones) and the log position it corresponds to.
|
||||
func (s *Store) GetDocSnapshot(docID string) (*DocSnapshot, error) {
|
||||
unlock := lockDoc(docID)
|
||||
defer unlock()
|
||||
d, head, err := loadCollabDoc(s.db, docID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrDocNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
snap := &DocSnapshot{DocID: docID, Title: d.Title, TitleVersion: d.TitleVersion, HeadSeq: head, Blocks: make([]collab.Block, 0, len(d.Blocks))}
|
||||
for _, b := range d.Blocks {
|
||||
snap.Blocks = append(snap.Blocks, *b)
|
||||
}
|
||||
sort.Slice(snap.Blocks, func(i, j int) bool { return snap.Blocks[i].ID < snap.Blocks[j].ID })
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// DocRole returns the user's role on a note ("owner", "editor", "viewer") or "" without access.
|
||||
// Sources: ownership, direct shares, team shares and shares of the shared folder the note lives in.
|
||||
func (s *Store) DocRole(userID, docID string) string {
|
||||
if userID == "" {
|
||||
return ""
|
||||
}
|
||||
var ownerID, sharedFolderID, folderOwner string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT d.user_id, d.shared_folder_id, COALESCE(wsf.owner_id, '')
|
||||
FROM documents d LEFT JOIN workspace_shared_folders wsf ON wsf.id = d.shared_folder_id
|
||||
WHERE d.id = ?`, docID).Scan(&ownerID, &sharedFolderID, &folderOwner)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if ownerID == userID {
|
||||
return DocRoleOwner
|
||||
}
|
||||
if folderOwner == userID {
|
||||
return DocRoleEditor
|
||||
}
|
||||
rows, err := s.db.Query(
|
||||
`SELECT permission FROM doc_shares WHERE doc_id = ? AND user_id = ?
|
||||
UNION ALL SELECT dts.permission FROM doc_team_shares dts JOIN team_members tm ON tm.team_id = dts.team_id
|
||||
WHERE dts.doc_id = ? AND tm.user_id = ?
|
||||
UNION ALL SELECT wfs.permission FROM workspace_folder_shares wfs
|
||||
WHERE wfs.folder_id = ? AND wfs.folder_id != '' AND (
|
||||
(wfs.target_type = 'user' AND wfs.target_id = ?)
|
||||
OR (wfs.target_type = 'team' AND wfs.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)))`,
|
||||
docID, userID, docID, userID, sharedFolderID, userID, userID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer rows.Close()
|
||||
role := ""
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if rows.Scan(&p) != nil {
|
||||
continue
|
||||
}
|
||||
if p == "write" {
|
||||
return DocRoleEditor
|
||||
}
|
||||
role = DocRoleViewer
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
// CanEditDoc reports whether the role allows changing a note's content.
|
||||
func CanEditDoc(role string) bool {
|
||||
return role == DocRoleOwner || role == DocRoleEditor
|
||||
}
|
||||
|
||||
// IsDocShared reports whether anyone besides the owner can access the note.
|
||||
func (s *Store) IsDocShared(docID string) bool {
|
||||
var n int
|
||||
_ = s.db.QueryRow(
|
||||
`SELECT (SELECT COUNT(*) FROM doc_shares WHERE doc_id = ?)
|
||||
+ (SELECT COUNT(*) FROM doc_team_shares WHERE doc_id = ?)
|
||||
+ (SELECT COUNT(*) FROM documents d JOIN workspace_folder_shares wfs ON wfs.folder_id = d.shared_folder_id
|
||||
WHERE d.id = ? AND d.shared_folder_id != '')`,
|
||||
docID, docID, docID).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// --- Adapter for the existing whole-document API (PUT /documents/{id}) ---
|
||||
|
||||
const legacyClientID = "legacy"
|
||||
|
||||
// replaceDocBlocksInDB stores a complete flat block list by diffing it against the current tree and
|
||||
// applying the difference as regular operations, so it shows up in the log like any other change.
|
||||
func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.DocumentBlock, _ string) {
|
||||
a := collab.Actor{UserID: userID, ClientID: legacyClientID}
|
||||
if _, _, err := s.applyDocChange(docID, a, nil, func(d *collab.Doc) []collab.Op { return legacyOps(d, blocks) }); err != nil {
|
||||
slog.Error("Speichern der Notiz fehlgeschlagen", "doc_id", docID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// applyLegacyTitle records a title change from the whole-document API in the log.
|
||||
func (s *Store) applyLegacyTitle(docID, userID, title string) {
|
||||
a := collab.Actor{UserID: userID, ClientID: legacyClientID}
|
||||
if _, _, err := s.applyDocChange(docID, a, nil, func(d *collab.Doc) []collab.Op {
|
||||
if d.Title == title {
|
||||
return nil
|
||||
}
|
||||
return []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpSetTitle, BaseVersion: d.TitleVersion, Title: &title}}
|
||||
}); err != nil {
|
||||
slog.Error("Speichern des Notiztitels fehlgeschlagen", "doc_id", docID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// legacyOps computes the operations that turn the current tree into the given flat list.
|
||||
// Last writer wins, exactly like the old whole-document save.
|
||||
func legacyOps(d *collab.Doc, blocks []domain.DocumentBlock) []collab.Op {
|
||||
// Normalize IDs (missing or duplicated IDs get new ones)
|
||||
seen := make(map[string]bool, len(blocks))
|
||||
list := make([]domain.DocumentBlock, len(blocks))
|
||||
for i, b := range blocks {
|
||||
id := strings.TrimSpace(b.ID)
|
||||
if id == "" || seen[id] {
|
||||
id = domain.NewID("blk")
|
||||
}
|
||||
seen[id] = true
|
||||
b.ID = id
|
||||
if b.Type == "" {
|
||||
b.Type = "bullet"
|
||||
}
|
||||
list[i] = b
|
||||
}
|
||||
|
||||
parentIdx, _ := treeFromLevels(len(list), func(i int) int { return list[i].Level })
|
||||
parentOf := make([]string, len(list))
|
||||
children := map[string][]int{}
|
||||
for i := range list {
|
||||
if parentIdx[i] >= 0 {
|
||||
parentOf[i] = list[parentIdx[i]].ID
|
||||
}
|
||||
children[parentOf[i]] = append(children[parentOf[i]], i)
|
||||
}
|
||||
keys := make([]string, len(list))
|
||||
for parent, idxs := range children {
|
||||
assignKeys(d, parent, idxs, list, keys)
|
||||
}
|
||||
|
||||
var ops []collab.Op
|
||||
newOp := func(op collab.Op) {
|
||||
op.OpID = domain.NewID("op")
|
||||
ops = append(ops, op)
|
||||
}
|
||||
wanted := make(map[string]bool, len(list))
|
||||
for i, b := range list {
|
||||
wanted[b.ID] = true
|
||||
parent := parentOf[i]
|
||||
patch := &collab.PropsPatch{Type: &list[i].Type, Checked: &list[i].Checked, Collapsed: &list[i].Collapsed, Lang: &list[i].Lang}
|
||||
cur, exists := d.Blocks[b.ID]
|
||||
if !exists {
|
||||
content := b.Content
|
||||
props := collab.Props{Checked: b.Checked, Collapsed: b.Collapsed, Lang: b.Lang}
|
||||
newOp(collab.Op{Kind: collab.OpInsert, BlockID: b.ID, ParentID: &parent, OrderKey: keys[i], Type: b.Type, Content: &content, Props: &props})
|
||||
continue
|
||||
}
|
||||
if cur.Deleted {
|
||||
newOp(collab.Op{Kind: collab.OpRestore, IDs: []string{b.ID}})
|
||||
}
|
||||
if cur.ParentID != parent || cur.OrderKey != keys[i] || cur.Deleted {
|
||||
newOp(collab.Op{Kind: collab.OpMove, BlockID: b.ID, ParentID: &parent, OrderKey: keys[i]})
|
||||
}
|
||||
if cur.Content != b.Content {
|
||||
content := b.Content
|
||||
newOp(collab.Op{Kind: collab.OpSetContent, BlockID: b.ID, BaseVersion: cur.Version, Content: &content})
|
||||
}
|
||||
if cur.Type != b.Type || cur.Props.Checked != b.Checked || cur.Props.Collapsed != b.Collapsed || cur.Props.Lang != b.Lang {
|
||||
newOp(collab.Op{Kind: collab.OpSetProps, BlockID: b.ID, Patch: patch})
|
||||
}
|
||||
}
|
||||
// Deletes last, after moves have rescued wanted children out of deleted branches
|
||||
for _, fb := range d.Flatten() {
|
||||
if !wanted[fb.ID] {
|
||||
newOp(collab.Op{Kind: collab.OpDelete, BlockID: fb.ID})
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// assignKeys picks order keys for the children idxs of parent. Existing keys that are already in the right
|
||||
// relative order (longest increasing subsequence) are kept, so unchanged blocks do not show up as moves.
|
||||
func assignKeys(d *collab.Doc, parent string, idxs []int, list []domain.DocumentBlock, keys []string) {
|
||||
n := len(idxs)
|
||||
existing := make([]string, n)
|
||||
for j, i := range idxs {
|
||||
if b, ok := d.Blocks[list[i].ID]; ok && !b.Deleted && b.ParentID == parent {
|
||||
existing[j] = b.OrderKey
|
||||
}
|
||||
}
|
||||
keep := longestIncreasing(existing)
|
||||
for j := 0; j < n; {
|
||||
if keep[j] {
|
||||
keys[idxs[j]] = existing[j]
|
||||
j++
|
||||
continue
|
||||
}
|
||||
start := j
|
||||
for j < n && !keep[j] {
|
||||
j++
|
||||
}
|
||||
left, right := "", ""
|
||||
if start > 0 {
|
||||
left = keys[idxs[start-1]]
|
||||
}
|
||||
if j < n {
|
||||
right = existing[j]
|
||||
}
|
||||
fresh, err := collab.KeysBetween(left, right, j-start)
|
||||
if err != nil {
|
||||
fresh, _ = collab.KeysBetween(left, "", j-start)
|
||||
}
|
||||
for k := start; k < j; k++ {
|
||||
keys[idxs[k]] = fresh[k-start]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// longestIncreasing marks a longest strictly increasing subsequence of the non-empty keys.
|
||||
func longestIncreasing(keys []string) []bool {
|
||||
n := len(keys)
|
||||
keep := make([]bool, n)
|
||||
tails := []int{} // indices of the smallest tail for each length
|
||||
prev := make([]int, n) // predecessor in the subsequence
|
||||
for i, k := range keys {
|
||||
prev[i] = -1
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
lo, hi := 0, len(tails)
|
||||
for lo < hi {
|
||||
mid := (lo + hi) / 2
|
||||
if keys[tails[mid]] < k {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
if lo > 0 {
|
||||
prev[i] = tails[lo-1]
|
||||
}
|
||||
if lo == len(tails) {
|
||||
tails = append(tails, i)
|
||||
} else {
|
||||
tails[lo] = i
|
||||
}
|
||||
}
|
||||
if len(tails) > 0 {
|
||||
for i := tails[len(tails)-1]; i >= 0; i = prev[i] {
|
||||
keep[i] = true
|
||||
}
|
||||
}
|
||||
return keep
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/collab"
|
||||
)
|
||||
|
||||
// The simulation drives several clients against the real store over an unreliable network and checks
|
||||
// that everyone converges to the server state without losing text or applying anything twice.
|
||||
|
||||
type simClient struct {
|
||||
actor collab.Actor
|
||||
confirmed *collab.Doc
|
||||
lastSeq int64
|
||||
pending []collab.Op // not sent yet; base versions are filled in when a batch is formed
|
||||
inflight []collab.Op // sent, response not received; resent unchanged (same op IDs)
|
||||
holds map[string]bool
|
||||
offline bool
|
||||
counter int
|
||||
}
|
||||
|
||||
type simWorld struct {
|
||||
t *testing.T
|
||||
r *rand.Rand
|
||||
store *Store
|
||||
docID string
|
||||
leases *collab.LeaseManager
|
||||
now time.Time
|
||||
clients []*simClient
|
||||
sent map[string]collab.Op // every op ever sent, by ID
|
||||
results map[string]collab.Result // first result received per op ID
|
||||
}
|
||||
|
||||
func (w *simWorld) newOpID(c *simClient) string {
|
||||
c.counter++
|
||||
return fmt.Sprintf("%s-%d", c.actor.ClientID, c.counter)
|
||||
}
|
||||
|
||||
// localView is what the client's editor shows: confirmed state plus its own unconfirmed operations.
|
||||
func (c *simClient) localView() *collab.Doc {
|
||||
v := c.confirmed.Clone()
|
||||
for _, op := range append(append([]collab.Op{}, c.inflight...), c.pending...) {
|
||||
if op.Kind == collab.OpSetContent {
|
||||
if b, ok := v.Blocks[op.BlockID]; ok {
|
||||
op.BaseVersion = b.Version
|
||||
}
|
||||
}
|
||||
if op.Kind == collab.OpSetTitle {
|
||||
op.BaseVersion = v.TitleVersion
|
||||
}
|
||||
collab.Apply(v, op, c.actor, collab.Env{})
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func pick[T any](r *rand.Rand, list []T) (T, bool) {
|
||||
var zero T
|
||||
if len(list) == 0 {
|
||||
return zero, false
|
||||
}
|
||||
return list[r.Intn(len(list))], true
|
||||
}
|
||||
|
||||
// edit lets a client perform one random editor action on its local view.
|
||||
func (w *simWorld) edit(c *simClient) {
|
||||
v := c.localView()
|
||||
flat := v.Flatten()
|
||||
var target *collab.Block
|
||||
if fb, ok := pick(w.r, flat); ok {
|
||||
target = fb.Block
|
||||
}
|
||||
switch n := w.r.Intn(100); {
|
||||
case n < 45 && target != nil: // typing
|
||||
content := target.Content + fmt.Sprintf(" %s%d", c.actor.ClientID, c.counter)
|
||||
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpSetContent, BlockID: target.ID, Content: &content})
|
||||
c.holds[target.ID] = true
|
||||
case n < 65: // Enter: new block after target (or first block)
|
||||
parent, after := "", ""
|
||||
if target != nil {
|
||||
parent, after = target.ParentID, target.OrderKey
|
||||
}
|
||||
next := ""
|
||||
for _, s := range v.Children(parent) {
|
||||
if s.OrderKey > after {
|
||||
next = s.OrderKey
|
||||
break
|
||||
}
|
||||
}
|
||||
key, err := collab.KeyBetween(after, next)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
id := fmt.Sprintf("blk_%s_%d", c.actor.ClientID, c.counter)
|
||||
content := "neu von " + c.actor.ClientID
|
||||
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpInsert, BlockID: id, ParentID: &parent, OrderKey: key, Content: &content})
|
||||
c.holds[id] = true
|
||||
case n < 73 && target != nil: // indent under previous sibling
|
||||
siblings := v.Children(target.ParentID)
|
||||
for i, s := range siblings {
|
||||
if s.ID == target.ID && i > 0 {
|
||||
prev := siblings[i-1]
|
||||
last := ""
|
||||
if kids := v.Children(prev.ID); len(kids) > 0 {
|
||||
last = kids[len(kids)-1].OrderKey
|
||||
}
|
||||
key, _ := collab.KeyBetween(last, "")
|
||||
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpMove, BlockID: target.ID, ParentID: &prev.ID, OrderKey: key})
|
||||
}
|
||||
}
|
||||
case n < 80 && target != nil: // outdent
|
||||
if p, ok := v.Blocks[target.ParentID]; ok {
|
||||
key, err := collab.KeyBetween(p.OrderKey, "")
|
||||
if err == nil {
|
||||
for _, s := range v.Children(p.ParentID) {
|
||||
if s.OrderKey > p.OrderKey {
|
||||
key, _ = collab.KeyBetween(p.OrderKey, s.OrderKey)
|
||||
break
|
||||
}
|
||||
}
|
||||
grand := p.ParentID
|
||||
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpMove, BlockID: target.ID, ParentID: &grand, OrderKey: key})
|
||||
}
|
||||
}
|
||||
case n < 87 && target != nil: // delete branch
|
||||
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpDelete, BlockID: target.ID})
|
||||
case n < 93 && target != nil: // tick checkbox
|
||||
checked := !target.Props.Checked
|
||||
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpSetProps, BlockID: target.ID, Patch: &collab.PropsPatch{Checked: &checked}})
|
||||
case n < 96: // rename note
|
||||
title := fmt.Sprintf("Titel %s%d", c.actor.ClientID, c.counter)
|
||||
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpSetTitle, Title: &title})
|
||||
default: // stop editing some block
|
||||
for id := range c.holds {
|
||||
delete(c.holds, id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formBatch takes pending operations, keeps only the last text change per block and fills in base versions.
|
||||
func (c *simClient) formBatch() []collab.Op {
|
||||
lastText := map[string]int{}
|
||||
lastTitle := -1
|
||||
for i, op := range c.pending {
|
||||
switch op.Kind {
|
||||
case collab.OpSetContent:
|
||||
lastText[op.BlockID] = i
|
||||
case collab.OpSetTitle:
|
||||
lastTitle = i
|
||||
}
|
||||
}
|
||||
var batch []collab.Op
|
||||
for i, op := range c.pending {
|
||||
switch op.Kind {
|
||||
case collab.OpSetContent:
|
||||
if lastText[op.BlockID] != i {
|
||||
continue
|
||||
}
|
||||
op.BaseVersion = 1
|
||||
if b, ok := c.confirmed.Blocks[op.BlockID]; ok {
|
||||
op.BaseVersion = b.Version
|
||||
}
|
||||
case collab.OpSetTitle:
|
||||
if i != lastTitle {
|
||||
continue
|
||||
}
|
||||
op.BaseVersion = c.confirmed.TitleVersion
|
||||
}
|
||||
batch = append(batch, op)
|
||||
}
|
||||
c.pending = nil
|
||||
return batch
|
||||
}
|
||||
|
||||
// serverSync is what POST /sync does, minus HTTP.
|
||||
func (w *simWorld) serverSync(c *simClient, batch []collab.Op, holds []string) []collab.Result {
|
||||
w.leases.Touch(w.docID, c.actor)
|
||||
w.leases.ReleaseAllExcept(w.docID, c.actor.ClientID, holds)
|
||||
w.leases.Hold(w.docID, c.actor, holds)
|
||||
results, _, err := w.store.ApplyDocOps(w.docID, c.actor, batch, w.leases.View(w.docID))
|
||||
if err != nil {
|
||||
w.t.Fatalf("ApplyDocOps: %v", err)
|
||||
}
|
||||
var own []string
|
||||
for i, res := range results {
|
||||
if res.Status == collab.StatusApplied && batch[i].Kind == collab.OpInsert {
|
||||
own = append(own, batch[i].BlockID)
|
||||
}
|
||||
if res.ConflictBlockID != "" {
|
||||
own = append(own, res.ConflictBlockID)
|
||||
}
|
||||
}
|
||||
w.leases.Hold(w.docID, c.actor, own)
|
||||
return results
|
||||
}
|
||||
|
||||
// pull fetches the log after lastSeq in small pages (to exercise paging) and replays it.
|
||||
func (w *simWorld) pull(c *simClient) {
|
||||
for {
|
||||
entries, head, err := w.store.GetDocOpsSince(w.docID, c.lastSeq, 7)
|
||||
if err != nil {
|
||||
w.t.Fatalf("GetDocOpsSince: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Seq != c.lastSeq+1 {
|
||||
w.t.Fatalf("%s: log gap, expected seq %d got %d", c.actor.ClientID, c.lastSeq+1, e.Seq)
|
||||
}
|
||||
c.confirmed.ApplyEffect(e.Effect)
|
||||
c.lastSeq = e.Seq
|
||||
}
|
||||
if c.lastSeq >= head {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sync runs one round trip over the unreliable (or, with reliable=true, a perfect) network.
|
||||
func (w *simWorld) sync(c *simClient, reliable bool) {
|
||||
if c.inflight == nil {
|
||||
c.inflight = c.formBatch()
|
||||
for _, op := range c.inflight {
|
||||
w.sent[op.OpID] = op
|
||||
}
|
||||
}
|
||||
holds := make([]string, 0, len(c.holds))
|
||||
for id := range c.holds {
|
||||
holds = append(holds, id)
|
||||
}
|
||||
sort.Strings(holds)
|
||||
|
||||
fate := 100
|
||||
if !reliable {
|
||||
fate = w.r.Intn(100)
|
||||
}
|
||||
switch {
|
||||
case fate < 10: // request lost
|
||||
return
|
||||
case fate < 20: // server applied it, response lost
|
||||
w.serverSync(c, c.inflight, holds)
|
||||
return
|
||||
case fate < 25: // request duplicated by a retrying proxy
|
||||
w.serverSync(c, c.inflight, holds)
|
||||
}
|
||||
results := w.serverSync(c, c.inflight, holds)
|
||||
for _, res := range results {
|
||||
if prev, ok := w.results[res.OpID]; ok && prev != res {
|
||||
w.t.Fatalf("op %s answered differently on retry: %+v vs %+v", res.OpID, prev, res)
|
||||
}
|
||||
w.results[res.OpID] = res
|
||||
}
|
||||
c.inflight = nil
|
||||
w.pull(c)
|
||||
}
|
||||
|
||||
func runSimulation(t *testing.T, seed int64, steps int) {
|
||||
store, u := newSyncTestStore(t)
|
||||
doc, err := store.CreateDocument(u.ID, "", "Sim", "📄", "", flatBlocks([3]interface{}{"root", 0, "Start"}), nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := &simWorld{t: t, r: rand.New(rand.NewSource(seed)), store: store, docID: doc.ID,
|
||||
now: time.Unix(1_700_000_000, 0), sent: map[string]collab.Op{}, results: map[string]collab.Result{}}
|
||||
w.leases = collab.NewLeaseManager(func() time.Time { return w.now })
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
snap, err := store.GetDocSnapshot(doc.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &simClient{actor: collab.Actor{UserID: fmt.Sprintf("u%d", i%2), ClientID: fmt.Sprintf("c%d", i), Name: fmt.Sprintf("Client %d", i)},
|
||||
confirmed: collab.NewDoc(), lastSeq: snap.HeadSeq, holds: map[string]bool{}}
|
||||
c.confirmed.Title, c.confirmed.TitleVersion = snap.Title, snap.TitleVersion
|
||||
for _, b := range snap.Blocks {
|
||||
b := b
|
||||
c.confirmed.Blocks[b.ID] = &b
|
||||
}
|
||||
w.clients = append(w.clients, c)
|
||||
}
|
||||
|
||||
for step := 0; step < steps; step++ {
|
||||
c := w.clients[w.r.Intn(len(w.clients))]
|
||||
switch n := w.r.Intn(100); {
|
||||
case n < 55:
|
||||
w.edit(c)
|
||||
case n < 90:
|
||||
if !c.offline {
|
||||
w.sync(c, false)
|
||||
}
|
||||
case n < 95:
|
||||
c.offline = !c.offline
|
||||
default:
|
||||
w.now = w.now.Add(time.Duration(w.r.Intn(25)) * time.Second) // leases may expire
|
||||
}
|
||||
}
|
||||
|
||||
// Everyone comes back online and syncs until nothing is left
|
||||
for round := 0; round < 10; round++ {
|
||||
for _, c := range w.clients {
|
||||
c.offline = false
|
||||
w.sync(c, true)
|
||||
}
|
||||
}
|
||||
for _, c := range w.clients {
|
||||
if len(c.pending) > 0 || len(c.inflight) > 0 {
|
||||
t.Fatalf("%s still has unsent operations", c.actor.ClientID)
|
||||
}
|
||||
w.pull(c)
|
||||
}
|
||||
|
||||
server, head, err := loadCollabDoc(store.db, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := server.Validate(); err != nil {
|
||||
t.Fatalf("seed %d: invalid tree: %v", seed, err)
|
||||
}
|
||||
for _, c := range w.clients {
|
||||
if c.lastSeq != head || !docsEqual(c.confirmed, server) {
|
||||
t.Fatalf("seed %d: %s diverged from the server (seq %d vs %d)", seed, c.actor.ClientID, c.lastSeq, head)
|
||||
}
|
||||
}
|
||||
|
||||
// Every operation got exactly one stored result, and no text was lost
|
||||
var stored int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM doc_op_results WHERE doc_id = ? AND user_id IN ('u0', 'u1')`, doc.ID).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored != len(w.sent) {
|
||||
t.Fatalf("seed %d: %d operations sent but %d results stored", seed, len(w.sent), stored)
|
||||
}
|
||||
log, _, _ := store.GetDocOpsSince(doc.ID, 0, 1_000_000)
|
||||
bySeq := map[int64]collab.Effect{}
|
||||
inserted := map[string]string{}
|
||||
for _, e := range log {
|
||||
bySeq[e.Seq] = e.Effect
|
||||
if e.Effect.Kind == collab.OpInsert {
|
||||
inserted[e.Effect.BlockID] = e.Effect.Content
|
||||
}
|
||||
}
|
||||
conflicts := 0
|
||||
for id, op := range w.sent {
|
||||
res, ok := w.results[id]
|
||||
if !ok {
|
||||
continue // result only seen by the server (response lost, never retried after success) is covered by the count above
|
||||
}
|
||||
switch op.Kind {
|
||||
case collab.OpInsert, collab.OpSetContent, collab.OpSetTitle:
|
||||
if res.Status == collab.StatusRejected {
|
||||
t.Fatalf("seed %d: text-carrying op %s was rejected: %+v", seed, id, res)
|
||||
}
|
||||
}
|
||||
if op.Kind == collab.OpSetContent && res.Status == collab.StatusApplied && res.Seq > 0 {
|
||||
if e := bySeq[res.Seq]; e.Kind != collab.OpSetContent || e.Content != *op.Content {
|
||||
t.Fatalf("seed %d: applied text of %s not in the log", seed, id)
|
||||
}
|
||||
}
|
||||
if res.Status == collab.StatusConflict {
|
||||
conflicts++
|
||||
text := ""
|
||||
if op.Content != nil {
|
||||
text = *op.Content
|
||||
} else if op.Title != nil {
|
||||
text = *op.Title
|
||||
}
|
||||
if got, ok := inserted[res.ConflictBlockID]; !ok || got != text {
|
||||
t.Fatalf("seed %d: conflict copy of %s missing or wrong", seed, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("seed %d: %d ops, %d log entries, %d conflict copies, %d blocks", seed, len(w.sent), len(log), conflicts, len(server.Blocks))
|
||||
}
|
||||
|
||||
func docsEqual(a, b *collab.Doc) bool {
|
||||
if a.Title != b.Title || a.TitleVersion != b.TitleVersion || len(a.Blocks) != len(b.Blocks) {
|
||||
return false
|
||||
}
|
||||
for id, x := range a.Blocks {
|
||||
y, ok := b.Blocks[id]
|
||||
if !ok || *x != *y {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestSyncSimulationUnreliableNetwork(t *testing.T) {
|
||||
seeds := 25
|
||||
if testing.Short() {
|
||||
seeds = 3
|
||||
}
|
||||
for seed := int64(1); seed <= int64(seeds); seed++ {
|
||||
seed := seed
|
||||
t.Run(fmt.Sprintf("seed-%d", seed), func(t *testing.T) {
|
||||
runSimulation(t, seed, 600)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/collab"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
func newSyncTestStore(t *testing.T) (*Store, *domain.User) {
|
||||
t.Helper()
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "sync.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
u, err := store.CreateUser("owner@example.com", "hash", "Owner", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("user: %v", err)
|
||||
}
|
||||
return store, u
|
||||
}
|
||||
|
||||
func flatBlocks(spec ...[3]interface{}) []domain.DocumentBlock { // {id, level, content}
|
||||
out := make([]domain.DocumentBlock, len(spec))
|
||||
for i, s := range spec {
|
||||
out[i] = domain.DocumentBlock{ID: s[0].(string), Level: s[1].(int), Content: s[2].(string), Type: "bullet"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func outline(blocks []domain.DocumentBlock) [][2]interface{} {
|
||||
out := make([][2]interface{}, len(blocks))
|
||||
for i, b := range blocks {
|
||||
out[i] = [2]interface{}{b.ID, b.Level}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sameOutline(t *testing.T, got []domain.DocumentBlock, want []domain.DocumentBlock) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", outline(got), outline(want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i].ID != want[i].ID || got[i].Level != want[i].Level || got[i].Content != want[i].Content {
|
||||
t.Fatalf("block %d: got %v, want %v", i, outline(got), outline(want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func headSeq(t *testing.T, s *Store, docID string) int64 {
|
||||
t.Helper()
|
||||
_, head, err := s.GetDocOpsSince(docID, 0, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return head
|
||||
}
|
||||
|
||||
func TestLegacySaveRoundTripsThroughTheLog(t *testing.T) {
|
||||
s, u := newSyncTestStore(t)
|
||||
initial := flatBlocks(
|
||||
[3]interface{}{"a", 0, "Projekt"},
|
||||
[3]interface{}{"b", 1, "Aufgabe 1"},
|
||||
[3]interface{}{"c", 2, "Detail"},
|
||||
[3]interface{}{"d", 1, "Aufgabe 2"},
|
||||
[3]interface{}{"e", 0, "Anderes"},
|
||||
)
|
||||
doc, err := s.CreateDocument(u.ID, "", "Notiz", "📄", "", initial, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := s.GetDocumentByID(u.ID, doc.ID)
|
||||
sameOutline(t, got.Blocks, initial)
|
||||
|
||||
// Saving the same content again must not produce any log entries
|
||||
head := headSeq(t, s, doc.ID)
|
||||
if _, err := s.UpdateDocument(u.ID, doc.ID, nil, nil, nil, &initial, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h := headSeq(t, s, doc.ID); h != head {
|
||||
t.Fatalf("unchanged save wrote %d log entries", h-head)
|
||||
}
|
||||
|
||||
// Reorder, outdent, edit and delete in one save
|
||||
changed := flatBlocks(
|
||||
[3]interface{}{"e", 0, "Anderes"},
|
||||
[3]interface{}{"a", 0, "Projekt (neu)"},
|
||||
[3]interface{}{"d", 1, "Aufgabe 2"},
|
||||
[3]interface{}{"c", 1, "Detail"},
|
||||
[3]interface{}{"n", 2, "Neu"},
|
||||
)
|
||||
if _, err := s.UpdateDocument(u.ID, doc.ID, nil, nil, nil, &changed, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = s.GetDocumentByID(u.ID, doc.ID)
|
||||
sameOutline(t, got.Blocks, changed)
|
||||
|
||||
// The log alone must rebuild exactly this state
|
||||
entries, _, err := s.GetDocOpsSince(doc.ID, 0, 10_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replay := collab.NewDoc()
|
||||
for _, e := range entries {
|
||||
replay.ApplyEffect(e.Effect)
|
||||
}
|
||||
sameOutline(t, flatDocumentBlocks(doc.ID, replay), changed)
|
||||
}
|
||||
|
||||
func TestApplyDocOpsIsIdempotent(t *testing.T) {
|
||||
s, u := newSyncTestStore(t)
|
||||
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "📄", "", flatBlocks([3]interface{}{"a", 0, "Start"}), nil, false)
|
||||
a := collab.Actor{UserID: u.ID, ClientID: "tab1", Name: "Owner"}
|
||||
text := "Geändert"
|
||||
op := collab.Op{OpID: "op-1", Kind: collab.OpSetContent, BlockID: "a", BaseVersion: 1, Content: &text}
|
||||
|
||||
first, head1, err := s.ApplyDocOps(doc.ID, a, []collab.Op{op}, nil)
|
||||
if err != nil || first[0].Status != collab.StatusApplied {
|
||||
t.Fatalf("first: %+v %v", first, err)
|
||||
}
|
||||
// Resend after a lost response: same result, nothing applied twice
|
||||
again, head2, err := s.ApplyDocOps(doc.ID, a, []collab.Op{op}, nil)
|
||||
if err != nil || again[0] != first[0] || head2 != head1 {
|
||||
t.Fatalf("retry changed something: %+v head %d -> %d", again, head1, head2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertCannotReuseBlockIDOfAnotherNote(t *testing.T) {
|
||||
s, u := newSyncTestStore(t)
|
||||
other, _ := s.CreateDocument(u.ID, "", "Andere", "📄", "", flatBlocks([3]interface{}{"shared_id", 0, "Fremd"}), nil, false)
|
||||
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "📄", "", nil, nil, false)
|
||||
a := collab.Actor{UserID: u.ID, ClientID: "tab1"}
|
||||
content, root := "Übernahme", ""
|
||||
res, _, err := s.ApplyDocOps(doc.ID, a, []collab.Op{{OpID: "x", Kind: collab.OpInsert, BlockID: "shared_id", ParentID: &root, OrderKey: "a0", Content: &content}}, nil)
|
||||
if err != nil || res[0].Reason != "exists" {
|
||||
t.Fatalf("got %+v %v", res, err)
|
||||
}
|
||||
got, _ := s.GetDocumentByID(u.ID, other.ID)
|
||||
if got.Blocks[0].Content != "Fremd" {
|
||||
t.Fatal("block of another note was modified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocRole(t *testing.T) {
|
||||
s, owner := newSyncTestStore(t)
|
||||
reader, _ := s.CreateUser("reader@example.com", "hash", "Reader", "member")
|
||||
writer, _ := s.CreateUser("writer@example.com", "hash", "Writer", "member")
|
||||
stranger, _ := s.CreateUser("stranger@example.com", "hash", "Stranger", "member")
|
||||
doc, _ := s.CreateDocument(owner.ID, "", "Notiz", "📄", "", nil, nil, false)
|
||||
|
||||
now := domain.NowISO()
|
||||
if _, err := s.db.Exec(`INSERT INTO doc_shares (id, doc_id, user_id, permission, created_at, created_by) VALUES ('s1', ?, ?, 'read', ?, ?), ('s2', ?, ?, 'write', ?, ?)`,
|
||||
doc.ID, reader.ID, now, owner.ID, doc.ID, writer.ID, now, owner.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for user, want := range map[string]string{owner.ID: DocRoleOwner, writer.ID: DocRoleEditor, reader.ID: DocRoleViewer, stranger.ID: ""} {
|
||||
if got := s.DocRole(user, doc.ID); got != want {
|
||||
t.Errorf("role of %s: got %q, want %q", user, got, want)
|
||||
}
|
||||
}
|
||||
if !s.IsDocShared(doc.ID) {
|
||||
t.Error("note with shares must count as shared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillConvertsFlatBlocksToTree(t *testing.T) {
|
||||
s, u := newSyncTestStore(t)
|
||||
doc, _ := s.CreateDocument(u.ID, "", "Alt", "📄", "", nil, nil, false)
|
||||
if _, err := s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, doc.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Rows as written by the old schema: only level and position
|
||||
for i, row := range [][2]interface{}{{"x", 0}, {"y", 1}, {"z", 1}, {"w", 0}} {
|
||||
if _, err := s.db.Exec(`INSERT INTO blocks (id, document_id, content, level, position, type) VALUES (?, ?, ?, ?, ?, 'bullet')`,
|
||||
row[0], doc.ID, row[0], row[1], (i+1)*1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s.backfillBlockTree()
|
||||
got, _ := s.GetDocumentByID(u.ID, doc.ID)
|
||||
want := flatBlocks([3]interface{}{"x", 0, "x"}, [3]interface{}{"y", 1, "y"}, [3]interface{}{"z", 1, "z"}, [3]interface{}{"w", 0, "w"})
|
||||
sameOutline(t, got.Blocks, want)
|
||||
}
|
||||
+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
|
||||
|
||||
@@ -574,7 +574,7 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
_, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "write")
|
||||
_, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "read")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user