From fa834501b58b358b5ae2d3b5a14fc3b7c01220d4 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 17:42:22 -0400 Subject: [PATCH] Implement tombstone delete, restore, and purge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deleteItem writes deleted: in place through updateIndex — the folder never moves, nothing beneath it is touched (hiding the subtree is the renderer's ancestor walk, not a stored flag). restoreItem removes the key — position-perfect by construction, every duplicate occurrence taken so a hand-written twin cannot resurrect the tombstone. purgeItem physically removes the tree; an already-missing folder is success (a Finder deletion converges on the same end state), and no prior tombstone is required (Delete Immediately skips the stage by design). Board-root deletion is structurally unreachable via the UUID-shape guard shared with move/copy. Neither delete nor restore polices liveness — re-deleting refreshes the timestamp, restoring a live item is a harmless stamped rewrite. 14 new unit tests; 234 total green. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A --- Kanban/Storage/BoardWriter.swift | 91 +++++++++- KanbanTests/BoardWriterTests.swift | 256 +++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+), 3 deletions(-) diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 9d3cadf..04430e2 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -732,6 +732,88 @@ public enum BoardWriter: Sendable { } } + // MARK: - Tombstone + + /// Tombstones a lane or card in place: writes `deleted: ` into its own `index.md` — + /// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never + /// renames, and nothing beneath it is touched: hiding the subtree is the renderer's + /// ancestor walk, not a stored flag, so deleting a lane rewrites *only* the lane's own + /// file — its cards' files are exactly as they were. + /// + /// **Board-root deletion is structurally unreachable at this layer**: `checkIsUUIDShaped` + /// — the same guard `moveItem`/`copyItem` lean on — refuses any folder whose name isn't + /// UUID-shaped, and a board root never is (§ Board naming). A board-level `deleted:` key + /// is legal-but-meaningless per the frontmatter table (the loader ignores and warns on + /// it), but this call is simply never able to *produce* one: it has no board-root code + /// path to fall through, only a refusal. + /// + /// Deleting an **already-tombstoned** item is not refused — it just refreshes the + /// timestamp, a harmless rewrite (the gesture happened again; this layer does not police + /// liveness, the store's UI does). Goes through `updateIndex`, so the usual contract + /// applies: fresh read, refuse an uneditable shape, `modified` stamped and `modified-by` + /// cleared, atomic replace. + public static func deleteItem(at itemFolder: URL) throws(BoardWriteError) { + let operation = "delete item" + try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation) + try checkIsUUIDShaped(itemFolder, operation: operation) + + try updateIndex(inItemFolder: itemFolder, operation: operation) { document in + document.set(FrontmatterKeys.deleted, to: .date(Date())) + } + } + + /// Put Back: removes the `deleted` key, undoing exactly what `deleteItem` wrote. + /// **Position-perfect by construction** — the folder never moved, so the item simply + /// re-enters the visible set at its recorded `order` among its current siblings + /// (01-storage-format.md § Deletion). `FrontmatterDocument.remove` takes *every* + /// occurrence of the key, so a hand-duplicated `deleted` line cannot resurrect the + /// tombstone the instant the winning occurrence is gone. + /// + /// Restoring an item that **isn't** tombstoned is not refused — it is a harmless stamped + /// rewrite, the same shrug `deleteItem` gives an already-deleted item: this layer does not + /// police liveness (a second, independent liveness check here could only drift from the + /// store UI's own, which is what actually decides whether Put Back is offered at all). + public static func restoreItem(at itemFolder: URL) throws(BoardWriteError) { + let operation = "restore item" + try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation) + try checkIsUUIDShaped(itemFolder, operation: operation) + + try updateIndex(inItemFolder: itemFolder, operation: operation) { document in + document.remove(FrontmatterKeys.deleted) + } + } + + /// Physical removal — Delete Immediately / Empty Trash (03-board-ui.md): deletes the + /// folder tree from disk. Irreversible, and distinct from tombstoning — this call does + /// **not** require the item to be tombstoned first, since Delete Immediately skips the + /// tombstone stage by design. + /// + /// **A folder that is already gone is success, not an error** — checked first, before the + /// shape guard below. A Finder deletion converges on exactly the end state a purge would + /// produce (01-storage-format.md § Deletion, "a folder that disappears without a + /// tombstone... is also a delete"), so there is nothing left here to distinguish: a stray + /// path that never existed and a once-real item someone already threw away in Finder both + /// purge cleanly, silently, without inspecting what used to be there. + /// + /// When the folder *does* exist, `checkIsUUIDShaped` guards the same unreachability + /// `deleteItem`/`restoreItem` rely on: a board root or a stray never purges through this + /// call, only a lane or a card. + public static func purgeItem(at itemFolder: URL) throws(BoardWriteError) { + let operation = "purge item" + guard FileManager.default.fileExists(atPath: itemFolder.path) else { return } + + try checkIsUUIDShaped(itemFolder, operation: operation) + do { + try FileManager.default.removeItem(at: itemFolder) + } catch { + throw BoardWriteError( + operation: operation, + path: itemFolder.path, + reason: .io(message: "could not remove folder: \(error.localizedDescription)") + ) + } + } + // MARK: - Move/copy pre-flight /// The rank a moved or copied root lands on: the caller's explicit value — a drop between @@ -754,14 +836,17 @@ public enum BoardWriter: Sendable { /// Refuses a folder that is not a lane or a card. Level detection is by name shape /// (01-storage-format.md § Fractal layout ▸ Rules), so a stray — `notes/`, an uppercase - /// UUID, a hand-made folder — is not an item, and moving or copying one as if it were would - /// invent an identity the loader would then ignore. + /// UUID, a hand-made folder — is not an item, and moving, copying, deleting, restoring, or + /// purging one as if it were would invent (or destroy) an identity the loader would + /// otherwise just ignore. Shared by every operation that must never reach a board root: a + /// board root's folder name is never UUID-shaped (§ Board naming), so this one check is + /// what makes board-root deletion/restore/purge structurally unreachable at this layer. private static func checkIsUUIDShaped(_ folder: URL, operation: String) throws(BoardWriteError) { guard BoardLoader.isUUIDShaped(folder.lastPathComponent) else { throw BoardWriteError( operation: operation, path: folder.path, - reason: .unreadable(message: "folder name is not UUID-shaped: only lanes and cards move and copy") + reason: .unreadable(message: "folder name is not UUID-shaped: only lanes and cards are valid here") ) } } diff --git a/KanbanTests/BoardWriterTests.swift b/KanbanTests/BoardWriterTests.swift index 3941bd8..f195e11 100644 --- a/KanbanTests/BoardWriterTests.swift +++ b/KanbanTests/BoardWriterTests.swift @@ -1598,6 +1598,262 @@ struct BoardWriterCopyTests { } } +// MARK: - Delete / Restore + +/// `BoardWriter.deleteItem`/`restoreItem` — the tombstone half of 01-storage-format.md § +/// Deletion: `deleted: ` written into the item's own `index.md` in place, and Put Back +/// (`remove(deleted)`) undoing exactly that. The folder never moves; hiding a tombstoned +/// subtree is the renderer's ancestor walk, not anything either call does — a deleted lane's +/// cards are never touched. +struct BoardWriterDeleteRestoreTests { + @Test func deletingALaneWritesATombstoneInPlaceAndPreservesEverythingElse() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("A.kanban", Item.board) + let laneFolder = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) + let before = try fixture.indexText("A.kanban/\(Ident.lane1)") + + try BoardWriter.deleteItem(at: laneFolder) + + // Same path, same name — a tombstone never moves or renames the folder. + #expect(fixture.exists("A.kanban/\(Ident.lane1)")) + #expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"]) + + let after = try fixture.indexText("A.kanban/\(Ident.lane1)") + let stamped = [FrontmatterKeys.modified, FrontmatterKeys.modifiedBy, FrontmatterKeys.deleted] + #expect(lines(of: after, excludingKeys: stamped) == lines(of: before, excludingKeys: stamped)) + + let document = try FrontmatterDocument.parse(after) + #expect(document.unknownFields.map(\.key) == ["project", "labels"]) + #expect(document.body.hasSuffix("body — with *markdown*.\n")) + #expect(document.modifiedBy == .missing) + #expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60) + let deleted = try #require(document.deleted.value) + #expect(abs(deleted.timeIntervalSinceNow) < 60) + + let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban")) + #expect(result.model.lanes.first?.isDeleted == true) + } + + /// Hiding beneath is the renderer's walk, not a stored flag: deleting a lane writes only + /// the lane's own file. + @Test func deletingALaneLeavesItsNestedCardUntouched() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("A.kanban", Item.board) + let laneFolder = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One")) + let cardBefore = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") + + try BoardWriter.deleteItem(at: laneFolder) + + #expect(try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") == cardBefore) + } + + @Test func loaderRoundTripDeletingACardShowsItDeletedStillInTheSnapshotAtItsRecordedOrder() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("A.kanban", Item.board) + try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) + let cardFolder = try fixture.item( + "A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One") + ) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two")) + + try BoardWriter.deleteItem(at: cardFolder) + + let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban")) + #expect(result.warnings.isEmpty) + let cards = try #require(result.model.lanes.first?.cards) + #expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2]) + #expect(cards[0].isDeleted == true) + #expect(cards[0].order == 1024) + #expect(cards[1].isDeleted == false) + } + + /// After restore the file carries no residue of `deleted` at all, and the item reappears + /// among its current siblings at the `order` it had all along. + @Test func deleteThenRestoreLeavesNoResidueAndReappearsAtItsRecordedOrder() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("A.kanban", Item.board) + try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) + let cardFolder = try fixture.item( + "A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1536", title: "Card One") + ) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two")) + + try BoardWriter.deleteItem(at: cardFolder) + #expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")) + .deleted.value != nil) + + try BoardWriter.restoreItem(at: cardFolder) + + let after = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") + #expect(!after.contains("deleted")) + let document = try FrontmatterDocument.parse(after) + #expect(document.deleted == .missing) + #expect(document.order == .valid(1536)) + + let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban")) + let cards = try #require(result.model.lanes.first?.cards) + #expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2]) + #expect(cards[0].isDeleted == false) + } + + /// `remove` takes every occurrence, so a hand-duplicated `deleted` line cannot resurrect + /// the tombstone the instant the winning one is gone. + @Test func restoreRemovesAHandDuplicatedDeletedKeyEntirely() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let text = """ + --- + schema: 1 + order: 1024 + title: Twice Gone + deleted: 2026-01-01T00:00:00Z + deleted: 2026-06-01T00:00:00Z + --- + body + + """ + let folder = try fixture.item(Ident.lane1, text) + + try BoardWriter.restoreItem(at: folder) + + let after = try fixture.indexText(Ident.lane1) + #expect(after.components(separatedBy: "\n").filter { $0.hasPrefix("deleted:") }.isEmpty) + #expect(try FrontmatterDocument.parse(after).deleted == .missing) + } + + /// Tombstones are inert to ordering: a sibling deleted via `deleteItem` must not factor into + /// a subsequent create's append target. + @Test func aTombstonedSiblingIsExcludedFromTheAppendRankAfterDeleteItem() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n") + let highOrder = try fixture.item(Child.b, "---\nschema: 1\norder: 9999\ntitle: B\n---\nbody\n") + + try BoardWriter.deleteItem(at: highOrder) + let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New") + + let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue)) + #expect(document.order == .valid(2048)) + } + + @Test func renumberLeavesATombstonedSiblingByteIdentical() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("lane/\(Child.a)", "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n") + let toDelete = try fixture.item("lane/\(Child.b)", "---\nschema: 1\norder: 3000\ntitle: B\n---\nbody\n") + + try BoardWriter.deleteItem(at: toDelete) + let tombstoneAfterDelete = try fixture.indexData("lane/\(Child.b)") + + try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) + + #expect(try fixture.indexData("lane/\(Child.b)") == tombstoneAfterDelete) + } + + /// Board-root deletion is structurally unreachable at the writer level: a board root's + /// folder name is never UUID-shaped. + @Test func deleteRefusesANonUUIDShapedFolder() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let root = try fixture.item("A.kanban", Item.board) + + let error = writeFailure { try BoardWriter.deleteItem(at: root) } + guard case let .unreadable(message) = error?.reason else { + Issue.record("expected .unreadable, got \(String(describing: error?.reason))") + return + } + #expect(message.contains("UUID-shaped")) + #expect(try fixture.indexText("A.kanban") == Item.board) + } + + @Test func restoreRefusesANonUUIDShapedFolder() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let root = try fixture.item("A.kanban", Item.board) + + let error = writeFailure { try BoardWriter.restoreItem(at: root) } + guard case let .unreadable(message) = error?.reason else { + Issue.record("expected .unreadable, got \(String(describing: error?.reason))") + return + } + #expect(message.contains("UUID-shaped")) + #expect(try fixture.indexText("A.kanban") == Item.board) + } + + /// Comes free via `updateIndex`'s pre-flight: a readable-but-uneditable shape refuses every + /// app-mediated write, delete included. + @Test func deleteOnAnUneditableItemRefuses() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let folder = try fixture.item(Ident.lane1, Fixture.flowMapping) + + let error = writeFailure { try BoardWriter.deleteItem(at: folder) } + #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) + #expect(try fixture.indexText(Ident.lane1) == Fixture.flowMapping) + } +} + +// MARK: - Purge + +/// `BoardWriter.purgeItem`: physical removal — Delete Immediately / Empty Trash +/// (01-storage-format.md § Deletion) — irreversible, and distinct from tombstoning. Does not +/// require the item to be tombstoned first: Delete Immediately skips that stage by design. +struct BoardWriterPurgeTests { + @Test func purgeRemovesTheFolderTreeIncludingNestedContentFromDisk() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("A.kanban", Item.board) + let laneFolder = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One")) + + try BoardWriter.purgeItem(at: laneFolder) + + #expect(!fixture.exists("A.kanban/\(Ident.lane1)")) + #expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)")) + } + + /// A folder that is already gone (a Finder deletion) is success, not an error — the same + /// end state a purge would have produced. + @Test func purgeOfAnAlreadyMissingFolderSucceedsSilently() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let gone = fixture.url("A.kanban/\(Ident.lane1)") + #expect(!fixture.exists("A.kanban/\(Ident.lane1)")) + + try BoardWriter.purgeItem(at: gone) + } + + @Test func purgeRefusesANonUUIDShapedFolder() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let root = try fixture.item("A.kanban", Item.board) + + let error = writeFailure { try BoardWriter.purgeItem(at: root) } + guard case let .unreadable(message) = error?.reason else { + Issue.record("expected .unreadable, got \(String(describing: error?.reason))") + return + } + #expect(message.contains("UUID-shaped")) + #expect(fixture.exists("A.kanban")) + } + + @Test func purgeDoesNotRequirePriorTombstone() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let folder = try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Live")) + #expect(try FrontmatterDocument.parse(fixture.indexText(Ident.lane1)).deleted == .missing) + + try BoardWriter.purgeItem(at: folder) + + #expect(!fixture.exists(Ident.lane1)) + } +} + // MARK: - Same-parent move degrades to a reorder /// A move whose destination is the item's current parent is the same gesture as any other drop