047a813278
Two spine infra items (feature-ranking #1, part of the migration prereq): - migrations.go: PRAGMA user_version runner, empty (no-op) migration slice, one tx per step, fail-closed. Mechanism in place before any real schema change needs it. - crypt.go: file-level at-rest encryption. On-disk file is always AES-256-GCM ciphertext; decrypted to a tmpfs working copy modernc sqlite operates on; re-encrypted atomically on Close, plaintext wiped, key zeroed. Pure stdlib, CGO stays off. Fails closed on wrong key/tamper, never falls back to plaintext. Key is a 32-byte seam (config db_key_b64/db_key_env today; the passkey-derived L3 cold-start key plugs into the same seam later). Chosen over cgo SQLCipher (would force libsqlcipher + CGO across the project) and over the ncruces page-level VFS (swaps the driver project-wide); noted as the upgrade path in a ponytail: comment. Threat model is disk-at-rest only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
50 lines
1.7 KiB
Go
50 lines
1.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
)
|
|
|
|
// migrations are ordered, forward-only schema steps applied after schema.sql.
|
|
// Index i (1-based) is the user_version the step at migrations[i-1] brings the
|
|
// DB TO; there is no step 0 — schema.sql is the idempotent baseline (version 0).
|
|
// An empty slice is a clean no-op that leaves user_version at 0.
|
|
//
|
|
// To add migration #1 (e.g. the sqlcipher rekey), append its SQL:
|
|
//
|
|
// var migrations = []string{
|
|
// `ALTER TABLE ...;`, // #1
|
|
// }
|
|
var migrations = []string{}
|
|
|
|
// migrate applies every migration with a number greater than the DB's current
|
|
// user_version, each in its own transaction that also bumps user_version. Fails
|
|
// closed: the first erroring step aborts and leaves prior steps committed.
|
|
func migrate(ctx context.Context, db *sql.DB) error {
|
|
var current int
|
|
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(¤t); err != nil {
|
|
return fmt.Errorf("read user_version: %w", err)
|
|
}
|
|
for i := current; i < len(migrations); i++ {
|
|
version := i + 1 // 1-based: migrations[i] brings DB to `version`
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("migration %d begin: %w", version, err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("migration %d: %w", version, err)
|
|
}
|
|
// PRAGMA user_version can't be parameterized; version is our own int.
|
|
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", version)); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("migration %d bump: %w", version, err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("migration %d commit: %w", version, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|