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:
2026-07-26 19:46:18 -04:00
parent 9818dfefbe
commit f0e1738964
4 changed files with 1432 additions and 0 deletions
+595
View File
@@ -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/<bundle id>/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<T>(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)")
}
}
}
+216
View File
@@ -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
}
}
+375
View File
@@ -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)
}
}
+246
View File
@@ -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)
}
}