package registry import ( "context" "database/sql" "strings" "reanimator/internal/domain" "reanimator/internal/idgen" ) type AssetRepository struct { db *sql.DB idgen *idgen.Generator } func NewAssetRepository(db *sql.DB) *AssetRepository { return &AssetRepository{ db: db, idgen: idgen.NewGenerator(db), } } func (r *AssetRepository) Create(ctx context.Context, asset domain.Asset) (domain.Asset, error) { id, err := r.idgen.Generate(ctx, idgen.Asset) if err != nil { return domain.Asset{}, err } _, err = r.db.ExecContext(ctx, `INSERT INTO machines (id, name, vendor, model, vendor_serial, machine_tag) VALUES (?, ?, ?, ?, ?, ?)`, id, asset.Name, asset.Vendor, asset.Model, asset.VendorSerial, asset.MachineTag, ) if err != nil { return domain.Asset{}, classifyError(err) } return r.Get(ctx, id) } func (r *AssetRepository) Get(ctx context.Context, id string) (domain.Asset, error) { var asset domain.Asset var vendor sql.NullString var model sql.NullString var machineTag sql.NullString row := r.db.QueryRowContext(ctx, `SELECT id, name, vendor, model, vendor_serial, machine_tag, created_at, updated_at FROM machines WHERE id = ?`, id, ) if err := row.Scan(&asset.ID, &asset.Name, &vendor, &model, &asset.VendorSerial, &machineTag, &asset.CreatedAt, &asset.UpdatedAt); err != nil { if err == sql.ErrNoRows { return domain.Asset{}, ErrNotFound } return domain.Asset{}, err } asset.Vendor = nullStringToPtr(vendor) asset.Model = nullStringToPtr(model) asset.MachineTag = nullStringToPtr(machineTag) return asset, nil } func (r *AssetRepository) List(ctx context.Context) ([]domain.Asset, error) { rows, err := r.db.QueryContext(ctx, `SELECT id, name, vendor, model, vendor_serial, machine_tag, created_at, updated_at FROM machines ORDER BY created_at DESC`, ) if err != nil { return nil, err } defer rows.Close() machines := make([]domain.Asset, 0) for rows.Next() { var asset domain.Asset var vendor sql.NullString var model sql.NullString var machineTag sql.NullString if err := rows.Scan(&asset.ID, &asset.Name, &vendor, &model, &asset.VendorSerial, &machineTag, &asset.CreatedAt, &asset.UpdatedAt); err != nil { return nil, err } asset.Vendor = nullStringToPtr(vendor) asset.Model = nullStringToPtr(model) asset.MachineTag = nullStringToPtr(machineTag) machines = append(machines, asset) } if err := rows.Err(); err != nil { return nil, err } return machines, nil } func (r *AssetRepository) Update(ctx context.Context, asset domain.Asset) (domain.Asset, error) { result, err := r.db.ExecContext(ctx, `UPDATE machines SET name = ?, vendor = ?, model = ?, vendor_serial = ?, machine_tag = ? WHERE id = ?`, strings.TrimSpace(asset.Name), asset.Vendor, asset.Model, strings.TrimSpace(asset.VendorSerial), asset.MachineTag, asset.ID, ) if err != nil { return domain.Asset{}, classifyError(err) } affected, err := result.RowsAffected() if err != nil { return domain.Asset{}, err } if affected == 0 { return domain.Asset{}, ErrNotFound } return r.Get(ctx, asset.ID) } type AssetDeleteResult struct { DeletedParts int } func (r *AssetRepository) DeleteWithDetails(ctx context.Context, assetID string) (AssetDeleteResult, error) { tx, err := r.db.BeginTx(ctx, &sql.TxOptions{}) if err != nil { return AssetDeleteResult{}, err } defer func() { _ = tx.Rollback() }() var exists int if err := tx.QueryRowContext(ctx, `SELECT 1 FROM machines WHERE id = ? FOR UPDATE`, assetID).Scan(&exists); err != nil { if err == sql.ErrNoRows { return AssetDeleteResult{}, ErrNotFound } return AssetDeleteResult{}, err } partIDs, err := listAssetPartIDs(ctx, tx, assetID) if err != nil { return AssetDeleteResult{}, err } if _, err := tx.ExecContext(ctx, `DELETE FROM failure_events WHERE machine_id = ?`, assetID); err != nil { return AssetDeleteResult{}, err } if _, err := tx.ExecContext(ctx, `DELETE FROM observations WHERE machine_id = ?`, assetID); err != nil { return AssetDeleteResult{}, err } if _, err := tx.ExecContext(ctx, `DELETE FROM log_bundles WHERE machine_id = ?`, assetID); err != nil { return AssetDeleteResult{}, err } if _, err := tx.ExecContext(ctx, `DELETE FROM installations WHERE machine_id = ?`, assetID); err != nil { return AssetDeleteResult{}, err } if _, err := tx.ExecContext(ctx, `DELETE FROM machine_firmware_states WHERE machine_id = ?`, assetID); err != nil { return AssetDeleteResult{}, err } if _, err := tx.ExecContext(ctx, `DELETE FROM timeline_events WHERE machine_id = ? OR (subject_type = 'asset' AND subject_id = ?)`, assetID, assetID); err != nil { return AssetDeleteResult{}, err } if _, err := tx.ExecContext(ctx, `DELETE FROM machines WHERE id = ?`, assetID); err != nil { return AssetDeleteResult{}, err } deletedPartIDs, err := deleteOrphanParts(ctx, tx, partIDs) if err != nil { return AssetDeleteResult{}, err } if err := tx.Commit(); err != nil { return AssetDeleteResult{}, err } return AssetDeleteResult{DeletedParts: len(deletedPartIDs)}, nil } func listAssetPartIDs(ctx context.Context, tx *sql.Tx, assetID string) ([]string, error) { rows, err := tx.QueryContext(ctx, ` SELECT DISTINCT part_id FROM ( SELECT part_id FROM installations WHERE machine_id = ? UNION ALL SELECT part_id FROM observations WHERE machine_id = ? UNION ALL SELECT part_id FROM failure_events WHERE machine_id = ? ) linked `, assetID, assetID, assetID) if err != nil { return nil, err } defer rows.Close() ids := make([]string, 0) for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } if err := rows.Err(); err != nil { return nil, err } return ids, nil } func deleteOrphanParts(ctx context.Context, tx *sql.Tx, candidatePartIDs []string) ([]string, error) { if len(candidatePartIDs) == 0 { return nil, nil } placeholders := make([]string, 0, len(candidatePartIDs)) args := make([]any, 0, len(candidatePartIDs)) for _, id := range candidatePartIDs { placeholders = append(placeholders, "?") args = append(args, id) } query := ` SELECT p.id FROM parts p WHERE p.id IN (` + strings.Join(placeholders, ",") + `) AND NOT EXISTS (SELECT 1 FROM installations i WHERE i.part_id = p.id) AND NOT EXISTS (SELECT 1 FROM observations o WHERE o.part_id = p.id) AND NOT EXISTS (SELECT 1 FROM failure_events f WHERE f.part_id = p.id) ` rows, err := tx.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() deletable := make([]string, 0) for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } deletable = append(deletable, id) } if err := rows.Err(); err != nil { return nil, err } if len(deletable) == 0 { return nil, nil } placeholders = placeholders[:0] args = args[:0] for _, id := range deletable { placeholders = append(placeholders, "?") args = append(args, id) } timelineQuery := `DELETE FROM timeline_events WHERE part_id IN (` + strings.Join(placeholders, ",") + `) OR (subject_type = 'component' AND subject_id IN (` + strings.Join(placeholders, ",") + `))` timelineArgs := append(append(make([]any, 0, len(args)*2), args...), args...) if _, err := tx.ExecContext(ctx, timelineQuery, timelineArgs...); err != nil { return nil, err } deleteQuery := `DELETE FROM parts WHERE id IN (` + strings.Join(placeholders, ",") + `)` if _, err := tx.ExecContext(ctx, deleteQuery, args...); err != nil { return nil, err } return deletable, nil }