diff --git a/internal/portable/export.go b/internal/portable/export.go index 159bac1..eaff936 100644 --- a/internal/portable/export.go +++ b/internal/portable/export.go @@ -38,7 +38,7 @@ const ( type Profile struct { Name string Version int - DroppedTables []string + ClearedTables []string Capabilities []string Excluded []string IndexProfile string @@ -48,7 +48,7 @@ type Profile struct { var currentStateProfile = Profile{ Name: CurrentStateV1, Version: currentProfileVersion, - DroppedTables: []string{ + ClearedTables: []string{ "comment_revisions", "thread_key_summaries", "cluster_closures", @@ -320,13 +320,9 @@ func (e exporter) export(ctx context.Context, options ExportOptions) (result Exp return result, err } var droppedTables []string - for _, table := range profile.DroppedTables { - dropped, err := dropTableIfPresent(ctx, st.DB(), table) - if err != nil { - return result, err - } - if dropped { - droppedTables = appendUnique(droppedTables, table) + for _, table := range profile.ClearedTables { + if _, err := st.DB().ExecContext(ctx, `delete from `+quoteIdentifier(table)); err != nil { + return result, fmt.Errorf("clear portable table %s: %w", table, err) } } if err := reportProgress(ctx, options.Progress, StageCanonicalShaping); err != nil { @@ -876,20 +872,6 @@ func verifyRepository(ctx context.Context, db *sql.DB, expected Repository) erro return nil } -func dropTableIfPresent(ctx context.Context, db *sql.DB, table string) (bool, error) { - var exists int - if err := db.QueryRowContext(ctx, `select exists(select 1 from sqlite_schema where type = 'table' and name = ?)`, table).Scan(&exists); err != nil { - return false, fmt.Errorf("inspect portable table %s: %w", table, err) - } - if exists == 0 { - return false, nil - } - if _, err := db.ExecContext(ctx, `drop table `+quoteIdentifier(table)); err != nil { - return false, fmt.Errorf("drop portable table %s: %w", table, err) - } - return true, nil -} - func quoteIdentifier(value string) string { return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` } diff --git a/internal/portable/export_failure_paths_test.go b/internal/portable/export_failure_paths_test.go index 4c431a2..8e26189 100644 --- a/internal/portable/export_failure_paths_test.go +++ b/internal/portable/export_failure_paths_test.go @@ -439,32 +439,6 @@ func TestPortableFileHelpersRejectUnsafeDestinations(t *testing.T) { } } -func TestPortableTableDropFailsClosedOnForeignKeyConstraint(t *testing.T) { - ctx := context.Background() - db := openRawDB(t, filepath.Join(t.TempDir(), "drop.db")) - defer db.Close() - db.SetMaxOpenConns(1) - if _, err := db.ExecContext(ctx, ` - pragma foreign_keys = on; - create table parent(id integer primary key); - create table child(parent_id integer references parent(id)); - insert into parent values(1); - insert into child values(1); - `); err != nil { - t.Fatal(err) - } - dropped, err := dropTableIfPresent(ctx, db, "parent") - if err == nil || dropped || !strings.Contains(err.Error(), "drop portable table parent") { - t.Fatalf("foreign-key constrained drop = %v, %v", dropped, err) - } - if !tableExists(t, db, "parent") { - t.Fatal("failed constrained drop removed parent table") - } - if dropped, err := dropTableIfPresent(ctx, db, "missing"); err != nil || dropped { - t.Fatalf("missing table drop = %v, %v", dropped, err) - } -} - func TestCompactReplacementRejectsRetainedSidecars(t *testing.T) { ctx := context.Background() for _, target := range []string{"candidate", "working"} { @@ -533,7 +507,6 @@ func TestPortableDatabaseHelpersPropagateCancellation(t *testing.T) { {name: "table stats", run: func() error { _, err := databaseTableStats(ctx, db); return err }}, {name: "repository", run: func() error { _, err := singleRepository(ctx, db); return err }}, {name: "verify repository", run: func() error { return verifyRepository(ctx, db, Repository{FullName: "openclaw/gitcrawl"}) }}, - {name: "drop table", run: func() error { _, err := dropTableIfPresent(ctx, db, "disposable"); return err }}, {name: "metadata", run: func() error { return writeMetadata(ctx, db, map[string]string{"schema": "portable-v1"}) }}, {name: "pragma", run: func() error { _, err := checkPragma(ctx, db, "quick_check"); return err }}, } diff --git a/internal/portable/export_test.go b/internal/portable/export_test.go index 03ffa71..375c169 100644 --- a/internal/portable/export_test.go +++ b/internal/portable/export_test.go @@ -71,9 +71,12 @@ func TestExportCurrentStateV1SnapshotsLiveWALWithoutMutatingSource(t *testing.T) if journalMode != "delete" { t.Fatalf("artifact journal mode = %q, want delete", journalMode) } - for _, table := range currentStateProfile.DroppedTables { - if tableExists(t, db, table) { - t.Fatalf("omitted table %s still exists", table) + for _, table := range currentStateProfile.ClearedTables { + if !tableExists(t, db, table) { + t.Fatalf("cleared table %s is missing", table) + } + if got := rowCount(t, db, table); got != 0 { + t.Fatalf("cleared table %s row count = %d, want 0", table, got) } } for _, table := range []string{ @@ -131,11 +134,13 @@ func TestExportCurrentStateV1SnapshotsLiveWALWithoutMutatingSource(t *testing.T) if slices.Contains(result.DroppedIndexes, "unique_threads_github_id") || !indexExists(t, db, "unique_threads_github_id") { t.Fatalf("explicit unique threads index was not preserved: dropped=%v exists=%v", result.DroppedIndexes, indexExists(t, db, "unique_threads_github_id")) } - if slices.Contains(result.DroppedIndexes, "idx_comment_revisions_comment") || slices.Contains(result.DroppedIndexes, "custom_comment_revisions_body") { - t.Fatalf("implicitly removed indexes were reported as explicitly dropped: %v", result.DroppedIndexes) + if !slices.Contains(result.DroppedIndexes, "idx_comment_revisions_comment") || !slices.Contains(result.DroppedIndexes, "custom_comment_revisions_body") { + t.Fatalf("cleared-table indexes were not removed explicitly: %v", result.DroppedIndexes) } - if len(result.DroppedTables) < len(currentStateProfile.DroppedTables) || !slices.Equal(result.DroppedTables[:len(currentStateProfile.DroppedTables)], currentStateProfile.DroppedTables) { - t.Fatalf("profile tables were not dropped first: %v", result.DroppedTables) + for _, table := range currentStateProfile.ClearedTables { + if slices.Contains(result.DroppedTables, table) { + t.Fatalf("cleared table %s reported as dropped: %v", table, result.DroppedTables) + } } seenDroppedTables := make(map[string]bool) for _, table := range result.DroppedTables { @@ -168,6 +173,31 @@ func TestExportCurrentStateV1SnapshotsLiveWALWithoutMutatingSource(t *testing.T) } } +func TestExportedDatabaseReportsStatusReadOnly(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + sourcePath := filepath.Join(dir, "source.db") + st := seedExportSource(t, ctx, sourcePath) + defer st.Close() + + result, err := Export(ctx, testExportOptions(sourcePath, filepath.Join(dir, "artifact"))) + if err != nil { + t.Fatalf("export: %v", err) + } + exported, err := store.OpenReadOnly(ctx, result.DatabasePath) + if err != nil { + t.Fatalf("open exported database read-only: %v", err) + } + defer exported.Close() + status, err := exported.Status(ctx) + if err != nil { + t.Fatalf("read exported database status: %v", err) + } + if status.RepositoryCount != 1 || status.ThreadCount != 1 || status.ClusterCount != 0 { + t.Fatalf("exported database status = %+v", status) + } +} + func TestExportArtifactIdentityIsDeterministicAcrossExportTimes(t *testing.T) { ctx := context.Background() dir := t.TempDir() @@ -392,8 +422,8 @@ func TestExportScopedRepositoryKeepsOnlyRequestedDependentData(t *testing.T) { t.Fatalf("scoped manifest repository = %+v, result = %+v", manifest.Repository, result.Repository) } assertManifestTableCounts(t, db, manifest.Tables) - if slices.Contains(result.DroppedIndexes, "idx_comment_revisions_comment") || slices.Contains(result.DroppedIndexes, "custom_comment_revisions_body") { - t.Fatalf("dropped indexes include indexes removed with a table: %v", result.DroppedIndexes) + if !slices.Contains(result.DroppedIndexes, "idx_comment_revisions_comment") || !slices.Contains(result.DroppedIndexes, "custom_comment_revisions_body") { + t.Fatalf("cleared-table indexes were not removed explicitly: %v", result.DroppedIndexes) } } @@ -825,7 +855,7 @@ func TestRemoveSQLiteSidecarsFailsClosed(t *testing.T) { } } -func TestExportedDatabaseReopensWritableAndRecreatesOmittedSchema(t *testing.T) { +func TestExportedDatabaseReopensWritableWithInvariantSchema(t *testing.T) { ctx := context.Background() dir := t.TempDir() sourcePath := filepath.Join(dir, "source.db") @@ -848,13 +878,13 @@ func TestExportedDatabaseReopensWritableAndRecreatesOmittedSchema(t *testing.T) t.Fatalf("writable reopen did not restore %s.%s", column.table, column.name) } } - for _, table := range currentStateProfile.DroppedTables { + for _, table := range currentStateProfile.ClearedTables { if !tableExists(t, writable.DB(), table) { - t.Fatalf("migration did not recreate %s", table) + t.Fatalf("writable reopen lost %s", table) } if table != "comment_revisions" && rowCount(t, writable.DB(), table) != 0 { got := rowCount(t, writable.DB(), table) - t.Fatalf("recreated history table %s has %d rows", table, got) + t.Fatalf("cleared table %s has %d rows", table, got) } } var historicalComments int