Sync folder structure via Git Sync (folders.json), plus bugs found testing it

Folders and conversation→folder assignments now sync across machines:
- Folder gains updatedAt (v11 migration) to resolve renames/reparents
  last-write-wins across machines.
- New folders.json manifest at the sync repo root: folder tree +
  conversationId→folderId assignments, imported before conversation
  files so new conversations land in the right folder immediately.
- Local folders missing from the manifest are pruned (reparent-safe),
  guarded the same way conversation-orphan cleanup already is against
  an empty/stale manifest wiping everything.

Three real bugs found and fixed during live multi-machine testing:
- Sidebar never refreshed after Git Sync imported conversations/folders
  directly into the database — only reloaded on launch or when the
  advanced conversation list closed, with no equivalent hook for the
  Settings sheet.
- "Sync Now" exported before pulling, so it could write folders.json
  as an untracked file that then collided with the remote's tracked
  copy on the next pull ("untracked working tree files would be
  overwritten by merge"). Reordered to pull → import → export → push.
- Folder assignment only applied to brand-new conversations during
  import, so any conversation already synced to a machine before this
  feature existed never got filed — which in practice is every
  conversation on a second machine, not an edge case. Now backfills
  a folder assignment for existing conversations that aren't filed
  anywhere locally yet, without clobbering an already-set folderId.

Also renamed the "Initialize Repository" button to "Clone Repository"
(it's always been a git clone, not new-repo creation) across the UI,
localization catalog, and Help Book.
This commit is contained in:
2026-08-03 11:48:23 +02:00
parent c3abc5a748
commit 6480a50eee
10 changed files with 362 additions and 27 deletions
+6 -6
View File
@@ -8059,36 +8059,36 @@
}
}
},
"Initialize Repository" : {
"Clone Repository" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Initialiser repository"
"value" : "Klon repository"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Repository initialisieren"
"value" : "Repository klonen"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Initialiser le dépôt"
"value" : "Cloner le dépôt"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Initialiser repositorium"
"value" : "Klon repositorium"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Initiera förvar"
"value" : "Klona förvar"
}
}
}
+4 -1
View File
@@ -29,19 +29,22 @@ struct Folder: Identifiable, Codable, Sendable {
var sortOrder: Int
let createdAt: Date
var parentId: UUID?
var updatedAt: Date
nonisolated init(
id: UUID = UUID(),
name: String,
sortOrder: Int = 0,
createdAt: Date = Date(),
parentId: UUID? = nil
parentId: UUID? = nil,
updatedAt: Date? = nil
) {
self.id = id
self.name = name
self.sortOrder = sortOrder
self.createdAt = createdAt
self.parentId = parentId
self.updatedAt = updatedAt ?? createdAt
}
}
+18
View File
@@ -68,6 +68,24 @@ struct SyncStatus: Equatable {
var remoteStatus: String? // "up-to-date", "ahead 3", "behind 2", etc.
}
/// Serialized as `folders.json` at the sync repo root. Unlike conversation exports, this is a
/// manifest meant to be machine-written/read only (the repo's README already warns against manual
/// edits), so plain JSON is used rather than the hand-rolled markdown format no need for that
/// format's human-readability tradeoffs here.
nonisolated struct FolderSyncManifest: Codable {
nonisolated struct FolderEntry: Codable {
let id: String
let name: String
let parentId: String?
let createdAt: Date
let updatedAt: Date
}
var folders: [FolderEntry]
/// conversationId -> folderId. Only present for conversations actually filed in a folder.
var assignments: [String: String]
}
nonisolated struct ConversationExport {
let id: String
let name: String
@@ -1615,7 +1615,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<li>Syncing happens automatically after every explicit save — no separate auto-save toggle needed. See <a href="#unsaved-changes">Unsaved Changes &amp; Crash Recovery</a>.</li>
<li><strong>Manual Sync</strong>:
<ul>
<li><strong>Initialize Repository</strong> - Clone repository for first-time setup</li>
<li><strong>Clone Repository</strong> - Clone repository for first-time setup</li>
<li><strong>Sync Now</strong> - Full sync (export + pull + import + push)</li>
</ul>
</li>
+51 -6
View File
@@ -46,6 +46,7 @@ struct FolderRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
var sortOrder: Int
var createdAt: String
var parentId: String?
var updatedAt: String?
}
struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
@@ -381,6 +382,16 @@ final class DatabaseService: Sendable {
try db.create(index: "idx_folders_parentId", on: "folders", columns: ["parentId"])
}
migrator.registerMigration("v11") { db in
// Needed to resolve folder renames/reparents last-write-wins when Git Sync brings in
// folder state from another machine without a timestamp there's no way to tell whose
// version of a rename is newer. Backfilled from createdAt for existing rows.
try db.alter(table: "folders") { t in
t.add(column: "updatedAt", .text)
}
try db.execute(sql: "UPDATE folders SET updatedAt = createdAt WHERE updatedAt IS NULL")
}
return migrator
}
@@ -632,7 +643,8 @@ final class DatabaseService: Sendable {
name: folder.name,
sortOrder: folder.sortOrder,
createdAt: Self.isoString(from: folder.createdAt),
parentId: parentId?.uuidString
parentId: parentId?.uuidString,
updatedAt: Self.isoString(from: folder.updatedAt)
)
try dbQueue.write { db in
try record.insert(db)
@@ -651,8 +663,8 @@ final class DatabaseService: Sendable {
nonisolated func renameFolder(id: UUID, name: String) throws {
try dbQueue.write { db in
try db.execute(
sql: "UPDATE folders SET name = ? WHERE id = ?",
arguments: [name, id.uuidString]
sql: "UPDATE folders SET name = ?, updatedAt = ? WHERE id = ?",
arguments: [name, Self.isoString(from: Date()), id.uuidString]
)
}
}
@@ -668,8 +680,38 @@ final class DatabaseService: Sendable {
throw FolderError.wouldCreateCycle
}
}
try db.execute(sql: "UPDATE folders SET parentId = ? WHERE id = ?",
arguments: [parentId?.uuidString, id.uuidString])
try db.execute(sql: "UPDATE folders SET parentId = ?, updatedAt = ? WHERE id = ?",
arguments: [parentId?.uuidString, Self.isoString(from: Date()), id.uuidString])
}
}
/// Creates or updates a folder with an externally-supplied identity and timestamps, for Git
/// Sync import as opposed to `createFolder`, which is for user-initiated creation and always
/// mints a fresh id/timestamps. Last-write-wins: if the folder already exists locally, only
/// overwrites name/parentId when `updatedAt` is strictly newer than the local row's.
nonisolated func upsertSyncedFolder(id: UUID, name: String, parentId: UUID?, createdAt: Date, updatedAt: Date) throws {
let existing = try dbQueue.read { db in
try FolderRecord.fetchOne(db, key: id.uuidString)
}
if let existing {
guard let existingUpdatedAt = existing.updatedAt.flatMap(Self.isoDate(from:)),
existingUpdatedAt < updatedAt
else { return }
try dbQueue.write { db in
try db.execute(
sql: "UPDATE folders SET name = ?, parentId = ?, updatedAt = ? WHERE id = ?",
arguments: [name, parentId?.uuidString, Self.isoString(from: updatedAt), id.uuidString]
)
}
} else {
let record = FolderRecord(
id: id.uuidString, name: name, sortOrder: try nextFolderSortOrder(),
createdAt: Self.isoString(from: createdAt), parentId: parentId?.uuidString,
updatedAt: Self.isoString(from: updatedAt)
)
try dbQueue.write { db in
try record.insert(db)
}
}
}
@@ -702,9 +744,12 @@ final class DatabaseService: Sendable {
guard let id = UUID(uuidString: record.id),
let createdAt = Self.isoDate(from: record.createdAt)
else { return nil }
// Falls back to createdAt if updatedAt is somehow missing (shouldn't happen post-v11
// migration, which backfills every existing row) rather than failing the whole fetch.
let updatedAt = record.updatedAt.flatMap(Self.isoDate(from:)) ?? createdAt
return Folder(
id: id, name: record.name, sortOrder: record.sortOrder, createdAt: createdAt,
parentId: record.parentId.flatMap { UUID(uuidString: $0) }
parentId: record.parentId.flatMap { UUID(uuidString: $0) }, updatedAt: updatedAt
)
}
+107 -6
View File
@@ -196,6 +196,28 @@ class GitSyncService {
log.info("Removed orphaned export for deleted conversation: \(filename)")
}
// Export the folder tree + conversationfolder assignments alongside the conversations
// themselves, so a fresh machine's import can restore folder structure too. See
// upsertSyncedFolder/orphanedLocalFolderIds for how the import side consumes this.
let allFolders = try db.listFolders()
let manifest = FolderSyncManifest(
folders: allFolders.map {
FolderSyncManifest.FolderEntry(
id: $0.id.uuidString, name: $0.name, parentId: $0.parentId?.uuidString,
createdAt: $0.createdAt, updatedAt: $0.updatedAt
)
},
assignments: Dictionary(uniqueKeysWithValues: conversations.compactMap { conv in
conv.folderId.map { (conv.id.uuidString, $0.uuidString) }
})
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let manifestData = try encoder.encode(manifest)
try manifestData.write(to: URL(fileURLWithPath: localPath + "/folders.json"))
log.debug("Exported folders.json (\(allFolders.count) folders)")
await updateStatus()
}
@@ -221,6 +243,21 @@ class GitSyncService {
}
}
/// Given the folder ids present in a just-pulled `folders.json` manifest and the folder ids
/// that exist locally, returns the local ids that should be deleted (folder was removed
/// upstream since the last sync). Same empty-manifest safety guard as
/// `orphanedExportFilenames` an empty manifest is indistinguishable from "haven't imported
/// folders.json yet" (e.g. an older sync repo with no manifest at all, or a fresh clone before
/// the first export), so treating it as "delete every local folder" would be exactly the same
/// class of mass-deletion bug that hit conversation sync.
nonisolated static func orphanedLocalFolderIds(
manifestFolderIds: Set<String>,
localFolderIds: Set<String>
) -> [String] {
guard !manifestFolderIds.isEmpty else { return [] }
return localFolderIds.filter { !manifestFolderIds.contains($0) }
}
/// Import conversations from markdown files
func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) {
try ensureCloned()
@@ -233,6 +270,49 @@ class GitSyncService {
return (0, 0, 0)
}
// Import the folder tree + assignments before any conversation, so a brand-new
// conversation created below can immediately reference a folder that already exists
// locally. Missing/unparsable folders.json (older sync repos, or a fresh clone before the
// first export) is treated as "no folders to import," not an error.
var folderAssignments: [String: String] = [:]
let manifestPath = localPath + "/folders.json"
if let manifestData = try? Data(contentsOf: URL(fileURLWithPath: manifestPath)) {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
if let manifest = try? decoder.decode(FolderSyncManifest.self, from: manifestData) {
folderAssignments = manifest.assignments
// Insert parents before children so parentId's foreign key reference (folders.
// parentId references folders(id)) is always satisfied.
let manifestFolders = manifest.folders.compactMap { entry -> Folder? in
guard let id = UUID(uuidString: entry.id) else { return nil }
return Folder(
id: id, name: entry.name, createdAt: entry.createdAt,
parentId: entry.parentId.flatMap { UUID(uuidString: $0) },
updatedAt: entry.updatedAt
)
}
for (folder, _) in Folder.orderedTree(from: manifestFolders) {
try? db.upsertSyncedFolder(
id: folder.id, name: folder.name, parentId: folder.parentId,
createdAt: folder.createdAt, updatedAt: folder.updatedAt
)
}
// Delete local folders no longer present upstream reparents their contents up
// one level via the existing deleteFolder semantics.
let manifestFolderIds = Set(manifest.folders.map { $0.id })
let localFolderIds = Set((try? db.listFolders())?.map { $0.id.uuidString } ?? [])
for idString in Self.orphanedLocalFolderIds(manifestFolderIds: manifestFolderIds, localFolderIds: localFolderIds) {
if let id = UUID(uuidString: idString) {
try? db.deleteFolder(id: id)
log.info("Removed local folder no longer present in sync repo: \(idString)")
}
}
log.debug("Imported folders.json (\(manifest.folders.count) folders)")
}
}
let files = try FileManager.default.contentsOfDirectory(atPath: conversationsDir)
let mdFiles = files.filter { $0.hasSuffix(".md") }
@@ -254,8 +334,20 @@ class GitSyncService {
// Check if conversation already exists (by ID)
if let existingId = UUID(uuidString: export.id) {
if (try? db.loadConversation(id: existingId)) != nil {
// Already exists - skip
if let (existingConversation, _) = try? db.loadConversation(id: existingId) {
// Already exists - skip re-importing its content, but still backfill a
// folder assignment if the manifest has one and this conversation isn't
// filed anywhere locally yet. Without this, a conversation that was synced
// to this machine before folder sync existed (or before it was ever put in
// a folder on any machine) would never get filed here every conversation
// in a multi-machine setup already exists locally by the time folders.json
// starts carrying assignments, so this isn't an edge case, it's the normal
// case. Never overwrites an existing local folderId, so a conversation
// already filed (by this machine or a prior import) isn't silently moved.
if existingConversation.folderId == nil,
let assignedFolderId = folderAssignments[export.id].flatMap(UUID.init) {
try? db.moveConversation(id: existingId, toFolder: assignedFolderId)
}
log.debug("Skipping existing conversation: \(export.name)")
skipped += 1
continue
@@ -282,13 +374,18 @@ class GitSyncService {
)
}
// Import to database with primaryModel
// Import to database with primaryModel, plus its folder assignment (if any) from
// folders.json only applies here at first-import; an existing local conversation
// that's later moved to a different folder on another machine doesn't get updated,
// matching how its content/name aren't updated either once already imported.
let conversationId = UUID(uuidString: export.id) ?? UUID()
let folderId = folderAssignments[export.id].flatMap { UUID(uuidString: $0) }
_ = try db.saveConversation(
id: conversationId,
name: export.name,
messages: messages,
primaryModel: export.primaryModel
primaryModel: export.primaryModel,
folderId: folderId
)
log.info("Imported: \(export.name)")
imported += 1
@@ -337,23 +434,27 @@ class GitSyncService {
### Export (Automatic)
- Confab saves conversations to its local database
- Auto-sync exports conversations to `conversations/*.md`
- Your folder structure (if you organize conversations into folders) is exported to `folders.json`
- Files are committed and pushed to this git repository
### Import (On New Machine)
- Clone this repository on a new machine
- Confab imports markdown files into its database
- Your conversation history is restored
- Confab imports markdown files and folders.json into its database
- Your conversation history and folder structure are restored
### Sync Across Machines
- Machine A: Chat → Auto-save → Export → Push to git
- Machine B: Pull from git → Auto-import → Database updated
- Conversations stay in sync across all machines
- Folder renames/moves also sync between machines; a conversation's folder is only set
the first time it's imported onto a new machine
## File Structure
```
/
├── README.md # This file
├── folders.json # Your folder structure (auto-managed, don't edit)
└── conversations/ # Your conversations
├── conversation-1.md
├── conversation-2.md
+5
View File
@@ -231,6 +231,11 @@ struct SidebarView: View {
// conversation list modal live in its own @State refresh ours once it closes.
if !isShowing { loadData() }
}
.onChange(of: chatViewModel.showSettings) { _, isShowing in
// Git Sync (Settings Sync) can import conversations/folders directly into the
// database refresh ours once the sheet closes so they show up without a relaunch.
if !isShowing { loadData() }
}
}
@ViewBuilder
+11 -7
View File
@@ -1737,7 +1737,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
} else {
Image(systemName: "arrow.down.circle")
}
Text("Initialize Repository")
Text("Clone Repository")
}
.frame(minWidth: 160)
}
@@ -3023,18 +3023,22 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
syncTestResult = nil
do {
// Step 1: Export all conversations
syncTestResult = "Exporting conversations..."
try await gitSync.exportAllConversations()
// Step 2: Pull from remote
// Step 1: Pull from remote first, into a working tree that hasn't been touched by
// this round's export yet. Exporting before pulling can write files (e.g.
// folders.json, which every machine writes to the same path) that git then refuses
// to merge over: "untracked working tree files would be overwritten by merge".
syncTestResult = "Pulling changes..."
try await gitSync.pull()
// Step 3: Import any new conversations
// Step 2: Import any new/updated conversations and folders from what was just pulled
syncTestResult = "Importing conversations..."
let result = try await gitSync.importAllConversations()
// Step 3: Export now safe, re-derives working tree files from the local DB state,
// which already reflects whatever was just imported plus this machine's own changes
syncTestResult = "Exporting conversations..."
try await gitSync.exportAllConversations()
// Step 4: Push to remote
syncTestResult = "Pushing changes..."
try await gitSync.push()
+92
View File
@@ -237,6 +237,98 @@ struct DatabaseServiceFolderTests {
#expect(db.columnNames(in: "folders").contains("parentId"))
}
@Test("v11 adds updatedAt to folders")
func v11AddsUpdatedAt() {
let db = DatabaseService.makeInMemory()
#expect(db.columnNames(in: "folders").contains("updatedAt"))
}
@Test("renameFolder bumps updatedAt")
func renameFolderBumpsUpdatedAt() throws {
let db = DatabaseService.makeInMemory()
let folder = try db.createFolder(name: "Work")
// Round-trip through the DB for the "before" value too, so both sides go through the same
// fractional-seconds truncation as the "after" read below comparing a raw in-memory
// Date() (full precision) against a DB-round-tripped one can flake when both timestamps
// land in the same millisecond window.
let originalUpdatedAt = try #require(db.listFolders().first(where: { $0.id == folder.id })?.updatedAt)
try db.renameFolder(id: folder.id, name: "Projects")
let updated = try db.listFolders().first(where: { $0.id == folder.id })
#expect(updated?.updatedAt ?? .distantPast >= originalUpdatedAt)
}
@Test("moveFolder bumps updatedAt")
func moveFolderBumpsUpdatedAt() throws {
let db = DatabaseService.makeInMemory()
let parent = try db.createFolder(name: "Work")
let child = try db.createFolder(name: "Personal")
// See renameFolderBumpsUpdatedAt's comment: round-trip through the DB for the "before"
// value so it's truncated the same way as the "after" read.
let originalUpdatedAt = try #require(db.listFolders().first(where: { $0.id == child.id })?.updatedAt)
try db.moveFolder(id: child.id, toParent: parent.id)
let updated = try db.listFolders().first(where: { $0.id == child.id })
#expect(updated?.updatedAt ?? .distantPast >= originalUpdatedAt)
}
@Test("upsertSyncedFolder creates a folder that doesn't exist locally yet")
func upsertSyncedFolderCreatesNew() throws {
let db = DatabaseService.makeInMemory()
let id = UUID()
let createdAt = Date(timeIntervalSince1970: 1_000)
let updatedAt = Date(timeIntervalSince1970: 2_000)
try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: updatedAt)
let folders = try db.listFolders()
let created = try #require(folders.first(where: { $0.id == id }))
#expect(created.name == "Work")
#expect(created.parentId == nil)
#expect(created.updatedAt == updatedAt)
}
@Test("upsertSyncedFolder is a no-op when the local version is the same age or newer")
func upsertSyncedFolderNoOpWhenLocalNotOlder() throws {
let db = DatabaseService.makeInMemory()
let id = UUID()
let createdAt = Date(timeIntervalSince1970: 1_000)
let localUpdatedAt = Date(timeIntervalSince1970: 5_000)
try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: localUpdatedAt)
// Incoming manifest entry is older than what's already local.
let staleIncomingUpdatedAt = Date(timeIntervalSince1970: 2_000)
try db.upsertSyncedFolder(id: id, name: "Renamed Elsewhere", parentId: nil, createdAt: createdAt, updatedAt: staleIncomingUpdatedAt)
let folders = try db.listFolders()
let unchanged = try #require(folders.first(where: { $0.id == id }))
#expect(unchanged.name == "Work")
#expect(unchanged.updatedAt == localUpdatedAt)
}
@Test("upsertSyncedFolder updates name and parent when the incoming version is newer")
func upsertSyncedFolderUpdatesWhenIncomingNewer() throws {
let db = DatabaseService.makeInMemory()
let id = UUID()
let otherParent = try db.createFolder(name: "Other Parent")
let createdAt = Date(timeIntervalSince1970: 1_000)
let localUpdatedAt = Date(timeIntervalSince1970: 2_000)
try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: localUpdatedAt)
let newerIncomingUpdatedAt = Date(timeIntervalSince1970: 9_000)
try db.upsertSyncedFolder(
id: id, name: "Projects", parentId: otherParent.id, createdAt: createdAt, updatedAt: newerIncomingUpdatedAt
)
let folders = try db.listFolders()
let updated = try #require(folders.first(where: { $0.id == id }))
#expect(updated.name == "Projects")
#expect(updated.parentId == otherParent.id)
#expect(updated.updatedAt == newerIncomingUpdatedAt)
}
@Test("createFolder(parentId:) nests the new folder under its parent")
func createFolderWithParent() throws {
let db = DatabaseService.makeInMemory()
+67
View File
@@ -165,4 +165,71 @@ struct GitSyncServiceTests {
let orphans = GitSyncService.orphanedExportFilenames(currentIds: [], files: files)
#expect(orphans.isEmpty)
}
// MARK: - orphanedLocalFolderIds
@Test("A local folder still present in the manifest is not orphaned")
func keepsLocalFoldersStillInManifest() {
let id = UUID().uuidString
let orphans = GitSyncService.orphanedLocalFolderIds(manifestFolderIds: [id], localFolderIds: [id])
#expect(orphans.isEmpty)
}
@Test("A local folder missing from the manifest is orphaned")
func flagsLocalFoldersMissingFromManifest() {
let keptId = UUID().uuidString
let deletedId = UUID().uuidString
let orphans = GitSyncService.orphanedLocalFolderIds(
manifestFolderIds: [keptId], localFolderIds: [keptId, deletedId]
)
#expect(orphans == [deletedId])
}
@Test("An empty manifest never orphans existing local folders")
func emptyManifestNeverOrphansLocalFolders() {
// Same safety guard as orphanedExportFilenames: an empty manifest is indistinguishable
// from "folders.json hasn't been imported yet" (older sync repo, or a fresh clone before
// the first export), so treating it as "delete every local folder" would repeat the exact
// class of mass-deletion bug that hit conversation sync.
let orphans = GitSyncService.orphanedLocalFolderIds(
manifestFolderIds: [], localFolderIds: [UUID().uuidString, UUID().uuidString]
)
#expect(orphans.isEmpty)
}
@Test("Empty local folder list produces no orphans")
func emptyLocalFolderListProducesNoOrphans() {
let orphans = GitSyncService.orphanedLocalFolderIds(manifestFolderIds: [UUID().uuidString], localFolderIds: [])
#expect(orphans.isEmpty)
}
// MARK: - FolderSyncManifest round-trip
@Test("FolderSyncManifest round-trips through JSON encode/decode")
func folderSyncManifestRoundTrips() throws {
let folderId = UUID().uuidString
let conversationId = UUID().uuidString
let manifest = FolderSyncManifest(
folders: [
FolderSyncManifest.FolderEntry(
id: folderId, name: "Work", parentId: nil,
createdAt: Date(timeIntervalSince1970: 1_000), updatedAt: Date(timeIntervalSince1970: 2_000)
)
],
assignments: [conversationId: folderId]
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let data = try encoder.encode(manifest)
let decoded = try decoder.decode(FolderSyncManifest.self, from: data)
#expect(decoded.folders.count == 1)
#expect(decoded.folders.first?.id == folderId)
#expect(decoded.folders.first?.name == "Work")
#expect(decoded.assignments[conversationId] == folderId)
}
}