1 Commits
Author SHA1 Message Date
sneak 9be8731dc8 Refuse an unversioned database that already has a files table (closes #11)
check / check (push) Failing after 2s
A database at user_version 0 that already has a files table was made
by something else: scan used to run its CREATE TABLE on it and fail
with a raw SQLite error, and report and trees gave only a bare version
mismatch. All three now refuse such a database with the schema-version
error telling the operator to remove the file and rescan. scan creates
the table and index and sets the version in one transaction, so a first
scan stopped partway leaves an empty database the next scan sets up,
never a files table at version 0. A genuinely empty database is
unchanged.

Model: opus-4-8 (implementation); opus-5-5 (rework)
2026-10-04 09:04:55 +00:00
4 changed files with 65 additions and 14 deletions
+6 -4
View File
@@ -209,10 +209,12 @@ All three subcommands operate on a single SQLite database file:
`-shm` files beside it, which SQLite creates with the database `-shm` files beside it, which SQLite creates with the database
file's permissions. file's permissions.
- Schema (`PRAGMA user_version` is the schema version, currently 1; a - Schema (`PRAGMA user_version` is the schema version, currently 1; a
database with any other version is a fatal error. A database at database with any other version is a fatal error. `scan` creates
version 0 that already has a `files` table was not made by sfdupes; the schema and sets the version in one transaction, so a first scan
every subcommand refuses it with an error telling the user to remove stopped while doing so leaves an empty database the next scan sets
the file and rescan): up. A database at version 0 that already has a `files` table was
therefore not made by sfdupes; every subcommand refuses it with an
error telling the user to remove the file and rescan):
```sql ```sql
CREATE TABLE files ( CREATE TABLE files (
+2 -2
View File
@@ -29,8 +29,8 @@
# Completed Steps # Completed Steps
- refuse an unversioned database that already has a `files` table with - `scan` creates the schema in one transaction; a version-0 database with a
a clear schema-version error (2026-10-04, `files` table is refused with a clear schema-version error (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/11) https://git.eeqj.de/sneak/sfdupes/issues/11)
- the `Dockerfile` build stage keeps the Go module cache out of `builder`'s - the `Dockerfile` build stage keeps the Go module cache out of `builder`'s
+23 -8
View File
@@ -266,10 +266,10 @@ func initSchema(ctx context.Context, db *sql.DB) error {
} }
// checkUnversioned checks a database at user_version 0 before it is // checkUnversioned checks a database at user_version 0 before it is
// taken for an empty one. A database that already has a files table // taken for an empty one. createSchema creates the files table and
// was not created by this build: a foreign or partially initialized // sets the version together, so a files table at version 0 was made by
// file. Adopting it could corrupt unrelated data, so that is a // something else. Adopting it could corrupt unrelated data, so that is
// schema-version error telling the operator to remove the file and // a schema-version error telling the operator to remove the file and
// rescan. // rescan.
func checkUnversioned(ctx context.Context, db *sql.DB) error { func checkUnversioned(ctx context.Context, db *sql.DB) error {
var name string var name string
@@ -291,24 +291,39 @@ func checkUnversioned(ctx context.Context, db *sql.DB) error {
} }
// createSchema applies the schema to a fresh database and stamps the // createSchema applies the schema to a fresh database and stamps the
// schema version. // schema version in one transaction, so a creation stopped partway, by
// an interrupt or an error, leaves an empty database the next scan
// sets up, never a files table at version 0, which checkUnversioned
// refuses.
func createSchema(ctx context.Context, db *sql.DB) error { func createSchema(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, createTableSQL) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return fmt.Errorf("create schema: %w", err) return fmt.Errorf("create schema: %w", err)
} }
_, err = db.ExecContext(ctx, createIndexSQL) defer func() { _ = tx.Rollback() }()
_, err = tx.ExecContext(ctx, createTableSQL)
if err != nil { if err != nil {
return fmt.Errorf("create schema: %w", err) return fmt.Errorf("create schema: %w", err)
} }
_, err = db.ExecContext(ctx, _, err = tx.ExecContext(ctx, createIndexSQL)
if err != nil {
return fmt.Errorf("create schema: %w", err)
}
_, err = tx.ExecContext(ctx,
"PRAGMA user_version = "+strconv.Itoa(schemaVersion)) "PRAGMA user_version = "+strconv.Itoa(schemaVersion))
if err != nil { if err != nil {
return fmt.Errorf("set schema version: %w", err) return fmt.Errorf("set schema version: %w", err)
} }
err = tx.Commit()
if err != nil {
return fmt.Errorf("create schema: %w", err)
}
return nil return nil
} }
+34
View File
@@ -114,6 +114,40 @@ func TestOpenDatabaseUnversionedForeign(t *testing.T) {
} }
} }
func TestSchemaCreationStoppedPartway(t *testing.T) {
t.Parallel()
// A first scan stopped while creating the schema must leave a
// database the next scan accepts. max_page_count(2) leaves room for
// the files table but not its index, so schema creation fails right
// after CREATE TABLE, a point an interrupt could also stop it at.
path := testDBPath(t)
db, err := openDB(path, scanParams+"&_pragma=max_page_count(2)")
if err != nil {
t.Fatal(err)
}
err = initSchema(t.Context(), db)
_ = db.Close()
if err == nil {
t.Fatal("initSchema with no room for the index succeeded")
}
db, err = openScanDatabase(t.Context(), path)
if err != nil {
t.Fatalf("next scan: %v", err)
}
defer func() { _ = db.Close() }()
v, err := userVersion(t.Context(), db)
if err != nil || v != schemaVersion {
t.Fatalf("userVersion = %d, %v; want %d, nil", v, err, schemaVersion)
}
}
func TestOpenReportDatabaseMissing(t *testing.T) { func TestOpenReportDatabaseMissing(t *testing.T) {
t.Parallel() t.Parallel()