The redline pipeline
From "change clause 9" to native Word tracked changes — proposal, review, and the accept that bakes the edit into the canonical .docx.
dochaus/lib/redlines.ts109 lines · recordRedline L14–52
Outline 5 symbols
- recordRedline function export
- PendingRedline type export
- pendingRedlinesForDoc function export
- conflictingRedlines function export
- supersedeRedlines function export
1import { Database } from "bun:sqlite"
2import { existsSync, mkdirSync } from "node:fs"
3import path from "node:path"
4
5// Record and read redline proposals in the matter's index DB. The redline tools
6// propose changes here instead of writing tracked changes into the .docx; the
7// ingest service reads these rows to render the redlined view and to accept
8// (bake) or reject them. The canonical .docx stays clean until a reviewer accepts.
9//
10// Ingest owns this database and creates the same `redlines` table in its openDb;
11// keep this DDL in sync with services/ingest/src/db.ts. We create-if-not-exists
12// here too so a tool can propose before the document is ever (re-)ingested.
13
14export function recordRedline(
15 matterDir: string,
16 row: {
17 docPath: string
18 docName: string
19 scope: "phrase" | "clause"
20 findText: string
21 oldText: string
22 newText: string
23 author: string
24 anchorId: string
25 },
26) {
27 const dir = path.join(matterDir, ".dochaus")
28 mkdirSync(dir, { recursive: true })
29 const db = new Database(path.join(dir, "legal.db"))
30 db.run("PRAGMA journal_mode = WAL")
31 db.run(`
32 CREATE TABLE IF NOT EXISTS redlines (
33 id INTEGER PRIMARY KEY AUTOINCREMENT,
34 doc_path TEXT NOT NULL,
35 doc_name TEXT NOT NULL,
36 scope TEXT NOT NULL,
37 find_text TEXT NOT NULL,
38 old_text TEXT NOT NULL,
39 new_text TEXT NOT NULL,
40 author TEXT NOT NULL,
41 anchor_id TEXT,
42 status TEXT NOT NULL DEFAULT 'pending',
43 created_at INTEGER NOT NULL
44 )
45 `)
46 const result = db.run(
47 "INSERT INTO redlines (doc_path, doc_name, scope, find_text, old_text, new_text, author, anchor_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
48 [row.docPath, row.docName, row.scope, row.findText, row.oldText, row.newText, row.author, row.anchorId, Date.now()],
49 )
50 db.close()
51 return Number(result.lastInsertRowid)
52}
53
54// A pending proposal, as the propose-time conflict check sees it. anchor_id is the
55// Docxodus block token the proposal targets; the canonical .docx stays clean while
56// proposals are pending, so two proposals carrying the same anchor_id address the
57// same paragraph and the replay would collide.
58export type PendingRedline = {
59 id: number
60 scope: "phrase" | "clause"
61 find_text: string
62 new_text: string
63 author: string
64 anchor_id: string | null
65}
66
67export function pendingRedlinesForDoc(matterDir: string, docPath: string): PendingRedline[] {
68 const dbFile = path.join(matterDir, ".dochaus", "legal.db")
69 if (!existsSync(dbFile)) return []
70 const db = new Database(dbFile, { readonly: true })
71 const rows = db
72 .query(
73 "SELECT id, scope, find_text, new_text, author, anchor_id FROM redlines WHERE doc_path = ? AND status = 'pending' ORDER BY created_at, id",
74 )
75 .all(docPath) as PendingRedline[]
76 db.close()
77 return rows
78}
79
80// Pending proposals that collide with the one about to be recorded. Two proposals
81// collide when they target the same paragraph (same anchor_id) AND either rewrites
82// the whole clause — a clause rewrite replaces the entire paragraph, so it voids
83// every other edit on it — or their find texts overlap (one contains the other),
84// so the replay of one erases the text the other anchors to. Independent phrase
85// edits in the same paragraph do not collide and coexist.
86export function conflictingRedlines(
87 pending: PendingRedline[],
88 next: { anchorId: string; scope: "phrase" | "clause"; findText: string },
89) {
90 return pending.filter((p) => {
91 if (p.anchor_id !== next.anchorId) return false
92 if (p.scope === "clause" || next.scope === "clause") return true
93 const existing = p.find_text.trim()
94 const incoming = next.findText.trim()
95 return existing.includes(incoming) || incoming.includes(existing)
96 })
97}
98
99// Retire superseded proposals so only the newest edit on a paragraph stays pending.
100// A separate 'superseded' status (not 'rejected') records that the reviewer never
101// declined the edit — a later proposal replaced it — without surfacing it in the
102// pending queue or the redlined view.
103export function supersedeRedlines(matterDir: string, ids: number[]) {
104 if (!ids.length) return
105 const db = new Database(path.join(matterDir, ".dochaus", "legal.db"))
106 for (const id of ids) db.run("UPDATE redlines SET status = 'superseded' WHERE id = ?", [id])
107 db.close()
108}
109