AtlatestRepositoryfolio

folio / tree / testtest-backlink-review.sgl

1(import (sigil test)
2 (sigil async)
3 (sigil fs)
4 (sigil path)
5 (sigil sqlite)
6 (sigil string)
7 (sigil time)
8 (sigil log)
9 (sigil io)
10 (folio store)
11 (folio note)
12 (folio index))
14;; Hold the shared writer lock until the census has taken its file listing
15;; and reached SQL work, then materialize a file before removal can acquire it.
16(define (restore-during-census! idx blocker restore!)
17 (let ((db (folio-index-db idx))
18 (overlap #f))
19 (assert-true (sqlite-exec blocker "BEGIN IMMEDIATE"))
20 (with-async
21 (go (index-reconcile! idx))
22 (go
23 (let ((deadline (+ (current-second) 2)))
24 (let wait ()
25 (cond
26 ((sqlite-connection-locked? db) (set! overlap #t))
27 ((< (current-second) deadline) (sleep 0.001) (wait)))))
28 (sleep 0.02)
29 (restore!)
30 (sqlite-exec blocker "ROLLBACK")))
31 (assert-true overlap)))
33(test-group "shared backlink maintenance"
34 (test "a note recreated after listing keeps metadata, search and links"
35 (call-with-temp-directory
36 (lambda (root)
37 (let* ((store (make-folio-store root))
38 (body "# Arrived\npreservedword [[target]]\n")
39 (path (note-create! store "arrived" '() body))
40 (idx (make-folio-index store))
41 (db (folio-index-db idx))
42 (blocker (sqlite-open (path-join root ".folio-index.db"))))
43 (index-reconcile! idx)
44 (delete-file path)
45 (restore-during-census! idx blocker
46 (lambda () (note-create! store "arrived" '() body)))
47 ;; Check immediately, before another census can repair lost rows.
48 (assert-equal 1
49 (length (sqlite-query db "SELECT name FROM notes_meta WHERE name = 'arrived'")))
50 (assert-equal 1 (length (search-index idx "preservedword" #f #f)))
51 (assert-equal '(("arrived" . note)) (index-backlinks idx "target"))
52 (sqlite-close blocker)
53 (sqlite-close db)))))
55 (test "a project renamed into place after listing keeps its links"
56 (call-with-temp-directory
57 (lambda (root)
58 (let* ((store (make-folio-store root))
59 (path (path-join (store-project-dir store) "arrived.md"))
60 (parked (path-join root "parked.md"))
61 (idx (make-folio-index store))
62 (db (folio-index-db idx))
63 (blocker (sqlite-open (path-join root ".folio-index.db"))))
64 (write-file-string path "# Arrived\n[[target]]\n")
65 (index-reconcile! idx)
66 (rename-file path parked)
67 (restore-during-census! idx blocker (lambda () (rename-file parked path)))
68 (assert-equal 1
69 (length (sqlite-query db "SELECT path FROM link_sources WHERE path = ?" path)))
70 (assert-equal '(("arrived" . project)) (index-backlinks idx "target"))
71 (sqlite-close blocker)
72 (sqlite-close db)))))
74 (test "a retryable failure is forgotten when both file and pending work vanish"
75 (call-with-temp-directory
76 (lambda (root)
77 (let* ((store (make-folio-store root))
78 (path (note-create! store "vanishing" '() "[[target]]"))
79 (idx (make-folio-index store))
80 (blocker (sqlite-open (path-join root ".folio-index.db")))
81 (budget (sqlite-busy-budget))
82 (failed #f))
83 (assert-true (sqlite-exec blocker "BEGIN IMMEDIATE"))
84 (sqlite-set-busy-budget! 0.05)
85 (guard (exn (else (set! failed #t))) (index-migrate! idx))
86 (assert-true failed)
87 (delete-file path)
88 (sqlite-exec blocker "ROLLBACK")
89 (sqlite-set-busy-budget! budget)
90 (assert-equal 0 (index-migrate! idx))
91 (sqlite-close blocker)
92 (sqlite-close (folio-index-db idx))))))
94 (test "opening the previous schema migrates unchanged note versions"
95 (call-with-temp-directory
96 (lambda (root)
97 (let* ((store (make-folio-store root))
98 (path (note-create! store "source" '() "[[target]]"))
99 (old (make-folio-index store))
100 (db (folio-index-db old)))
101 (index-reconcile! old)
102 ;; The previous release has FTS/meta/versions, but no link tables.
103 (assert-true (sqlite-exec db "DROP TABLE note_links"))
104 (assert-true (sqlite-exec db "DROP TABLE link_sources"))
105 (sqlite-close db)
106 (let ((upgraded (make-folio-index store)))
107 (assert-equal 1 (index-migrate! upgraded))
108 (assert-equal '(("source" . note)) (index-backlinks upgraded "target"))
109 (index-reconcile! upgraded)
110 (assert-equal '(("source" . note)) (index-backlinks upgraded "target"))
111 (sqlite-close (folio-index-db upgraded)))))))
113 (test "stale process cache adopts another process's refresh without replacement"
114 (call-with-temp-directory
115 (lambda (root)
116 (let* ((store (make-folio-store root))
117 (path (note-create! store "source" '() "[[before]]"))
118 (first (make-folio-index store)))
119 (index-reconcile! first)
120 (let* ((second (make-folio-index store))
121 (db (folio-index-db first)))
122 (assert-true (sqlite-exec db "CREATE TABLE refresh_count (n INTEGER)"))
123 (assert-true
124 (sqlite-exec db "CREATE TRIGGER count_refresh AFTER INSERT ON notes_versions
125 BEGIN INSERT INTO refresh_count VALUES (1); END"))
126 (write-file-string path "# Source\n[[after]]\n")
127 (index-reconcile! first)
128 (index-reconcile! second)
129 (assert-equal 1
130 (cdr (assoc 'n (car (sqlite-query db "SELECT count(*) AS n FROM refresh_count")))))
131 (assert-equal '(("source" . note)) (index-backlinks second "after"))
132 (sqlite-close (folio-index-db second))
133 (sqlite-close db))))))
135 (test "dangling project is remembered without starving later projects"
136 (call-with-temp-directory
137 (lambda (root)
138 (let* ((store (make-folio-store root))
139 (bad (path-join (store-project-dir store) "a-bad.md"))
140 (good (path-join (store-project-dir store) "z-good.md"))
141 (idx (make-folio-index store))
142 (output (open-output-string)))
143 (make-symlink (path-join root "missing") bad)
144 (write-file-string good "# Good\n[[target]]\n")
145 (log-configure! target: output level: 'warn)
146 (index-reconcile! idx)
147 (let ((first-log (get-output-string output)))
148 (assert-true (string-contains? first-log "a-bad.md"))
149 (index-reconcile! idx)
150 (assert-equal first-log (get-output-string output)))
151 (log-configure! target: 'console level: 'info)
152 (assert-equal '(("z-good" . project)) (index-backlinks idx "target"))
153 (delete-file bad)
154 (index-reconcile! idx)
155 (assert-equal 0 (index-migrate! idx))
156 (sqlite-close (folio-index-db idx))))))
158 (test "a busy removal is retryable while reads and later census work survive"
159 (call-with-temp-directory
160 (lambda (root)
161 (let* ((store (make-folio-store root))
162 (source (note-create! store "source" '() "[[target]]"))
163 (removed (note-create! store "removed" '() "[[target]]"))
164 (idx (make-folio-index store))
165 (blocker (sqlite-open (path-join root ".folio-index.db")))
166 (budget (sqlite-busy-budget)))
167 (index-reconcile! idx)
168 (delete-file removed)
169 (assert-true (sqlite-exec blocker "BEGIN IMMEDIATE"))
170 (sqlite-set-busy-budget! 0.05)
171 (index-reconcile! idx)
172 (assert-equal '(("source" . note)) (index-backlinks idx "target"))
173 (assert-true (sqlite-exec blocker "ROLLBACK"))
174 (sqlite-set-busy-budget! budget)
175 (index-reconcile! idx)
176 (assert-equal 0
177 (cdr (assoc 'n (car (sqlite-query (folio-index-db idx)
178 "SELECT count(*) AS n FROM notes_meta WHERE name = 'removed'")))))
179 (sqlite-close blocker)
180 (sqlite-close (folio-index-db idx))))))
182 (test "a busy removal does not inherit an earlier permanent read failure"
183 (call-with-temp-directory
184 (lambda (root)
185 (let* ((store (make-folio-store root))
186 (path (path-join (store-project-dir store) "project.md"))
187 (idx (make-folio-index store))
188 (blocker (sqlite-open (path-join root ".folio-index.db")))
189 (budget (sqlite-busy-budget)))
190 (write-file-string path "# Project\n[[target]]\n")
191 (index-reconcile! idx)
192 (delete-file path)
193 (make-symlink (path-join root "missing") path)
194 (index-reconcile! idx)
195 (delete-file path)
196 (assert-true (sqlite-exec blocker "BEGIN IMMEDIATE"))
197 (sqlite-set-busy-budget! 0.05)
198 (let ((failed #f))
199 (guard (exn (else (set! failed #t))) (index-migrate! idx))
200 (assert-true failed))
201 (sqlite-exec blocker "ROLLBACK")
202 (sqlite-set-busy-budget! budget)
203 (assert-equal 0 (index-migrate! idx))
204 (sqlite-close blocker)
205 (sqlite-close (folio-index-db idx))))))
207 (test "a project archived after listing cannot abort the census"
208 (call-with-temp-directory
209 (lambda (root)
210 (let* ((store (make-folio-store root))
211 (victim (path-join (store-project-dir store) "a-victim.md"))
212 (later (path-join (store-project-dir store) "z-later.md"))
213 (idx (make-folio-index store))
214 (db (folio-index-db idx))
215 (blocker (sqlite-open (path-join root ".folio-index.db")))
216 (overlap #f))
217 (write-file-string victim "# Victim\n[[target]]\n")
218 (write-file-string later "# Later\n[[target]]\n")
219 (assert-true (sqlite-exec blocker "BEGIN IMMEDIATE"))
220 (with-async
221 (go (index-reconcile! idx))
222 (go
223 (let ((deadline (+ (current-second) 2)))
224 (let wait ()
225 (cond
226 ((sqlite-connection-locked? db) (set! overlap #t))
227 ((< (current-second) deadline) (sleep 0.001) (wait)))))
228 (rename-file victim (path-join (store-archive-project-dir store) "a-victim.md"))
229 (sqlite-exec blocker "ROLLBACK")))
230 (assert-true overlap)
231 (assert-equal '(("z-later" . project)) (index-backlinks idx "target"))
232 (sqlite-close blocker)
233 (sqlite-close db)))))
235 (test "rebuild never exposes a cleared shared link table"
236 (call-with-temp-directory
237 (lambda (root)
238 (let* ((store (make-folio-store root))
239 (one (note-create! store "one" '() "[[target]]"))
240 (two (note-create! store "two" '() "[[target]]"))
241 (idx (make-folio-index store))
242 (db (folio-index-db idx)))
243 (index-reconcile! idx)
244 ;; Every replacement must leave the other document present. A
245 ;; global DELETE before the rebuild violates this trigger at once.
246 (assert-true
247 (sqlite-exec db "CREATE TRIGGER preserve_peer BEFORE INSERT ON notes_meta
248 WHEN (SELECT count(*) FROM notes_meta) < 1
249 BEGIN SELECT RAISE(ABORT, 'shared corpus was cleared'); END"))
250 (assert-equal 2 (index-rebuild! idx))
251 (assert-equal 2 (length (index-backlinks idx "target")))
252 (assert-equal 2 (index-migrate! idx))
253 (sqlite-close db)))))
255 (test "normalized stores consume only their own tracked paths"
256 (call-with-temp-directory
257 (lambda (root)
258 (let ((store (make-folio-store (string-append root "/./notes/.."))))
259 (store-take-changes! store)
260 (store-record-change! "/not-a-registered-store/unowned.md")
261 (assert-equal '() (store-take-changes! store))
262 (let ((path (note-create! store "source" '() "body")))
263 (assert-equal (list path) (store-take-changes! store))))))))