AtlatestRepositoryfolio
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 listing15
;; 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-async21
(go (index-reconcile! idx))22
(go23
(let ((deadline (+ (current-second) 2)))24
(let wait ()25
(cond26
((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-directory36
(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 blocker46
(lambda () (note-create! store "arrived" '() body)))47
;; Check immediately, before another census can repair lost rows.48
(assert-equal 149
(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-directory57
(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 169
(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-directory76
(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-directory96
(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-directory115
(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-true124
(sqlite-exec db "CREATE TRIGGER count_refresh AFTER INSERT ON notes_versions125
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 1130
(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-directory137
(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-directory160
(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 0177
(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-directory184
(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-directory209
(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-async221
(go (index-reconcile! idx))222
(go223
(let ((deadline (+ (current-second) 2)))224
(let wait ()225
(cond226
((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-directory237
(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. A245
;; global DELETE before the rebuild violates this trigger at once.246
(assert-true247
(sqlite-exec db "CREATE TRIGGER preserve_peer BEFORE INSERT ON notes_meta248
WHEN (SELECT count(*) FROM notes_meta) < 1249
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-directory257
(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))))))))