Files
CableGUI/internal/db/store.go
mAi 4202d0465f feat(v5 slice 1): clamps schema + store helpers + snapshot
Migration 007 introduces the v5 routing primitive:
- clamps table (project-scoped, optional frame_id, excalidraw_id).
- cable_clamps join (cable_id, clamp_id, ord) with PK on (cable_id, ord)
  and UNIQUE (cable_id, clamp_id) to block a clamp visiting the same
  cable twice.

Store helpers in internal/db/clamps.go:
- CreateClamp / GetClamp / ListClamps / UpdateClamp / DeleteClamp —
  standard project-scoped CRUD. UpdateClamp uses FrameRef tri-state.
- AttachClampToCable — appends or inserts at a given ord. Mid-sequence
  inserts use a two-pass shift (bump by 10000, settle to ord+1) since
  SQLite UPDATE doesn't support ORDER BY and a single bulk +1 would
  collide with the UNIQUE (cable_id, ord) PK.
- DetachClampFromCable — removes the row then closes the gap.
- ReorderCableClamps — replaces the whole sequence in one tx.
- ListClampsForCable / ListCableClamps — read helpers.

Snapshot now carries clamps + cable_clamps arrays so the frontend can
hydrate everything in one call.

Tests cover create / update / cascade-delete / attach (append + insert
+ duplicate-rejected) / detach (gap closes) / reorder / snapshot.
2026-05-16 13:40:53 +02:00

362 lines
9.8 KiB
Go

package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// Sentinel errors callers can match against. The server layer maps these
// to HTTP status codes.
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict") // UNIQUE violation
ErrInUse = errors.New("in use") // cable_type referenced by a cable
ErrConfirmName = errors.New("confirm name missing or mismatched")
ErrInvalidInput = errors.New("invalid input")
)
// -----------------------------------------------------------------------------
// Projects
// -----------------------------------------------------------------------------
// CreateProject inserts a new project. drawingName, if empty, defaults to
// "<name>.excalidraw". name and drawingName are trimmed; an empty name
// after trimming is rejected.
func (s *Store) CreateProject(name, drawingName, description string) (*Project, error) {
name = strings.TrimSpace(name)
drawingName = strings.TrimSpace(drawingName)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if drawingName == "" {
drawingName = name + ".excalidraw"
}
res, err := s.db.Exec(
`INSERT INTO projects (name, drawing_name, description) VALUES (?, ?, ?)`,
name, drawingName, description,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetProject(id)
}
// GetProject loads a project by ID.
func (s *Store) GetProject(id int64) (*Project, error) {
var p Project
err := s.db.QueryRow(
`SELECT id, name, drawing_name, description, created_at, updated_at
FROM projects WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.DrawingName, &p.Description, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &p, nil
}
// ListProjects returns every project ordered by name.
func (s *Store) ListProjects() ([]Project, error) {
rows, err := s.db.Query(
`SELECT id, name, drawing_name, description, created_at, updated_at
FROM projects ORDER BY name`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Project
for rows.Next() {
var p Project
if err := rows.Scan(&p.ID, &p.Name, &p.DrawingName, &p.Description, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ProjectUpdate carries partial fields for PATCH. A nil pointer means
// "leave this field untouched".
type ProjectUpdate struct {
Name *string
DrawingName *string
Description *string
}
// UpdateProject applies the partial update. Empty struct = no-op (just
// bumps updated_at). Empty Name (after trim) is rejected; whitespace-only
// DrawingName is treated as "use <name>.excalidraw" — same default as
// CreateProject.
func (s *Store) UpdateProject(id int64, u ProjectUpdate) (*Project, error) {
cur, err := s.GetProject(id)
if err != nil {
return nil, err
}
if u.Name != nil {
v := strings.TrimSpace(*u.Name)
if v == "" {
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
}
cur.Name = v
}
if u.DrawingName != nil {
v := strings.TrimSpace(*u.DrawingName)
if v == "" {
v = cur.Name + ".excalidraw"
}
cur.DrawingName = v
}
if u.Description != nil {
cur.Description = *u.Description
}
if _, err := s.db.Exec(
`UPDATE projects
SET name = ?, drawing_name = ?, description = ?, updated_at = datetime('now')
WHERE id = ?`,
cur.Name, cur.DrawingName, cur.Description, id,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetProject(id)
}
// DeleteProject removes the project (cascading frames, devices, ports,
// cables, io_markers, bundles, bundle_cables). confirmName must match the
// project's current name; otherwise ErrConfirmName is returned and nothing
// is deleted.
func (s *Store) DeleteProject(id int64, confirmName string) error {
p, err := s.GetProject(id)
if err != nil {
return err
}
if confirmName != p.Name {
return ErrConfirmName
}
if _, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id); err != nil {
return err
}
return nil
}
// Snapshot loads the full editor-init payload for one project. Slice 2
// populates frames + devices; ports / cables / io_markers / bundles
// still ship empty until their slices land.
func (s *Store) Snapshot(id int64) (*Snapshot, error) {
p, err := s.GetProject(id)
if err != nil {
return nil, err
}
types, err := s.ListCableTypes()
if err != nil {
return nil, err
}
frames, err := s.ListFrames(id)
if err != nil {
return nil, err
}
devices, err := s.ListDevices(id, nil)
if err != nil {
return nil, err
}
ios, err := s.ListIOMarkers(id)
if err != nil {
return nil, err
}
ports, err := s.ListPortsForProject(id)
if err != nil {
return nil, err
}
reqs, err := s.ListConnectionRequirements(id)
if err != nil {
return nil, err
}
cables, err := s.ListCables(id)
if err != nil {
return nil, err
}
bundles, err := s.ListBundles(id)
if err != nil {
return nil, err
}
clamps, err := s.ListClamps(id)
if err != nil {
return nil, err
}
cableClamps, err := s.ListCableClamps(id)
if err != nil {
return nil, err
}
return &Snapshot{
Project: *p,
Frames: frames,
Devices: devices,
Ports: ports,
Cables: cables,
IOMarkers: ios,
Bundles: bundles,
CableTypes: types,
ConnectionRequirements: reqs,
Clamps: clamps,
CableClamps: cableClamps,
}, nil
}
// -----------------------------------------------------------------------------
// Cable types (global)
// -----------------------------------------------------------------------------
// CreateCableType inserts a global cable type. name must be globally unique.
func (s *Store) CreateCableType(name, color string) (*CableType, error) {
name = strings.TrimSpace(name)
color = strings.TrimSpace(color)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if color == "" {
return nil, fmt.Errorf("%w: color is required", ErrInvalidInput)
}
res, err := s.db.Exec(
`INSERT INTO cable_types (name, color) VALUES (?, ?)`, name, color,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetCableType(id)
}
// GetCableType loads a cable type by ID.
func (s *Store) GetCableType(id int64) (*CableType, error) {
var t CableType
err := s.db.QueryRow(
`SELECT id, name, color, created_at, updated_at
FROM cable_types WHERE id = ?`, id,
).Scan(&t.ID, &t.Name, &t.Color, &t.CreatedAt, &t.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &t, nil
}
// ListCableTypes returns every cable type ordered by id (insertion order,
// so the legend renders in the same order across reloads).
func (s *Store) ListCableTypes() ([]CableType, error) {
rows, err := s.db.Query(
`SELECT id, name, color, created_at, updated_at
FROM cable_types ORDER BY id`,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CableType{}
for rows.Next() {
var t CableType
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// CableTypeUpdate is the partial-update shape for PATCH.
type CableTypeUpdate struct {
Name *string
Color *string
}
// UpdateCableType applies a partial update.
func (s *Store) UpdateCableType(id int64, u CableTypeUpdate) (*CableType, error) {
cur, err := s.GetCableType(id)
if err != nil {
return nil, err
}
if u.Name != nil {
v := strings.TrimSpace(*u.Name)
if v == "" {
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
}
cur.Name = v
}
if u.Color != nil {
v := strings.TrimSpace(*u.Color)
if v == "" {
return nil, fmt.Errorf("%w: color cannot be empty", ErrInvalidInput)
}
cur.Color = v
}
if _, err := s.db.Exec(
`UPDATE cable_types
SET name = ?, color = ?, updated_at = datetime('now')
WHERE id = ?`,
cur.Name, cur.Color, id,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetCableType(id)
}
// DeleteCableType removes a cable type. SQLite enforces ON DELETE RESTRICT
// from cables.type_id and ports.type_id; we surface that as ErrInUse plus
// the count of referencing cables (so the UI can show "blocked by N cables").
func (s *Store) DeleteCableType(id int64) error {
if _, err := s.GetCableType(id); err != nil {
return err
}
if _, err := s.db.Exec(`DELETE FROM cable_types WHERE id = ?`, id); err != nil {
if isForeignKeyConstraint(err) {
return ErrInUse
}
return err
}
return nil
}
// CountCablesUsingType returns how many cables reference this cable_type.
// Used by the server to enrich a 409 InUse response with a helpful number.
func (s *Store) CountCablesUsingType(id int64) (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM cables WHERE type_id = ?`, id).Scan(&n)
return n, err
}
// -----------------------------------------------------------------------------
// Error mapping
// -----------------------------------------------------------------------------
// mapWriteErr classifies SQLite write errors into our sentinel errors so
// the handler layer can pick the right HTTP status. Falls through to the
// raw error for anything we don't recognise.
func mapWriteErr(err error) error {
if err == nil {
return nil
}
msg := err.Error()
switch {
case strings.Contains(msg, "UNIQUE constraint failed"):
return fmt.Errorf("%w: %s", ErrConflict, msg)
case strings.Contains(msg, "FOREIGN KEY constraint failed"):
return fmt.Errorf("%w: %s", ErrInUse, msg)
case strings.Contains(msg, "CHECK constraint failed"):
return fmt.Errorf("%w: %s", ErrInvalidInput, msg)
}
return err
}
func isForeignKeyConstraint(err error) bool {
return err != nil && strings.Contains(err.Error(), "FOREIGN KEY constraint failed")
}