SwiftSwiftDataShellby

Deleted and Back Again — An Object That Writes Itself Into the Database

A Shellby postmortem: adding per-conversation deletion to AI chat history, and it comes right back after deletion. The culprit is the session's own auto-persistence callback — abort triggers one last state change that upserts the deleted record back, then CloudKit resurrects it across every device. The fix is one line, but its position is everything.

Shellby’s AI Agent conversation history could only ever accumulate — you couldn’t delete anything. Today I added per-conversation deletion: a right-click / long-press menu, a confirmation dialog, a warning that deleting a running conversation also aborts its task, and selection falling back to the most recent remaining conversation. The feature itself is simple — reuse the existing AIConversationStore.delete(_:). What’s worth writing about is the eerie thing that happened afterward: the conversation deleted, and a moment later it was back.

A conversation is a live object that saves itself

To explain this bug, you first have to know how an AI conversation is persisted. It’s not a static record — it’s a live object that writes itself back to the database.

Each AIAgentSession carries a persistence callback; whenever its state changes (streaming tokens, tool calls, an appended message) it fires, writing the current snapshot into SwiftData:

private func attachPersist(_ session: AIAgentSession) {
    session.onPersist = { [weak self, weak session] in
        guard let self, let session else { return }
        self.persist(session)
    }
}

And the key property of persist is that it’s an upsert — fetch by id, update if it exists, insert a new one if it doesn’t:

private func persist(_ session: AIAgentSession) {
    let id = snap.id
    let existing = try? context.fetch(/* id == id */).first
    if let rec = existing {
        rec.updatedAt = snap.updatedAt
        rec.messagesData = data          // update
    } else {
        context.insert(AIConversation(id: snap.id, /* ... */))   // insert
    }
    try? context.save()
}

With iCloud on, this save() is propagated to other devices by the existing CloudKit private-database mirror — deletion adds no new model, field, or hand-written CloudKit code, riding entirely on the existing sync channel. That’s a virtue here, and it’s about to become an amplifier.

How it resurrects

The first version of delete was intuitive: abort the session, remove it from memory, delete the database record.

func delete(_ session: AIAgentSession) {
    session.abort()                              // ①
    conversations.removeAll { $0.id == session.id }
    // ...reselect...
    deleteRecord(id: session.id)                 // ②
}

Looks fine, but it misses a link: aborting a running session itself causes a state change (teardown, appending an abort marker, and so on) — and any state change fires that onPersist callback. So the timeline becomes:

  1. abort() → fires onPersist → queues a persist;
  2. deleteRecord() deletes the database record;
  3. that persist actually runs — the upsert fetches by id, the record is already gone, so it takes the else branch, context.inserts a new record with the same id, and save()s.

The delete and the insert happen back to back, and the net effect is: the conversation resurrects with its original id. Next launch reads it right back into the list; with iCloud on, CloudKit faithfully syncs this ghost to all your devices — a local ordering bug amplified by the sync mechanism into a multi-device ghost.

There’s a point worth pausing on: the upsert’s insert branch upgrades “the delete didn’t stick” into “the delete resurrected.” If persist could only update, never insert, the worst case would be a failed delete (record still there). It’s precisely because it creates a new one when it can’t find the old that a late persist can recreate an already-deleted record.

The fix is one line, but its position is everything

func delete(_ session: AIAgentSession) {
    session.onPersist = nil     // ← sever the write-back path first
    session.abort()
    conversations.removeAll { $0.id == session.id }
    // ...reselect...
    deleteRecord(id: session.id)
}

session.onPersist = nil goes on the first line, before abort(). The meaning: before you stop this object and trigger any of its teardown side effects, cut off its write-back path to the database first. After that, however abort() changes the state, no callback fires, no persist is queued, and that else-branch insert never happens.

Order is the entire fix. Put onPersist = nil after abort() and you’ve fixed nothing — the abort’s side effect already queued the persist in that instant. You cut off its hand first, then let it fall.

An easy misread: the callback clearly captures [weak self, weak session] — why didn’t that save us? Because the session is still alive throughout the delete (just removed from the in-memory list, but the session parameter in your hand still retains it), so the weak refs resolve fine. Weak references guard against “the object is gone and something still calls into it”; here the object is perfectly alive, the problem is it shouldn’t write anymore. The real cut is niling the closure, not hoping it gets deallocated.

Aside: extracting “which to delete, which to select next” into a pure function

Deletion also has a chunk of logic that has nothing to do with the database but is worth testing on its own: the confirmation dialog’s pending state, and “which one to select after deleting one.” These are extracted into a pure value type, ConversationDeletionIntent:

public struct ConversationDeletionIntent {
    public private(set) var pendingID: UUID?
    public mutating func request(_ id: UUID) { pendingID = id }
    public mutating func cancel() { pendingID = nil }
    public mutating func confirm() -> UUID? { defer { pendingID = nil }; return pendingID }

    // deleting the selected one → fall back to the most recent remaining; otherwise selection unchanged
    public static func selection(afterDeleting deletedID: UUID,
                                 selectedID: UUID?, remainingIDs: [UUID]) -> UUID? {
        selectedID == deletedID ? remainingIDs.first : selectedID
    }
}

This layer touches no SwiftData, no UI — it’s purely a “request delete → cancel / confirm” state machine plus one reselection rule. The payoff of extracting it: deleting the selected item falls back, deleting a non-selected one leaves selection alone, a target that no longer exists is a no-op — these branches need no running app and no database, and a set of unit tests pins them down. The genuinely nasty persistence race belongs to the persistence layer; the part that can be covered by pure logic shouldn’t be mixed in there and left to manual click-testing.

Takeaways

  • Deleting a “live, self-persisting object” is not deleting a record. A static record doesn’t fight back; an observable object with an auto-save callback will write itself back in between your “remove” and your “delete from DB.” Before deleting it, sever its write-back path;
  • An upsert’s insert branch upgrades “didn’t delete” into “resurrected.” A late write with the same id can recreate a deleted record. Any persistence that “creates if not found” needs you to reason about whether a trailing call after deletion can trigger it;
  • Order is the fix. onPersist = nil must come before the abort() that triggers it; move the line and it does nothing. For any cleanup involving “shut down + teardown side effects,” detach the listeners first, then trigger the shutdown;
  • Sync amplifies local ordering bugs. A purely local delete race becomes, through CloudKit, a ghost synced to every device. A cross-device mirror faithfully propagates whatever local state wins — including the wrong one;
  • Logic that can be a pure function shouldn’t marinate in side effects. Confirmation state and post-delete reselection are pure logic; extract them into a value type pinned by unit tests, and save your manual-verification budget for the persistence race you genuinely can’t avoid.

Comments

  • Loading…

Comments are reviewed before publishing; email is visible only to me.