AtlatestRepositoryfolio

folio / tree / testtest-status-cache.sgl

1;;; folio/status reuses its index work while nothing it reads has changed.
2;;;
3;;; The note summaries and orphan names behind status are cached on the
4;;; notes/ listing, SQLite's data_version (another connection's commit) and
5;;; total_changes() (this connection's own writes), plus its transaction count.
6;;; Every invalidation case
7;;; compares the cached answer with a FRESH index connection on the same
8;;; database, which has no cache, so a stale hit fails here.
9(import (sigil test)
10 (sigil async)
11 (sigil fs)
12 (sigil path)
13 (sigil sqlite)
14 (sigil string)
15 (sigil process)
16 (sigil list)
17 (sigil mcp server)
18 (sigil mcp protocol)
19 (folio store)
20 (folio note)
21 (folio index)
22 (folio query)
23 (folio tools))
25(census-settle-seconds 0)
27(define (sh . args)
28 (assert-equal 0 (apply process-run args)))
30(define (granule) (sleep 0.05))
32(define (dated-note body date)
33 (string-append "---\ntags: []\nlast-updated: " date "\n---\n\n" body "\n"))
35;; Three notes: a links b, c stands alone (an orphan).
36(define (with-status-store thunk)
37 (call-with-temp-directory
38 (lambda (root)
39 (let* ((store (make-folio-store root))
40 (_ (store-ensure-dirs! store))
41 (notes (store-notes-dir store)))
42 (write-file-string (path-join notes "a.md") (dated-note "# A\n[[b]]" "2026-01-01"))
43 (write-file-string (path-join notes "b.md") (dated-note "# B" "2026-01-02"))
44 (write-file-string (path-join notes "c.md") (dated-note "# C" "2026-01-03"))
45 (let* ((idx (make-folio-index store))
46 (server (mcp-server name: "status-cache-test")))
47 (register-folio-tools! server store idx)
48 (index-census-tick! idx)
49 (thunk root store idx server)
50 (sqlite-close (folio-index-db idx)))))))
52(define (call-tool server name args)
53 (response->json
54 (mcp-server-handle-message server
55 (jsonrpc-request id: 1 method: "tools/call"
56 params: (dict name: name arguments: args)))))
58(define (summary-view notes)
59 (map (lambda (n)
60 (list (folio-note-name n) (folio-note-file-path n)
61 (dict-ref (folio-note-frontmatter n) last-updated: #f)))
62 notes))
64(define (status-view s)
65 (list (dict-ref s total-notes:)
66 (map folio-note-name (dict-ref s recent-notes:))
67 (map folio-note-name (dict-ref s orphaned-notes:))))
69;; The cached answers of idx against a fresh, cache-free connection.
70(define (assert-matches-fresh store idx)
71 (let ((fresh (make-folio-index store)))
72 (assert-equal (summary-view (index-note-summaries fresh))
73 (summary-view (index-note-summaries idx)))
74 (assert-equal (index-orphan-names fresh) (index-orphan-names idx))
75 (assert-equal (status-view (folio-status store fresh))
76 (status-view (folio-status store idx)))
77 (sqlite-close (folio-index-db fresh))))
79(test-group "status cache"
80 (test "an unchanged store reuses the summaries and orphan names"
81 (with-status-store
82 (lambda (root store idx server)
83 (let ((s1 (index-note-summaries idx))
84 (o1 (index-orphan-names idx)))
85 (assert-true (eq? s1 (index-note-summaries idx)))
86 (assert-true (eq? o1 (index-orphan-names idx)))
87 (assert-equal '("c") o1)
88 (assert-matches-fresh store idx)))))
90 (test "a tool write on this connection is seen at once"
91 (with-status-store
92 (lambda (root store idx server)
93 (index-note-summaries idx)
94 (index-orphan-names idx)
95 (assert-true (string-contains?
96 (call-tool server "folio/note-create"
97 (dict name: "d" content: "# D\n[[c]]"))
98 "Created note"))
99 (assert-equal 4 (dict-ref (folio-status store idx) total-notes:))
100 ;; d links c, so neither is an orphan any more.
101 (assert-equal '() (index-orphan-names idx))
102 (assert-matches-fresh store idx))))
104 (test "another connection's commit is seen at once"
105 (with-status-store
106 (lambda (root store idx server)
107 (index-note-summaries idx)
108 (let ((other (sqlite-open (path-join root ".folio-index.db"))))
109 (assert-true (sqlite-run other
110 "UPDATE notes_meta SET last_updated = ? WHERE name = ?"
111 "2099-12-31" "a"))
112 (sqlite-close other))
113 (assert-equal "a" (folio-note-name (car (dict-ref (folio-status store idx) recent-notes:))))
114 (assert-matches-fresh store idx))))
116 ;; The transaction counter changes only on entry and exit. Between two
117 ;; reads inside it, only total_changes distinguishes this connection's writes.
118 (test "successive writes inside one transaction refresh the summaries"
119 (with-status-store
120 (lambda (root store idx server)
121 (with-index-transaction idx
122 (lambda ()
123 (assert-true (sqlite-run (folio-index-db idx)
124 "UPDATE notes_meta SET last_updated = ? WHERE name = ?"
125 "2098-06-01" "a"))
126 (assert-equal "a"
127 (folio-note-name
128 (car (dict-ref (folio-status store idx) recent-notes:))))
129 (assert-true (sqlite-run (folio-index-db idx)
130 "UPDATE notes_meta SET last_updated = ? WHERE name = ?"
131 "2099-06-01" "b"))
132 (assert-equal "b"
133 (folio-note-name
134 (car (dict-ref (folio-status store idx) recent-notes:))))))
135 (assert-matches-fresh store idx))))
137 ;; A TEMP table shadows the main one for this connection only, and creating
138 ;; or dropping it moves no part of the key, so a failure cached here would
139 ;; stay until something unrelated changed.
140 (test "a summaries query that fails is not cached"
141 (with-status-store
142 (lambda (root store idx server)
143 (index-note-summaries idx)
144 (assert-true (sqlite-exec (folio-index-db idx) "CREATE TEMP TABLE notes_meta (x)"))
145 (let ((other (sqlite-open (path-join root ".folio-index.db"))))
146 (assert-true (sqlite-run other
147 "UPDATE notes_meta SET last_updated = ? WHERE name = ?"
148 "2099-12-31" "a"))
149 (sqlite-close other))
150 (assert-equal '() (index-note-summaries idx))
151 (assert-true (sqlite-exec (folio-index-db idx) "DROP TABLE temp.notes_meta"))
152 (assert-equal 3 (length (index-note-summaries idx)))
153 (assert-matches-fresh store idx))))
155 ;; A read inside this connection's transaction sees its uncommitted rows,
156 ;; and a ROLLBACK moves neither data_version nor total_changes.
157 (test "a read inside a transaction that rolls back is not cached"
158 (with-status-store
159 (lambda (root store idx server)
160 (index-note-summaries idx)
161 (let ((inside #f))
162 (guard (e ((eq? e 'abort) #f))
163 (with-index-transaction idx
164 (lambda ()
165 (assert-true (sqlite-run (folio-index-db idx)
166 "UPDATE notes_meta SET last_updated = ? WHERE name = ?"
167 "2099-12-31" "a"))
168 (set! inside (folio-note-name
169 (car (dict-ref (folio-status store idx) recent-notes:))))
170 (raise 'abort))))
171 (assert-equal "a" inside))
172 (assert-equal "c" (folio-note-name (car (dict-ref (folio-status store idx) recent-notes:))))
173 (assert-matches-fresh store idx))))
175 (test "a note deleted outside folio leaves status before any census"
176 (with-status-store
177 (lambda (root store idx server)
178 (index-note-summaries idx)
179 (delete-file (path-join (store-notes-dir store) "c.md"))
180 (assert-equal 2 (dict-ref (folio-status store idx) total-notes:))
181 (assert-matches-fresh store idx))))
183 (test "an outside edit picked up by the census is seen"
184 (with-status-store
185 (lambda (root store idx server)
186 (index-note-summaries idx)
187 (granule)
188 (write-file-string (path-join (store-notes-dir store) "b.md")
189 (dated-note "# B" "2098-06-01"))
190 ;; Read between the edit and the census: the cache now holds the new
191 ;; listing with the old rows, and the census's own writes move only
192 ;; total_changes (not data_version, not the listing).
193 (assert-equal "c" (folio-note-name (car (dict-ref (folio-status store idx) recent-notes:))))
194 (assert-equal 'reconciled (index-census-tick! idx))
195 (assert-equal "b" (folio-note-name (car (dict-ref (folio-status store idx) recent-notes:))))
196 (assert-matches-fresh store idx))))
198 (test "a symlink the walk follows turns the cache off"
199 (with-status-store
200 (lambda (root store idx server)
201 (let ((outside (path-join root "outside")))
202 (ensure-directory outside)
203 (write-file-string (path-join outside "e.md") (dated-note "# E" "2026-02-01"))
204 (sh "ln" "-s" outside (path-join (store-notes-dir store) "linked"))
205 (index-census-tick! idx)
206 ;; The walk follows the link, so its note is indexed and listed.
207 (assert-true (member "linked/e" (map folio-note-name (index-note-summaries idx))))
208 (assert-false (eq? (index-note-summaries idx) (index-note-summaries idx)))
209 ;; A note removed behind the link changes no listing line.
210 (delete-file (path-join outside "e.md"))
211 (assert-matches-fresh store idx))))))