diff --git a/mdl-examples/bug-tests/258-drop-create-microflow-unitid-preservation.mdl b/mdl-examples/bug-tests/258-drop-create-microflow-unitid-preservation.mdl new file mode 100644 index 00000000..b895d827 --- /dev/null +++ b/mdl-examples/bug-tests/258-drop-create-microflow-unitid-preservation.mdl @@ -0,0 +1,45 @@ +-- ============================================================================ +-- Bug #258: DROP + CREATE OR MODIFY microflow UnitID preservation +-- ============================================================================ +-- +-- Symptom (before fix): +-- A DROP MICROFLOW X followed by CREATE OR MODIFY MICROFLOW X in the same +-- session produced a *different* UnitID for the new row. Studio Pro treats +-- Unit rows with a different UnitID as unrelated documents and refuses to +-- open the project: +-- ".mpr does not look like a Mendix Studio Pro project file" +-- +-- After fix: +-- The executor remembers the dropped UnitID (and ContainerID, AllowedRoles) +-- and reuses them on the subsequent CREATE so Studio Pro sees an in-place +-- update. Also exercises v1 ContentsHash persistence — MPR v1 projects need +-- the hash populated on every write or Studio Pro rejects the file as +-- corrupt. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/258-drop-create-microflow-unitid-preservation.mdl -p app.mpr +-- Open the project in Studio Pro — it must load without corruption errors +-- and the microflow MF_Replaced must be visible under BugTest258. +-- ============================================================================ + +create module BugTest258; + +create microflow BugTest258.MF_Original + returns string +( + set $return = 'original' +); + +-- Drop + recreate in the same session. Before the fix, this produced a +-- dangling Unit row with a fresh UUID and the project became unopenable in +-- Studio Pro. After the fix, the new microflow reuses the original UnitID. +drop microflow BugTest258.MF_Original; + +create or modify microflow BugTest258.MF_Original + returns string +( + set $return = 'replaced' +); + +-- DESCRIBE must round-trip cleanly (no placeholder UUIDs leaking). +describe microflow BugTest258.MF_Original; diff --git a/mdl/executor/cmd_microflows_create.go b/mdl/executor/cmd_microflows_create.go index 747f38ca..807c1036 100644 --- a/mdl/executor/cmd_microflows_create.go +++ b/mdl/executor/cmd_microflows_create.go @@ -57,6 +57,8 @@ func execCreateMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) error { // Check if microflow with same name already exists in this module var existingID model.ID var existingContainerID model.ID + var existingAllowedRoles []model.ID + preserveAllowedRoles := false existingMicroflows, err := ctx.Backend.ListMicroflows() if err != nil { return mdlerrors.NewBackend("check existing microflows", err) @@ -68,11 +70,14 @@ func execCreateMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) error { } existingID = existing.ID existingContainerID = existing.ContainerID + existingAllowedRoles = cloneRoleIDs(existing.AllowedModuleRoles) + preserveAllowedRoles = true break } } // For CREATE OR REPLACE/MODIFY, reuse the existing ID to preserve references + qualifiedName := s.Name.Module + "." + s.Name.Name microflowID := model.ID(types.GenerateID()) if existingID != "" { microflowID = existingID @@ -80,6 +85,20 @@ func execCreateMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) error { if s.Folder == "" { containerID = existingContainerID } + } else if dropped := consumeDroppedMicroflow(ctx, qualifiedName); dropped != nil { + // A prior DROP MICROFLOW in the same session removed the unit. Reuse + // its original UnitID and (unless a new folder is specified) + // ContainerID so that Studio Pro sees the rewrite as an in-place + // update rather than a delete+insert pair, which produces + // ".mpr does not look like a Mendix Studio Pro project file" errors. + microflowID = dropped.ID + if s.Folder == "" && dropped.ContainerID != "" { + containerID = dropped.ContainerID + } + // consumeDroppedMicroflow removed the cache entry, so we own this + // slice — no need to clone it again. + existingAllowedRoles = dropped.AllowedRoles + preserveAllowedRoles = true } // Build the microflow @@ -94,6 +113,9 @@ func execCreateMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) error { MarkAsUsed: false, Excluded: s.Excluded, } + if preserveAllowedRoles { + mf.AllowedModuleRoles = existingAllowedRoles + } // Build entity resolver function for parameter/return types entityResolver := func(qn ast.QualifiedName) model.ID { diff --git a/mdl/executor/cmd_microflows_drop.go b/mdl/executor/cmd_microflows_drop.go index a0d74fe9..304af10b 100644 --- a/mdl/executor/cmd_microflows_drop.go +++ b/mdl/executor/cmd_microflows_drop.go @@ -32,11 +32,17 @@ func execDropMicroflow(ctx *ExecContext, s *ast.DropMicroflowStmt) error { modID := h.FindModuleID(mf.ContainerID) modName := h.GetModuleName(modID) if modName == s.Name.Module && mf.Name == s.Name.Name { + qualifiedName := s.Name.Module + "." + s.Name.Name + // Remember the UnitID and ContainerID *before* deletion so that a + // subsequent CREATE OR REPLACE/MODIFY for the same qualified name + // can reuse them. This keeps Studio Pro compatible by turning + // delete+insert into an in-place update from the file's + // perspective — same UnitID, same folder, just new bytes. + rememberDroppedMicroflow(ctx, qualifiedName, mf.ID, mf.ContainerID, mf.AllowedModuleRoles) if err := ctx.Backend.DeleteMicroflow(mf.ID); err != nil { return mdlerrors.NewBackend("delete microflow", err) } // Clear executor-level caches so subsequent CREATE sees fresh state - qualifiedName := s.Name.Module + "." + s.Name.Name if ctx.Cache != nil && ctx.Cache.createdMicroflows != nil { delete(ctx.Cache.createdMicroflows, qualifiedName) } diff --git a/mdl/executor/cmd_write_handlers_mock_test.go b/mdl/executor/cmd_write_handlers_mock_test.go index 7e965347..ab3dcd00 100644 --- a/mdl/executor/cmd_write_handlers_mock_test.go +++ b/mdl/executor/cmd_write_handlers_mock_test.go @@ -12,6 +12,7 @@ import ( "github.com/mendixlabs/mxcli/sdk/domainmodel" "github.com/mendixlabs/mxcli/sdk/microflows" "github.com/mendixlabs/mxcli/sdk/pages" + "github.com/mendixlabs/mxcli/sdk/security" ) func TestExecCreateModule_Mock(t *testing.T) { @@ -435,3 +436,175 @@ func TestExecDropAssociation_Mock_NotFound(t *testing.T) { Name: ast.QualifiedName{Module: "MyModule", Name: "NonExistent"}, })) } + +// TestDropThenCreatePreservesMicroflowUnitID is a regression test for the +// MPR corruption bug documented in docs/MXCLI_MPR_CORRUPTION_PROMPT_0015.md. +// +// When a script runs `DROP MICROFLOW X; CREATE OR MODIFY MICROFLOW X ...` in +// the same session, the executor used to delete the Unit row and then insert +// a new one with a freshly generated UUID. Studio Pro treats the rewritten +// ContainerID/UnitID pair as an unrelated document and refuses to open the +// resulting .mpr ("file does not look like a Mendix Studio Pro project"). +// +// The fix records the UnitID of dropped microflows on the executor cache and +// reuses it when a subsequent CREATE OR REPLACE/MODIFY targets the same +// qualified name, so the delete+insert behaves like an in-place update. +func TestDropThenCreatePreservesMicroflowUnitID(t *testing.T) { + mod := mkModule("MyModule") + mf := mkMicroflow(mod.ID, "DoSomething") + originalID := mf.ID + mf.AllowedModuleRoles = []model.ID{"MyModule.Admin", "MyModule.User"} + + h := mkHierarchy(mod) + withContainer(h, mf.ContainerID, mod.ID) + + listedMicroflows := []*microflows.Microflow{mf} + var createdID model.ID + var createdRoles []model.ID + + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { + return []*model.Module{mod}, nil + }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { + return listedMicroflows, nil + }, + DeleteMicroflowFunc: func(id model.ID) error { + // Simulate real deletion: hide the microflow from subsequent + // ListMicroflows calls so CREATE OR MODIFY sees no existing unit + // (matching the bug reproduction exactly). + listedMicroflows = nil + return nil + }, + CreateMicroflowFunc: func(m *microflows.Microflow) error { + createdID = m.ID + createdRoles = cloneRoleIDs(m.AllowedModuleRoles) + return nil + }, + GetModuleSecurityFunc: func(moduleID model.ID) (*security.ModuleSecurity, error) { + return &security.ModuleSecurity{ + BaseElement: model.BaseElement{ID: nextID("ms")}, + ContainerID: moduleID, + }, nil + }, + AddModuleRoleFunc: func(moduleSecurityID model.ID, name, description string) error { + return nil + }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { + return nil, nil + }, + ListConsumedRestServicesFunc: func() ([]*model.ConsumedRestService, error) { + return nil, nil + }, + } + + // Need an Executor so ctx.executor is set (trackCreatedMicroflow uses it). + exec := New(&bytesWriter{}) + ctx := &ExecContext{ + Context: t.Context(), + Backend: mb, + Output: exec.output, + Format: FormatTable, + executor: exec, + } + exec.backend = mb + withHierarchy(h)(ctx) + + if err := execDropMicroflow(ctx, &ast.DropMicroflowStmt{ + Name: ast.QualifiedName{Module: "MyModule", Name: "DoSomething"}, + }); err != nil { + t.Fatalf("DROP MICROFLOW failed: %v", err) + } + + // The UnitID and ContainerID must have been stashed on the cache before deletion. + if ctx.Cache == nil || ctx.Cache.droppedMicroflows == nil || + ctx.Cache.droppedMicroflows["MyModule.DoSomething"] == nil || + ctx.Cache.droppedMicroflows["MyModule.DoSomething"].ID != originalID { + t.Fatalf("expected droppedMicroflows[MyModule.DoSomething].ID = %q, got cache=%+v", + originalID, ctx.Cache) + } + + // CREATE OR MODIFY with the same qualified name must reuse the dropped ID. + createStmt := &ast.CreateMicroflowStmt{ + Name: ast.QualifiedName{Module: "MyModule", Name: "DoSomething"}, + CreateOrModify: true, + Body: nil, // empty body is fine for this test + } + if err := execCreateMicroflow(ctx, createStmt); err != nil { + t.Fatalf("CREATE OR MODIFY MICROFLOW failed: %v", err) + } + + if createdID != originalID { + t.Fatalf("CREATE OR MODIFY must reuse dropped UnitID: got %q, want %q", + createdID, originalID) + } + if len(createdRoles) != 2 || createdRoles[0] != "MyModule.Admin" || createdRoles[1] != "MyModule.User" { + t.Fatalf("CREATE OR MODIFY must preserve dropped allowed roles: got %v", createdRoles) + } + + // The cache entry must be consumed so repeated CREATEs don't collide. + if ctx.Cache != nil && ctx.Cache.droppedMicroflows != nil { + if _, stillThere := ctx.Cache.droppedMicroflows["MyModule.DoSomething"]; stillThere { + t.Errorf("droppedMicroflows entry should be cleared after reuse") + } + } +} + +func TestCreateOrModifyMicroflowPreservesAllowedRoles(t *testing.T) { + mod := mkModule("MyModule") + mf := mkMicroflow(mod.ID, "DoSomething") + mf.AllowedModuleRoles = []model.ID{"MyModule.Admin"} + + h := mkHierarchy(mod) + withContainer(h, mf.ContainerID, mod.ID) + + var updatedRoles []model.ID + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { + return []*model.Module{mod}, nil + }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { + return []*microflows.Microflow{mf}, nil + }, + UpdateMicroflowFunc: func(updated *microflows.Microflow) error { + updatedRoles = cloneRoleIDs(updated.AllowedModuleRoles) + return nil + }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { + return nil, nil + }, + ListConsumedRestServicesFunc: func() ([]*model.ConsumedRestService, error) { + return nil, nil + }, + } + + exec := New(&bytesWriter{}) + ctx := &ExecContext{ + Context: t.Context(), + Backend: mb, + Output: exec.output, + Format: FormatTable, + executor: exec, + } + exec.backend = mb + withHierarchy(h)(ctx) + + if err := execCreateMicroflow(ctx, &ast.CreateMicroflowStmt{ + Name: ast.QualifiedName{Module: "MyModule", Name: "DoSomething"}, + CreateOrModify: true, + }); err != nil { + t.Fatalf("CREATE OR MODIFY MICROFLOW failed: %v", err) + } + + if len(updatedRoles) != 1 || updatedRoles[0] != "MyModule.Admin" { + t.Fatalf("expected existing allowed roles to be preserved, got %v", updatedRoles) + } +} + +// bytesWriter is a trivial io.Writer used to satisfy New() in the regression +// test above. We don't care about captured output for this test. +type bytesWriter struct{} + +func (*bytesWriter) Write(p []byte) (int, error) { return len(p), nil } diff --git a/mdl/executor/executor.go b/mdl/executor/executor.go index d781c85f..85fe76cc 100644 --- a/mdl/executor/executor.go +++ b/mdl/executor/executor.go @@ -35,6 +35,15 @@ type executorCache struct { createdPages map[string]*createdPageInfo // qualifiedName -> info createdSnippets map[string]*createdSnippetInfo // qualifiedName -> info + // Track items dropped during this session so that a subsequent + // CREATE OR REPLACE/MODIFY with the same qualified name can reuse the + // original UnitID and ContainerID. Studio Pro treats Unit rows with a + // different UnitID (or the same UnitID under a different container) as + // unrelated documents, producing broken projects on delete+insert + // rewrites. Reusing both keeps the rewrite semantically equivalent to an + // in-place update. + droppedMicroflows map[string]*droppedUnitInfo // qualifiedName -> original IDs + // Track domain models modified during this session for finalization modifiedDomainModels map[model.ID]string // domain model unit ID -> module name @@ -69,6 +78,15 @@ type createdSnippetInfo struct { ContainerID model.ID } +// droppedUnitInfo remembers the original UnitID and ContainerID of a document +// dropped during this session so that a subsequent CREATE OR REPLACE/MODIFY +// with the same qualified name can reuse them instead of generating new UUIDs. +type droppedUnitInfo struct { + ID model.ID + ContainerID model.ID + AllowedRoles []model.ID +} + // getEntityNames returns the entity name lookup map, using the pre-warmed cache if available. func getEntityNames(ctx *ExecContext, h *ContainerHierarchy) map[model.ID]string { if ctx.Cache != nil && len(ctx.Cache.entityNames) > 0 { @@ -416,3 +434,53 @@ func (e *Executor) ensureCache() { e.cache = &executorCache{} } } + +// rememberDroppedMicroflow records the UnitID and ContainerID of a microflow +// that is about to be deleted via DROP MICROFLOW. A follow-up CREATE OR +// REPLACE/MODIFY for the same qualified name will reuse both instead of +// generating a fresh UUID and defaulting to the module root, so Studio Pro +// continues to see the unit as "updated in place" rather than a delete+insert +// pair. +func rememberDroppedMicroflow(ctx *ExecContext, qualifiedName string, id, containerID model.ID, allowedRoles []model.ID) { + if ctx == nil || qualifiedName == "" || id == "" { + return + } + if ctx.Cache == nil { + ctx.Cache = &executorCache{} + if ctx.executor != nil { + ctx.executor.cache = ctx.Cache + } + } + if ctx.Cache.droppedMicroflows == nil { + ctx.Cache.droppedMicroflows = make(map[string]*droppedUnitInfo) + } + ctx.Cache.droppedMicroflows[qualifiedName] = &droppedUnitInfo{ + ID: id, + ContainerID: containerID, + AllowedRoles: cloneRoleIDs(allowedRoles), + } +} + +func cloneRoleIDs(roles []model.ID) []model.ID { + if len(roles) == 0 { + return nil + } + cloned := make([]model.ID, len(roles)) + copy(cloned, roles) + return cloned +} + +// consumeDroppedMicroflow returns the original IDs of a microflow dropped +// earlier in this session (if any) and removes the entry so repeated CREATEs +// don't collide on the same ID. Returns nil when nothing was remembered. +func consumeDroppedMicroflow(ctx *ExecContext, qualifiedName string) *droppedUnitInfo { + if ctx == nil || ctx.Cache == nil || ctx.Cache.droppedMicroflows == nil { + return nil + } + info, ok := ctx.Cache.droppedMicroflows[qualifiedName] + if !ok { + return nil + } + delete(ctx.Cache.droppedMicroflows, qualifiedName) + return info +} diff --git a/scripts/run-mdl-tests.sh b/scripts/run-mdl-tests.sh new file mode 100755 index 00000000..a3fd8e3c --- /dev/null +++ b/scripts/run-mdl-tests.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PROJECT_MPR="${1:?usage: run-mdl-tests.sh [mxcli-bin] [test-spec] [bootstrap-mdl]}" +MXCLI_BIN="${2:-$ROOT_DIR/bin/mxcli}" +TEST_SPEC="${3:-$ROOT_DIR/mdl-examples/doctype-tests/microflow-spec.test.mdl}" +BOOTSTRAP_MDL="${4:-$ROOT_DIR/mdl-examples/doctype-tests/02-microflow-examples.mdl}" + +# Validate the inputs up front — `${VAR:?...}` only fires for *unset* variables, +# so a typo'd path would otherwise get silently copied as an empty sandbox. +[[ -f "$PROJECT_MPR" ]] || { echo "error: project MPR not found: $PROJECT_MPR" >&2; exit 1; } +[[ -x "$MXCLI_BIN" ]] || { echo "error: mxcli binary not executable: $MXCLI_BIN" >&2; exit 1; } +[[ -f "$TEST_SPEC" ]] || { echo "error: test spec not found: $TEST_SPEC" >&2; exit 1; } +[[ -f "$BOOTSTRAP_MDL" ]] || { echo "error: bootstrap MDL not found: $BOOTSTRAP_MDL" >&2; exit 1; } + +SOURCE_DIR="$(cd "$(dirname "$PROJECT_MPR")" && pwd)" +PROJECT_NAME="$(basename "$PROJECT_MPR")" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +cp -R "$SOURCE_DIR"/. "$TMP_DIR"/ + +"$MXCLI_BIN" exec "$BOOTSTRAP_MDL" -p "$TMP_DIR/$PROJECT_NAME" +"$MXCLI_BIN" test "$TEST_SPEC" -p "$TMP_DIR/$PROJECT_NAME" diff --git a/sdk/mpr/writer_core.go b/sdk/mpr/writer_core.go index 5146496a..90f4863e 100644 --- a/sdk/mpr/writer_core.go +++ b/sdk/mpr/writer_core.go @@ -3,9 +3,7 @@ package mpr import ( - "crypto/sha256" "database/sql" - "encoding/base64" "fmt" "os" "path/filepath" @@ -134,9 +132,7 @@ func (wt *WriteTransaction) WriteUnit(unitID string, contents []byte) error { finalPath: finalPath, }) - // Update hash in DB - hash := sha256.Sum256(contents) - contentsHash := base64.StdEncoding.EncodeToString(hash[:]) + contentsHash := contentHashBase64(contents) _, err := wt.tx.Exec(` UPDATE Unit SET ContentsHash = ? WHERE UnitID = ? `, contentsHash, unitIDBlob) @@ -144,9 +140,17 @@ func (wt *WriteTransaction) WriteUnit(unitID string, contents []byte) error { } // V1: Update in database directly + contentsHash := contentHashBase64(contents) _, err := wt.tx.Exec(` - UPDATE Unit SET Contents = ? WHERE UnitID = ? - `, contents, unitIDBlob) + UPDATE Unit SET Contents = ?, ContentsHash = ? WHERE UnitID = ? + `, contents, contentsHash, unitIDBlob) + if err != nil && isContentsHashSchemaError(err) { + // Older v1 schemas do not have ContentsHash; retry without it. + // Any other error (disk full, invalid UnitID, rolled-back tx) propagates. + _, err = wt.tx.Exec(` + UPDATE Unit SET Contents = ? WHERE UnitID = ? + `, contents, unitIDBlob) + } return err } diff --git a/sdk/mpr/writer_units.go b/sdk/mpr/writer_units.go index a42e3dd8..4e0662cd 100644 --- a/sdk/mpr/writer_units.go +++ b/sdk/mpr/writer_units.go @@ -9,10 +9,24 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/mendixlabs/mxcli/sdk/domainmodel" ) +// isContentsHashSchemaError returns true when the error looks like SQLite complaining +// about the absence of the ContentsHash column (i.e. an old MPR v1 schema from pre-Mx +// versions that predate ContentsHash). Anything else — a disk-full error, a missing +// UnitID, a rolled-back transaction — must propagate so writes don't silently succeed +// without updating Contents. +func isContentsHashSchemaError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "ContentsHash") +} + // updateTransactionID updates the _Transaction table with a new UUID. // Studio Pro uses this to detect external changes during F4 sync. // Only applies to MPR v2 projects (Mendix >= 10.18). @@ -45,6 +59,11 @@ func validateNoPlaceholderIDs(unitID string, contents []byte) error { return nil } +func contentHashBase64(contents []byte) string { + hash := sha256.Sum256(contents) + return base64.StdEncoding.EncodeToString(hash[:]) +} + func (w *Writer) insertUnit(unitID, containerID, containmentName, unitType string, contents []byte) error { if err := validateNoPlaceholderIDs(unitID, contents); err != nil { return err @@ -70,9 +89,7 @@ func (w *Writer) insertUnit(unitID, containerID, containmentName, unitType strin return fmt.Errorf("failed to write unit file: %w", err) } - // Compute content hash (base64-encoded SHA256) - hash := sha256.Sum256(contents) - contentsHash := base64.StdEncoding.EncodeToString(hash[:]) + contentsHash := contentHashBase64(contents) // Insert reference to database _, err := w.reader.db.Exec(` @@ -90,11 +107,13 @@ func (w *Writer) insertUnit(unitID, containerID, containmentName, unitType strin } // MPR v1: Store directly in database + contentsHash := contentHashBase64(contents) + // Try new schema first (without Type column - Mendix 11.6.2+) _, err := w.reader.db.Exec(` INSERT INTO Unit (UnitID, ContainerID, ContainmentName, TreeConflict, ContentsHash, ContentsConflicts, Contents) - VALUES (?, ?, ?, 0, '', '', ?) - `, unitIDBlob, containerIDBlob, containmentName, contents) + VALUES (?, ?, ?, 0, ?, '', ?) + `, unitIDBlob, containerIDBlob, containmentName, contentsHash, contents) if err != nil { // Try old schema with Type column _, err = w.reader.db.Exec(` @@ -133,9 +152,7 @@ func (w *Writer) updateUnit(unitID string, contents []byte) error { return fmt.Errorf("failed to write unit file: %w", err) } - // Update ContentsHash in database - hash := sha256.Sum256(contents) - contentsHash := base64.StdEncoding.EncodeToString(hash[:]) + contentsHash := contentHashBase64(contents) _, err := w.reader.db.Exec(` UPDATE Unit SET ContentsHash = ? WHERE UnitID = ? `, contentsHash, unitIDBlob) @@ -147,9 +164,17 @@ func (w *Writer) updateUnit(unitID string, contents []byte) error { } // MPR v1: Update in database + contentsHash := contentHashBase64(contents) _, err := w.reader.db.Exec(` - UPDATE Unit SET Contents = ? WHERE UnitID = ? - `, contents, unitIDBlob) + UPDATE Unit SET Contents = ?, ContentsHash = ? WHERE UnitID = ? + `, contents, contentsHash, unitIDBlob) + if err != nil && isContentsHashSchemaError(err) { + // Older v1 schemas do not have ContentsHash; retry without it. + // Any other error (disk full, invalid UnitID, rolled-back tx) propagates. + _, err = w.reader.db.Exec(` + UPDATE Unit SET Contents = ? WHERE UnitID = ? + `, contents, unitIDBlob) + } return err } diff --git a/sdk/mpr/writer_units_test.go b/sdk/mpr/writer_units_test.go new file mode 100644 index 00000000..0094e088 --- /dev/null +++ b/sdk/mpr/writer_units_test.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import ( + "database/sql" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" +) + +func newTestWriterV1(t *testing.T, unitSchema string) (*Writer, *sql.DB) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "test.mpr") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("failed to open sqlite database: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + if _, err := db.Exec(unitSchema); err != nil { + t.Fatalf("failed to create Unit table: %v", err) + } + + reader := &Reader{ + db: db, + version: MPRVersionV1, + } + return &Writer{reader: reader}, db +} + +func TestInsertUnitV1_PopulatesContentsHash(t *testing.T) { + writer, db := newTestWriterV1(t, ` + CREATE TABLE Unit ( + UnitID BLOB PRIMARY KEY NOT NULL, + ContainerID BLOB, + ContainmentName TEXT, + TreeConflict LONG, + ContentsHash TEXT, + ContentsConflicts TEXT, + Contents BLOB + ) + `) + + unitID := "11111111-1111-1111-1111-111111111111" + containerID := "22222222-2222-2222-2222-222222222222" + contents := []byte("new microflow bytes") + if err := writer.insertUnit(unitID, containerID, "Documents", "Microflows$Microflow", contents); err != nil { + t.Fatalf("insertUnit failed: %v", err) + } + + var gotHash string + var gotContents []byte + err := db.QueryRow(`SELECT ContentsHash, Contents FROM Unit WHERE UnitID = ?`, uuidToBlob(unitID)).Scan(&gotHash, &gotContents) + if err != nil { + t.Fatalf("failed to read inserted row: %v", err) + } + + if gotHash == "" { + t.Fatal("insertUnit wrote empty ContentsHash") + } + if want := contentHashBase64(contents); gotHash != want { + t.Fatalf("ContentsHash = %q, want %q", gotHash, want) + } + if string(gotContents) != string(contents) { + t.Fatalf("Contents = %q, want %q", string(gotContents), string(contents)) + } +} + +func TestUpdateUnitV1_UpdatesContentsHash(t *testing.T) { + writer, db := newTestWriterV1(t, ` + CREATE TABLE Unit ( + UnitID BLOB PRIMARY KEY NOT NULL, + ContainerID BLOB, + ContainmentName TEXT, + TreeConflict LONG, + ContentsHash TEXT, + ContentsConflicts TEXT, + Contents BLOB + ) + `) + + unitID := "33333333-3333-3333-3333-333333333333" + containerID := "44444444-4444-4444-4444-444444444444" + oldContents := []byte("old bytes") + newContents := []byte("updated bytes") + if _, err := db.Exec(` + INSERT INTO Unit (UnitID, ContainerID, ContainmentName, TreeConflict, ContentsHash, ContentsConflicts, Contents) + VALUES (?, ?, 'Documents', 0, ?, '', ?) + `, uuidToBlob(unitID), uuidToBlob(containerID), contentHashBase64(oldContents), oldContents); err != nil { + t.Fatalf("failed to seed row: %v", err) + } + + if err := writer.updateUnit(unitID, newContents); err != nil { + t.Fatalf("updateUnit failed: %v", err) + } + + var gotHash string + var gotContents []byte + err := db.QueryRow(`SELECT ContentsHash, Contents FROM Unit WHERE UnitID = ?`, uuidToBlob(unitID)).Scan(&gotHash, &gotContents) + if err != nil { + t.Fatalf("failed to read updated row: %v", err) + } + + if gotHash == "" { + t.Fatal("updateUnit wrote empty ContentsHash") + } + if want := contentHashBase64(newContents); gotHash != want { + t.Fatalf("ContentsHash = %q, want %q", gotHash, want) + } + if string(gotContents) != string(newContents) { + t.Fatalf("Contents = %q, want %q", string(gotContents), string(newContents)) + } +} + +func TestUnitV1_OldSchemaWithoutContentsHashStillWorks(t *testing.T) { + writer, db := newTestWriterV1(t, ` + CREATE TABLE Unit ( + UnitID BLOB PRIMARY KEY NOT NULL, + ContainerID BLOB, + ContainmentName TEXT, + Type TEXT, + Contents BLOB + ) + `) + + unitID := "55555555-5555-5555-5555-555555555555" + containerID := "66666666-6666-6666-6666-666666666666" + initialContents := []byte("initial bytes") + updatedContents := []byte("updated old schema bytes") + + if err := writer.insertUnit(unitID, containerID, "Documents", "Microflows$Microflow", initialContents); err != nil { + t.Fatalf("insertUnit failed on old schema: %v", err) + } + if err := writer.updateUnit(unitID, updatedContents); err != nil { + t.Fatalf("updateUnit failed on old schema: %v", err) + } + + var gotType string + var gotContents []byte + err := db.QueryRow(`SELECT Type, Contents FROM Unit WHERE UnitID = ?`, uuidToBlob(unitID)).Scan(&gotType, &gotContents) + if err != nil { + t.Fatalf("failed to read old-schema row: %v", err) + } + + if gotType != "Microflows$Microflow" { + t.Fatalf("Type = %q, want %q", gotType, "Microflows$Microflow") + } + if string(gotContents) != string(updatedContents) { + t.Fatalf("Contents = %q, want %q", string(gotContents), string(updatedContents)) + } +}