feat(workspace): tabula-rasa outliner architecture, SQLite blocks/block_tags schema & echo-protected live room sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
This commit is contained in:
1 parent
f3b3f07e04
commit
86867b0d6f
17 files changed
+3409
-7178
No files matched your search
+185
-175
@@ -162,9 +162,11 @@ func (s *Store) migrate() error {
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
space_id TEXT DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
folder_id TEXT NOT NULL DEFAULT '',
|
||||
markdown_cache TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT NOT NULL DEFAULT '📄',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
blocks_json TEXT NOT NULL DEFAULT '[]',
|
||||
@@ -172,13 +174,40 @@ func (s *Store) migrate() error {
|
||||
folder_path TEXT NOT NULL DEFAULT '',
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
||||
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
level INT NOT NULL DEFAULT 0,
|
||||
position REAL NOT NULL DEFAULT 0,
|
||||
type TEXT NOT NULL DEFAULT 'bullet',
|
||||
checked INTEGER NOT NULL DEFAULT 0,
|
||||
collapsed INTEGER NOT NULL DEFAULT 0,
|
||||
lang TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_doc_pos ON blocks(document_id, position ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS block_tags (
|
||||
tag TEXT NOT NULL,
|
||||
document_id TEXT NOT NULL,
|
||||
block_id TEXT NOT NULL,
|
||||
PRIMARY KEY(tag, block_id),
|
||||
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS doc_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
doc_id TEXT NOT NULL,
|
||||
@@ -208,22 +237,6 @@ func (s *Store) migrate() error {
|
||||
INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at)
|
||||
SELECT user_id, id, updated_at FROM documents WHERE is_pinned = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS doc_block_tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
tag TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL,
|
||||
block_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(doc_id, block_id, tag),
|
||||
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_tag ON doc_block_tags(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_user_tag ON doc_block_tags(user_id, tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_doc ON doc_block_tags(doc_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tag_pages (
|
||||
user_id TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
@@ -232,25 +245,6 @@ 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,
|
||||
@@ -382,17 +376,28 @@ func (s *Store) migrate() error {
|
||||
"CREATE INDEX IF NOT EXISTS idx_workspace_folders_user ON workspace_folders(user_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_workspace_folders_sf ON workspace_folders(shared_folder_id);",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS document_blocks (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, sort_order REAL NOT NULL DEFAULT 0, type TEXT NOT NULL DEFAULT 'bullet', content TEXT NOT NULL DEFAULT '', level INTEGER NOT NULL DEFAULT 0, checked INTEGER NOT NULL DEFAULT 0, collapsed INTEGER NOT NULL DEFAULT 0, lang TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL, updated_by TEXT NOT NULL DEFAULT '', FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_document_blocks_doc_order ON document_blocks(doc_id, sort_order);",
|
||||
"ALTER TABLE documents ADD COLUMN folder_id TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE documents ADD COLUMN markdown_cache TEXT NOT NULL DEFAULT '';",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS blocks (id TEXT PRIMARY KEY, document_id TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', level INT NOT NULL DEFAULT 0, position REAL NOT NULL DEFAULT 0, type TEXT NOT NULL DEFAULT 'bullet', checked INTEGER NOT NULL DEFAULT 0, collapsed INTEGER NOT NULL DEFAULT 0, lang TEXT NOT NULL DEFAULT '', FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_blocks_doc_pos ON blocks(document_id, position ASC);",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS block_tags (tag TEXT NOT NULL, document_id TEXT NOT NULL, block_id TEXT NOT NULL, PRIMARY KEY(tag, block_id), FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE, FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);",
|
||||
}
|
||||
for _, q := range columnMigrations {
|
||||
_, _ = s.db.Exec(q)
|
||||
}
|
||||
|
||||
// Clean up legacy space_id from documents and task_lists if empty or orphaned
|
||||
// Clean up legacy space and old document block/crdt tables
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_state`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_updates`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_blocks`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS doc_block_tags`)
|
||||
|
||||
// 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`)
|
||||
@@ -445,7 +450,7 @@ func (s *Store) migrate() error {
|
||||
|
||||
// Reindex block tags if table is newly added or empty
|
||||
var tagCount int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM doc_block_tags`).Scan(&tagCount)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM block_tags`).Scan(&tagCount)
|
||||
if tagCount == 0 {
|
||||
_ = s.reindexAllDocBlockTags()
|
||||
}
|
||||
@@ -1269,7 +1274,7 @@ func (s *Store) migrateWorkspaceToPureDB() {
|
||||
|
||||
for _, md := range docsToMigrate {
|
||||
var count int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM document_blocks WHERE doc_id = ?`, md.id).Scan(&count)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM blocks WHERE document_id = ?`, md.id).Scan(&count)
|
||||
if count == 0 && md.blocksJSON != "" && md.blocksJSON != "[]" {
|
||||
var blocks []domain.DocumentBlock
|
||||
if err := json.Unmarshal([]byte(md.blocksJSON), &blocks); err == nil && len(blocks) > 0 {
|
||||
@@ -1327,17 +1332,17 @@ func (s *Store) migrateWorkspaceToPureDB() {
|
||||
|
||||
func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, type, content, level, checked, collapsed, lang
|
||||
FROM document_blocks
|
||||
WHERE doc_id = ?
|
||||
ORDER BY sort_order ASC, rowid ASC
|
||||
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.Type, &b.Content, &b.Level, &chk, &col, &b.Lang); scanErr == nil {
|
||||
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)
|
||||
@@ -1359,50 +1364,111 @@ func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.Docume
|
||||
return blocks
|
||||
}
|
||||
|
||||
func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.DocumentBlock, now string) {
|
||||
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, docID)
|
||||
if len(blocks) == 0 {
|
||||
return
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
stmt, err := s.db.Prepare(`
|
||||
INSERT INTO document_blocks (id, doc_id, sort_order, type, content, level, checked, collapsed, lang, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
doc_id = excluded.doc_id,
|
||||
sort_order = excluded.sort_order,
|
||||
type = excluded.type,
|
||||
content = excluded.content,
|
||||
level = excluded.level,
|
||||
checked = excluded.checked,
|
||||
collapsed = excluded.collapsed,
|
||||
lang = excluded.lang,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by
|
||||
`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
_, _ = stmt.Exec(bID, docID, float64((i+1)*1000), bType, b.Content, b.Level, chk, col, b.Lang, now, userID)
|
||||
// Update markdown_cache on documents
|
||||
mdCache := domain.CompileBlocksToMarkdown(blocks)
|
||||
_, _ = s.db.Exec(`UPDATE documents SET markdown_cache = ?, updated_at = ? WHERE id = ?`, mdCache, now, docID)
|
||||
}
|
||||
|
||||
func (s *Store) LoadDocumentState(docID string) (string, []domain.DocumentBlock, error) {
|
||||
var title, blocksJSON string
|
||||
err := s.db.QueryRow(`SELECT title, blocks_json FROM documents WHERE id = ?`, docID).Scan(&title, &blocksJSON)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
blocks := s.loadDocBlocks(docID, blocksJSON)
|
||||
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`.
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE documents
|
||||
SET markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, mdCache, plainContent, string(blocksJSON), now, docID)
|
||||
return err
|
||||
}
|
||||
|
||||
// PatchDocumentBlocks applies atomic block-level upserts, deletions, and order reconciliation without overwriting concurrent edits from other sessions.
|
||||
@@ -1417,16 +1483,16 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
|
||||
now := domain.NowISO()
|
||||
|
||||
// Ensure document_blocks is seeded if previously only in blocks_json
|
||||
// Ensure blocks table is seeded if previously only in blocks_json
|
||||
var existingCount int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM document_blocks WHERE doc_id = ?`, docID).Scan(&existingCount)
|
||||
_ = 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 document_blocks WHERE doc_id = ? ORDER BY sort_order ASC, rowid ASC`, docID)
|
||||
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
|
||||
@@ -1445,7 +1511,7 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
continue
|
||||
}
|
||||
deletedSet[delID] = true
|
||||
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ? AND id = ?`, docID, delID)
|
||||
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ? AND id = ?`, docID, delID)
|
||||
}
|
||||
|
||||
// Filter existingOrder to exclude deleted blocks
|
||||
@@ -1479,18 +1545,16 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
}
|
||||
defaultSort := float64((len(survivingExisting) + idx + 1) * 1000)
|
||||
_, _ = s.db.Exec(`
|
||||
INSERT INTO document_blocks (id, doc_id, sort_order, type, content, level, checked, collapsed, lang, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by
|
||||
`, bID, docID, defaultSort, bType, b.Content, b.Level, chk, col, b.Lang, now, userID)
|
||||
lang = excluded.lang
|
||||
`, bID, docID, b.Content, b.Level, defaultSort, bType, chk, col, b.Lang)
|
||||
upsertedIDs = append(upsertedIDs, bID)
|
||||
survivingSet[bID] = true
|
||||
}
|
||||
@@ -1506,8 +1570,6 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
inMerged[bid] = true
|
||||
}
|
||||
}
|
||||
// Any surviving block in DB that was not in req.BlockOrder (e.g. concurrently added by another user)
|
||||
// is inserted right after its predecessor from survivingExisting!
|
||||
for i, bid := range survivingExisting {
|
||||
if inMerged[bid] {
|
||||
continue
|
||||
@@ -1528,7 +1590,6 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
mergedOrder = append(mergedOrder[:insertPos], append([]string{bid}, mergedOrder[insertPos:]...)...)
|
||||
inMerged[bid] = true
|
||||
}
|
||||
// Ensure any upserted block not in BlockOrder is also appended
|
||||
for _, bid := range upsertedIDs {
|
||||
if !inMerged[bid] {
|
||||
mergedOrder = append(mergedOrder, bid)
|
||||
@@ -1550,9 +1611,9 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
}
|
||||
}
|
||||
|
||||
// Update sort_order in document_blocks
|
||||
// Update position in blocks
|
||||
for idx, bid := range mergedOrder {
|
||||
_, _ = s.db.Exec(`UPDATE document_blocks SET sort_order = ? WHERE doc_id = ? AND id = ?`, float64((idx+1)*1000), docID, bid)
|
||||
_, _ = s.db.Exec(`UPDATE blocks SET position = ? WHERE document_id = ? AND id = ?`, float64((idx+1)*1000), docID, bid)
|
||||
}
|
||||
|
||||
// Reload authoritative block list and update document snapshot
|
||||
@@ -1565,12 +1626,14 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
}
|
||||
}
|
||||
plainContent := plainBuilder.String()
|
||||
mdCache := domain.CompileBlocksToMarkdown(finalBlocks)
|
||||
blocksJSON, _ := json.Marshal(finalBlocks)
|
||||
|
||||
_, _ = s.db.Exec(`UPDATE documents SET content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`,
|
||||
plainContent, string(blocksJSON), now, docID)
|
||||
_, _ = 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)
|
||||
@@ -1578,59 +1641,6 @@ 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)
|
||||
@@ -1964,6 +1974,8 @@ func (s *Store) scanDocumentWithBlocks(rows interface {
|
||||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||||
d.Tags = stripFolderTags(d.Tags)
|
||||
d.Blocks = s.loadDocBlocks(d.ID, blocksStr)
|
||||
d.MarkdownCache = domain.CompileBlocksToMarkdown(d.Blocks)
|
||||
d.FolderID = d.FolderPath
|
||||
return d, nil
|
||||
}
|
||||
|
||||
@@ -2303,8 +2315,8 @@ func (s *Store) DeleteDocument(userID, docID string) error {
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
|
||||
return s.DeleteDocShare(userID, docID, userID)
|
||||
}
|
||||
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, docID)
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID)
|
||||
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, docID)
|
||||
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID)
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, docID)
|
||||
_, err = s.db.Exec(`DELETE FROM documents WHERE id = ?`, docID)
|
||||
return err
|
||||
@@ -2338,27 +2350,25 @@ func (s *Store) reindexAllDocBlockTags() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) indexDocBlockTags(docID, userID string, blocks []domain.DocumentBlock) error {
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID)
|
||||
func (s *Store) indexDocBlockTags(docID, _ string, blocks []domain.DocumentBlock) error {
|
||||
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID)
|
||||
if len(blocks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO doc_block_tags (id, tag, user_id, doc_id, block_id, created_at) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO block_tags (tag, document_id, block_id) VALUES (?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
now := domain.NowISO()
|
||||
for _, b := range blocks {
|
||||
if b.Content == "" {
|
||||
continue
|
||||
}
|
||||
tags := domain.ExtractTags(b.Content)
|
||||
for _, tag := range tags {
|
||||
tagID := domain.NewID("dbtag")
|
||||
_, _ = stmt.Exec(tagID, tag, userID, docID, b.ID, now)
|
||||
_, _ = stmt.Exec(tag, docID, b.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -2384,9 +2394,9 @@ func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) {
|
||||
|
||||
func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT t.tag, COUNT(DISTINCT t.doc_id || ':' || t.block_id) as count
|
||||
FROM doc_block_tags t
|
||||
JOIN documents d ON t.doc_id = d.id
|
||||
`SELECT t.tag, COUNT(DISTINCT t.document_id || ':' || t.block_id) as count
|
||||
FROM block_tags t
|
||||
JOIN documents d ON t.document_id = d.id
|
||||
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
||||
LEFT JOIN (
|
||||
SELECT dts2.doc_id
|
||||
@@ -2451,8 +2461,8 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
|
||||
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
|
||||
COALESCE(u.display_name, '') as owner_name,
|
||||
COALESCE(s.permission, dts.permission, '') as shared_permission
|
||||
FROM doc_block_tags t
|
||||
JOIN documents d ON t.doc_id = d.id
|
||||
FROM block_tags t
|
||||
JOIN documents d ON t.document_id = d.id
|
||||
LEFT JOIN users u ON d.user_id = u.id
|
||||
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
||||
LEFT JOIN (
|
||||
@@ -2768,8 +2778,8 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
|
||||
|
||||
// Delete documents from SQLite, blocks, pins, shares, tags, user_folders
|
||||
for _, id := range deletedDocIDs {
|
||||
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ?`, id)
|
||||
|
||||
@@ -683,9 +683,9 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentCRDTState(t *testing.T) {
|
||||
func TestFlushDocumentStateAndSchema(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "crdt_test.db")
|
||||
dbPath := filepath.Join(tmpDir, "tabula_rasa_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
@@ -693,52 +693,60 @@ func TestDocumentCRDTState(t *testing.T) {
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
user, err := store.CreateUser("crdt@example.com", "hash", "CRDT User", "member")
|
||||
user, err := store.CreateUser("outliner@example.com", "hash", "Outliner User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
|
||||
doc, err := store.CreateDocument(user.ID, "", "CRDT Test Doc", "📝", "", nil, nil, false)
|
||||
doc, err := store.CreateDocument(user.ID, "", "Initial Note", "📝", "Projekte", 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)
|
||||
blocks := []domain.DocumentBlock{
|
||||
{
|
||||
ID: "blk_root_1",
|
||||
Type: "text",
|
||||
Content: "Architektur Übersicht #backend #go",
|
||||
Level: 0,
|
||||
},
|
||||
{
|
||||
ID: "blk_child_1",
|
||||
Type: "todo",
|
||||
Content: "SQLite WAL Modus aktivieren #sqlite",
|
||||
Level: 1,
|
||||
Checked: true,
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.FlushDocumentState(doc.ID, "Architektur Notiz", blocks); err != nil {
|
||||
t.Fatalf("FlushDocumentState failed: %v", err)
|
||||
}
|
||||
|
||||
updatedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentCRDTState failed: %v", err)
|
||||
t.Fatalf("GetDocumentByID 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))
|
||||
if updatedDoc.Title != "Architektur Notiz" {
|
||||
t.Fatalf("expected title 'Architektur Notiz', got '%s'", updatedDoc.Title)
|
||||
}
|
||||
if len(updatedDoc.Blocks) != 2 {
|
||||
t.Fatalf("expected 2 blocks, got %d", len(updatedDoc.Blocks))
|
||||
}
|
||||
if updatedDoc.Blocks[1].Level != 1 || updatedDoc.Blocks[1].Position != 2000 {
|
||||
t.Fatalf("expected block[1] level=1 position=2000, got level=%d position=%v", updatedDoc.Blocks[1].Level, updatedDoc.Blocks[1].Position)
|
||||
}
|
||||
if updatedDoc.MarkdownCache == "" {
|
||||
t.Fatalf("expected markdown_cache to be populated")
|
||||
}
|
||||
|
||||
// 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)
|
||||
tags, err := store.GetWorkspaceTags(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentCRDTState after compact failed: %v", err)
|
||||
t.Fatalf("GetWorkspaceTags 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))
|
||||
if len(tags) != 3 {
|
||||
t.Fatalf("expected 3 extracted tags in block_tags (#backend, #go, #sqlite), got %d: %+v", len(tags), tags)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user