diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs index 87ea1aae..2da3276e 100644 --- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs @@ -32,28 +32,46 @@ public sealed class DrawingLibraryDatabaseTests } [Fact] - public void V4Upgrade_ReplacesTheDrawingTablesAndKeepsNests() + public void V4Upgrade_KeepsEveryRevisionAndStoresEachPackageOnce() { using var factory = new ServerFactory(); - CreateVersion3Database(factory.DatabasePath); - // The v4 layout: one column set with the package blob copied into every revision. - Execute(factory.DatabasePath, """ - CREATE TABLE drawings (id TEXT NOT NULL, revision INTEGER NOT NULL, name TEXT NOT NULL, package BLOB NOT NULL, PRIMARY KEY (id)); - CREATE TABLE drawing_revisions (id TEXT NOT NULL, revision INTEGER NOT NULL, name TEXT NOT NULL, package BLOB NOT NULL); - INSERT INTO drawings VALUES ('00000000-0000-0000-0000-000000000001', 1, 'Old', x'504B'); - INSERT INTO drawing_revisions SELECT * FROM drawings; - PRAGMA user_version=4; - """); + CreateVersion4Database(factory.DatabasePath); var before = NestDigest(factory.DatabasePath); + var bracket = Guid.Parse("00000000-0000-0000-0000-00000000000a"); + var plate = Guid.Parse("00000000-0000-0000-0000-00000000000b"); - using var database = new NestDatabase(factory.DatabasePath, pooling: false); + using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) + { + Assert.Equal(5L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(before, NestDigest(factory.DatabasePath)); + // Two distinct blobs across four revisions: the bracket's notes edit and the + // plate both reuse the bracket's first package. + Assert.Equal(2L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM drawing_packages")); + Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE '%_v4'")); - Assert.Equal(5L, Scalar(factory.DatabasePath, "PRAGMA user_version")); - Assert.Equal(before, NestDigest(factory.DatabasePath)); - Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items); - var record = Record(); - database.InsertDrawing(record.Id, record, Package()); - Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM drawing_packages")); + var current = database.GetDrawing(bracket)!; + Assert.Equal((3, "Synthetic bracket", "Edited", Sha256(V4PackageB)), + (current.Revision, current.Name, current.Notes, current.PackageSha256)); + Assert.Equal(new DrawingNestingSettings(), current.Settings); + var history = database.DrawingHistory(bracket, 0, 100)!; + Assert.Equal(new[] { 3, 2, 1 }, history.Items.Select(r => r.Revision)); + Assert.Equal(new[] { "Edited", "Edited", "Original" }, history.Items.Select(r => r.Notes)); + Assert.Equal(V4PackageA, database.GetDrawingRevisionPackage(bracket, 1)); + Assert.Equal(V4PackageA, database.GetDrawingRevisionPackage(bracket, 2)); + Assert.Equal(V4PackageB, database.GetDrawingPackage(bracket)); + Assert.True(database.GetDrawing(plate)!.Archived); + Assert.Equal(V4PackageA, database.GetDrawingPackage(plate)); + Assert.Single(database.QueryDrawings(new DrawingLibraryQuery { Customer = " synthetic customer " }).Items); + + // The upgraded drawing keeps working: a settings-only revision adds no blob. + var edit = database.GetDrawing(bracket)!; + edit.Settings = new DrawingNestingSettings { CutFirst = true }; + Assert.Equal(4, database.UpdateDrawing(bracket, edit, null, 3)!.Revision); + Assert.Equal(2L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM drawing_packages")); + } + + using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.True(reopened.GetDrawing(bracket)!.Settings.CutFirst); } [Fact] @@ -220,6 +238,65 @@ public sealed class DrawingLibraryDatabaseTests Execute(path, "DROP TABLE drawing_revisions; DROP TABLE drawings; DROP TABLE drawing_packages; PRAGMA user_version=3;"); } + private static readonly byte[] V4PackageA = Package("v4 original"); + private static readonly byte[] V4PackageB = Package("v4 changed"); + + /// + /// A schema-v4 file in the v4 layout: the same columns in both drawing tables and the + /// package blob copied into every revision. The bracket has a package, a notes edit + /// and a new package; the archived plate shares the bracket's first package. + /// + private static void CreateVersion4Database(string path) + { + CreateVersion3Database(path); + const string columns = """ + id TEXT NOT NULL, revision INTEGER NOT NULL, name TEXT NOT NULL, customer TEXT NOT NULL DEFAULT '', + nameKey TEXT NOT NULL, customerKey TEXT NOT NULL DEFAULT '', materialName TEXT NOT NULL DEFAULT '', + materialKey TEXT NOT NULL DEFAULT '', materialGrade TEXT NOT NULL DEFAULT '', gradeKey TEXT NOT NULL DEFAULT '', + thickness REAL NOT NULL, units TEXT NOT NULL, cutType TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '', + archived INTEGER NOT NULL DEFAULT 0, packageSha256 TEXT NOT NULL, packageSize INTEGER NOT NULL, + savedAt TEXT NOT NULL, savedBy TEXT NOT NULL DEFAULT '', package BLOB NOT NULL + """; + Execute(path, $""" + CREATE TABLE drawings ({columns}, PRIMARY KEY (id)); + CREATE TABLE drawing_revisions ({columns}); + CREATE UNIQUE INDEX ix_drawing_revisions_key ON drawing_revisions(id, revision); + CREATE INDEX ix_drawings_customer_name ON drawings(customerKey, nameKey); + CREATE INDEX ix_drawings_material ON drawings(materialKey); + CREATE INDEX ix_drawings_thickness ON drawings(thickness); + CREATE INDEX ix_drawings_savedAt ON drawings(savedAt DESC); + PRAGMA user_version=4; + """); + + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + void Revision(string id, int revision, string name, string notes, bool archived, byte[] package) + { + using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO drawing_revisions VALUES ($id, $revision, $name, 'Synthetic customer', $nameKey, + 'SYNTHETIC CUSTOMER', 'Synthetic Steel', 'SYNTHETIC STEEL', 'Grade A', 'GRADE A', 0.25, 'Inches', + 'Laser', $notes, $archived, $sha, $size, '2026-01-01T00:00:00.0000000', 'Synthetic operator', $package); + INSERT OR REPLACE INTO drawings SELECT * FROM drawing_revisions WHERE id=$id AND revision=$revision; + """; + command.Parameters.AddWithValue("$id", id); + command.Parameters.AddWithValue("$revision", revision); + command.Parameters.AddWithValue("$name", name); + command.Parameters.AddWithValue("$nameKey", SharedListNames.Key(name)); + command.Parameters.AddWithValue("$notes", notes); + command.Parameters.AddWithValue("$archived", archived ? 1 : 0); + command.Parameters.AddWithValue("$sha", Sha256(package)); + command.Parameters.AddWithValue("$size", package.LongLength); + command.Parameters.AddWithValue("$package", package); + command.ExecuteNonQuery(); + } + + Revision("00000000-0000-0000-0000-00000000000a", 1, "Synthetic bracket", "Original", false, V4PackageA); + Revision("00000000-0000-0000-0000-00000000000a", 2, "Synthetic bracket", "Edited", false, V4PackageA); + Revision("00000000-0000-0000-0000-00000000000a", 3, "Synthetic bracket", "Edited", false, V4PackageB); + Revision("00000000-0000-0000-0000-00000000000b", 1, "Synthetic plate", "", true, V4PackageA); + } + private static string NestDigest(string path) { using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); diff --git a/OpenNest.Server/NestDatabase.Drawings.cs b/OpenNest.Server/NestDatabase.Drawings.cs index 15057981..47ac7770 100644 --- a/OpenNest.Server/NestDatabase.Drawings.cs +++ b/OpenNest.Server/NestDatabase.Drawings.cs @@ -43,11 +43,8 @@ public sealed partial class NestDatabase savedBy TEXT NOT NULL DEFAULT '' """; - // Schema v5 replaces the v4 drawing tables (which copied the blob into every revision). - private const string DrawingLibrarySchema = $""" - DROP TABLE IF EXISTS drawings; - DROP TABLE IF EXISTS drawing_revisions; - DROP TABLE IF EXISTS drawing_packages; + // The v5 drawing tables: created directly on a pre-v4 database and by the v4 upgrade. + private const string DrawingLibraryTables = $""" CREATE TABLE drawing_packages ( sha256 TEXT NOT NULL PRIMARY KEY, size INTEGER NOT NULL, @@ -59,6 +56,34 @@ public sealed partial class NestDatabase CREATE INDEX ix_drawings_thickness ON drawings(thickness); CREATE INDEX ix_drawings_cutFirst ON drawings(cutFirst); CREATE INDEX ix_drawings_savedAt ON drawings(savedAt DESC); + """; + + private const string DrawingLibrarySchema = DrawingLibraryTables + "PRAGMA user_version=5;"; + + // v4 kept one column set in both tables plus a package blob copied into every + // revision. The upgrade stores each distinct blob once and copies every revision + // and current row as it was; nesting settings take their defaults (v4 had none). + private const string V4CopiedColumns = """ + id, revision, name, customer, nameKey, customerKey, materialName, materialKey, + materialGrade, gradeKey, thickness, units, cutType, notes, archived, + packageSha256, packageSize, savedAt, savedBy + """; + + private const string DrawingLibraryUpgradeFromV4 = $""" + DROP INDEX ix_drawing_revisions_key; + DROP INDEX ix_drawings_customer_name; + DROP INDEX ix_drawings_material; + DROP INDEX ix_drawings_thickness; + DROP INDEX ix_drawings_savedAt; + ALTER TABLE drawings RENAME TO drawings_v4; + ALTER TABLE drawing_revisions RENAME TO drawing_revisions_v4; + {DrawingLibraryTables} + INSERT OR IGNORE INTO drawing_packages (sha256, size, package) + SELECT packageSha256, packageSize, package FROM drawing_revisions_v4; + INSERT INTO drawing_revisions ({V4CopiedColumns}) SELECT {V4CopiedColumns} FROM drawing_revisions_v4; + INSERT INTO drawings ({V4CopiedColumns}) SELECT {V4CopiedColumns} FROM drawings_v4; + DROP TABLE drawings_v4; + DROP TABLE drawing_revisions_v4; PRAGMA user_version=5; """; diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index d585beeb..1456cc41 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -346,13 +346,18 @@ public sealed partial class NestDatabase : IDisposable """; command.ExecuteNonQuery(); } - if (version < 5) + if (version < 4) { - // v5 replaces the v4 drawing tables outright (no production library data - // existed); the nest tables are untouched. + // No drawing tables yet: create the current (v5) layout directly. command.CommandText = DrawingLibrarySchema; command.ExecuteNonQuery(); } + else if (version == 4) + { + // Packages become content-addressed; the nest tables are untouched. + command.CommandText = DrawingLibraryUpgradeFromV4; + command.ExecuteNonQuery(); + } transaction.Commit(); } } diff --git a/docs/nest-storage.md b/docs/nest-storage.md index 2c67e200..3295b94b 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -211,9 +211,11 @@ each nest. Schema v5 keeps three drawing tables in the same file: `drawing_packages` (`sha256`, `size`, `package`), `drawing_revisions` (every immutable revision's details, settings, archived flag and package hash) and `drawings` (each -drawing's current revision, indexed for browsing). Upgrading replaces the v4 -drawing tables outright, discarding any v4 library data (no production library -existed), and leaves the nest tables untouched. A server older than v5 refuses +drawing's current revision, indexed for browsing). Upgrading from v4 runs in +one transaction: each distinct v4 blob is stored once by its hash, every +revision and current row is copied as it was with default nesting settings +(v4 had none), and the v4 drawing tables are dropped. A pre-v4 database gets +the v5 tables directly. The nest tables are untouched. A server older than v5 refuses to open a v5 database, so rolling back after the upgrade needs the pre-upgrade backup (see [Backup, restore, and upgrade](#backup-restore-and-upgrade)). The stopped-volume `tar` backup includes the drawing tables, but `nest_manifest.py` and