Materialize the trash — storage layer
Phase 1 of the trash pivot: the file format learns .trash/. The loader parses the reserved root container — cards only, one shared parseCard for both containers so fail-fast, attachments, and verbatim documents are literally the same code; absent means empty; symlinks and lane-shaped nestings fall out as strays by construction. BoardModel grows snapshot.trash as a plain rank-ordered card list — the container has no identity to carry. Legacy deleted: keys keep flowing through the retiring flag path so every tombstone consumer stays green, and are additionally reported through LoadResult.legacyTombstones in the loose-file idiom for phase 2's migration scheduling — nothing vanishes from view before its folder has actually moved, which is also 01's lock-deferral posture. Writer primitives land value-passing: move to trash with caller-minted rank and the deliberate modified stamp, tombstone migrations that surgically remove the key, physical lane removal, per-card and whole-container purge that leaves strays verbatim, and byte-faithful whole-subtree capture/recreate for lane undo. Board-wide identity now spans the trash, so an import colliding with a trashed UUID remints instead of colliding. The watcher already delivered .trash events — isGitInternal tests a component, not a dot — now stated and pinned rather than relied on. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -43,6 +43,33 @@ import os
|
||||
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
|
||||
/// followed, whether it points to a file or a directory — this loader does not resolve
|
||||
/// cross-volume or cyclic trees.
|
||||
///
|
||||
/// ## The trash is a container, not a level
|
||||
///
|
||||
/// `<root>/.trash/` is a **reserved, app-claimed board-root name** holding card folders directly
|
||||
/// (01-storage-format.md § Deletion, resettled 2026-07-28) — "same shape as a lane's children, no
|
||||
/// `index.md` of its own". The walk therefore treats it as a second card container beside the
|
||||
/// lanes: `trashCards(in:)` parses its UUID-shaped children with exactly the card parse the lane
|
||||
/// walk uses (same fail-fast on `schema`/`order`, same skip-and-warn rules), and the result lands
|
||||
/// in `BoardModel.trash` rather than under any lane. Being reserved, it is **never a stray** and
|
||||
/// never warns; absent, the trash is simply empty.
|
||||
///
|
||||
/// ## The migration window
|
||||
///
|
||||
/// The tombstone model is retired: no `deleted:` key is ever written again, and a key found on
|
||||
/// load is *migration input* — a card relocates into `.trash/` with the key removed, a lane
|
||||
/// returns live with the key removed, a board-level key stays meaningless (ignored + logged).
|
||||
/// Detection is read-only here, the loose-file carve-out's posture exactly: this loader reports
|
||||
/// what it found through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated
|
||||
/// fix.
|
||||
///
|
||||
/// **Until that write lands, such items still load through the retiring tombstone path** —
|
||||
/// a `deleted:`-carrying lane or card stays filed under its lane with `isDeleted` set. That is a
|
||||
/// deliberate intermediate, not an oversight: the migration's whole promise is "never destroy",
|
||||
/// and the safe direction while the fix is still pending (it is deferred under any read-only lock,
|
||||
/// and can be pending for a whole session) is for nothing to silently vanish from view before its
|
||||
/// folder has actually moved. The window closes per board on the first successful migration write,
|
||||
/// after which no `deleted:` key exists to read and the flag is permanently `false`.
|
||||
public enum BoardLoader: Sendable {
|
||||
|
||||
/// Schema version this app understands; anything higher fails fast
|
||||
@@ -61,6 +88,31 @@ public enum BoardLoader: Sendable {
|
||||
/// the writer must never disagree about which file a folder's content lives in.
|
||||
static let indexFileName = "index.md"
|
||||
|
||||
/// The materialized trash container at board root (01-storage-format.md § Deletion, resettled
|
||||
/// 2026-07-28) — **app-claimed, never a stray**, joining `CLAUDE.md`, `CLAUDE.user.md` and the
|
||||
/// seeded `.gitignore` on the claimed list.
|
||||
///
|
||||
/// Dot-prefixed, which is doing real work rather than being decoration: `directoryCandidates`
|
||||
/// skips hidden entries, so the container can never be mistaken for a lane candidate and can
|
||||
/// never earn a `.nonUUIDFolderIgnored` warning. The lane walk skips it by name as well
|
||||
/// (`reservedRootNames`) so the rule holds even where hidden-file semantics don't.
|
||||
///
|
||||
/// Internal rather than `private`: `BoardWriter` moves folders into and out of this exact
|
||||
/// name, and a board can have only one trash.
|
||||
static let trashFolderName = ".trash"
|
||||
|
||||
/// Board-root names the app claims, and therefore the names the lane walk skips **without a
|
||||
/// stray warning** (01-storage-format.md § Fractal layout ▸ Rules: "Three board-root names are
|
||||
/// app-claimed, not strays", plus `.trash/` from § Deletion).
|
||||
///
|
||||
/// Only `.trash` is a *folder* and so the only one the directory walk could ever reach; the
|
||||
/// files are listed because the claim is about names, and a future check that needs the set
|
||||
/// should find it complete rather than build a second one. Compared lowercased, like
|
||||
/// `reservedCardChildNames` and for its reason — the filesystem this runs on usually is.
|
||||
static let reservedRootNames: Set<String> = [
|
||||
trashFolderName, "claude.md", "claude.user.md", ".gitignore",
|
||||
]
|
||||
|
||||
/// The card-level names the app claims, and therefore the three the loose-file carve-out
|
||||
/// never touches (01-storage-format.md § Fractal layout ▸ Rules: "Reserved card-level names
|
||||
/// … untouched"): the card's own `index.md` plus the two reserved children. `comments` is
|
||||
@@ -101,8 +153,15 @@ public enum BoardLoader: Sendable {
|
||||
// stray-*tolerance* vocabulary (see `LoadResult.looseCardFiles`).
|
||||
var looseCardFiles: [LooseCardFiles] = []
|
||||
|
||||
// The retired tombstone model's detection channel, on the same reasoning and in the same
|
||||
// idiom (see `LoadResult.legacyTombstones`).
|
||||
var legacyTombstones: [LegacyTombstone] = []
|
||||
|
||||
// Legal per the frontmatter table, meaningless at board level — ignore and log, never
|
||||
// tombstone (01-storage-format.md § Deletion).
|
||||
// tombstone, and **never migrate**: "a `deleted:` key at board level remains meaningless
|
||||
// — ignored and logged, preserved verbatim" (01-storage-format.md § Deletion). It is
|
||||
// deliberately absent from `legacyTombstones`: there is no item to relocate and no key
|
||||
// the app has any business removing from a file it was told to leave alone.
|
||||
if !boardDocument.deleted.isMissing {
|
||||
warn(.boardLevelDeletedIgnored)
|
||||
}
|
||||
@@ -110,6 +169,10 @@ public enum BoardLoader: Sendable {
|
||||
var lanes: [Lane] = []
|
||||
for laneURL in try directoryCandidates(in: boardRoot) {
|
||||
let laneName = laneURL.lastPathComponent
|
||||
// The app-claimed board-root names are not strays and must not warn as such. Hidden
|
||||
// entries never reach here anyway (`.trash` included), so this is the rule stated
|
||||
// rather than the mechanism relied on.
|
||||
guard !reservedRootNames.contains(laneName.lowercased()) else { continue }
|
||||
guard isUUIDShaped(laneName) else {
|
||||
warn(.nonUUIDFolderIgnored(path: laneName))
|
||||
continue
|
||||
@@ -137,10 +200,7 @@ public enum BoardLoader: Sendable {
|
||||
continue
|
||||
}
|
||||
|
||||
let cardPath = cardRelPath + "/" + indexFileName
|
||||
let cardDocument = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath)
|
||||
let cardSchema = try validatedSchema(in: cardDocument, path: cardPath)
|
||||
let cardOrder = try validatedOrder(in: cardDocument, path: cardPath)
|
||||
let card = try parseCard(at: cardURL, path: cardRelPath)
|
||||
|
||||
// Noticed, never acted on: the relocation is the store's, through the Writer.
|
||||
let loose = looseFileNames(in: cardURL)
|
||||
@@ -148,27 +208,35 @@ public enum BoardLoader: Sendable {
|
||||
looseCardFiles.append(LooseCardFiles(
|
||||
laneID: ItemID(rawValue: laneName),
|
||||
cardID: ItemID(rawValue: cardName),
|
||||
title: cardDocument.title.value,
|
||||
title: card.title.value,
|
||||
fileNames: loose
|
||||
))
|
||||
logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/")
|
||||
}
|
||||
|
||||
cards.append(Card(
|
||||
id: ItemID(rawValue: cardName),
|
||||
schema: cardSchema,
|
||||
title: cardDocument.title,
|
||||
created: cardDocument.created,
|
||||
modified: cardDocument.modified,
|
||||
modifiedBy: cardDocument.modifiedBy,
|
||||
deleted: cardDocument.deleted,
|
||||
background: cardDocument.background,
|
||||
icon: cardDocument.icon,
|
||||
iconColor: cardDocument.iconColor,
|
||||
order: cardOrder,
|
||||
attachments: attachmentNames(in: cardURL),
|
||||
document: cardDocument
|
||||
// Detection only, the loose-file precedent exactly: the relocation into `.trash/`
|
||||
// and the key's removal are the store's, through the Writer.
|
||||
if card.isDeleted {
|
||||
legacyTombstones.append(LegacyTombstone(
|
||||
kind: .card,
|
||||
laneID: ItemID(rawValue: laneName),
|
||||
cardID: ItemID(rawValue: cardName),
|
||||
title: card.title.value
|
||||
))
|
||||
logger.info("\(cardRelPath, privacy: .public): legacy 'deleted' key — card to be relocated into \(trashFolderName, privacy: .public)/")
|
||||
}
|
||||
|
||||
cards.append(card)
|
||||
}
|
||||
|
||||
if !laneDocument.deleted.isMissing {
|
||||
legacyTombstones.append(LegacyTombstone(
|
||||
kind: .lane,
|
||||
laneID: ItemID(rawValue: laneName),
|
||||
cardID: nil,
|
||||
title: laneDocument.title.value
|
||||
))
|
||||
logger.info("\(laneName, privacy: .public): legacy 'deleted' key — lane to be returned live with the key removed")
|
||||
}
|
||||
|
||||
lanes.append(Lane(
|
||||
@@ -189,6 +257,21 @@ public enum BoardLoader: Sendable {
|
||||
))
|
||||
}
|
||||
|
||||
var trash: [Card] = []
|
||||
for cardURL in trashCandidates(in: boardRoot) {
|
||||
let cardName = cardURL.lastPathComponent
|
||||
let cardRelPath = trashFolderName + "/" + cardName
|
||||
guard isUUIDShaped(cardName) else {
|
||||
warn(.nonUUIDFolderIgnored(path: cardRelPath))
|
||||
continue
|
||||
}
|
||||
guard hasIndex(cardURL) else {
|
||||
warn(.missingIndex(path: cardRelPath))
|
||||
continue
|
||||
}
|
||||
trash.append(try parseCard(at: cardURL, path: cardRelPath))
|
||||
}
|
||||
|
||||
let model = BoardModel(
|
||||
rootURL: boardRoot,
|
||||
schema: boardSchema,
|
||||
@@ -202,10 +285,80 @@ public enum BoardLoader: Sendable {
|
||||
iconColor: boardDocument.iconColor,
|
||||
template: boardDocument.value(for: templateKey),
|
||||
lanes: Ranks.sortedForDisplay(lanes, order: \.order, name: { $0.id.rawValue }),
|
||||
trash: Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue }),
|
||||
document: boardDocument
|
||||
)
|
||||
|
||||
return LoadResult(model: model, warnings: warnings, looseCardFiles: looseCardFiles)
|
||||
return LoadResult(
|
||||
model: model,
|
||||
warnings: warnings,
|
||||
looseCardFiles: looseCardFiles,
|
||||
legacyTombstones: legacyTombstones
|
||||
)
|
||||
}
|
||||
|
||||
/// One card folder read into a `Card` — **the card parse, shared by both containers**.
|
||||
///
|
||||
/// A trashed card is "an ordinary card in a special place" (03-board-ui.md § Trash), and this
|
||||
/// function is what makes that literally true rather than a claim two code paths have to keep
|
||||
/// agreeing on: the same strict `schema`/`order` validation, the same attachment listing, the
|
||||
/// same verbatim document. Its callers keep what genuinely differs by container — the
|
||||
/// lane-keyed loose-file and legacy-tombstone channels — outside it.
|
||||
///
|
||||
/// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`.
|
||||
/// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has.
|
||||
private static func parseCard(at cardURL: URL, path: String) throws(BoardLoadError) -> Card {
|
||||
let cardPath = path + "/" + indexFileName
|
||||
let document = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath)
|
||||
let schema = try validatedSchema(in: document, path: cardPath)
|
||||
let order = try validatedOrder(in: document, path: cardPath)
|
||||
|
||||
return Card(
|
||||
id: ItemID(rawValue: cardURL.lastPathComponent),
|
||||
schema: schema,
|
||||
title: document.title,
|
||||
created: document.created,
|
||||
modified: document.modified,
|
||||
modifiedBy: document.modifiedBy,
|
||||
deleted: document.deleted,
|
||||
background: document.background,
|
||||
icon: document.icon,
|
||||
iconColor: document.iconColor,
|
||||
order: order,
|
||||
attachments: attachmentNames(in: cardURL),
|
||||
document: document
|
||||
)
|
||||
}
|
||||
|
||||
/// The candidate card folders inside `<root>/.trash/`, or `[]` when there is no trash.
|
||||
///
|
||||
/// **Absent is empty, not an error** — the container is minted by the first delete, so most
|
||||
/// boards never have one, and a board without a trash is a board with an empty trash.
|
||||
///
|
||||
/// **A `.trash` that is not a plain directory yields nothing**: a file by that name, or a
|
||||
/// *symlink* — "symlinks are never traversed" (01-storage-format.md § Fractal layout ▸ Rules),
|
||||
/// and a symlinked trash would render bytes living outside the board that FSEvents never
|
||||
/// reports. Logged rather than warned: `LoadWarning` is the stray vocabulary and a claimed
|
||||
/// name is not a stray, so there is no case here that fits and nothing for a user to do about
|
||||
/// a name the app claims.
|
||||
///
|
||||
/// Entries are `directoryCandidates` — hidden entries and symlinks already excluded, in
|
||||
/// folder-name order — so the trash gets the same stray tolerance every other container gets,
|
||||
/// including the one that matters most here: a **lane-shaped nesting** inside `.trash` (a
|
||||
/// whole lane folder dropped in by hand) is not a level. Its own UUID-shaped children are
|
||||
/// never enumerated, because the walk stops at a card exactly as it does under a lane; the
|
||||
/// outer folder either parses as a card (it has an `index.md`) or is skipped as
|
||||
/// `.missingIndex`, and either way nothing below it renders.
|
||||
private static func trashCandidates(in boardRoot: URL) -> [URL] {
|
||||
let trashURL = boardRoot.appendingPathComponent(trashFolderName, isDirectory: true)
|
||||
guard let values = try? trashURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
|
||||
return []
|
||||
}
|
||||
guard values.isDirectory == true, values.isSymbolicLink != true else {
|
||||
logger.warning("\(trashFolderName, privacy: .public): not a plain directory, treated as an empty trash")
|
||||
return []
|
||||
}
|
||||
return (try? directoryCandidates(in: trashURL)) ?? []
|
||||
}
|
||||
|
||||
// MARK: - Filesystem helpers
|
||||
@@ -364,6 +517,11 @@ public enum BoardLoader: Sendable {
|
||||
/// those to actual lane/card candidates by name shape (`isUUIDShaped`) before doing
|
||||
/// anything else with them.
|
||||
///
|
||||
/// **`.trash/` is hidden, so it never appears among a board root's candidates** — which is
|
||||
/// exactly right: it is a container, not a level, and the walk reaches it by name instead
|
||||
/// (`trashCandidates(in:)`). This same call then enumerates *inside* it, so the trash's own
|
||||
/// children get identical stray tolerance.
|
||||
///
|
||||
/// An unreadable non-root folder (permission changed mid-walk, races) degrades to "no
|
||||
/// candidates" rather than failing the whole load — fail-fast is reserved for the board
|
||||
/// root and for malformed `index.md` content, not transient directory-listing races below
|
||||
@@ -512,7 +670,73 @@ public struct LoadResult: Sendable {
|
||||
/// belongs on disk is a question about the *tree*, not about what the board is currently
|
||||
/// rendering — the same reason the loader flags a tombstoned card at all rather than dropping
|
||||
/// it.
|
||||
///
|
||||
/// **Cards in `.trash/` are deliberately *not* walked for loose files in this version.** The
|
||||
/// channel is keyed by lane (`LooseCardFiles.laneID`, the store's path key) and a trashed card
|
||||
/// has no lane; widening the key is the store-side change that belongs with the store-side
|
||||
/// scheduling. Loose files beside a trashed card's `index.md` therefore keep the ordinary
|
||||
/// stray posture — tolerated, preserved verbatim — and are tidied the moment the card is
|
||||
/// restored into a lane, which is the only state in which they matter.
|
||||
public var looseCardFiles: [LooseCardFiles] = []
|
||||
|
||||
/// The legacy `deleted:` keys this walk found — the retired tombstone model's **migration
|
||||
/// input** (01-storage-format.md § Deletion, resettled 2026-07-28: "Legacy `deleted:` keys
|
||||
/// migrate on load-and-write, never destroy").
|
||||
///
|
||||
/// **The `looseCardFiles` idiom, for the same reason it exists**: `warnings` is the
|
||||
/// stray-*tolerance* vocabulary — "this was ignored, it is staying exactly where it is, there
|
||||
/// is nothing to do" — and a legacy tombstone is the opposite, pending work the store acts on.
|
||||
/// Folding it into the warning channel would also throw away everything the act needs (which
|
||||
/// lane, which card, which title) and force it to be re-derived from a display string.
|
||||
///
|
||||
/// Nothing renders this. Its consumer is the store, which relocates each `.card` into
|
||||
/// `.trash/` with the key removed, strips each `.lane`'s key in place (a lane returns **live**
|
||||
/// — resurrection is the safe direction), and posts the warning-tone notice. Like the
|
||||
/// relocation it mirrors, the write is deferred under any read-only lock; the items stay
|
||||
/// rendered through the retiring tombstone path until it lands (see this type's `BoardLoader`
|
||||
/// note on the migration window).
|
||||
///
|
||||
/// Board-level `deleted:` never appears here — it is meaningless, ignored and logged
|
||||
/// (`LoadWarning.boardLevelDeletedIgnored`), and nothing about it is the app's to rewrite.
|
||||
///
|
||||
/// Order is the walk's: a lane's tombstoned cards, then the lane itself, lane by lane.
|
||||
public var legacyTombstones: [LegacyTombstone] = []
|
||||
}
|
||||
|
||||
/// One item found carrying a legacy `deleted:` key — everything its migration and notice need,
|
||||
/// and nothing more.
|
||||
///
|
||||
/// The path is carried as its identity components rather than as a URL — `LooseCardFiles`'
|
||||
/// convention, for its reason: the write derives its path from the store's *current* root, which
|
||||
/// may have been re-resolved since the load. `title` is the item's as written, `nil` for an
|
||||
/// untitled one, because "Untitled" is a rendering and never a value (03-board-ui.md § Card face).
|
||||
public struct LegacyTombstone: Sendable, Equatable {
|
||||
/// Which migration this item takes — the two are genuinely different acts, not one act at two
|
||||
/// levels: a card *moves* (into `.trash/`, at a minted top-of-trash rank) and a lane stays
|
||||
/// exactly where it is (the key is stripped and it returns live).
|
||||
public enum Kind: Sendable, Equatable {
|
||||
case card
|
||||
case lane
|
||||
}
|
||||
|
||||
public let kind: Kind
|
||||
|
||||
/// The lane's own identity for `.lane`; the card's **containing** lane for `.card` — the
|
||||
/// context the relocation needs to find the folder at all.
|
||||
public let laneID: ItemID
|
||||
|
||||
/// The card's identity for `.card`, `nil` for `.lane`. Two fields rather than an enum payload
|
||||
/// so the common "which folder is this" question is one path join at every call site.
|
||||
public let cardID: ItemID?
|
||||
|
||||
public let title: String?
|
||||
|
||||
public init(kind: Kind, laneID: ItemID, cardID: ItemID?, title: String?) {
|
||||
self.kind = kind
|
||||
self.laneID = laneID
|
||||
self.cardID = cardID
|
||||
self.title = title
|
||||
}
|
||||
}
|
||||
|
||||
/// One card found holding files that belong in its `attachments/` — everything the relocation and
|
||||
|
||||
Reference in New Issue
Block a user