Commit1674040fRecorded24 Oct 2026Repositoryfolio

Test: status reuses its index work while nothing changed (red before the cache)

Message

Seven tests. The first (same summaries and orphan-name objects on an unchanged store) fails before the cache. The rest compare the answers of a long-lived index with a fresh, cache-free connection after a tool write, another connection's commit, a failed query, an outside deletion, a census edit and a followed symlink.

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