diff --git a/YKanBan.Tests/Platform/PathComparerTests.cs b/YKanBan.Tests/Platform/PathComparerTests.cs
new file mode 100644
index 0000000..534ca9d
--- /dev/null
+++ b/YKanBan.Tests/Platform/PathComparerTests.cs
@@ -0,0 +1,93 @@
+using YKanBan.Platform;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Platform;
+
+/// Tests for path normalization and platform-parameterized comparison.
+[TestClass]
+public class PathComparerTests
+{
+ [TestMethod]
+ public void OrdinalComparerTreatsCaseAsSignificant()
+ {
+ // Unix-style rule, injected so it is testable on any host.
+ var comparer = new PathComparer(ignoreCase: false);
+ Assert.IsFalse(comparer.Equals(@"C:\A", @"C:\a"));
+ }
+
+ [TestMethod]
+ public void IgnoreCaseComparerTreatsCaseAsInsignificant()
+ {
+ // Windows-style rule.
+ var comparer = new PathComparer(ignoreCase: true);
+ Assert.IsTrue(comparer.Equals(@"C:\A", @"c:\a"));
+ }
+
+ [TestMethod]
+ public void EqualPathsProduceEqualHashCodes()
+ {
+ var comparer = new PathComparer(ignoreCase: true);
+ Assert.AreEqual(
+ comparer.GetHashCode(@"C:\Users\Data\MyRepo"),
+ comparer.GetHashCode(@"c:\users\data\myrepo"));
+ }
+
+ [TestMethod]
+ public void RelativeSegmentsCollapse()
+ {
+ var comparer = new PathComparer(ignoreCase: true);
+ using var directory = new TempDirectory();
+
+ string viaParent = Path.Combine(directory.FullPath, "x", "..", "a");
+ string direct = Path.Combine(directory.FullPath, "a");
+
+ Assert.IsTrue(comparer.Equals(viaParent, direct));
+ }
+
+ [TestMethod]
+ public void SeparatorStylesUnify()
+ {
+ var comparer = new PathComparer(ignoreCase: true);
+
+ Assert.IsTrue(comparer.Equals(@"C:\a/b\c", @"C:\a\b\c"));
+ }
+
+ [TestMethod]
+ public void TrailingSeparatorIsTrimmedForNonRoots()
+ {
+ var comparer = new PathComparer(ignoreCase: true);
+ using var directory = new TempDirectory();
+
+ string withTrailing = directory.FullPath + Path.DirectorySeparatorChar;
+ Assert.IsTrue(comparer.Equals(withTrailing, directory.FullPath));
+ }
+
+ [TestMethod]
+ public void NormalizeReturnsCanonicalAbsoluteForm()
+ {
+ var comparer = new PathComparer(ignoreCase: false);
+ using var directory = new TempDirectory();
+
+ string withRelativeSegment = Path.Combine(directory.FullPath, "sub", "..");
+ Assert.AreEqual(directory.FullPath, comparer.Normalize(withRelativeSegment));
+ }
+
+ [TestMethod]
+ public void RootPathKeepsItsTrailingSeparator()
+ {
+ var comparer = new PathComparer(ignoreCase: true);
+
+ Assert.AreEqual(@"C:\", comparer.Normalize(@"C:\"));
+ Assert.AreEqual(@"C:\x", comparer.Normalize(@"C:\x\"));
+ }
+
+ [TestMethod]
+ public void ForCurrentPlatformMatchesHostRule()
+ {
+ PathComparer comparer = PathComparer.ForCurrentPlatform();
+
+ Assert.AreEqual(
+ OperatingSystem.IsWindows(),
+ comparer.Equals(@"C:\Same", @"c:\same"));
+ }
+}
diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceConnectionPoolTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceConnectionPoolTests.cs
new file mode 100644
index 0000000..538f51e
--- /dev/null
+++ b/YKanBan.Tests/Storage/Workspace/WorkspaceConnectionPoolTests.cs
@@ -0,0 +1,191 @@
+using YKanBan.Platform;
+using YKanBan.Storage;
+using YKanBan.Storage.AppData;
+using YKanBan.Storage.Workspace;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.Workspace;
+
+/// Tests for the fixed-capacity LRU workspace session pool.
+[TestClass]
+public class WorkspaceConnectionPoolTests
+{
+ /// Owns three initialized workspaces (a, b, c), a registry and the pool under test.
+ private sealed class PoolFixture : IDisposable
+ {
+ public TempDirectory Directory { get; } = new();
+
+ public AppDatabase AppDb { get; }
+
+ public WorkspaceConnectionPool Pool { get; }
+
+ public PoolFixture(int capacity = 2)
+ {
+ foreach (string name in new[] { "a", "b", "c" })
+ {
+ string path = Path.Combine(Directory.FullPath, name);
+ System.IO.Directory.CreateDirectory(path);
+ WorkspaceInitializer.Initialize(path);
+ }
+ AppDb = new AppDatabase(Path.Combine(Directory.FullPath, "app.db"));
+ Pool = new WorkspaceConnectionPool(capacity, AppDb, new PathComparer(ignoreCase: true));
+ }
+
+ /// Workspace folder path by name.
+ public string Workspace(string name) => Path.Combine(Directory.FullPath, name);
+
+ public void Dispose()
+ {
+ Pool.Dispose();
+ AppDb.Dispose();
+ Directory.Dispose();
+ }
+ }
+
+ [TestMethod]
+ public void OpenBeyondCapacityEvictsLeastRecentlyUsed()
+ {
+ using var fixture = new PoolFixture(capacity: 2);
+
+ fixture.Pool.Open(fixture.Workspace("a"));
+ fixture.Pool.Open(fixture.Workspace("b"));
+ fixture.Pool.Open(fixture.Workspace("c"));
+
+ Assert.AreEqual(2, fixture.Pool.OpenCount);
+ Assert.IsNull(fixture.Pool.Find(fixture.Workspace("a")));
+ Assert.IsNotNull(fixture.Pool.Find(fixture.Workspace("b")));
+ Assert.IsNotNull(fixture.Pool.Find(fixture.Workspace("c")));
+ }
+
+ [TestMethod]
+ public void OpenTwiceReturnsSameSession()
+ {
+ using var fixture = new PoolFixture();
+
+ WorkspaceSession first = fixture.Pool.Open(fixture.Workspace("a"));
+ WorkspaceSession second = fixture.Pool.Open(fixture.Workspace("a"));
+
+ Assert.AreSame(first, second);
+ Assert.AreEqual(1, fixture.Pool.OpenCount);
+ }
+
+ [TestMethod]
+ public void TouchPromotesRecency()
+ {
+ using var fixture = new PoolFixture(capacity: 2);
+
+ fixture.Pool.Open(fixture.Workspace("a"));
+ fixture.Pool.Open(fixture.Workspace("b"));
+ fixture.Pool.Touch(fixture.Workspace("a"));
+ fixture.Pool.Open(fixture.Workspace("c"));
+
+ // "a" was touched after "b" opened, so "b" is the LRU victim now.
+ Assert.IsNotNull(fixture.Pool.Find(fixture.Workspace("a")));
+ Assert.IsNull(fixture.Pool.Find(fixture.Workspace("b")));
+ }
+
+ [TestMethod]
+ public void EvictedWorkspaceCanBeReopened()
+ {
+ using var fixture = new PoolFixture(capacity: 2);
+
+ fixture.Pool.Open(fixture.Workspace("a"));
+ fixture.Pool.Open(fixture.Workspace("b"));
+ fixture.Pool.Open(fixture.Workspace("c"));
+
+ // "a" was evicted; reopening it works and now evicts the LRU "b".
+ WorkspaceSession reopened = fixture.Pool.Open(fixture.Workspace("a"));
+ Assert.AreEqual("a", Path.GetFileName(reopened.FolderPath));
+ Assert.IsNull(fixture.Pool.Find(fixture.Workspace("b")));
+ }
+
+ [TestMethod]
+ public void OpenRegistersWorkspaceInSidebarRegistry()
+ {
+ using var fixture = new PoolFixture();
+
+ fixture.Pool.Open(fixture.Workspace("a"));
+ fixture.Pool.Open(fixture.Workspace("b"));
+
+ IReadOnlyList registered = fixture.AppDb.GetWorkspaces().Select(record => record.Path).ToList();
+ Assert.AreEqual(2, registered.Count);
+ Assert.IsTrue(registered.Contains(new PathComparer(ignoreCase: true).Normalize(fixture.Workspace("a"))));
+ Assert.IsTrue(registered.Contains(new PathComparer(ignoreCase: true).Normalize(fixture.Workspace("b"))));
+ }
+
+ [TestMethod]
+ public void OpenMissingDirectoryThrows()
+ {
+ using var fixture = new PoolFixture();
+ string missing = Path.Combine(fixture.Directory.FullPath, "nowhere");
+
+ Assert.ThrowsExactly(() => fixture.Pool.Open(missing));
+ }
+
+ [TestMethod]
+ public void OpenFolderWithoutYkanbanThrows()
+ {
+ using var fixture = new PoolFixture();
+ string plain = Path.Combine(fixture.Directory.FullPath, "plain");
+ System.IO.Directory.CreateDirectory(plain);
+
+ Assert.ThrowsExactly(() => fixture.Pool.Open(plain));
+ }
+
+ [TestMethod]
+ public void OpenWithLockHeldElsewhereThrowsThenSucceedsAfterRelease()
+ {
+ using var fixture = new PoolFixture();
+
+ using WorkspaceLock holder = WorkspaceLock.Acquire(fixture.Workspace("a"));
+ Assert.ThrowsExactly(() => fixture.Pool.Open(fixture.Workspace("a")));
+
+ holder.Dispose();
+ WorkspaceSession session = fixture.Pool.Open(fixture.Workspace("a"));
+ Assert.AreEqual("a", Path.GetFileName(session.FolderPath));
+ }
+
+ [TestMethod]
+ public void RemoveReleasesResourcesAndForgetsRegistryEntry()
+ {
+ using var fixture = new PoolFixture();
+
+ fixture.Pool.Open(fixture.Workspace("a"));
+ Assert.IsTrue(fixture.Pool.Remove(fixture.Workspace("a")));
+
+ Assert.IsNull(fixture.Pool.Find(fixture.Workspace("a")));
+ Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
+
+ // Resources are truly released: the lock can be taken again.
+ using WorkspaceLock reacquired = WorkspaceLock.Acquire(fixture.Workspace("a"));
+ }
+
+ [TestMethod]
+ public void PathEquivalentsShareOneSession()
+ {
+ using var fixture = new PoolFixture();
+ string path = fixture.Workspace("a");
+ string differentlyCased = Path.Combine(
+ Path.GetDirectoryName(path)!,
+ Path.GetFileName(path).ToUpperInvariant());
+
+ WorkspaceSession session = fixture.Pool.Open(path);
+ WorkspaceSession same = fixture.Pool.Open(differentlyCased);
+
+ Assert.AreSame(session, same);
+ Assert.AreEqual(1, fixture.Pool.OpenCount);
+ }
+
+ [TestMethod]
+ public void DisposeReleasesAllLocks()
+ {
+ using var fixture = new PoolFixture();
+
+ fixture.Pool.Open(fixture.Workspace("a"));
+ fixture.Pool.Open(fixture.Workspace("b"));
+ fixture.Pool.Dispose();
+
+ using WorkspaceLock lockA = WorkspaceLock.Acquire(fixture.Workspace("a"));
+ using WorkspaceLock lockB = WorkspaceLock.Acquire(fixture.Workspace("b"));
+ }
+}
diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs
new file mode 100644
index 0000000..1582980
--- /dev/null
+++ b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs
@@ -0,0 +1,275 @@
+using Microsoft.Data.Sqlite;
+using YKanBan.Models;
+using YKanBan.Storage.AppData;
+using YKanBan.Storage.Workspace;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.Workspace;
+
+///
+/// Tests for workspace CRUD, the three cascade chains, edit-confirm
+/// transaction atomicity, timestamp refresh rules and the last_edited_at
+/// write-through.
+///
+[TestClass]
+public class WorkspaceRepositoryTests
+{
+ ///
+ /// Owns a workspace with fixed-timestamp fixture data (one column, one
+ /// card, two tags linked to the card), a fake-clock app.db and the
+ /// repository under test.
+ ///
+ private sealed class RepositoryFixture : IDisposable
+ {
+ public TempDirectory Directory { get; } = new();
+
+ public long Clock { get; set; } = 1000;
+
+ public AppDatabase AppDb { get; }
+
+ public WorkspaceRepository Repository { get; }
+
+ public long ColumnId { get; } = 1;
+
+ public long CardId { get; } = 1;
+
+ public long BugTagId { get; } = 1;
+
+ public long UiTagId { get; } = 2;
+
+ public RepositoryFixture()
+ {
+ WorkspaceInitializer.Initialize(Directory.FullPath);
+
+ // Fixture rows carry literal timestamps so refresh vs. no-refresh stays observable.
+ using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(Directory.FullPath);
+ SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('Col', '', 100, 100);");
+ SqliteTestHelper.Exec(connection, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (1, 'T', 'C', 100, 100);");
+ SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color, description) VALUES ('bug', '#ff0000', '');");
+ SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color, description) VALUES ('ui', '#00ff00', '');");
+ SqliteTestHelper.Exec(connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);");
+ SqliteTestHelper.Exec(connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 2);");
+
+ AppDb = new AppDatabase(Path.Combine(Directory.FullPath, "app.db"), () => Clock);
+ Repository = new WorkspaceRepository(Directory.FullPath, AppDb);
+ }
+
+ /// The last_edited_at currently mirrored into the registry.
+ public long LastEditedAt => AppDb.GetWorkspaces().Single().LastEditedAt;
+
+ public void Dispose()
+ {
+ Repository.Dispose();
+ AppDb.Dispose();
+ Directory.Dispose();
+ }
+ }
+
+ [TestMethod]
+ public void UpdateColumnWithoutChangeWritesNothing()
+ {
+ using var fixture = new RepositoryFixture();
+ ColumnModel before = fixture.Repository.GetColumn(fixture.ColumnId)!;
+
+ ColumnModel result = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col", "");
+
+ Assert.AreEqual(before.UpdatedAt, result.UpdatedAt);
+ Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
+ }
+
+ [TestMethod]
+ public void UpdateColumnChangeRefreshesColumnTimestampAndLastEdited()
+ {
+ using var fixture = new RepositoryFixture();
+ fixture.Clock = 2000;
+
+ ColumnModel updated = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col2", "desc");
+
+ Assert.IsTrue(updated.UpdatedAt > 100L);
+ Assert.AreEqual(2000L, fixture.LastEditedAt);
+ }
+
+ [TestMethod]
+ public void DeleteColumnCascadesToCardsAndTagLinks()
+ {
+ using var fixture = new RepositoryFixture();
+ fixture.Clock = 2000;
+
+ Assert.IsTrue(fixture.Repository.DeleteColumn(fixture.ColumnId));
+
+ Assert.AreEqual(0, fixture.Repository.GetCards().Count);
+ // The tags themselves survive the column deletion.
+ Assert.AreEqual(2, fixture.Repository.GetTags().Count);
+ using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(fixture.Directory.FullPath);
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM card_tags;"));
+ Assert.AreEqual(2000L, fixture.LastEditedAt);
+ }
+
+ [TestMethod]
+ public void DeleteCardRemovesTagLinksButKeepsTags()
+ {
+ using var fixture = new RepositoryFixture();
+
+ Assert.IsTrue(fixture.Repository.DeleteCard(fixture.CardId));
+
+ Assert.IsNull(fixture.Repository.GetCard(fixture.CardId));
+ Assert.AreEqual(2, fixture.Repository.GetTags().Count);
+ using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(fixture.Directory.FullPath);
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM card_tags;"));
+ }
+
+ [TestMethod]
+ public void DeleteTagDetachesFromCardsButKeepsCards()
+ {
+ using var fixture = new RepositoryFixture();
+
+ Assert.IsTrue(fixture.Repository.DeleteTag(fixture.BugTagId));
+
+ Assert.AreEqual(1, fixture.Repository.GetTags().Count);
+ CardModel card = fixture.Repository.GetCard(fixture.CardId)!;
+ Assert.AreEqual(1, card.Tags.Count);
+ Assert.AreEqual("ui", card.Tags[0].Name);
+ }
+
+ [TestMethod]
+ public void UpdateCardWithoutEffectiveChangeWritesNothing()
+ {
+ using var fixture = new RepositoryFixture();
+ fixture.Clock = 2000;
+ CardModel before = fixture.Repository.GetCard(fixture.CardId)!;
+
+ CardModel result = fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.BugTagId, fixture.UiTagId]);
+
+ Assert.AreEqual(before.UpdatedAt, result.UpdatedAt);
+ Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
+ }
+
+ [TestMethod]
+ public void UpdateCardTitleChangeRefreshesTimestampsAndLastEdited()
+ {
+ using var fixture = new RepositoryFixture();
+ fixture.Clock = 2000;
+
+ CardModel updated = fixture.Repository.UpdateCard(fixture.CardId, "T2", "C", [fixture.BugTagId, fixture.UiTagId]);
+
+ Assert.IsTrue(updated.UpdatedAt > 100L);
+ Assert.AreEqual(2000L, fixture.LastEditedAt);
+ }
+
+ [TestMethod]
+ public void UpdateCardTagSetOnlyChangeStillRefreshesCardTimestamp()
+ {
+ using var fixture = new RepositoryFixture();
+ fixture.Clock = 2000;
+
+ CardModel updated = fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.BugTagId]);
+
+ Assert.IsTrue(updated.UpdatedAt > 100L);
+ Assert.AreEqual(1, updated.Tags.Count);
+ Assert.AreEqual(2000L, fixture.LastEditedAt);
+ }
+
+ [TestMethod]
+ public void UpdateCardWithUnknownTagRollsBackAtomically()
+ {
+ using var fixture = new RepositoryFixture();
+ fixture.Clock = 2000;
+
+ // Tag 999 does not exist: the FK fires mid-transaction and the whole edit must roll back.
+ Assert.ThrowsExactly(() =>
+ fixture.Repository.UpdateCard(fixture.CardId, "TX", "CX", [fixture.BugTagId, 999]));
+
+ CardModel after = fixture.Repository.GetCard(fixture.CardId)!;
+ Assert.AreEqual("T", after.Title);
+ Assert.AreEqual("C", after.Content);
+ Assert.AreEqual(100L, after.UpdatedAt);
+ Assert.AreEqual(2, after.Tags.Count);
+ Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
+ }
+
+ [TestMethod]
+ public void MoveCardRefreshesLastEditedButNotCardTimestamp()
+ {
+ using var fixture = new RepositoryFixture();
+ long target = fixture.Repository.AddColumn("Target", "").Id;
+ fixture.Clock = 2000;
+
+ Assert.IsTrue(fixture.Repository.MoveCard(fixture.CardId, target));
+
+ CardModel moved = fixture.Repository.GetCard(fixture.CardId)!;
+ Assert.AreEqual(target, moved.ColumnId);
+ // The move is a write but deliberately leaves the card's updated_at alone.
+ Assert.AreEqual(100L, moved.UpdatedAt);
+ Assert.AreEqual(2000L, fixture.LastEditedAt);
+ }
+
+ [TestMethod]
+ public void MoveCardToCurrentColumnWritesNothing()
+ {
+ using var fixture = new RepositoryFixture();
+ fixture.Clock = 2000;
+
+ Assert.IsFalse(fixture.Repository.MoveCard(fixture.CardId, fixture.ColumnId));
+
+ Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
+ }
+
+ [TestMethod]
+ public void AddCardCreatesWithEqualTimestampsAndLinkedTags()
+ {
+ using var fixture = new RepositoryFixture();
+ fixture.Clock = 2000;
+
+ CardModel card = fixture.Repository.AddCard(fixture.ColumnId, "N", "body", [fixture.UiTagId]);
+
+ Assert.IsTrue(card.CreatedAt > 0L);
+ Assert.AreEqual(card.CreatedAt, card.UpdatedAt);
+ Assert.AreEqual(1, card.Tags.Count);
+ Assert.AreEqual("ui", card.Tags[0].Name);
+ Assert.AreEqual(2000L, fixture.LastEditedAt);
+ }
+
+ [TestMethod]
+ public void UpdateTagWithoutChangeWritesNothing()
+ {
+ using var fixture = new RepositoryFixture();
+ fixture.Clock = 2000;
+
+ TagModel result = fixture.Repository.UpdateTag(fixture.BugTagId, "bug", "#ff0000", "");
+
+ Assert.AreEqual("bug", result.Name);
+ Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
+ }
+
+ [TestMethod]
+ public void UpdateTagChangeWritesAndTouchesLastEdited()
+ {
+ using var fixture = new RepositoryFixture();
+ fixture.Clock = 3000;
+
+ TagModel updated = fixture.Repository.UpdateTag(fixture.BugTagId, "defect", "#ee0a0a", "desc");
+
+ Assert.AreEqual("defect", updated.Name);
+ Assert.AreEqual("#ee0a0a", updated.Color);
+ Assert.AreEqual("desc", updated.Description);
+ Assert.AreEqual(3000L, fixture.LastEditedAt);
+ }
+
+ [TestMethod]
+ public void GetTagsWithUsageCountsAssignments()
+ {
+ using var fixture = new RepositoryFixture();
+
+ IReadOnlyList<(TagModel Tag, long UsageCount)> before = fixture.Repository.GetTagsWithUsage();
+ Assert.AreEqual(1L, before.Single(entry => entry.Tag.Name == "bug").UsageCount);
+ Assert.AreEqual(1L, before.Single(entry => entry.Tag.Name == "ui").UsageCount);
+
+ // Detaching one tag via an edit confirmation drops its usage to zero but keeps the tag.
+ fixture.Clock = 2000;
+ fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.UiTagId]);
+
+ IReadOnlyList<(TagModel Tag, long UsageCount)> after = fixture.Repository.GetTagsWithUsage();
+ Assert.AreEqual(0L, after.Single(entry => entry.Tag.Name == "bug").UsageCount);
+ Assert.AreEqual(1L, after.Single(entry => entry.Tag.Name == "ui").UsageCount);
+ }
+}
diff --git a/YKanBan/Models/CardModel.cs b/YKanBan/Models/CardModel.cs
new file mode 100644
index 0000000..aa36827
--- /dev/null
+++ b/YKanBan/Models/CardModel.cs
@@ -0,0 +1,18 @@
+namespace YKanBan.Models;
+
+/// A card with its assigned tags loaded.
+/// Row id.
+/// Owning column.
+/// Possibly empty; the empty string is the canonical "no title".
+/// Multiline body, possibly empty.
+/// Unix seconds of creation.
+/// Unix seconds of the last confirmed edit write.
+/// Tags assigned to this card, ordered by tag id.
+public sealed record CardModel(
+ long Id,
+ long ColumnId,
+ string Title,
+ string Content,
+ long CreatedAt,
+ long UpdatedAt,
+ IReadOnlyList Tags);
diff --git a/YKanBan/Models/ColumnModel.cs b/YKanBan/Models/ColumnModel.cs
new file mode 100644
index 0000000..992ac99
--- /dev/null
+++ b/YKanBan/Models/ColumnModel.cs
@@ -0,0 +1,9 @@
+namespace YKanBan.Models;
+
+/// A board column as stored in the workspace database.
+/// Row id.
+/// Non-empty unique title.
+/// Free-form description, possibly empty.
+/// Unix seconds of creation.
+/// Unix seconds of the last title/description change.
+public sealed record ColumnModel(long Id, string Title, string Description, long CreatedAt, long UpdatedAt);
diff --git a/YKanBan/Models/TagModel.cs b/YKanBan/Models/TagModel.cs
new file mode 100644
index 0000000..c124ce6
--- /dev/null
+++ b/YKanBan/Models/TagModel.cs
@@ -0,0 +1,8 @@
+namespace YKanBan.Models;
+
+/// A tag assignable to cards. Tags are never garbage-collected: unreferenced tags stay until deleted explicitly.
+/// Row id.
+/// Arbitrary non-empty text; no charset or length rules.
+/// Fixed #RRGGBB form.
+/// Free-form description, possibly empty.
+public sealed record TagModel(long Id, string Name, string Color, string Description);
diff --git a/YKanBan/Platform/PathComparer.cs b/YKanBan/Platform/PathComparer.cs
new file mode 100644
index 0000000..a1d7cd3
--- /dev/null
+++ b/YKanBan/Platform/PathComparer.cs
@@ -0,0 +1,64 @@
+namespace YKanBan.Platform;
+
+///
+/// Compares and normalizes workspace folder paths. Normalization produces
+/// the canonical absolute form: separators unified by the operating system,
+/// "." and ".." collapsed, trailing separator trimmed except on roots.
+/// Symbolic links are deliberately NOT resolved. Case handling is an
+/// injected platform rule — ignored on Windows, significant on Unix and
+/// macOS — so the Unix behavior is testable on any host.
+///
+public sealed class PathComparer : IEqualityComparer
+{
+ private readonly StringComparison _comparison;
+
+ /// Creates a comparer with an explicit case-sensitivity rule.
+ /// True for Windows-style path semantics.
+ public PathComparer(bool ignoreCase)
+ {
+ _comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ }
+
+ /// The comparer matching the current operating system's path semantics.
+ public static PathComparer ForCurrentPlatform() => new(OperatingSystem.IsWindows());
+
+ /// Returns the canonical absolute form used for storage and display.
+ public string Normalize(string path)
+ {
+ // GetFullPath makes the path absolute, collapses relative segments and
+ // unifies separators; it preserves a trailing separator, so trim it to
+ // keep the canonical form unique ("D:\repo" and "D:\repo\" are one path).
+ string full = Path.GetFullPath(path);
+ return TrimTrailingSeparator(full);
+ }
+
+ /// Determines whether two paths denote the same location under the configured platform rules.
+ public bool Equals(string? x, string? y)
+ {
+ if (x is null || y is null)
+ {
+ return ReferenceEquals(x, y);
+ }
+ return string.Equals(Normalize(x), Normalize(y), _comparison);
+ }
+
+ /// Returns a hash code consistent with .
+ public int GetHashCode(string path) => Normalize(path).GetHashCode(_comparison);
+
+ /// Removes trailing separators while keeping root forms intact ("C:\", "/").
+ private static string TrimTrailingSeparator(string fullPath)
+ {
+ bool isDriveRoot = fullPath.Length == 3
+ && char.IsAsciiLetter(fullPath[0])
+ && fullPath[1] == ':'
+ && IsSeparator(fullPath[2]);
+ if (isDriveRoot || fullPath == "/")
+ {
+ return fullPath;
+ }
+ return fullPath.TrimEnd('/', '\\');
+ }
+
+ /// Returns true for both supported separator characters.
+ private static bool IsSeparator(char character) => character is '/' or '\\';
+}
diff --git a/YKanBan/Storage/Workspace/WorkspaceConnectionPool.cs b/YKanBan/Storage/Workspace/WorkspaceConnectionPool.cs
new file mode 100644
index 0000000..f5772db
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspaceConnectionPool.cs
@@ -0,0 +1,183 @@
+using YKanBan.Platform;
+using YKanBan.Storage.AppData;
+
+namespace YKanBan.Storage.Workspace;
+
+///
+/// Fixed-capacity LRU pool of open workspace sessions (exclusive lock plus
+/// repository per workspace). Opening a workspace also registers it in the
+/// global app.db sidebar registry. Eviction — by capacity or explicitly —
+/// only releases resources: the sidebar entry survives, and the workspace
+/// can be reopened at any time (a lock held elsewhere then surfaces as a
+/// lock conflict).
+///
+public sealed class WorkspaceConnectionPool : IDisposable
+{
+ private readonly object _gate = new();
+ private readonly int _capacity;
+ private readonly AppDatabase _appDatabase;
+ private readonly PathComparer _pathComparer;
+ private readonly Dictionary> _sessions;
+ private readonly LinkedList _recency = new();
+
+ /// Creates a pool.
+ /// Maximum number of simultaneously open workspaces.
+ /// Global registry for sidebar entries and last_edited_at.
+ /// Platform path rules used to identify workspaces.
+ public WorkspaceConnectionPool(int capacity, AppDatabase appDatabase, PathComparer pathComparer)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
+ _capacity = capacity;
+ _appDatabase = appDatabase;
+ _pathComparer = pathComparer;
+ _sessions = new Dictionary>(pathComparer);
+ }
+
+ /// Number of currently open workspaces.
+ public int OpenCount
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _sessions.Count;
+ }
+ }
+ }
+
+ /// Returns the open session and marks it most recently used, or null when the workspace is not open.
+ public WorkspaceSession? Find(string folderPath)
+ {
+ lock (_gate)
+ {
+ if (_sessions.TryGetValue(_pathComparer.Normalize(folderPath), out LinkedListNode? node))
+ {
+ Promote(node);
+ return node.Value;
+ }
+ return null;
+ }
+ }
+
+ /// Records user activity (click/switch) for an open workspace; no-op when it is not open.
+ public void Touch(string folderPath) => Find(folderPath);
+
+ /// Opens the workspace session, or returns the existing one; evicts LRU entries beyond capacity.
+ /// The folder does not exist.
+ /// The folder has no .ykanban.
+ /// Another instance holds the lock.
+ public WorkspaceSession Open(string folderPath)
+ {
+ string canonical = _pathComparer.Normalize(folderPath);
+
+ // Fast path: already open → promote and return.
+ lock (_gate)
+ {
+ if (_sessions.TryGetValue(canonical, out LinkedListNode? open))
+ {
+ Promote(open);
+ return open.Value;
+ }
+ }
+
+ // Validate the folder state up front so callers get precise state errors.
+ if (!Directory.Exists(canonical))
+ {
+ throw new WorkspaceDirectoryMissingException(canonical);
+ }
+ if (!WorkspaceInitializer.IsWorkspace(canonical))
+ {
+ throw new WorkspaceNotInitializedException(canonical);
+ }
+
+ // Acquire the exclusive lock before touching the database.
+ WorkspaceLock workspaceLock = WorkspaceLock.Acquire(canonical);
+ WorkspaceSession session;
+ try
+ {
+ var repository = new WorkspaceRepository(canonical, _appDatabase);
+ session = new WorkspaceSession
+ {
+ FolderPath = canonical,
+ Lock = workspaceLock,
+ Repository = repository,
+ };
+ }
+ catch
+ {
+ workspaceLock.Dispose();
+ throw;
+ }
+
+ lock (_gate)
+ {
+ // Re-check: another open may have won the race; the existing session wins.
+ if (_sessions.TryGetValue(canonical, out LinkedListNode? winner))
+ {
+ session.Dispose();
+ Promote(winner);
+ return winner.Value;
+ }
+
+ _sessions.Add(canonical, _recency.AddFirst(session));
+
+ // Evict least recently used entries beyond capacity; releases resources only.
+ while (_sessions.Count > _capacity)
+ {
+ LinkedListNode lru = _recency.Last!;
+ _recency.RemoveLast();
+ _sessions.Remove(lru.Value.FolderPath);
+ lru.Value.Dispose();
+ }
+
+ // Opening registers the workspace in the persistent sidebar registry.
+ _appDatabase.UpsertWorkspace(canonical);
+ return session;
+ }
+ }
+
+ /// Releases a workspace's resources; the sidebar entry is kept. Returns true when it was open.
+ public bool Close(string folderPath)
+ {
+ lock (_gate)
+ {
+ if (!_sessions.Remove(_pathComparer.Normalize(folderPath), out LinkedListNode? node))
+ {
+ return false;
+ }
+ _recency.Remove(node);
+ node.Value.Dispose();
+ return true;
+ }
+ }
+
+ /// Releases the workspace's resources and removes its sidebar entry. Returns true when it was open.
+ public bool Remove(string folderPath)
+ {
+ string canonical = _pathComparer.Normalize(folderPath);
+ bool wasOpen = Close(canonical);
+ _appDatabase.RemoveWorkspace(canonical);
+ return wasOpen;
+ }
+
+ /// Releases all sessions.
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ foreach (WorkspaceSession session in _recency)
+ {
+ session.Dispose();
+ }
+ _recency.Clear();
+ _sessions.Clear();
+ }
+ }
+
+ /// Moves a session node to the most-recently-used position.
+ private void Promote(LinkedListNode node)
+ {
+ _recency.Remove(node);
+ _recency.AddFirst(node);
+ }
+}
diff --git a/YKanBan/Storage/Workspace/WorkspaceRepository.cs b/YKanBan/Storage/Workspace/WorkspaceRepository.cs
new file mode 100644
index 0000000..565e53d
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspaceRepository.cs
@@ -0,0 +1,446 @@
+using Microsoft.Data.Sqlite;
+using YKanBan.Models;
+using YKanBan.Storage.AppData;
+
+namespace YKanBan.Storage.Workspace;
+
+///
+/// Data access for one open workspace database: CRUD for columns, cards and
+/// tags, card moves and tag assignment, plus the write-through that
+/// refreshes the workspace's last_edited_at in the global app.db after
+/// every successful data write.
+///
+/// Timestamp rules:
+/// - cards.updated_at refreshes only when a card edit is confirmed with
+/// changes; moving a card to another column never refreshes it;
+/// - columns.updated_at refreshes only when title or description changes;
+/// - a confirmation with no effective change performs no write at all.
+///
+public sealed class WorkspaceRepository : IDisposable
+{
+ private readonly string _folderPath;
+ private readonly AppDatabase _appDatabase;
+ private readonly SqliteConnection _connection;
+
+ /// Opens the repository over an initialized workspace database.
+ /// Workspace folder; the exact value is used as the app.db registry key.
+ /// Global registry refreshed after every successful write.
+ public WorkspaceRepository(string folderPath, AppDatabase appDatabase)
+ {
+ _folderPath = folderPath;
+ _appDatabase = appDatabase;
+ _connection = SqliteDatabase.Open(WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations);
+ }
+
+ /// Closes the underlying connection.
+ public void Dispose() => _connection.Dispose();
+
+ #region Columns
+
+ /// Returns all columns ordered by id.
+ public IReadOnlyList GetColumns()
+ {
+ using var command = CreateCommand(
+ "SELECT id, title, description, created_at, updated_at FROM columns ORDER BY id ASC;");
+ using var reader = command.ExecuteReader();
+
+ var columns = new List();
+ while (reader.Read())
+ {
+ columns.Add(ReadColumn(reader));
+ }
+ return columns;
+ }
+
+ /// Returns the column with the given id, or null.
+ public ColumnModel? GetColumn(long columnId)
+ {
+ using var command = CreateCommand(
+ "SELECT id, title, description, created_at, updated_at FROM columns WHERE id = $id;");
+ command.Parameters.AddWithValue("$id", columnId);
+ using var reader = command.ExecuteReader();
+ return reader.Read() ? ReadColumn(reader) : null;
+ }
+
+ /// Creates a column with created_at = updated_at.
+ /// The title is empty or not unique.
+ public ColumnModel AddColumn(string title, string description)
+ {
+ long now = UnixTime.Now;
+ using var transaction = _connection.BeginTransaction();
+ long id = ExecuteInsert(transaction,
+ "INSERT INTO columns (title, description, created_at, updated_at) VALUES ($title, $description, $now, $now);",
+ ("$title", title), ("$description", description), ("$now", now));
+ transaction.Commit();
+
+ TouchEdited();
+ return new ColumnModel(id, title, description, now, now);
+ }
+
+ /// Rewrites title and description; a no-change call writes nothing.
+ /// The column does not exist.
+ /// The new title is empty or not unique.
+ public ColumnModel UpdateColumn(long columnId, string title, string description)
+ {
+ ColumnModel existing = GetColumn(columnId)
+ ?? throw new InvalidOperationException($"Column {columnId} does not exist.");
+
+ // Unchanged title and description: no write, no timestamps.
+ if (existing.Title == title && existing.Description == description)
+ {
+ return existing;
+ }
+
+ long now = UnixTime.Now;
+ using var command = CreateCommand(
+ "UPDATE columns SET title = $title, description = $description, updated_at = $now WHERE id = $id;");
+ command.Parameters.AddWithValue("$title", title);
+ command.Parameters.AddWithValue("$description", description);
+ command.Parameters.AddWithValue("$now", now);
+ command.Parameters.AddWithValue("$id", columnId);
+ command.ExecuteNonQuery();
+
+ TouchEdited();
+ return new ColumnModel(columnId, title, description, existing.CreatedAt, now);
+ }
+
+ /// Deletes a column; the DDL cascade removes its cards and their tag links. Returns true when a row was deleted.
+ public bool DeleteColumn(long columnId) => ExecuteDelete("DELETE FROM columns WHERE id = $id;", columnId);
+
+ #endregion
+
+ #region Cards
+
+ /// Returns all cards with their tags loaded, ordered by id.
+ public IReadOnlyList GetCards()
+ {
+ using var command = CreateCommand("""
+ SELECT c.id, c.column_id, c.title, c.content, c.created_at, c.updated_at,
+ t.id, t.name, t.color, t.description
+ FROM cards c
+ LEFT JOIN card_tags ct ON ct.card_id = c.id
+ LEFT JOIN tags t ON t.id = ct.tag_id
+ ORDER BY c.id ASC;
+ """);
+ using var reader = command.ExecuteReader();
+
+ // Single grouped pass: one output card per distinct card id, its tags accumulated alongside.
+ var cards = new List();
+ var tagsByCard = new Dictionary>();
+ while (reader.Read())
+ {
+ long cardId = reader.GetInt64(0);
+ if (!tagsByCard.TryGetValue(cardId, out List? tags))
+ {
+ tags = new List();
+ tagsByCard[cardId] = tags;
+ cards.Add(new CardModel(
+ cardId,
+ reader.GetInt64(1),
+ reader.GetString(2),
+ reader.GetString(3),
+ reader.GetInt64(4),
+ reader.GetInt64(5),
+ tags));
+ }
+ if (!reader.IsDBNull(6))
+ {
+ tags.Add(new TagModel(reader.GetInt64(6), reader.GetString(7), reader.GetString(8), reader.GetString(9)));
+ }
+ }
+ return cards;
+ }
+
+ /// Returns the card with the given id (tags loaded), or null.
+ public CardModel? GetCard(long cardId)
+ {
+ using var command = CreateCommand("""
+ SELECT c.id, c.column_id, c.title, c.content, c.created_at, c.updated_at,
+ t.id, t.name, t.color, t.description
+ FROM cards c
+ LEFT JOIN card_tags ct ON ct.card_id = c.id
+ LEFT JOIN tags t ON t.id = ct.tag_id
+ WHERE c.id = $id
+ ORDER BY c.id ASC;
+ """);
+ command.Parameters.AddWithValue("$id", cardId);
+ using var reader = command.ExecuteReader();
+
+ CardModel? card = null;
+ var tags = new List();
+ while (reader.Read())
+ {
+ if (card is null)
+ {
+ card = new CardModel(
+ reader.GetInt64(0),
+ reader.GetInt64(1),
+ reader.GetString(2),
+ reader.GetString(3),
+ reader.GetInt64(4),
+ reader.GetInt64(5),
+ tags);
+ }
+ if (!reader.IsDBNull(6))
+ {
+ tags.Add(new TagModel(reader.GetInt64(6), reader.GetString(7), reader.GetString(8), reader.GetString(9)));
+ }
+ }
+ return card;
+ }
+
+ /// Creates a card with its initial tag assignment (created_at = updated_at).
+ /// The column does not exist, or a tag id does not exist.
+ public CardModel AddCard(long columnId, string title, string content, IReadOnlyCollection tagIds)
+ {
+ long now = UnixTime.Now;
+ using var transaction = _connection.BeginTransaction();
+ long id = ExecuteInsert(transaction,
+ "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES ($columnId, $title, $content, $now, $now);",
+ ("$columnId", columnId), ("$title", title), ("$content", content), ("$now", now));
+ foreach (long tagId in tagIds.Distinct())
+ {
+ ExecuteNonQuery(transaction,
+ "INSERT INTO card_tags (card_id, tag_id) VALUES ($cardId, $tagId);",
+ ("$cardId", id), ("$tagId", tagId));
+ }
+ transaction.Commit();
+
+ TouchEdited();
+ return new CardModel(id, columnId, title, content, now, now, LoadTags(id));
+ }
+
+ ///
+ /// Applies one card edit confirmation as a single atomic unit: the card
+ /// row (title, content, refreshed updated_at) plus the complete tag
+ /// assignment. A confirmation with no effective change writes nothing;
+ /// a failure mid-way rolls the whole edit back.
+ ///
+ /// The card does not exist.
+ /// A tag id does not exist; nothing is written.
+ public CardModel UpdateCard(long cardId, string title, string content, IReadOnlyCollection tagIds)
+ {
+ CardModel existing = GetCard(cardId)
+ ?? throw new InvalidOperationException($"Card {cardId} does not exist.");
+
+ // No change in title, content or tag set: no write, no timestamps.
+ bool tagsChanged = !tagIds.Distinct().ToHashSet().SetEquals(existing.Tags.Select(tag => tag.Id));
+ if (existing.Title == title && existing.Content == content && !tagsChanged)
+ {
+ return existing;
+ }
+
+ long now = UnixTime.Now;
+ using var transaction = _connection.BeginTransaction();
+ // The confirm write refreshes updated_at even when only the tag set changed.
+ ExecuteNonQuery(transaction,
+ "UPDATE cards SET title = $title, content = $content, updated_at = $now WHERE id = $id;",
+ ("$title", title), ("$content", content), ("$now", now), ("$id", cardId));
+ // Tag assignment is rewritten wholesale; card_tags carries nothing beyond the pair.
+ ExecuteNonQuery(transaction, "DELETE FROM card_tags WHERE card_id = $id;", ("$id", cardId));
+ foreach (long tagId in tagIds.Distinct())
+ {
+ ExecuteNonQuery(transaction,
+ "INSERT INTO card_tags (card_id, tag_id) VALUES ($cardId, $tagId);",
+ ("$cardId", cardId), ("$tagId", tagId));
+ }
+ transaction.Commit();
+
+ TouchEdited();
+ return new CardModel(cardId, existing.ColumnId, title, content, existing.CreatedAt, now, LoadTags(cardId));
+ }
+
+ ///
+ /// Moves a card to another column. This is a data write (refreshes the
+ /// workspace last_edited_at) but deliberately does NOT refresh the
+ /// card's updated_at; moving to the current column is a no-write no-op.
+ /// Returns true when the card moved.
+ ///
+ public bool MoveCard(long cardId, long targetColumnId)
+ {
+ using var command = CreateCommand(
+ "UPDATE cards SET column_id = $columnId WHERE id = $id AND column_id <> $columnId;");
+ command.Parameters.AddWithValue("$columnId", targetColumnId);
+ command.Parameters.AddWithValue("$id", cardId);
+ bool moved = command.ExecuteNonQuery() > 0;
+ if (moved)
+ {
+ TouchEdited();
+ }
+ return moved;
+ }
+
+ /// Deletes a card; the DDL cascade removes its tag links. Returns true when a row was deleted.
+ public bool DeleteCard(long cardId) => ExecuteDelete("DELETE FROM cards WHERE id = $id;", cardId);
+
+ #endregion
+
+ #region Tags
+
+ /// Returns all tags ordered by id; unreferenced tags are included.
+ public IReadOnlyList GetTags()
+ {
+ using var command = CreateCommand(
+ "SELECT id, name, color, description FROM tags ORDER BY id ASC;");
+ using var reader = command.ExecuteReader();
+
+ var tags = new List();
+ while (reader.Read())
+ {
+ tags.Add(ReadTag(reader));
+ }
+ return tags;
+ }
+
+ /// Returns every tag together with the number of cards using it, ordered by id.
+ public IReadOnlyList<(TagModel Tag, long UsageCount)> GetTagsWithUsage()
+ {
+ using var command = CreateCommand("""
+ SELECT t.id, t.name, t.color, t.description, COUNT(ct.card_id)
+ FROM tags t
+ LEFT JOIN card_tags ct ON ct.tag_id = t.id
+ GROUP BY t.id, t.name, t.color, t.description
+ ORDER BY t.id ASC;
+ """);
+ using var reader = command.ExecuteReader();
+
+ var tags = new List<(TagModel Tag, long UsageCount)>();
+ while (reader.Read())
+ {
+ tags.Add((ReadTag(reader), reader.GetInt64(4)));
+ }
+ return tags;
+ }
+
+ /// Creates a tag.
+ /// The name is empty/not unique, or the color is not #RRGGBB.
+ public TagModel AddTag(string name, string color, string description)
+ {
+ using var transaction = _connection.BeginTransaction();
+ long id = ExecuteInsert(transaction,
+ "INSERT INTO tags (name, color, description) VALUES ($name, $color, $description);",
+ ("$name", name), ("$color", color), ("$description", description));
+ transaction.Commit();
+
+ TouchEdited();
+ return new TagModel(id, name, color, description);
+ }
+
+ /// Rewrites name, color and description; a no-change call writes nothing.
+ /// The tag does not exist.
+ /// The new name is empty/not unique, or the color is not #RRGGBB.
+ public TagModel UpdateTag(long tagId, string name, string color, string description)
+ {
+ TagModel existing = GetTag(tagId)
+ ?? throw new InvalidOperationException($"Tag {tagId} does not exist.");
+
+ // Unchanged name, color and description: no write.
+ if (existing.Name == name && existing.Color == color && existing.Description == description)
+ {
+ return existing;
+ }
+
+ using var command = CreateCommand(
+ "UPDATE tags SET name = $name, color = $color, description = $description WHERE id = $id;");
+ command.Parameters.AddWithValue("$name", name);
+ command.Parameters.AddWithValue("$color", color);
+ command.Parameters.AddWithValue("$description", description);
+ command.Parameters.AddWithValue("$id", tagId);
+ command.ExecuteNonQuery();
+
+ TouchEdited();
+ return new TagModel(tagId, name, color, description);
+ }
+
+ /// Deletes a tag; the DDL cascade removes its card links while the cards survive. Returns true when a row was deleted.
+ public bool DeleteTag(long tagId) => ExecuteDelete("DELETE FROM tags WHERE id = $id;", tagId);
+
+ #endregion
+
+ #region Helpers
+
+ /// Returns the tag with the given id, or null.
+ private TagModel? GetTag(long tagId)
+ {
+ using var command = CreateCommand(
+ "SELECT id, name, color, description FROM tags WHERE id = $id;");
+ command.Parameters.AddWithValue("$id", tagId);
+ using var reader = command.ExecuteReader();
+ return reader.Read() ? ReadTag(reader) : null;
+ }
+
+ /// Returns the tags currently assigned to a card, ordered by id.
+ private IReadOnlyList LoadTags(long cardId)
+ {
+ using var command = CreateCommand("""
+ SELECT t.id, t.name, t.color, t.description
+ FROM tags t
+ JOIN card_tags ct ON ct.tag_id = t.id
+ WHERE ct.card_id = $cardId
+ ORDER BY t.id ASC;
+ """);
+ command.Parameters.AddWithValue("$cardId", cardId);
+ using var reader = command.ExecuteReader();
+
+ var tags = new List();
+ while (reader.Read())
+ {
+ tags.Add(ReadTag(reader));
+ }
+ return tags;
+ }
+
+ /// Reads a columns row from the reader cursor.
+ private static ColumnModel ReadColumn(SqliteDataReader reader) =>
+ new(reader.GetInt64(0), reader.GetString(1), reader.GetString(2), reader.GetInt64(3), reader.GetInt64(4));
+
+ /// Reads a tags row from the reader cursor.
+ private static TagModel ReadTag(SqliteDataReader reader) =>
+ new(reader.GetInt64(0), reader.GetString(1), reader.GetString(2), reader.GetString(3));
+
+ /// Creates a command bound to this connection.
+ private SqliteCommand CreateCommand(string sql, SqliteTransaction? transaction = null)
+ {
+ SqliteCommand command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = sql;
+ return command;
+ }
+
+ /// Executes a parameterized non-query statement inside a transaction.
+ private void ExecuteNonQuery(SqliteTransaction transaction, string sql, params (string Name, object? Value)[] parameters)
+ {
+ using SqliteCommand command = CreateCommand(sql, transaction);
+ foreach ((string name, object? value) in parameters)
+ {
+ command.Parameters.AddWithValue(name, value);
+ }
+ command.ExecuteNonQuery();
+ }
+
+ /// Inserts a row inside a transaction and returns the generated rowid.
+ private long ExecuteInsert(SqliteTransaction transaction, string sql, params (string Name, object? Value)[] parameters)
+ {
+ ExecuteNonQuery(transaction, sql, parameters);
+ using SqliteCommand rowId = CreateCommand("SELECT last_insert_rowid();", transaction);
+ return (long)rowId.ExecuteScalar()!;
+ }
+
+ /// Deletes by id; refreshes last_edited_at only when a row was actually removed.
+ private bool ExecuteDelete(string sql, long id)
+ {
+ using SqliteCommand command = CreateCommand(sql);
+ command.Parameters.AddWithValue("$id", id);
+ if (command.ExecuteNonQuery() == 0)
+ {
+ return false;
+ }
+ TouchEdited();
+ return true;
+ }
+
+ /// Mirrors a successful data write into the global registry.
+ private void TouchEdited() => _appDatabase.TouchLastEdited(_folderPath);
+
+ #endregion
+}
diff --git a/YKanBan/Storage/Workspace/WorkspaceSession.cs b/YKanBan/Storage/Workspace/WorkspaceSession.cs
new file mode 100644
index 0000000..7f6eb4c
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspaceSession.cs
@@ -0,0 +1,21 @@
+namespace YKanBan.Storage.Workspace;
+
+/// One open workspace: the exclusive lock plus its repository.
+public sealed class WorkspaceSession : IDisposable
+{
+ /// Canonical workspace folder path.
+ public required string FolderPath { get; init; }
+
+ /// The exclusive lock held while this session is open.
+ public required WorkspaceLock Lock { get; init; }
+
+ /// The data access object for this workspace.
+ public required WorkspaceRepository Repository { get; init; }
+
+ /// Disposes the repository, then releases the lock.
+ public void Dispose()
+ {
+ Repository.Dispose();
+ Lock.Dispose();
+ }
+}