Build the board registry and per-board app state
Two registries (Kanban/LiveStore/): BoardStoreRegistry shares one live store and one started, fully wired watcher per open board across its windows — keyed by file identity (fileResourceIdentifier), never path, refcounted to order teardown; release matches by store identity so a board renamed while open can't leak its watcher. BoardRegistry persists app-private per-board state in Application Support as diff-stable JSON: records anchored by security-scoped bookmarks, recents = the registry sorted by lastOpened (counts registry-cached, never scanned), graceful orphaning with Forget, corrupt files quarantined aside, and files-first verified — the board tree is untouched byte-for-byte. Timestamps use ISO8601DateFormatter with fractional seconds: the FormatStyle variant truncates-then-rounds and drifts a millisecond per round trip. 16 registry tests; full suite 297 tests in 56 suites green. Four findings filed on the Redesign board. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - File identity
|
||||
|
||||
/// A file's identity as the filesystem understands it — the opaque token behind
|
||||
/// `URLResourceKey.fileResourceIdentifierKey` — wrapped so it can key a dictionary.
|
||||
///
|
||||
/// **Both registries in this folder key on this, never on a path string** (02-architecture.md
|
||||
/// § Per-board app state, "Keyed by file identity, never by path"). A board is the *folder*, not
|
||||
/// the string that currently names it: Finder renames are ordinary (01-storage-format.md), and a
|
||||
/// board renamed while open — or reopened through a moved path — must land on the store and the
|
||||
/// record it already has rather than on a second copy of itself.
|
||||
///
|
||||
/// The token is documented as opaque and comparable only with `isEqual:`; nothing here reads into
|
||||
/// it. `hash` is `NSObject`'s, which is the hash that agrees with `isEqual:` — the pair is what
|
||||
/// makes this usable as a `Hashable` key at all.
|
||||
///
|
||||
/// **Not `Sendable`, deliberately**: the token is an Objective-C object with no concurrency
|
||||
/// contract. Both registries are `@MainActor`, so it never leaves that actor, and claiming
|
||||
/// otherwise would be an unchecked promise for no gain.
|
||||
struct FileIdentity: Hashable {
|
||||
private let token: NSObject
|
||||
|
||||
/// `nil` when the URL has no identity to read — it does not exist, or its volume cannot answer.
|
||||
///
|
||||
/// Failable rather than throwing on purpose: every caller here already has a better error to
|
||||
/// produce than "no resource value". `BoardStoreRegistry.acquire` lets `BoardStore`'s own
|
||||
/// fail-fast load speak (it produces the *right* `BoardLoadError` for a missing, unreadable, or
|
||||
/// non-directory root), and `BoardRegistry` treats an unreadable identity as "no match", which
|
||||
/// is exactly what it means there.
|
||||
init?(of url: URL) {
|
||||
guard
|
||||
let values = try? url.resourceValues(forKeys: [.fileResourceIdentifierKey]),
|
||||
let identifier = values.fileResourceIdentifier as? NSObject
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
token = identifier
|
||||
}
|
||||
|
||||
static func == (lhs: FileIdentity, rhs: FileIdentity) -> Bool {
|
||||
lhs.token.isEqual(rhs.token)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(token.hash)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BoardStoreRegistry
|
||||
|
||||
/// The live side of "one board, one truth": every window showing a board shares **one**
|
||||
/// `BoardStore` and **one** `FolderWatcher`, and this is what hands them out
|
||||
/// (02-architecture.md § Layering ▸ Components).
|
||||
///
|
||||
/// ### Why a refcount when the answer is already known
|
||||
///
|
||||
/// **The board window owns the board** (settled, § Components): card windows never outlive it, so
|
||||
/// the last release and the board window's close always coincide — the count could in principle be
|
||||
/// replaced by "the board window closed". It earns its keep anyway, and only, as *ordering*:
|
||||
/// closing a board window closes its card windows too, and those closes arrive as a handful of
|
||||
/// separate SwiftUI teardowns. The count is what stops the first of them from stopping the watcher
|
||||
/// out from under the ones still on screen.
|
||||
///
|
||||
/// ### What it owns, and what it deliberately does not
|
||||
///
|
||||
/// It owns the pairing: it creates the store, creates and starts the watcher, and wires them to
|
||||
/// each other. It owns no policy — every reload rule lives in `BoardStore`, every debounce and
|
||||
/// bracket rule in `FolderWatcher`. It is not a singleton either: the app holds one instance, so a
|
||||
/// test can hold its own without the two colliding.
|
||||
///
|
||||
/// ### The app's own instance is the only one that matters
|
||||
///
|
||||
/// Two registries over the same board would defeat the point (two stores, two watchers, two
|
||||
/// snapshots drifting apart). That is a wiring rule for the app, not something this type can
|
||||
/// enforce, and it is stated here rather than defended in code.
|
||||
@MainActor
|
||||
public final class BoardStoreRegistry {
|
||||
|
||||
/// One open board: the shared store, the watcher feeding it, and how many windows are holding
|
||||
/// them open.
|
||||
private struct Entry {
|
||||
let store: BoardStore
|
||||
let watcher: FolderWatcher
|
||||
var referenceCount: Int
|
||||
}
|
||||
|
||||
/// Keyed by `FileIdentity`, so a board acquired through a renamed or moved path finds the entry
|
||||
/// it already has. A `[URL: Entry]` here would be a latent bug with a plausible shape.
|
||||
private var entries: [FileIdentity: Entry] = [:]
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store-registry")
|
||||
|
||||
/// The app owns one instance and passes it down; there is no shared singleton to reach for.
|
||||
public init() {}
|
||||
|
||||
/// How many boards are open — entries, not references. This is the welcome-window and
|
||||
/// quit-time question ("is anything still open?"), never the refcount.
|
||||
public var openBoardCount: Int { entries.count }
|
||||
|
||||
// MARK: - Acquire / release
|
||||
|
||||
/// The store for `rootURL`, opening the board if this is the first window to ask for it.
|
||||
///
|
||||
/// **First acquire**: loads the board (fail-fast — the `BoardLoadError` is rethrown untouched,
|
||||
/// because there is nothing to render and nothing to fall back on), then creates and starts the
|
||||
/// watcher and ties the two together in both directions — watcher events into
|
||||
/// `BoardStore.handleWatcherEvent(_:)`, the store's write brackets out to
|
||||
/// `FolderWatcher.beginBracket()`/`endBracket()`.
|
||||
///
|
||||
/// **Subsequent acquires** return the *same* store and bump the count. Not a fresh load, not a
|
||||
/// second watcher: a card window opening must not cost a tree walk, and two watchers over one
|
||||
/// board would double every reload for nothing.
|
||||
///
|
||||
/// The identity read happens *before* the load so an already-open board is found under whatever
|
||||
/// path it is being asked for now. An identity that cannot be read is not decided here — it
|
||||
/// falls through to `BoardStore.init`, whose load produces the honest error for a root that is
|
||||
/// missing, unreadable, or not a directory. This type invents no error of its own.
|
||||
///
|
||||
/// A failed load leaves **nothing behind**: no entry, no watcher, no count. A board that failed
|
||||
/// to open is not open.
|
||||
public func acquire(_ rootURL: URL) throws(BoardLoadError) -> BoardStore {
|
||||
if let identity = FileIdentity(of: rootURL), var entry = entries[identity] {
|
||||
entry.referenceCount += 1
|
||||
entries[identity] = entry
|
||||
Self.logger.debug("acquire: existing board, \(entry.referenceCount, privacy: .public) references")
|
||||
return entry.store
|
||||
}
|
||||
|
||||
let store = try BoardStore(rootURL: rootURL)
|
||||
|
||||
// Unreachable in practice — the load above just walked this directory — but the alternative
|
||||
// is a force-unwrap on a resource value the filesystem is free to refuse, so it is spelled
|
||||
// out. The loader's own vocabulary says it; no new error path is invented for a case that
|
||||
// means exactly what `unreadableRoot` already means.
|
||||
guard let identity = FileIdentity(of: rootURL) else {
|
||||
throw BoardLoadError(path: ".", reason: .unreadableRoot(message: "the board root has no file identity"))
|
||||
}
|
||||
|
||||
// Both directions of the wiring capture weakly, and the registry's entry is what keeps the
|
||||
// pair alive: the watcher never props up a store nobody is showing, and the store's brackets
|
||||
// become no-ops the moment `release` drops the watcher. Neither closure can form a cycle
|
||||
// with the object it calls into.
|
||||
let watcher = FolderWatcher(root: rootURL) { [weak store] event in
|
||||
// `.rootChanged` rides through like any other event. The store stubs it today; the
|
||||
// settled response — re-resolve the board's security-scoped bookmark and either
|
||||
// `reattach(to:)` at the new location or enter the vanished-root read-only lock
|
||||
// (02-architecture.md § Write-failure surfacing) — needs `BoardRegistry`'s bookmark and
|
||||
// belongs to the card that brings the two together. This registry is the natural place
|
||||
// for that seam, since it is the only object holding the store, the watcher, and (by
|
||||
// then) the record at once.
|
||||
store?.handleWatcherEvent(event)
|
||||
}
|
||||
store.watcherBrackets = (
|
||||
begin: { [weak watcher] in watcher?.beginBracket() },
|
||||
end: { [weak watcher] in watcher?.endBracket() }
|
||||
)
|
||||
|
||||
// Default debounce and latency: the numbers are settled in 02-architecture.md § Components
|
||||
// (200 ms trailing over 50 ms FSEvents latency) and live in `FolderWatcher`'s defaults.
|
||||
// Restating them here would be a second place for them to drift.
|
||||
if !watcher.start() {
|
||||
// Not a failure to open. A board whose stream would not come up still loads, still
|
||||
// renders, and still writes — it simply will not notice foreign edits, which is the
|
||||
// same degradation 07-sync-collab.md already accepts on warned-against volumes. The
|
||||
// reconciling reloads that wake and activation drive are the recovery path.
|
||||
Self.logger.error("watcher stream failed to start; live reload is degraded for this board")
|
||||
}
|
||||
|
||||
entries[identity] = Entry(store: store, watcher: watcher, referenceCount: 1)
|
||||
return store
|
||||
}
|
||||
|
||||
/// Drops one window's reference; at zero the watcher stops and the entry goes.
|
||||
///
|
||||
/// Looked up by **object identity of the store**, not by its `rootURL`: a board renamed while
|
||||
/// open keeps the `rootURL` it was opened with (see `BoardStore.rootURL`), so a path-based
|
||||
/// lookup would miss precisely the case the whole file-identity keying exists for, and leak the
|
||||
/// entry — a watcher running over a board with no windows.
|
||||
///
|
||||
/// **Releasing a store this registry never handed out is a no-op**, not a trap. Teardown races
|
||||
/// are the ordinary shape of window closing (a card window and its board window closing in the
|
||||
/// same run loop turn, a double-dismiss, a store built directly in a test), and none of them is
|
||||
/// a programming error worth taking the app down for.
|
||||
public func release(_ store: BoardStore) {
|
||||
guard let identity = entries.first(where: { $0.value.store === store })?.key else {
|
||||
Self.logger.debug("release: store is not registered — ignored")
|
||||
return
|
||||
}
|
||||
|
||||
guard var entry = entries[identity] else { return }
|
||||
entry.referenceCount -= 1
|
||||
guard entry.referenceCount <= 0 else {
|
||||
entries[identity] = entry
|
||||
return
|
||||
}
|
||||
|
||||
entry.watcher.stop()
|
||||
// Explicit rather than left to the weak captures: after this the store may still be alive in
|
||||
// whatever is closing, and brackets that quietly did nothing would be a lie about a watcher
|
||||
// that is gone.
|
||||
entry.store.watcherBrackets = nil
|
||||
entries[identity] = nil
|
||||
}
|
||||
|
||||
/// The live store for a board, if any window has it open — **without** taking a reference.
|
||||
///
|
||||
/// This is the "is this board already open?" question: focusing an existing window rather than
|
||||
/// opening a second one, routing a cross-board drag into a board that happens to be on screen.
|
||||
/// A bump here would be a leak, since nothing is being opened.
|
||||
public func liveStore(for rootURL: URL) -> BoardStore? {
|
||||
guard let identity = FileIdentity(of: rootURL) else { return nil }
|
||||
return entries[identity]?.store
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user