Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ internal/
gatewayrpc/ shared gRPC transport contract (control-plane <-> agent)
dbsqlc/ sqlc-generated DB layer — DO NOT edit manually
db/
migrations/ PostgreSQL schema
migrations/ goose-миграции, два дерева: postgres/ (источник для
sqlc) + sqlite/ — правила в db/migrations/README.md
queries/ SQL source for sqlc
proto/ Protobuf definitions for gRPC gateway
web/ React dashboard (web/src/ui/ is the in-tree UI kit)
Expand Down
5 changes: 5 additions & 0 deletions cmd/control-plane/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,11 @@ func verifyRestoreArchive(ctx context.Context, archivePath, storageDriver, stora
if err != nil {
return err
}
// NB: после P9-squash свежие БД несут goose-версию 1, а архивы,
// снятые до squash, — версии <= 58: для такой пары сравнение
// "ahead/older" инвертируется, хотя схемы бит-эквивалентны.
// Pre-prod: принято, восстановление до-squash архивов не
// поддерживается (пересоздать стенд).
switch {
case meta.SchemaVersion > targetVersion:
fmt.Printf(" schema: archive (v%d) is AHEAD of target (v%d) — run migrate-schema after restoring\n", meta.SchemaVersion, targetVersion)
Expand Down
77 changes: 77 additions & 0 deletions cmd/sqlite-rebuild/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Command sqlite-rebuild печатает готовый goose-файл пересборки SQLite-таблицы
// (create/copy/drop/rename/index в crash-safe транзакционных рамках).
//
// Использование (одна таблица за вызов; для нескольких таблиц в одной
// миграции — объединить блоки между PRAGMA-строками вручную):
//
// go run ./cmd/sqlite-rebuild \
// -table jobs \
// -create new_jobs.sql \
// -columns id,action,payload_json \
// -index 'CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status);' \
// > db/migrations/sqlite/0059_jobs_add_check.sql
//
// Флаг -copy file.sql заменяет дефолтный INSERT..SELECT кастомным (backfill).
package main

import (
"flag"
"fmt"
"os"
"strings"
)

type stringSlice []string

func (s *stringSlice) String() string { return strings.Join(*s, "; ") }
func (s *stringSlice) Set(v string) error {
*s = append(*s, v)
return nil
}

func main() {
table := flag.String("table", "", "имя пересобираемой таблицы (обязателен)")
createFile := flag.String("create", "", "файл с CREATE TABLE <table>_new (...) (обязателен)")
columns := flag.String("columns", "", "общие колонки через запятую (обязателен без -copy)")
copyFile := flag.String("copy", "", "файл с кастомным INSERT..SELECT (вместо -columns)")
var indexes stringSlice
flag.Var(&indexes, "index", "CREATE INDEX statement (повторяемый флаг)")
flag.Parse()

if err := run(*table, *createFile, *columns, *copyFile, indexes); err != nil {
fmt.Fprintln(os.Stderr, "sqlite-rebuild:", err)
os.Exit(1)
}
}

func run(table, createFile, columns, copyFile string, indexes []string) error {
if table == "" || createFile == "" {
return fmt.Errorf("-table and -create are required")
}
createSQL, err := os.ReadFile(createFile) //nolint:gosec // reason: local dev tool; the operator supplies the CREATE-TABLE file path deliberately.
if err != nil {
return err
}
spec := Spec{Table: table, CreateSQL: string(createSQL), Indexes: indexes}
switch {
case copyFile != "":
copySQL, err := os.ReadFile(copyFile) //nolint:gosec // reason: local dev tool; the operator supplies the custom-copy file path deliberately.
if err != nil {
return err
}
spec.CopySQL = string(copySQL)
case columns != "":
for _, c := range strings.Split(columns, ",") {
spec.Columns = append(spec.Columns, strings.TrimSpace(c))
}
default:
return fmt.Errorf("either -columns or -copy is required")
}

script, err := Script([]Spec{spec})
if err != nil {
return err
}
_, err = os.Stdout.WriteString(script)
return err
}
107 changes: 107 additions & 0 deletions cmd/sqlite-rebuild/script.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package main

import (
"errors"
"fmt"
"strings"
)

// Spec описывает пересборку одной таблицы по рецепту
// create/copy/drop/rename/index.
type Spec struct {
// Table — имя пересобираемой таблицы (например "jobs").
Table string
// CreateSQL — полный CREATE TABLE <Table>_new (...) с новой схемой.
CreateSQL string
// Columns — общие колонки для дефолтного копирования
// INSERT INTO <Table>_new (cols) SELECT cols FROM <Table>.
// Игнорируется, если задан CopySQL.
Columns []string
// CopySQL — полный кастомный INSERT ... SELECT ... (с backfill'ом,
// CASE-преобразованиями и т.п.), когда прямого копирования мало.
CopySQL string
// Indexes — CREATE INDEX statements, воссоздаваемые после RENAME
// (DROP TABLE уносит индексы старой таблицы вместе с ней).
Indexes []string
}

const scriptHeader = `-- +goose Up
-- +goose NO TRANSACTION
-- Сгенерировано cmd/sqlite-rebuild. Рецепт пересборки таблицы (SQLite не
-- умеет ALTER TABLE ADD/DROP CONSTRAINT): create/copy/drop/rename/index.
-- Каждая пара DROP/RENAME — в собственном явном BEGIN/COMMIT, чтобы крэш
-- между ними не оставил таблицу удалённой-но-не-переименованной (guard:
-- migrate.TestSQLiteTableRebuildsAreTransactionWrapped). PRAGMA
-- foreign_keys переключается ВНЕ транзакций — SQLite запрещает менять его
-- внутри, поэтому весь файл идёт под NO TRANSACTION.

PRAGMA foreign_keys = OFF;
`

const scriptFooter = `
PRAGMA foreign_keys = ON;

-- +goose Down
-- Обратная пересборка не автоматизируется: напиши обратный rebuild вручную
-- или оставь no-op, если даунгрейд не поддерживается.
SELECT 1;
`

// Script собирает готовый goose-файл из одной или нескольких пересборок.
func Script(specs []Spec) (string, error) {
if len(specs) == 0 {
return "", errors.New("at least one Spec is required")
}
var b strings.Builder
b.WriteString(scriptHeader)
for _, s := range specs {
block, err := rebuildBlock(s)
if err != nil {
return "", err
}
b.WriteString(block)
}
b.WriteString(scriptFooter)
return b.String(), nil
}

func rebuildBlock(s Spec) (string, error) {
if strings.TrimSpace(s.Table) == "" {
return "", errors.New("Spec.Table is required")
}
newName := s.Table + "_new"
if !strings.Contains(s.CreateSQL, newName) {
return "", fmt.Errorf("Spec.CreateSQL for %q must create %q (got: %.60s...)", s.Table, newName, s.CreateSQL)
}
copyStmt := strings.TrimSpace(s.CopySQL)
if copyStmt == "" {
if len(s.Columns) == 0 {
return "", fmt.Errorf("Spec for %q needs Columns or CopySQL", s.Table)
}
cols := strings.Join(s.Columns, ", ")
copyStmt = fmt.Sprintf("INSERT INTO %s (%s)\nSELECT %s FROM %s;", newName, cols, cols, s.Table)
}

var b strings.Builder
fmt.Fprintf(&b, "\n-- ─── %s ───\nBEGIN;\n\n", s.Table)
b.WriteString(ensureSemicolon(s.CreateSQL))
b.WriteString("\n\n")
b.WriteString(ensureSemicolon(copyStmt))
b.WriteString("\n\n")
fmt.Fprintf(&b, "DROP TABLE %s;\n", s.Table)
fmt.Fprintf(&b, "ALTER TABLE %s RENAME TO %s;\n", newName, s.Table)
if len(s.Indexes) > 0 {
b.WriteString("\n")
for _, idx := range s.Indexes {
b.WriteString(ensureSemicolon(idx))
b.WriteString("\n")
}
}
b.WriteString("\nCOMMIT;\n")
return b.String(), nil
}

func ensureSemicolon(stmt string) string {
trimmed := strings.TrimRight(strings.TrimSpace(stmt), ";")
return trimmed + ";"
}
160 changes: 160 additions & 0 deletions cmd/sqlite-rebuild/script_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package main

import (
"context"
"database/sql"
"path/filepath"
"strings"
"testing"

// register the pure-Go SQLite driver under "sqlite" for database/sql
_ "modernc.org/sqlite"
)

func TestScriptGolden(t *testing.T) {
got, err := Script([]Spec{{
Table: "jobs",
CreateSQL: `CREATE TABLE jobs_new (
id TEXT PRIMARY KEY,
payload_json TEXT NOT NULL DEFAULT ''
CHECK (payload_json = '' OR json_valid(payload_json))
);`,
Columns: []string{"id", "payload_json"},
Indexes: []string{"CREATE INDEX IF NOT EXISTS idx_jobs_payload ON jobs (payload_json);"},
}})
if err != nil {
t.Fatalf("Script: %v", err)
}

want := `-- +goose Up
-- +goose NO TRANSACTION
-- Сгенерировано cmd/sqlite-rebuild. Рецепт пересборки таблицы (SQLite не
-- умеет ALTER TABLE ADD/DROP CONSTRAINT): create/copy/drop/rename/index.
-- Каждая пара DROP/RENAME — в собственном явном BEGIN/COMMIT, чтобы крэш
-- между ними не оставил таблицу удалённой-но-не-переименованной (guard:
-- migrate.TestSQLiteTableRebuildsAreTransactionWrapped). PRAGMA
-- foreign_keys переключается ВНЕ транзакций — SQLite запрещает менять его
-- внутри, поэтому весь файл идёт под NO TRANSACTION.

PRAGMA foreign_keys = OFF;

-- ─── jobs ───
BEGIN;

CREATE TABLE jobs_new (
id TEXT PRIMARY KEY,
payload_json TEXT NOT NULL DEFAULT ''
CHECK (payload_json = '' OR json_valid(payload_json))
);

INSERT INTO jobs_new (id, payload_json)
SELECT id, payload_json FROM jobs;

DROP TABLE jobs;
ALTER TABLE jobs_new RENAME TO jobs;

CREATE INDEX IF NOT EXISTS idx_jobs_payload ON jobs (payload_json);

COMMIT;

PRAGMA foreign_keys = ON;

-- +goose Down
-- Обратная пересборка не автоматизируется: напиши обратный rebuild вручную
-- или оставь no-op, если даунгрейд не поддерживается.
SELECT 1;
`
if got != want {
t.Fatalf("golden mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}

func TestScriptValidation(t *testing.T) {
if _, err := Script([]Spec{{Table: "jobs", CreateSQL: "CREATE TABLE wrong_name (id TEXT);", Columns: []string{"id"}}}); err == nil {
t.Fatal("CreateSQL without <table>_new must be rejected")
}
if _, err := Script([]Spec{{Table: "jobs", CreateSQL: "CREATE TABLE jobs_new (id TEXT);"}}); err == nil {
t.Fatal("Spec without Columns and without CopySQL must be rejected")
}
if _, err := Script(nil); err == nil {
t.Fatal("empty spec list must be rejected")
}
}

// TestScriptFunctional applies a generated script to a live DB with FK
// references, rows and an index, and proves the rebuild is lossless.
func TestScriptFunctional(t *testing.T) {
ctx := context.Background()
db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "rebuild.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
db.SetMaxOpenConns(1)

setup := []string{
`PRAGMA foreign_keys = ON;`,
`CREATE TABLE parents (id TEXT PRIMARY KEY);`,
`CREATE TABLE children (
id TEXT PRIMARY KEY,
parent_id TEXT NOT NULL REFERENCES parents (id) ON DELETE CASCADE,
note TEXT NOT NULL DEFAULT ''
);`,
`CREATE INDEX idx_children_parent ON children (parent_id);`,
`INSERT INTO parents (id) VALUES ('p1');`,
`INSERT INTO children (id, parent_id, note) VALUES ('c1', 'p1', 'keep'), ('c2', 'p1', 'also');`,
}
for _, stmt := range setup {
if _, err := db.ExecContext(ctx, stmt); err != nil {
t.Fatalf("setup %q: %v", stmt, err)
}
}

script, err := Script([]Spec{{
Table: "children",
CreateSQL: `CREATE TABLE children_new (
id TEXT PRIMARY KEY,
parent_id TEXT NOT NULL REFERENCES parents (id) ON DELETE CASCADE,
note TEXT NOT NULL DEFAULT '' CHECK (length(note) <= 64)
);`,
Columns: []string{"id", "parent_id", "note"},
Indexes: []string{"CREATE INDEX IF NOT EXISTS idx_children_parent ON children (parent_id);"},
}})
if err != nil {
t.Fatalf("Script: %v", err)
}
// Отрезаем goose-аннотации: вне goose это обычный многостейтментный SQL.
// Якорь — сам statement "PRAGMA foreign_keys = OFF", а не слово "PRAGMA"
// из шапки-комментария, которое встречается раньше.
body := script[strings.Index(script, "PRAGMA foreign_keys = OFF"):strings.Index(script, "-- +goose Down")]
if _, err := db.ExecContext(ctx, body); err != nil {
t.Fatalf("apply generated script: %v", err)
}

var n int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM children`).Scan(&n); err != nil || n != 2 {
t.Fatalf("rows after rebuild: n=%d err=%v", n, err)
}
var ddl string
if err := db.QueryRowContext(ctx, `SELECT sql FROM sqlite_master WHERE type='table' AND name='children'`).Scan(&ddl); err != nil {
t.Fatalf("read rebuilt DDL: %v", err)
}
if !strings.Contains(ddl, "length(note) <= 64") {
t.Fatalf("rebuilt table lost the new CHECK: %s", ddl)
}
var idx string
if err := db.QueryRowContext(ctx, `SELECT name FROM sqlite_master WHERE type='index' AND name='idx_children_parent'`).Scan(&idx); err != nil {
t.Fatalf("index not recreated: %v", err)
}
rows, err := db.QueryContext(ctx, `PRAGMA foreign_key_check`)
if err != nil {
t.Fatalf("foreign_key_check: %v", err)
}
defer rows.Close()
if rows.Next() {
t.Fatal("foreign_key_check reported violations after rebuild")
}
if err := rows.Err(); err != nil {
t.Fatalf("foreign_key_check rows: %v", err)
}
}
Loading
Loading