Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

feat(db): add AppendSteps, GetStepsSince, GetDocVersion for step log

+149
+68
internal/db/db.go
··· 180 180 _, err := db.Exec(`DELETE FROM invites WHERE token = ?`, token) 181 181 return err 182 182 } 183 + 184 + // --- Document Steps (prosemirror-collab) --- 185 + 186 + type StepRow struct { 187 + Version int 188 + JSON string 189 + } 190 + 191 + func (db *DB) GetDocVersion(docRKey string) (int, error) { 192 + var v int 193 + err := db.QueryRow( 194 + `SELECT COALESCE(MAX(version), 0) FROM doc_steps WHERE doc_rkey = ?`, docRKey, 195 + ).Scan(&v) 196 + if err != nil { 197 + return 0, fmt.Errorf("GetDocVersion: %w", err) 198 + } 199 + return v, nil 200 + } 201 + 202 + func (db *DB) AppendSteps(docRKey string, clientVersion int, stepsJSON []string, clientID string) (int, error) { 203 + tx, err := db.Begin() 204 + if err != nil { 205 + return 0, fmt.Errorf("AppendSteps begin: %w", err) 206 + } 207 + defer tx.Rollback() 208 + 209 + var current int 210 + tx.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM doc_steps WHERE doc_rkey = ?`, docRKey).Scan(&current) 211 + if current != clientVersion { 212 + return 0, fmt.Errorf("version conflict: server=%d client=%d", current, clientVersion) 213 + } 214 + 215 + for i, stepJSON := range stepsJSON { 216 + version := clientVersion + i + 1 217 + _, err := tx.Exec( 218 + `INSERT INTO doc_steps (doc_rkey, version, step_json, client_id) VALUES (?, ?, ?, ?)`, 219 + docRKey, version, stepJSON, clientID, 220 + ) 221 + if err != nil { 222 + return 0, fmt.Errorf("AppendSteps insert v%d: %w", version, err) 223 + } 224 + } 225 + 226 + if err := tx.Commit(); err != nil { 227 + return 0, fmt.Errorf("AppendSteps commit: %w", err) 228 + } 229 + return clientVersion + len(stepsJSON), nil 230 + } 231 + 232 + func (db *DB) GetStepsSince(docRKey string, sinceVersion int) ([]StepRow, error) { 233 + rows, err := db.Query( 234 + `SELECT version, step_json FROM doc_steps WHERE doc_rkey = ? AND version > ? ORDER BY version ASC`, 235 + docRKey, sinceVersion, 236 + ) 237 + if err != nil { 238 + return nil, fmt.Errorf("GetStepsSince: %w", err) 239 + } 240 + defer rows.Close() 241 + var result []StepRow 242 + for rows.Next() { 243 + var r StepRow 244 + if err := rows.Scan(&r.Version, &r.JSON); err != nil { 245 + return nil, err 246 + } 247 + result = append(result, r) 248 + } 249 + return result, rows.Err() 250 + }
+81
internal/db/db_steps_test.go
··· 1 + package db_test 2 + 3 + import ( 4 + "os" 5 + "testing" 6 + 7 + "github.com/limeleaf/diffdown/internal/db" 8 + ) 9 + 10 + func openTestDB(t *testing.T) *db.DB { 11 + t.Helper() 12 + f, err := os.CreateTemp("", "diffdown-test-*.db") 13 + if err != nil { 14 + t.Fatal(err) 15 + } 16 + t.Cleanup(func() { os.Remove(f.Name()) }) 17 + f.Close() 18 + 19 + database, err := db.Open(f.Name()) 20 + if err != nil { 21 + t.Fatal(err) 22 + } 23 + db.SetMigrationsDir("../../migrations") 24 + if err := database.Migrate(); err != nil { 25 + t.Fatal(err) 26 + } 27 + return database 28 + } 29 + 30 + func TestGetDocVersion_Empty(t *testing.T) { 31 + d := openTestDB(t) 32 + v, err := d.GetDocVersion("rkey1") 33 + if err != nil { 34 + t.Fatal(err) 35 + } 36 + if v != 0 { 37 + t.Errorf("expected version 0 for new doc, got %d", v) 38 + } 39 + } 40 + 41 + func TestAppendSteps_IncreasesVersion(t *testing.T) { 42 + d := openTestDB(t) 43 + steps := []string{`{"stepType":"replace"}`, `{"stepType":"replace"}`} 44 + newVersion, err := d.AppendSteps("rkey1", 0, steps, "client-a") 45 + if err != nil { 46 + t.Fatal(err) 47 + } 48 + if newVersion != 2 { 49 + t.Errorf("expected version 2, got %d", newVersion) 50 + } 51 + } 52 + 53 + func TestAppendSteps_VersionConflict(t *testing.T) { 54 + d := openTestDB(t) 55 + _, err := d.AppendSteps("rkey1", 0, []string{`{}`}, "client-a") 56 + if err != nil { 57 + t.Fatal(err) 58 + } 59 + // Submit again from clientVersion=0 (stale) — must fail. 60 + _, err = d.AppendSteps("rkey1", 0, []string{`{}`}, "client-b") 61 + if err == nil { 62 + t.Fatal("expected conflict error, got nil") 63 + } 64 + } 65 + 66 + func TestGetStepsSince(t *testing.T) { 67 + d := openTestDB(t) 68 + steps := []string{`{"a":1}`, `{"a":2}`, `{"a":3}`} 69 + d.AppendSteps("rkey1", 0, steps, "client-a") 70 + 71 + rows, err := d.GetStepsSince("rkey1", 1) 72 + if err != nil { 73 + t.Fatal(err) 74 + } 75 + if len(rows) != 2 { 76 + t.Errorf("expected 2 steps since v1, got %d", len(rows)) 77 + } 78 + if rows[0].JSON != `{"a":2}` { 79 + t.Errorf("unexpected step: %s", rows[0].JSON) 80 + } 81 + }