package spool import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "fmt" ) type migration struct { version uint32 sql string } var migrations = []migration{{version: 1, sql: schemaV1}} func applyMigrations(ctx context.Context, db *sql.DB) error { if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY CHECK(version > 0), checksum TEXT NOT NULL, applied_at INTEGER NOT NULL ) STRICT`); err != nil { return fmt.Errorf("create client spool migration table: %w", err) } for _, current := range migrations { checksumBytes := sha256.Sum256([]byte(current.sql)) checksum := hex.EncodeToString(checksumBytes[:]) var stored string err := db.QueryRowContext(ctx, `SELECT checksum FROM schema_migrations WHERE version = ?`, current.version).Scan(&stored) if err == nil { if stored != checksum { return fmt.Errorf("client spool migration %d checksum mismatch", current.version) } continue } if err != sql.ErrNoRows { return err } tx, err := db.BeginTx(ctx, nil) if err != nil { return err } if _, err = tx.ExecContext(ctx, current.sql); err == nil { _, err = tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, checksum, applied_at) VALUES (?, ?, unixepoch())`, current.version, checksum) } if err != nil { _ = tx.Rollback() return fmt.Errorf("apply client spool migration %d: %w", current.version, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit client spool migration %d: %w", current.version, err) } } return nil } const schemaV1 = ` CREATE TABLE commands ( issue_uuid BLOB PRIMARY KEY CHECK(length(issue_uuid) = 16), immutable_sha256 BLOB NOT NULL CHECK(length(immutable_sha256) = 32), command_revision INTEGER NOT NULL CHECK(command_revision > 0), phase INTEGER NOT NULL CHECK(phase BETWEEN 1 AND 11), terminal INTEGER NOT NULL DEFAULT 0 CHECK(terminal IN (0, 1)), next_local_ordinal INTEGER NOT NULL DEFAULT 1 CHECK(next_local_ordinal > 0), next_event_seq INTEGER NOT NULL DEFAULT 1 CHECK(next_event_seq > 0), last_server_ack INTEGER NOT NULL DEFAULT 0 CHECK(last_server_ack >= 0), accepted_at INTEGER NOT NULL ) STRICT; CREATE TABLE events ( issue_uuid BLOB NOT NULL REFERENCES commands(issue_uuid) ON DELETE CASCADE, local_ordinal INTEGER NOT NULL CHECK(local_ordinal > 0), event_seq INTEGER CHECK(event_seq IS NULL OR event_seq > 0), event_kind INTEGER NOT NULL CHECK(event_kind > 0), compression INTEGER NOT NULL CHECK(compression IN (1, 2)), raw_bytes INTEGER NOT NULL CHECK(raw_bytes >= 0), payload BLOB NOT NULL, payload_sha256 BLOB NOT NULL CHECK(length(payload_sha256) = 32), created_at INTEGER NOT NULL, PRIMARY KEY(issue_uuid, local_ordinal), UNIQUE(issue_uuid, event_seq) ) STRICT, WITHOUT ROWID; CREATE INDEX events_send_window ON events(issue_uuid, event_seq, local_ordinal); CREATE TABLE command_tombstones ( issue_uuid BLOB PRIMARY KEY CHECK(length(issue_uuid) = 16), immutable_sha256 BLOB NOT NULL CHECK(length(immutable_sha256) = 32), acknowledged_at INTEGER NOT NULL ) STRICT; CREATE INDEX tombstones_fifo ON command_tombstones(acknowledged_at, issue_uuid); `