// Package store — доступ до PostgreSQL/TimescaleDB. // // Головне правило шару: жоден запит не виконується без tenant_id. // Ізоляція забезпечується двома незалежними механізмами, і це навмисно: // // 1. RLS у БД — політика tenant_isolation на кожній звичайній таблиці // з колонкою tenant_id. Вмикається через SET LOCAL app.tenant_id. // 2. Явний предикат tenant_id у кожному запиті цього пакета. // // Дублювання потрібне, бо RLS НЕ працює на гіпертаблях: TimescaleDB // не поєднує row level security зі стисненням. Саме туди йде вся // телеметрія, тому для неї другий механізм — єдиний. package store import ( "context" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/netpulse/netpulse/server/internal/gitstore" ) type Store struct { pool *pgxpool.Pool // Версіювання конфігів. Не обов'язкове: без нього NCM працює як і // раніше — тіла лежать зашифрованими в базі, а замість sha коміту // пишеться хеш вмісту. Порожнє значення тут — робочий стан, а не // недороблена ініціалізація. git *gitstore.Store } // UseGit під'єднує сховище версій. Окремим викликом, а не параметром // New: конструктор потрібен і там, де Git не потрібен зовсім, — у // міграторі, в утилітах командного рядка, у тестах. func (s *Store) UseGit(g *gitstore.Store) { s.git = g } func New(ctx context.Context, dsn string) (*Store, error) { cfg, err := pgxpool.ParseConfig(dsn) if err != nil { return nil, fmt.Errorf("розбір DSN: %w", err) } cfg.MaxConns = 16 cfg.MinConns = 2 cfg.MaxConnLifetime = time.Hour cfg.MaxConnIdleTime = 10 * time.Minute pool, err := pgxpool.NewWithConfig(ctx, cfg) if err != nil { return nil, err } if err := pool.Ping(ctx); err != nil { pool.Close() return nil, fmt.Errorf("ping: %w", err) } return &Store{pool: pool}, nil } func (s *Store) Close() { s.pool.Close() } func (s *Store) Pool() *pgxpool.Pool { return s.pool } // InTenantTx виконує fn у транзакції з виставленим app.tenant_id. // // SET LOCAL, а не SET: значення живе рівно до кінця транзакції й не // протікає на наступний запит, який візьме те саме з'єднання з пулу. // Протікання тут означало б показ чужих даних, тому це не оптимізація, // а вимога. func (s *Store) InTenantTx(ctx context.Context, tenantID string, fn func(pgx.Tx) error) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer func() { _ = tx.Rollback(ctx) }() if _, err := tx.Exec(ctx, "SELECT set_config('app.tenant_id', $1, true)", tenantID); err != nil { return fmt.Errorf("встановити app.tenant_id: %w", err) } if err := fn(tx); err != nil { return err } return tx.Commit(ctx) } // nullUUID перетворює порожній рядок на NULL — protobuf не має // nullable-рядків, тому "" з дроту означає «не задано». func nullUUID(s string) any { if s == "" { return nil } return s } func nullString(s string) any { if s == "" { return nil } return s }