The loader gains a ParseMemo — the previous walk's parsed documents keyed by root-relative path, trusted on the git-index heuristic (mtime + size, no hashing) and passed as an input so the loader stays stateless. A hit skips exactly one file read; schema, order, coercions, dedupe, and every directory listing run fresh, so memoized and cold walks are output- identical (golden-corpus equivalence suite). Entries record only past the schema gate, so a defect can never be answered from the memo. The store skips the snapshot assignment wholesale when the fresh model is value-equal — no @Observable churn, no render pass, no snapshotGeneration bump — and a new landedReloads counter carries walk-completion for the three consumers whose subject is the walk, not the snapshot: the card window's comment thread, the comment search index, and the auto-committer's covering gate (which now counts a completed walk as covering even when nothing changed). Warnings and defects move on their own equality; failed reloads bump neither counter. An injectable ParseCounter makes the single-file-echo claim a test. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
278 lines
14 KiB
Swift
278 lines
14 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
// MARK: - What one card contributes to the sweep
|
|
|
|
/// One card the sweep will read a thread from: its identity, and where its folder is right now.
|
|
///
|
|
/// A value rather than a pair of parallel arrays because the sweep runs **off the main actor** and
|
|
/// the snapshot cannot travel with it — `BoardModel` is the board's whole tree, and handing it to a
|
|
/// detached task to walk twice would be doing the walk the design keeps at O(cards) all over again.
|
|
/// So the walk happens once, on the main actor, and what crosses the boundary is this: identity plus
|
|
/// a URL, per card, and nothing else.
|
|
public struct CommentSearchTarget: Sendable, Equatable {
|
|
public let id: ItemID
|
|
/// The card's folder — `<root>/<lane>/<card>`, or `<root>/.trash/<card>`. The thread hangs off it
|
|
/// (`CommentThread.folder(inCard:)`).
|
|
public let folder: URL
|
|
|
|
public init(id: ItemID, folder: URL) {
|
|
self.id = id
|
|
self.folder = folder
|
|
}
|
|
}
|
|
|
|
// MARK: - CommentSearchIndex
|
|
|
|
/// **Board search's transient comment index** — the search-owned sweep that lets a query reach comment
|
|
/// *bodies* without the board snapshot ever carrying one (04-interactions.md ▸ Search, re-ruled
|
|
/// 2026-07-29; 01-storage-format.md ▸ Enhanced schema):
|
|
///
|
|
/// > **comment bodies join when comments ship** — via a search-owned transient comment index, never
|
|
/// > the snapshot: the first live-query keystroke kicks an async sweep of `comments/*/index.md` bodies
|
|
/// > (`.draft` and `comments/.trash/` excluded), kept fresh … while a query is active and discarded
|
|
/// > when it clears — the board walk stays O(cards), 01's window-scoped read untouched.
|
|
///
|
|
/// ### Why it is its own object and not a member of `TransientBoardState`
|
|
///
|
|
/// Because `TransientBoardState`'s charter forbids exactly the thing this is. Its kind 2 is "derived
|
|
/// state, stored as its inputs only — `searchQuery` is kept; its *result set* is deliberately absent
|
|
/// … a stored result set would be a second, staler answer to a question the snapshot can always
|
|
/// answer". This index is the **opposite** case: the snapshot cannot answer it at all, because comment
|
|
/// content is deliberately outside the snapshot, so there is nothing to re-derive from and the answer
|
|
/// has to be kept. Keeping it there would mean giving that type a freshness rule of its own — a sweep
|
|
/// to schedule, a generation to compare, a task to cancel — which is precisely the accretion it exists
|
|
/// to keep out. So the index lives here, beside `SearchFilter`, owned by the search: one `let` on
|
|
/// `BoardStore` (`commentIndex`), created with the store and dying with it, like `echoes` and `heals`.
|
|
///
|
|
/// ### What it holds, and what that costs
|
|
///
|
|
/// The index proper is `[ItemID: [String]]` — one card's posted comment bodies, kept **as a list**
|
|
/// rather than joined, so a query can never match across the seam between two comments. It exists only
|
|
/// while a query does: the first keystroke builds it, every later keystroke re-filters it in memory
|
|
/// (no I/O per keystroke — that is what makes it an index rather than a search), and clearing the
|
|
/// query throws it away. The honest cost is stated rather than hidden: while a query is active, every
|
|
/// comment body on the board is in memory. A board with a thousand comments of a paragraph each is a
|
|
/// few hundred kilobytes, and the alternative — re-reading the tree per keystroke — is the thing an
|
|
/// index is for.
|
|
///
|
|
/// ### Freshness is snapshot-generation-shaped, and that is an interim
|
|
///
|
|
/// 04 says "kept fresh by the same FSEvents stream while a query is active". The store has no
|
|
/// changed-path channel — `FolderWatcher` reports only *that* the tree changed (02-architecture.md),
|
|
/// and the reload seam turns that into two snapshots rather than a path list — so the freshness signal
|
|
/// available today is `BoardStore.landedReloads`: **while a query is active, a landed reload
|
|
/// re-sweeps**. That is coarser than the ruling asks for (it re-reads threads a reload may not have
|
|
/// touched) and it is bounded by the same thing that bounds the reload itself, the watcher's debounce.
|
|
///
|
|
/// The counter is the *walk's*, not the applied snapshot's, and it has to be: comments are outside the
|
|
/// snapshot, so a comment arriving leaves the model value-equal and a value-equal reload skips its
|
|
/// assignment (blessed 2026-07-31). Keyed on the applied counter, the index would sleep through the
|
|
/// one kind of change it exists to notice.
|
|
/// A changed-path channel would narrow it to the cards whose `comments/` actually moved; until one
|
|
/// exists, this is the accepted interim and is written down here rather than discovered later.
|
|
///
|
|
/// ### Asynchrony, and the first keystroke's honest lag
|
|
///
|
|
/// The sweep is I/O and never runs on the main actor. Until it lands, the filter sees whatever the
|
|
/// index held before — nothing at all on the first keystroke of a fresh query — so **the first
|
|
/// keystroke may briefly show field-only matches**, and comment matches join a moment later. That is
|
|
/// the accepted behaviour rather than a defect: the alternative is a synchronous tree read on the
|
|
/// keystroke path, which is the one thing 01's window-scoped rule exists to prevent. Results
|
|
/// *refine*; they never regress, because a landed sweep only ever adds the cards it found.
|
|
@MainActor
|
|
@Observable
|
|
public final class CommentSearchIndex {
|
|
|
|
/// **The cards whose comments match the live query** — the index's whole public surface, and the
|
|
/// set `BoardStore.searchFilter` hands to `SearchFilter` so that one predicate keeps answering
|
|
/// "does this card match" for every surface on the board.
|
|
///
|
|
/// Empty means "no comment match", which is also what it means with no query, with no index yet,
|
|
/// and on a board with no comments — four states with one honest answer, so no consumer branches.
|
|
public private(set) var matchingCards: Set<ItemID> = []
|
|
|
|
/// The index proper: posted comment bodies per card, in the thread's own order. `nil` while there
|
|
/// is no index at all — before the first sweep of a query, and after a clear.
|
|
///
|
|
/// Not `@ObservationIgnored`: a view reading `matchingCards` must not also wake on the megabytes
|
|
/// behind it, but nothing outside this type reads this at all, so its observability costs nothing
|
|
/// and its privacy is the guard.
|
|
private var bodies: [ItemID: [String]]?
|
|
|
|
/// The query the current `matchingCards` was computed against.
|
|
private var query = ""
|
|
|
|
/// The snapshot generation `bodies` was swept at, and the one a sweep in flight is for. Together
|
|
/// they are the whole freshness rule: an index at the current generation is fresh, one at an older
|
|
/// generation is re-swept, and a sweep already in flight for this generation is not started twice.
|
|
private var sweptGeneration: Int?
|
|
private var sweepingGeneration: Int?
|
|
|
|
/// The sweep in flight. Cancelled — never awaited — when a newer one supersedes it: a stale sweep's
|
|
/// result would describe a tree that has already changed, and the landing guard drops it anyway.
|
|
private var sweep: Task<Void, Never>?
|
|
|
|
/// **What to do when a sweep lands and the visible universe therefore widened or narrowed** —
|
|
/// filled in by `BoardStore` with the selection constraint (04-interactions.md ▸ Search's "hidden
|
|
/// cards leave the selection").
|
|
///
|
|
/// It is a seam rather than a call into the store because this type has no business knowing what a
|
|
/// selection is: the refine is an event, and what it costs is the store's rule. `nil` in every test
|
|
/// that drives the index alone.
|
|
@ObservationIgnored
|
|
public var onRefine: (() -> Void)?
|
|
|
|
public init() {}
|
|
|
|
// MARK: - The two things that happen to it
|
|
|
|
/// **Every write to the query, and every landed reload under one** — the single funnel, so the
|
|
/// activation, the re-filter and the re-sweep are one decision rather than three call sites
|
|
/// agreeing.
|
|
///
|
|
/// - An **inactive** query discards everything (below), which is 04's "discarded when it clears".
|
|
/// - An index already at `generation` is simply **re-filtered** — no I/O, which is the whole point
|
|
/// of holding bodies rather than a result set.
|
|
/// - Anything else starts a sweep and re-filters against whatever is in hand meanwhile, so the
|
|
/// board never blanks while a fresher answer is being read.
|
|
public func update(query: String, generation: Int, targets: @autoclosure () -> [CommentSearchTarget]) {
|
|
guard SearchFilter(query: query).isActive else {
|
|
discard()
|
|
return
|
|
}
|
|
self.query = query
|
|
|
|
if sweptGeneration != generation, sweepingGeneration != generation {
|
|
startSweep(generation: generation, targets: targets())
|
|
}
|
|
recompute()
|
|
}
|
|
|
|
/// **The query cleared** — the index is thrown away, the sweep in flight is cancelled, and the
|
|
/// board goes back to being O(cards) with nothing held (04 ▸ Search: "discarded when it clears").
|
|
///
|
|
/// Idempotent: `BoardStore.searchQuery`'s setter funnels every clear through here, including the
|
|
/// ones that were already clear.
|
|
public func discard() {
|
|
sweep?.cancel()
|
|
sweep = nil
|
|
sweepingGeneration = nil
|
|
sweptGeneration = nil
|
|
bodies = nil
|
|
query = ""
|
|
matchingCards = []
|
|
}
|
|
|
|
// MARK: - The sweep
|
|
|
|
private func startSweep(generation: Int, targets: [CommentSearchTarget]) {
|
|
sweep?.cancel()
|
|
sweepingGeneration = generation
|
|
sweep = Task { [weak self] in
|
|
// Detached, so the tree read runs off the main actor: this is a directory enumeration and
|
|
// a frontmatter parse per comment, and it is happening on the keystroke path.
|
|
let swept = await Task.detached(priority: .userInitiated) {
|
|
CommentSearchIndex.sweep(targets)
|
|
}.value
|
|
guard let self, !Task.isCancelled else { return }
|
|
self.land(swept, generation: generation)
|
|
}
|
|
}
|
|
|
|
/// One sweep's result, if it is still the one being waited for.
|
|
///
|
|
/// The guard is the stale-apply rule `BoardStore.apply` keeps for reloads, and for its reason: a
|
|
/// result whose generation is no longer the one in flight describes a tree that has moved on, and
|
|
/// installing it would make the index *less* fresh than the one it replaced.
|
|
private func land(_ swept: [ItemID: [String]], generation: Int) {
|
|
guard sweepingGeneration == generation else { return }
|
|
sweepingGeneration = nil
|
|
sweptGeneration = generation
|
|
bodies = swept
|
|
let before = matchingCards
|
|
recompute()
|
|
// Only a change is worth telling anyone about: a sweep that confirmed what the filter already
|
|
// showed must not re-run the selection constraint, which is a board-sized set computation.
|
|
guard matchingCards != before else { return }
|
|
onRefine?()
|
|
}
|
|
|
|
/// **The sweep itself** — every target's posted comment bodies, read off disk.
|
|
///
|
|
/// `nonisolated` and taking only `Sendable` values, because it runs on a detached task. It is total
|
|
/// and silent: a card with no `comments/`, one whose thread is unreadable, and one holding nothing
|
|
/// but strays all contribute nothing, and none of them is a defect this read has any business
|
|
/// reporting — the thread's own loader is where comment defects are logged and healed
|
|
/// (`CommentThread.load(inCard:path:)`), and a search sweep that logged a warning per stray on
|
|
/// every keystroke's re-sweep would be a log nobody could read.
|
|
nonisolated static func sweep(_ targets: [CommentSearchTarget]) -> [ItemID: [String]] {
|
|
var index: [ItemID: [String]] = [:]
|
|
for target in targets {
|
|
if Task.isCancelled { return index }
|
|
let bodies = CommentThread.searchableBodies(inCard: target.folder)
|
|
guard !bodies.isEmpty else { continue }
|
|
index[target.id] = bodies
|
|
}
|
|
return index
|
|
}
|
|
|
|
/// **Which cards the current query matches through their comments** — `SearchFilter`'s own
|
|
/// predicate, borrowed rather than restated.
|
|
///
|
|
/// That is not tidiness: 04 gives the query *one* matching rule (case- and diacritic-insensitive
|
|
/// substring, folded locale-stably), and a second spelling of it here would let a board's comments
|
|
/// filter by a different rule than its titles do. `matches(title:body:)` with no title is exactly
|
|
/// "does this text contain the query", which is all a comment body has to answer.
|
|
nonisolated static func matchingCards(in bodies: [ItemID: [String]], query: String) -> Set<ItemID> {
|
|
let filter = SearchFilter(query: query)
|
|
guard filter.isActive else { return [] }
|
|
var matches: Set<ItemID> = []
|
|
for (id, texts) in bodies where texts.contains(where: { filter.matches(title: nil, body: $0) }) {
|
|
matches.insert(id)
|
|
}
|
|
return matches
|
|
}
|
|
|
|
private func recompute() {
|
|
matchingCards = Self.matchingCards(in: bodies ?? [:], query: query)
|
|
}
|
|
|
|
// MARK: - The board's cards, as targets
|
|
|
|
/// Every card whose thread the sweep reads — **the live board and the trash**, because a trashed
|
|
/// card carries its `comments/` (01-storage-format.md ▸ Enhanced schema) and the trash column's
|
|
/// rows are filtered by the same predicate as the board's.
|
|
///
|
|
/// One walk, on the main actor, producing values the sweep can carry (see `CommentSearchTarget`).
|
|
/// Trashed *lanes* have no thread of their own — the row is an opaque unit matched by title alone
|
|
/// (`SearchFilter.matches(_ lane:)`) — so they contribute nothing here.
|
|
public nonisolated static func targets(in snapshot: BoardModel) -> [CommentSearchTarget] {
|
|
let root = snapshot.rootURL
|
|
var targets: [CommentSearchTarget] = []
|
|
for lane in snapshot.lanes {
|
|
for card in lane.cards {
|
|
targets.append(CommentSearchTarget(
|
|
id: card.id,
|
|
folder: ItemPath.card(lane: lane.id, id: card.id).folder(under: root)
|
|
))
|
|
}
|
|
}
|
|
for card in snapshot.trash {
|
|
targets.append(CommentSearchTarget(
|
|
id: card.id,
|
|
folder: ItemPath.trashCard(card.id).folder(under: root)
|
|
))
|
|
}
|
|
return targets
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
/// Suspends until the sweep in flight has landed — the index's own `awaitQuiescence`, and the only
|
|
/// way a suite can assert about an answer that arrives a task later.
|
|
public func awaitSweep() async {
|
|
await sweep?.value
|
|
}
|
|
}
|