From f0e17389647e8e812520166c1a93acbd85d224e3 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 19:46:18 -0400 Subject: [PATCH] Build the board registry and per-board app state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/LiveStore/BoardRegistry.swift | 595 ++++++++++++++++++++++ Kanban/LiveStore/BoardStoreRegistry.swift | 216 ++++++++ KanbanTests/BoardRegistryTests.swift | 375 ++++++++++++++ KanbanTests/BoardStoreRegistryTests.swift | 246 +++++++++ 4 files changed, 1432 insertions(+) create mode 100644 Kanban/LiveStore/BoardRegistry.swift create mode 100644 Kanban/LiveStore/BoardStoreRegistry.swift create mode 100644 KanbanTests/BoardRegistryTests.swift create mode 100644 KanbanTests/BoardStoreRegistryTests.swift diff --git a/Kanban/LiveStore/BoardRegistry.swift b/Kanban/LiveStore/BoardRegistry.swift new file mode 100644 index 0000000..8abdd59 --- /dev/null +++ b/Kanban/LiveStore/BoardRegistry.swift @@ -0,0 +1,595 @@ +import Foundation +import os + +// MARK: - Timestamps + +/// The one shape a `Date` takes in the registry file — ISO 8601 **with fractional seconds**, UTC. +/// +/// **Why fractional seconds** rather than `JSONEncoder.DateEncodingStrategy.iso8601`'s whole ones: +/// `lastOpened` is not a display stamp, it is the recents list's *sort key* (02-architecture.md +/// § Per-board app state, "The recents list *is* this registry sorted by last-opened"). Restoring +/// several boards at launch happens well inside one second, and at whole-second resolution those +/// rows would tie and order arbitrarily. +/// +/// **Why `ISO8601DateFormatter`** and not `Date.ISO8601FormatStyle`, the modern value type that +/// would be `Sendable` and need none of the ceremony below: the format style *truncates* to the +/// millisecond while its own parser rounds, so `parse(format(d)) != d` for about half of all dates +/// (measured, not assumed — it drifts a millisecond earlier on each round trip). This formatter is +/// an exact fixed point, which is what lets `BoardRegistry.stamp(_:)` guarantee that a record in +/// memory and the same record reloaded from disk are *equal*, not merely close. +/// +/// **Built per use rather than shared**: the formatter is a non-`Sendable` class and the encoder's +/// and decoder's date strategies are `@Sendable` closures, so a captured or global instance would +/// be an unchecked concurrency promise. One costs microseconds and this file holds a handful of +/// records — the safe answer is also the cheap one. +private func boardRegistryTimestampFormatter() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter +} + +// MARK: - Records + +/// A board's window frame, remembered per board (02-architecture.md § Windows, "per-board frame +/// memory"). +/// +/// Stored as four `Double`s rather than an `NSRect`/`CGRect` so the persisted JSON is a shape this +/// file owns, independent of any framework's `Codable` conformance. Repositioning a frame onto a +/// live screen when the saved one is gone is the window layer's job, not this type's — nothing here +/// validates the numbers. +public struct WindowFrame: Codable, Sendable, Equatable { + public var x: Double + public var y: Double + public var width: Double + public var height: Double + + public init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } +} + +/// One known board: everything the app keeps *about* a board that must never be written *into* it. +/// +/// **Files-first is absolute** (02-architecture.md § Per-board app state): no frontmatter key, no +/// sidecar, no xattr — this record lives wholly in Application Support, which is also why two +/// machines sharing a board through a remote each keep their own (push-on-commit and window frames +/// are genuinely per-machine choices). +/// +/// The **bookmark is the identity**; `lastKnownPath` is display text and nothing else. Matching an +/// opened folder to its record resolves the bookmark and compares file identity, so a board renamed +/// or moved on the same volume keeps its settings and its place in recents. +/// +/// ### Evolving this struct +/// +/// The synthesized `Codable` conformance rejects a file missing any non-optional key, and +/// `BoardRegistry` responds to a rejected file by quarantining it — every record lost. So **a key +/// added here must be optional or have a decoding default**, or the addition silently empties every +/// existing user's recents on upgrade. That policy, not a version field, is what keeps this format +/// readable across releases. +public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { + public let id: UUID + + /// The security-scoped bookmark — **the board's identity**, refreshed on every open. Empty only + /// in the degenerate case where the system refused to make one at all, which is a record born + /// orphaned: it shows in recents with Forget and never matches an open. + public var bookmark: Data + + /// Last-known title or folder name, for the recents row. Display only. + public var displayName: String + + /// Where the board was last seen, for the recents row when the bookmark no longer resolves and + /// there is nothing else to show. **Never used for matching** — that is the bookmark's job, and + /// a path that matched would reintroduce exactly the identity-by-string bug this design excludes. + public var lastKnownPath: String + + public var lastOpened: Date + + /// Counts stamped at last close, `nil` until a close has stamped them. + /// + /// Registry-cached on purpose: the welcome window renders these instead of scanning + /// (§ Per-board app state, "no directory scan at welcome time" — which would be slow on a big + /// board and would hang on an unavailable one). Staleness until the next open is accepted, and a + /// board that has never been closed simply shows no counts. + public var laneCount: Int? + public var cardCount: Int? + + public var windowFrame: WindowFrame? + + /// Whether committing also pushes (07-sync-collab.md). Off by default: pushing is a decision, + /// not a side effect. + public var pushOnCommit: Bool + + /// Whether this board's once-per-board iCloud/network-volume warning has been shown + /// (07-sync-collab.md). Once-per-*board*, which is why it lives on the record rather than in + /// `UserDefaults`. + public var remoteLocationWarned: Bool + + public init( + id: UUID = UUID(), + bookmark: Data, + displayName: String, + lastKnownPath: String, + lastOpened: Date, + laneCount: Int? = nil, + cardCount: Int? = nil, + windowFrame: WindowFrame? = nil, + pushOnCommit: Bool = false, + remoteLocationWarned: Bool = false + ) { + self.id = id + self.bookmark = bookmark + self.displayName = displayName + self.lastKnownPath = lastKnownPath + self.lastOpened = lastOpened + self.laneCount = laneCount + self.cardCount = cardCount + self.windowFrame = windowFrame + self.pushOnCommit = pushOnCommit + self.remoteLocationWarned = remoteLocationWarned + } +} + +/// A recents row: the record, plus whether its board can be reached right now. +/// +/// The classification is a **bookmark resolution and nothing else** — no `fileExists`, no listing, +/// no counting. That is what makes `BoardRegistry.recents()` safe to call while a network volume is +/// offline (§ Per-board app state, and § Graceful orphaning for what `unavailable` means to the UI: +/// the row shows with Forget rather than disappearing). +public enum RecentBoard: Sendable, Equatable { + /// Resolved. `at` is where the board lives *now*, which is not necessarily `lastKnownPath` — a + /// bookmark follows renames and moves on the same volume. + case available(BoardRecord, at: URL) + /// The bookmark no longer resolves: deleted, or moved across a volume boundary a bookmark + /// cannot follow. Orphaned — "its settings are conveniences and die with it". + case unavailable(BoardRecord) + + public var record: BoardRecord { + switch self { + case let .available(record, _): record + case let .unavailable(record): record + } + } + + /// Where the board is now, or `nil` for an orphan. + public var url: URL? { + switch self { + case let .available(_, url): url + case .unavailable: nil + } + } +} + +// MARK: - BoardRegistry + +/// The persistent side of per-board app state: one record per known board, in Application Support +/// (02-architecture.md § Per-board app state). +/// +/// ### Three rules do most of the work +/// +/// 1. **Identity, never paths.** An opened folder finds its record by resolving each record's +/// bookmark and comparing file identity. A renamed board keeps its settings; a board reopened +/// through a symlink or a moved parent does not acquire a second record. +/// 2. **Nothing it does touches a board folder.** Not a byte, not an xattr. The one file it writes +/// is its own, and it lives somewhere else entirely. +/// 3. **It never takes the app down.** A missing file is an empty registry; a corrupt one is moved +/// aside and the registry starts empty; a save that fails is logged. Every method here is +/// non-throwing on purpose — "its settings are conveniences", and losing a window frame must +/// never cost the user a board. +/// +/// ### Deliberately not here +/// +/// App-wide state that is not board-scoped — quick-style recents, the SSH host-key table, the +/// last-used card-window size — lives *beside* this file, not in it (§ Per-board app state, "App-wide +/// state has the same home"). Secrets live in the Keychain and nowhere near here. +@MainActor +public final class BoardRegistry { + + /// The JSON file this registry is. Public because a diagnostic ("Reveal registry in Finder") and + /// every test want to name it, and because injecting it is how a test stays out of the real + /// Application Support directory. + public let storageURL: URL + + private var records: [BoardRecord] + + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-registry") + + /// `~/Library/Application Support//board-registry.json`, inside the sandbox + /// container. The bundle-id subfolder is Apple's convention and keeps the app's files together + /// as the app-wide neighbours arrive. + public static var defaultStorageURL: URL { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library/Application Support", isDirectory: true) + let bundleIdentifier = Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban" + return support + .appendingPathComponent(bundleIdentifier, isDirectory: true) + .appendingPathComponent("board-registry.json", isDirectory: false) + } + + /// Loads the registry, tolerating everything a file on disk can be. + /// + /// Missing file: an empty registry — the first-launch case, and not an error. **Corrupt file: + /// renamed aside with a timestamp and the registry starts empty.** Renamed rather than deleted + /// because the file may be the only trace of a user's board list, and a support request can read + /// it even when this app cannot; empty rather than fatal because a truncated convenience file + /// must not stand between the user and their boards. + public init(storageURL: URL) { + self.storageURL = storageURL + self.records = [] + self.records = loadFromDisk() + } + + // MARK: - Opening and closing + + /// Records that a board was opened, and answers with the id of the record it belongs to. + /// + /// Matching is the interesting half: every existing record's bookmark is resolved and its file + /// identity compared with the opened folder's. A hit is *the* record no matter what path either + /// side is spelled with — the done-when criterion for a renamed board keeping its settings. + /// Records whose bookmarks no longer resolve are skipped rather than repaired: an orphan is a + /// recents row with Forget, not a candidate for a board that is demonstrably somewhere else. + /// + /// A match is updated in place with a **fresh bookmark** (subsuming the stale-refresh case), the + /// caller's `displayName`, the path it was opened at, and `lastOpened` = now. No match creates a + /// record. Either way the file is saved before returning. + @discardableResult + public func recordOpen(of rootURL: URL, displayName: String) -> UUID { + let bookmark = Self.makeBookmark(for: rootURL)?.data ?? Data() + if bookmark.isEmpty { + // Both the security-scoped and the plain attempt failed — vanishingly unlikely for a + // folder the app has just opened. The record is still created: a row the user can see + // and Forget beats a board that silently never joins recents. + Self.logger.error("no bookmark could be created for the opened board; its record is born orphaned") + } + + if let index = indexOfRecord(matching: rootURL) { + records[index].bookmark = bookmark + records[index].displayName = displayName + records[index].lastKnownPath = rootURL.path + records[index].lastOpened = Self.stamp() + save() + return records[index].id + } + + let record = BoardRecord( + bookmark: bookmark, + displayName: displayName, + lastKnownPath: rootURL.path, + lastOpened: Self.stamp() + ) + records.append(record) + save() + return record.id + } + + /// Stamps the counts the welcome window will render for this board until it is opened again + /// (§ Per-board app state, "Recents counts are registry-cached ... stamped at last close"). + /// + /// Called from the close-flush sequence (02-architecture.md § Launch and window lifecycle), + /// where the store's last snapshot is still in hand — which is the whole reason the counts are + /// free here and expensive anywhere else. + public func recordClose(id: UUID, laneCount: Int, cardCount: Int) { + update(id) { record in + record.laneCount = laneCount + record.cardCount = cardCount + } + } + + // MARK: - Per-board settings + + public func updateWindowFrame(id: UUID, frame: WindowFrame) { + update(id) { $0.windowFrame = frame } + } + + public func setPushOnCommit(id: UUID, _ value: Bool) { + update(id) { $0.pushOnCommit = value } + } + + /// Marks this board's once-per-board remote-location warning as shown (07-sync-collab.md). + /// One-way: there is no un-warn, because the warning is about a location the board is already at. + public func setRemoteLocationWarned(id: UUID) { + update(id) { $0.remoteLocationWarned = true } + } + + // MARK: - Reading + + /// Every known board, most recently opened first, each classified by whether its bookmark + /// resolves — the recents list, which *is* this registry sorted by last-opened. + /// + /// **No directory is scanned and nothing is counted here.** Counts come from the records; the + /// only filesystem work is bookmark resolution, done with `.withoutUI` and `.withoutMounting` + /// so an offline volume classifies as unavailable instead of blocking the welcome window on a + /// mount. + /// + /// Two quiet side effects, both of them the "stale is fine — refresh silently" rule: a bookmark + /// that resolves *stale* is replaced with a fresh one, and if any was, the file is saved. A + /// stale bookmark still works, but only for a while — refreshing it here is what keeps a board + /// that moves around from eventually orphaning itself. `lastKnownPath` is deliberately **not** + /// refreshed: it is the display fallback for a record that *cannot* be resolved, so the resolved + /// URL, not the record, is what an available row shows. + /// + /// The sort is total — `lastOpened` descending, then id — because `sorted(by:)` is not stable + /// and two rows that tie should still come back in the same order every call. + public func recents() -> [RecentBoard] { + var resolvedURLs: [UUID: URL] = [:] + var refreshedAny = false + + for index in records.indices { + guard let resolution = Self.resolve(records[index].bookmark) else { continue } + resolvedURLs[records[index].id] = resolution.url + guard resolution.isStale else { continue } + if let refreshed = Self.withScopedAccess(to: resolution.url, { Self.makeBookmark(for: $0) }) { + records[index].bookmark = refreshed.data + refreshedAny = true + } + } + if refreshedAny { + save() + } + + return records + .sorted { lhs, rhs in + lhs.lastOpened == rhs.lastOpened + ? lhs.id.uuidString > rhs.id.uuidString + : lhs.lastOpened > rhs.lastOpened + } + .map { record in + if let url = resolvedURLs[record.id] { + .available(record, at: url) + } else { + .unavailable(record) + } + } + } + + public func record(id: UUID) -> BoardRecord? { + records.first { $0.id == id } + } + + /// Drops a record — the Forget action on an orphaned recents row, and the only way a record + /// leaves. Nothing else prunes: a board that is merely unavailable today may be a remounted + /// volume tomorrow, so forgetting is always the user's call. + public func forget(id: UUID) { + guard let index = indexOfRecord(id) else { return } + records.remove(at: index) + save() + } + + // MARK: - Matching + + /// The index of the record whose bookmark resolves to the same file as `url`, if any. + private func indexOfRecord(matching url: URL) -> Int? { + guard let target = FileIdentity(of: url) else { return nil } + return records.firstIndex { record in + guard let resolution = Self.resolve(record.bookmark) else { return false } + // Scope is started around the identity read and stopped immediately. Resolving a + // security-scoped bookmark grants nothing by itself, and in the sandbox an unscoped + // `resourceValues` call on some *other* board's folder is exactly the read that gets + // refused — which would silently turn "the same board" into "a new one" and duplicate + // the record. The pairing is balanced, so an outer scope this app holds elsewhere is + // unaffected. + return Self.withScopedAccess(to: resolution.url) { FileIdentity(of: $0) == target } + } + } + + private func indexOfRecord(_ id: UUID) -> Int? { + records.firstIndex { $0.id == id } + } + + /// Mutates a record and saves. An unknown id is a no-op: a window that outlived its record — + /// the user pressed Forget while the board was open — must not crash on its way out. + private func update(_ id: UUID, _ mutate: (inout BoardRecord) -> Void) { + guard let index = indexOfRecord(id) else { + Self.logger.debug("update: no record for this id — ignored") + return + } + mutate(&records[index]) + save() + } + + // MARK: - Bookmarks + + /// A bookmark and which flavor the system actually gave us. + struct Bookmark { + let data: Data + let isSecurityScoped: Bool + } + + /// Creates a bookmark for `url`, security-scoped if the system allows it. + /// + /// **The security-scoped path is the real one.** In production every board URL arrives through + /// NSOpenPanel, a Finder drag, or a previously resolved bookmark, so the app already holds + /// access and `.withSecurityScope` succeeds — which is what lets the board reopen after a + /// relaunch at all (02-architecture.md § Platform, "security-scoped bookmarks for reopening + /// boards across launches"; the `com.apple.security.files.bookmarks.app-scope` entitlement is + /// what backs it). + /// + /// **The plain fallback exists for the cases where that is not true**: a unit test pointing at a + /// temp directory the panel never blessed, and a non-sandboxed debug build where the option is + /// meaningless. A plain bookmark still tracks renames and moves — the identity story is intact — + /// it simply grants no access on resolution, which outside the sandbox is nothing to grant. + /// Returning `nil` from both attempts is left to the caller, which records an orphan rather than + /// dropping the board. + static func makeBookmark(for url: URL) -> Bookmark? { + if let data = try? url.bookmarkData(options: [.withSecurityScope]) { + return Bookmark(data: data, isSecurityScoped: true) + } + if let data = try? url.bookmarkData(options: []) { + Self.logger.debug("security-scoped bookmark unavailable; fell back to a plain bookmark") + return Bookmark(data: data, isSecurityScoped: false) + } + return nil + } + + struct Resolution { + let url: URL + let isStale: Bool + } + + /// Resolves a bookmark, mirroring `makeBookmark(for:)`'s two flavors in the same order. + /// + /// `.withoutUI` and `.withoutMounting`: resolution runs during a recents listing, and a recents + /// listing must never put up a dialog or block for seconds mounting a server. A board on an + /// unmounted volume is *unavailable right now*, which is precisely what the row should say. + /// + /// Resolution alone does **not** start security-scoped access — the caller does that around the + /// use, balanced. That is why `recents()` can classify every record without ever holding a scope + /// open. + static func resolve(_ bookmark: Data) -> Resolution? { + guard !bookmark.isEmpty else { return nil } + + var isStale = false + if let url = try? URL( + resolvingBookmarkData: bookmark, + options: [.withSecurityScope, .withoutUI, .withoutMounting], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) { + return Resolution(url: url, isStale: isStale) + } + + isStale = false + if let url = try? URL( + resolvingBookmarkData: bookmark, + options: [.withoutUI, .withoutMounting], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) { + return Resolution(url: url, isStale: isStale) + } + + return nil + } + + /// Runs `body` with security-scoped access held, if this URL has any to hold. + /// + /// `startAccessingSecurityScopedResource()` returns `false` for a URL that is not + /// security-scoped (a plain bookmark's, or anything already inside the container), and then + /// there is nothing to stop — so the pairing stays balanced either way. + static func withScopedAccess(to url: URL, _ body: (URL) -> T) -> T { + let started = url.startAccessingSecurityScopedResource() + defer { + if started { + url.stopAccessingSecurityScopedResource() + } + } + return body(url) + } + + // MARK: - Persistence + + /// Now, at the resolution the file records — so the registry in memory and the registry on disk + /// are *identical*, not merely close. + /// + /// Stamping through the same formatter the encoder uses makes the round trip exact by + /// construction: what `string(from:)` produces, `date(from:)` maps back to the value that will + /// be decoded, and that value formats to the same string again. Without this a freshly stamped + /// record and its reloaded self would differ in the microseconds no format carries — an equality + /// that fails only sometimes, which is the worst kind. + private static func stamp(_ date: Date = Date()) -> Date { + let formatter = boardRegistryTimestampFormatter() + return formatter.date(from: formatter.string(from: date)) ?? date + } + + /// Writes the whole file, atomically, and never throws. + /// + /// Whole-file because it is a handful of small records and a partial writer would be a + /// consistency problem in exchange for nothing. Atomic because a crash mid-write must leave the + /// previous file, not half of this one — the same temp-then-rename discipline `BoardWriter` uses + /// on the user's own files. A failure is logged and swallowed: the caller is a window close or a + /// settings toggle, and neither has anything useful to do about a full disk. + private func save() { + let encoder = JSONEncoder() + // `.sortedKeys` for a byte-stable file (the same records always produce the same bytes) and + // `.prettyPrinted` because the one time anybody reads this file by hand is when something + // has gone wrong. + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + encoder.dateEncodingStrategy = .custom { date, encoder in + var container = encoder.singleValueContainer() + try container.encode(boardRegistryTimestampFormatter().string(from: date)) + } + + // Sorted by id, not by recency: the array's order carries no meaning (`recents()` sorts), + // so pinning it keeps the file from churning every time a board is opened. + let ordered = records.sorted { $0.id.uuidString < $1.id.uuidString } + + do { + try FileManager.default.createDirectory( + at: storageURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try encoder.encode(ordered).write(to: storageURL, options: .atomic) + } catch { + Self.logger.error("could not save the board registry: \(error.localizedDescription, privacy: .public)") + } + } + + private func loadFromDisk() -> [BoardRecord] { + guard FileManager.default.fileExists(atPath: storageURL.path) else { return [] } + + guard let data = try? Data(contentsOf: storageURL) else { + quarantine("unreadable") + return [] + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let text = try container.decode(String.self) + guard let date = boardRegistryTimestampFormatter().date(from: text) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "not an ISO 8601 timestamp: \(text)" + ) + } + return date + } + + do { + return try decoder.decode([BoardRecord].self, from: data) + } catch { + quarantine(error.localizedDescription) + return [] + } + } + + /// Renames the unreadable file aside so the next save starts clean. + /// + /// Renamed, never deleted: this file may be the only record of which boards a user had, and a + /// bug that corrupts it should leave evidence to diagnose rather than a hole. The timestamp + /// keeps repeat corruptions from overwriting each other; the id suffix covers two in the same + /// millisecond. + private func quarantine(_ reason: String) { + Self.logger.error("board registry is unreadable (\(reason, privacy: .public)); quarantining it") + + // The timestamp's colons are legal in a path but read as `/` in Finder, so they go. + let stamp = boardRegistryTimestampFormatter() + .string(from: Date()) + .replacingOccurrences(of: ":", with: "-") + let folder = storageURL.deletingLastPathComponent() + let base = storageURL.deletingPathExtension().lastPathComponent + let fileExtension = storageURL.pathExtension + + func destination(_ name: String) -> URL { + let file = folder.appendingPathComponent(name, isDirectory: false) + return fileExtension.isEmpty ? file : file.appendingPathExtension(fileExtension) + } + + var target = destination("\(base)-corrupt-\(stamp)") + if FileManager.default.fileExists(atPath: target.path) { + target = destination("\(base)-corrupt-\(stamp)-\(UUID().uuidString.prefix(8))") + } + + do { + try FileManager.default.moveItem(at: storageURL, to: target) + } catch { + // Nothing further to do: the next `save()` overwrites the file atomically anyway, so a + // failed quarantine costs the evidence, not the registry. + Self.logger.error("could not quarantine it: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/Kanban/LiveStore/BoardStoreRegistry.swift b/Kanban/LiveStore/BoardStoreRegistry.swift new file mode 100644 index 0000000..a459fa3 --- /dev/null +++ b/Kanban/LiveStore/BoardStoreRegistry.swift @@ -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 + } +} diff --git a/KanbanTests/BoardRegistryTests.swift b/KanbanTests/BoardRegistryTests.swift new file mode 100644 index 0000000..a6c5378 --- /dev/null +++ b/KanbanTests/BoardRegistryTests.swift @@ -0,0 +1,375 @@ +import Foundation +import Testing +@testable import Kanban + +/// `BoardRegistry` is app-private state about the user's boards, and its two hardest promises are +/// negative ones: it must match a board that moved (so settings survive a rename), and it must +/// never write a byte into a board folder (so files-first stays absolute). Both are tested here +/// against real directories and a real storage file in temp — a fake filesystem would prove neither, +/// since both are claims about file identity and about what is on disk. +/// +/// The third promise is that nothing it does can take the app down: a missing file, a corrupt file, +/// and a bookmark that no longer resolves all have expected, boring outcomes, and each has a test. + +// MARK: - Fixtures + +/// A temp folder holding the registry's JSON file, kept separate from every board folder so the +/// quarantine test can see exactly what the registry put next to it. +@MainActor +private struct RegistryStorage { + let folder: URL + + var url: URL { folder.appendingPathComponent("board-registry.json", isDirectory: false) } + + init() throws { + folder = FileManager.default.temporaryDirectory + .appendingPathComponent("BoardRegistryTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + } + + func tearDown() { + try? FileManager.default.removeItem(at: folder) + } + + func entryNames() throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: folder.path).sorted() + } +} + +/// A small real board — the registry never reads inside one, but a folder with contents is what +/// makes the files-first test able to notice a single stray byte. +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + return fixture +} + +/// Every path under `root` (the root itself included, hidden entries included) with its +/// modification date — the shape "the registry touched nothing" takes as an assertion. +private func treeSnapshot(of root: URL) throws -> [String: Date] { + var snapshot: [String: Date] = [:] + + func modified(_ url: URL) throws -> Date { + try url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate ?? .distantPast + } + + snapshot["."] = try modified(root) + let walker = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [] + ) + while let url = walker?.nextObject() as? URL { + let relative = url.path.replacingOccurrences(of: root.path + "/", with: "") + snapshot[relative] = try modified(url) + } + return snapshot +} + +private func ids(_ recents: [RecentBoard]) -> [UUID] { + recents.map(\.record.id) +} + +// MARK: - Tests + +@MainActor +@Suite("BoardRegistry") +struct BoardRegistryTests { + + // MARK: Matching on open + + @Test("A second open of the same board updates its record; a rename does not fool it") + func recordOpenMatchesByIdentity() async throws { + let storage = try RegistryStorage() + defer { storage.tearDown() } + let fixture = try makeBoard() + defer { fixture.tearDown() } + let registry = BoardRegistry(storageURL: storage.url) + + let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board") + let created = try #require(registry.record(id: id)) + #expect(!created.bookmark.isEmpty) + #expect(created.displayName == "Todo Board") + #expect(created.lastKnownPath == fixture.root.path) + #expect(created.laneCount == nil, "counts are stamped at close, never guessed at open") + #expect(created.pushOnCommit == false) + #expect(created.remoteLocationWarned == false) + #expect(registry.recents().count == 1) + + try await Task.sleep(for: .milliseconds(5)) + let again = registry.recordOpen(of: fixture.root, displayName: "Renamed In Title") + #expect(again == id, "the same folder is the same board") + #expect(registry.recents().count == 1, "a second open updates a record, it does not add one") + let updated = try #require(registry.record(id: id)) + #expect(updated.displayName == "Renamed In Title") + #expect(updated.lastOpened > created.lastOpened) + + // The done-when criterion: a Finder rename, then an open through the new path. Only file + // identity gets this right — every path-keyed answer produces a second record here. + let renamed = fixture.root + .deletingLastPathComponent() + .appendingPathComponent("renamed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.moveItem(at: fixture.root, to: renamed) + defer { try? FileManager.default.removeItem(at: renamed) } + + let afterRename = registry.recordOpen(of: renamed, displayName: "Todo Board") + #expect(afterRename == id, "a renamed board keeps its record, and so keeps its settings") + #expect(registry.recents().count == 1) + #expect(registry.record(id: id)?.lastKnownPath == renamed.path) + } + + // MARK: Counts + + @Test("Counts are stamped at close and read back without a scan") + func closeStampsCountsAndRecentsNeverScans() async throws { + let storage = try RegistryStorage() + defer { storage.tearDown() } + let fixture = try makeBoard() + defer { fixture.tearDown() } + let registry = BoardRegistry(storageURL: storage.url) + + let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board") + registry.recordClose(id: id, laneCount: 2, cardCount: 7) + + // The board grows after the close — an agent filing cards, a colleague's pull. A welcome + // window that scanned would notice; this one must not, because scanning is what makes + // welcome slow on a big board and hangs it on an unavailable one. + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + try fixture.item("\(Ident.lane2)/\(Ident.card2)", Item.rich(order: "1024", title: "Second")) + try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "2048", title: "Third")) + + let rows = registry.recents() + #expect(rows.count == 1) + guard case let .available(record, at: url) = rows[0] else { + Issue.record("expected the board to be available, got \(rows[0])") + return + } + #expect(record.laneCount == 2, "the stamped count, not the tree's count") + #expect(record.cardCount == 7) + #expect(FileIdentity(of: url) == FileIdentity(of: fixture.root)) + } + + // MARK: Orphaning + + @Test("A deleted board is orphaned in recents and can be forgotten") + func deletedBoardIsOrphaned() async throws { + let storage = try RegistryStorage() + defer { storage.tearDown() } + let fixture = try makeBoard() + let registry = BoardRegistry(storageURL: storage.url) + + let id = registry.recordOpen(of: fixture.root, displayName: "Doomed") + registry.recordClose(id: id, laneCount: 1, cardCount: 1) + fixture.tearDown() + + let rows = registry.recents() + #expect(rows.count == 1) + guard case let .unavailable(record) = rows[0] else { + Issue.record("expected an orphan, got \(rows[0])") + return + } + #expect(record.id == id) + #expect(record.displayName == "Doomed", "an orphan still renders — with Forget, not nothing") + #expect(record.lastKnownPath == fixture.root.path) + + registry.forget(id: id) + #expect(registry.recents().isEmpty) + #expect(registry.record(id: id) == nil) + #expect(BoardRegistry(storageURL: storage.url).recents().isEmpty, "forgetting persists") + } + + @Test("A bookmark that cannot resolve at all classifies as unavailable") + func unresolvableBookmarkIsUnavailable() async throws { + let storage = try RegistryStorage() + defer { storage.tearDown() } + + // Hand-written rather than produced by the registry: deleting a folder *ought* to make its + // bookmark stop resolving, but "ought to" is the filesystem's opinion, and this rule needs a + // case that cannot resolve by construction. It doubles as the only place the on-disk shape + // is pinned literally — including the fractional-seconds timestamp format. + let id = UUID() + let garbage = Data("not a bookmark".utf8).base64EncodedString() + let json = """ + [ + { + "bookmark" : "\(garbage)", + "cardCount" : 9, + "displayName" : "Archive", + "id" : "\(id.uuidString)", + "laneCount" : 4, + "lastKnownPath" : "/Volumes/Archive/Boards/Archive", + "lastOpened" : "2026-01-01T09:00:00.000Z", + "pushOnCommit" : true, + "remoteLocationWarned" : true + } + ] + """ + try Data(json.utf8).write(to: storage.url) + + let registry = BoardRegistry(storageURL: storage.url) + let rows = registry.recents() + #expect(rows.count == 1, "an unreadable bookmark is an orphaned row, not a decoding failure") + guard case let .unavailable(record) = rows[0] else { + Issue.record("expected an orphan, got \(rows[0])") + return + } + #expect(record.id == id) + #expect(record.laneCount == 4, "an orphan still shows the counts it was closed with") + #expect(record.pushOnCommit) + #expect(record.remoteLocationWarned) + #expect(record.lastKnownPath == "/Volumes/Archive/Boards/Archive") + + registry.forget(id: id) + #expect(registry.recents().isEmpty) + } + + // MARK: Persistence + + @Test("Every mutation survives a reload of the file, dates included") + func mutationsPersistAcrossInstances() async throws { + let storage = try RegistryStorage() + defer { storage.tearDown() } + let first = try makeBoard() + defer { first.tearDown() } + let second = try makeBoard() + defer { second.tearDown() } + + let registry = BoardRegistry(storageURL: storage.url) + let firstID = registry.recordOpen(of: first.root, displayName: "First") + try await Task.sleep(for: .milliseconds(5)) + let secondID = registry.recordOpen(of: second.root, displayName: "Second") + registry.recordClose(id: firstID, laneCount: 3, cardCount: 11) + registry.updateWindowFrame(id: firstID, frame: WindowFrame(x: 120, y: 60, width: 1440, height: 900)) + registry.setPushOnCommit(id: firstID, true) + registry.setRemoteLocationWarned(id: firstID) + + let reloaded = BoardRegistry(storageURL: storage.url) + + // Equality of the whole record, not a field-by-field approximation: `lastOpened` is stamped + // at the resolution the file records, so the reloaded value is the *same* date rather than + // one within a second of it. + #expect(reloaded.record(id: firstID) == registry.record(id: firstID)) + #expect(reloaded.record(id: secondID) == registry.record(id: secondID)) + + let restored = try #require(reloaded.record(id: firstID)) + #expect(restored.laneCount == 3) + #expect(restored.cardCount == 11) + #expect(restored.windowFrame == WindowFrame(x: 120, y: 60, width: 1440, height: 900)) + #expect(restored.pushOnCommit) + #expect(restored.remoteLocationWarned) + #expect(restored.lastOpened == registry.record(id: firstID)?.lastOpened) + + #expect(ids(reloaded.recents()) == ids(registry.recents()), "order survives too") + } + + // MARK: Corruption + + @Test("A corrupt file is quarantined, not deleted, and the registry carries on empty") + func corruptFileIsQuarantined() async throws { + let storage = try RegistryStorage() + defer { storage.tearDown() } + let fixture = try makeBoard() + defer { fixture.tearDown() } + + let garbage = Data("{ this is not the registry you are looking for".utf8) + try garbage.write(to: storage.url) + + let registry = BoardRegistry(storageURL: storage.url) + #expect(registry.recents().isEmpty, "app-private convenience state never takes the app down") + + let quarantined = try storage.entryNames().filter { $0 != storage.url.lastPathComponent } + #expect(quarantined.count == 1) + let quarantinedName = try #require(quarantined.first) + #expect(quarantinedName.contains("corrupt")) + #expect(quarantinedName.hasSuffix(".json")) + let preserved = try Data(contentsOf: storage.folder.appendingPathComponent(quarantinedName)) + #expect(preserved == garbage, "renamed aside, never deleted — it may be the only trace of the user's boards") + + // And the registry is usable from here: the next save writes a clean file over the hole the + // quarantine left. + let id = registry.recordOpen(of: fixture.root, displayName: "Fresh Start") + #expect(BoardRegistry(storageURL: storage.url).record(id: id)?.displayName == "Fresh Start") + } + + // MARK: Files-first + + @Test("Nothing the registry does touches the board folder") + func theBoardFolderIsNeverTouched() async throws { + let storage = try RegistryStorage() + defer { storage.tearDown() } + let fixture = try makeBoard() + defer { fixture.tearDown() } + + let before = try treeSnapshot(of: fixture.root) + + let registry = BoardRegistry(storageURL: storage.url) + let id = registry.recordOpen(of: fixture.root, displayName: "Untouched") + registry.recordClose(id: id, laneCount: 1, cardCount: 1) + registry.updateWindowFrame(id: id, frame: WindowFrame(x: 0, y: 0, width: 800, height: 600)) + registry.setPushOnCommit(id: id, true) + registry.setRemoteLocationWarned(id: id) + _ = registry.recents() + _ = BoardRegistry(storageURL: storage.url).recents() + + let after = try treeSnapshot(of: fixture.root) + #expect(after == before, "no sidecar, no frontmatter key, no xattr — nothing app-private goes in the board") + #expect(!storage.url.path.hasPrefix(fixture.root.path), "and the file itself lives elsewhere entirely") + } + + // MARK: Ordering + + @Test("Recents is this registry sorted by last-opened, and an open bumps a board to the top") + func recentsOrderFollowsLastOpened() async throws { + let storage = try RegistryStorage() + defer { storage.tearDown() } + let first = try makeBoard() + defer { first.tearDown() } + let second = try makeBoard() + defer { second.tearDown() } + let third = try makeBoard() + defer { third.tearDown() } + let registry = BoardRegistry(storageURL: storage.url) + + let firstID = registry.recordOpen(of: first.root, displayName: "First") + try await Task.sleep(for: .milliseconds(5)) + let secondID = registry.recordOpen(of: second.root, displayName: "Second") + try await Task.sleep(for: .milliseconds(5)) + let thirdID = registry.recordOpen(of: third.root, displayName: "Third") + + #expect(ids(registry.recents()) == [thirdID, secondID, firstID]) + + try await Task.sleep(for: .milliseconds(5)) + _ = registry.recordOpen(of: first.root, displayName: "First") + #expect(ids(registry.recents()) == [firstID, thirdID, secondID]) + } + + // MARK: Bookmarks in a sandboxed host + + @Test("A bookmark is always produced, and resolves back to the same folder") + func bookmarkCreationAndResolutionRoundTrip() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + + // The contract the fallback exists for: *some* bookmark is always available. Which flavor is + // the sandbox's call, and production's answer is the security-scoped one — every board URL + // there arrives through NSOpenPanel or a drag and already carries access. + let bookmark = try #require(BoardRegistry.makeBookmark(for: fixture.root)) + print("BoardRegistryTests: bookmark flavor in this test host = \(bookmark.isSecurityScoped ? "security-scoped" : "plain")") + + let resolution = try #require(BoardRegistry.resolve(bookmark.data)) + let target = try #require(FileIdentity(of: fixture.root)) + let resolved = BoardRegistry.withScopedAccess(to: resolution.url) { FileIdentity(of: $0) } + #expect(resolved == target, "a bookmark of either flavor names the file, not the path") + + // Resolution's own fallback, forced: whichever flavor this host hands out, `resolve` must + // also cope with a plain bookmark, because a registry file written by a non-sandboxed debug + // build (or on a host where the security-scoped attempt failed) is full of them. Nothing + // else in this suite reaches that branch when the sandbox is cooperating. + let plain = try #require(try? fixture.root.bookmarkData(options: [])) + let plainResolution = try #require(BoardRegistry.resolve(plain)) + #expect(FileIdentity(of: plainResolution.url) == target) + } +} diff --git a/KanbanTests/BoardStoreRegistryTests.swift b/KanbanTests/BoardStoreRegistryTests.swift new file mode 100644 index 0000000..b96e7ec --- /dev/null +++ b/KanbanTests/BoardStoreRegistryTests.swift @@ -0,0 +1,246 @@ +import Foundation +import Testing +@testable import Kanban + +/// `BoardStoreRegistry`'s whole job is *sharing*: one store and one watcher per board, handed to +/// every window that asks, torn down once the last one lets go. So these tests are almost entirely +/// about object identity (`===`/`!==`) and about the count that orders teardown — the two things a +/// second store or a leaked watcher would break silently. +/// +/// One test deliberately runs the **real** FSEvents path rather than poking +/// `handleWatcherEvent(_:)` by hand: the registry's reason to exist is the wiring, and wiring +/// asserted against a hand-delivered event is wiring that was never tested. It borrows +/// `FolderWatcherTests`' idiom for that — generous polling when waiting *for* something, never a +/// fixed sleep standing in for an ordering claim. + +// MARK: - Fixtures + +/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`, like every other suite +/// here that needs a real board in a real temp directory. + +/// Frontmatter that opens and closes but does not parse — the fail-fast case, borrowed in shape +/// from `BoardStoreTests`. +private let brokenIndex = "---\nschema: 1\norder: 1024\nlabels: [a, b\n---\nbody\n" + +/// Two lanes, two cards in the first. Enough tree that a reload has something to notice. +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + return fixture +} + +private func cardTitles(inLane id: String, of snapshot: BoardModel) -> [String] { + (snapshot.lanes.first { $0.id.rawValue == id }?.cards ?? []).compactMap(\.title.value) +} + +/// Polls until `condition` holds or the deadline passes — generous, because FSEvents delivery is +/// not a bounded-latency promise and a slow machine must not fail a correctness test. +@MainActor +private func waitUntil(_ deadline: Duration = .seconds(10), _ condition: () -> Bool) async { + let start = ContinuousClock.now + while ContinuousClock.now - start < deadline { + if condition() { return } + try? await Task.sleep(for: .milliseconds(25)) + } +} + +/// Gives a freshly started stream a beat to register with `fseventsd`. Without it the first write +/// of a test can land in the window between `FSEventStreamStart` and the stream actually being +/// live — see `FolderWatcherTests` for the same note. +@MainActor +private func settle() async { + try? await Task.sleep(for: .milliseconds(300)) +} + +// MARK: - Tests + +@MainActor +@Suite("BoardStoreRegistry") +struct BoardStoreRegistryTests { + + // MARK: Sharing + + @Test("Two acquires of one board share a store; two boards get two") + func acquireSharesOneStorePerBoard() async throws { + let first = try makeBoard() + defer { first.tearDown() } + let second = try makeBoard() + defer { second.tearDown() } + let registry = BoardStoreRegistry() + + // The board window, then one of its card windows. + let boardWindowStore = try registry.acquire(first.root) + let cardWindowStore = try registry.acquire(first.root) + + #expect(boardWindowStore === cardWindowStore, "a card window must share its board's store, not load a second one") + #expect(registry.openBoardCount == 1, "two references, one open board") + + let other = try registry.acquire(second.root) + #expect(other !== boardWindowStore) + #expect(registry.openBoardCount == 2) + + registry.release(boardWindowStore) + registry.release(cardWindowStore) + registry.release(other) + } + + // MARK: Refcounted teardown + + @Test("The last release tears the board down; a later acquire opens it fresh") + func refcountOrdersTeardown() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let registry = BoardStoreRegistry() + + let store = try registry.acquire(fixture.root) + _ = try registry.acquire(fixture.root) + + // The card window closes first. The board is still on screen, so nothing may be torn down — + // this is the ordering the refcount exists for. + registry.release(store) + #expect(registry.liveStore(for: fixture.root) === store) + #expect(registry.openBoardCount == 1) + + registry.release(store) + #expect(registry.liveStore(for: fixture.root) == nil) + #expect(registry.openBoardCount == 0) + + // Reopening is a genuine open — a fresh load, a fresh watcher — not a resurrection of the + // store that was let go. + let reopened = try registry.acquire(fixture.root) + #expect(reopened !== store) + #expect(registry.openBoardCount == 1) + registry.release(reopened) + } + + // MARK: Identity, not paths + + @Test("A board acquired through a renamed path lands on the store it already has") + func acquireFollowsFileIdentityAcrossARename() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let registry = BoardStoreRegistry() + + let store = try registry.acquire(fixture.root) + + // A Finder rename, which 01-storage-format.md calls ordinary: same volume, same folder, new + // name. The board is the file, not the string that names it. + let renamed = fixture.root + .deletingLastPathComponent() + .appendingPathComponent("renamed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.moveItem(at: fixture.root, to: renamed) + defer { try? FileManager.default.removeItem(at: renamed) } + + let again = try registry.acquire(renamed) + #expect(again === store, "path-keyed registries open a second store here; identity-keyed ones do not") + #expect(registry.openBoardCount == 1) + #expect(registry.liveStore(for: renamed) === store) + + registry.release(store) + registry.release(store) + #expect(registry.openBoardCount == 0) + } + + // MARK: The wiring + + @Test("The watcher the registry attaches really drives the store") + func watcherWiringIsReal() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let registry = BoardStoreRegistry() + + let store = try registry.acquire(fixture.root) + defer { registry.release(store) } + + // The other half of the wiring: the store can suspend the watcher for its own writes. + #expect(store.watcherBrackets != nil) + + await settle() + + // A card folder appearing with no Writer and no bracket anywhere near it — an agent, or an + // editor. Nothing in this test hands the store an event; FSEvents does. + try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third")) + + await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Third") } + #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second", "Third"]) + #expect(store.reloadFailure == nil) + } + + // MARK: Teardown races + + @Test("Releasing a store the registry never handed out is a no-op") + func releasingAnUnknownStoreIsHarmless() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let stranger = try makeBoard() + defer { stranger.tearDown() } + let registry = BoardStoreRegistry() + + let store = try registry.acquire(fixture.root) + + // A store nobody registered — the shape a double-dismiss or a directly built test store + // takes. Trapping here would turn an ordinary window-close race into a crash. + registry.release(try BoardStore(rootURL: stranger.root)) + #expect(registry.openBoardCount == 1) + #expect(registry.liveStore(for: fixture.root) === store) + + // And releasing one twice past zero. + registry.release(store) + registry.release(store) + #expect(registry.openBoardCount == 0) + } + + // MARK: Fail-fast + + @Test("A board that fails to open leaves no entry behind") + func failedAcquireRegistersNothing() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item(Ident.lane1, brokenIndex) + let registry = BoardStoreRegistry() + + do throws(BoardLoadError) { + _ = try registry.acquire(fixture.root) + Issue.record("expected the load to fail fast") + } catch { + if case .unparseableYAML = error.reason {} else { + Issue.record("expected unparseable YAML, got \(error.reason)") + } + } + + #expect(registry.openBoardCount == 0, "a board that failed to open is not open") + #expect(registry.liveStore(for: fixture.root) == nil) + + // And the registry is not poisoned by the failure: the repaired board opens normally. + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + let store = try registry.acquire(fixture.root) + #expect(registry.openBoardCount == 1) + #expect(store.snapshot.lanes.count == 2) + registry.release(store) + } + + @Test("Acquiring a root that does not exist throws the loader's own error") + func acquireOfAMissingRootThrows() async throws { + let registry = BoardStoreRegistry() + let missing = FileManager.default.temporaryDirectory + .appendingPathComponent("no-such-board-\(UUID().uuidString)", isDirectory: true) + + do throws(BoardLoadError) { + _ = try registry.acquire(missing) + Issue.record("expected a missing root to fail") + } catch { + // The identity read fails first, and the registry deliberately says nothing about that — + // it lets `BoardStore`'s load produce the honest reason. + #expect(error.path == ".") + if case .unreadableRoot = error.reason {} else { + Issue.record("expected an unreadable root, got \(error.reason)") + } + } + #expect(registry.openBoardCount == 0) + } +}