Migration 004:
- setup_templates + setup_template_devices + setup_template_requirements
- 3 built-in templates seeded: Living Room (TV+Soundbar+ChromeCast,
2× HDMI), Home Office (PC+Screen+Keyboard+Mouse, 1× HDMI + 2× USB),
Server Rack (NAS+Switch+fritz, 2× RJ45).
Cables store (cables.go):
- CRUD with endpoint validation (port|device|io exactly-one, project-
scoped). Tx-aware: validateEndpointEx + assertCableTypeEx avoid
deadlocks when the solver Apply tx holds the MaxOpenConns(1) connection.
Bundles store (bundles.go):
- CRUD with cable_ids replacement on PATCH. createBundle(ex, …, ownTx)
inherits the caller's tx for solver-internal use; returns a locally-
constructed Bundle when ownTx=false (re-fetching via s.db would
deadlock).
Solver (solver.go) implements design v4.1 §5b.2 exactly:
- Pre-fetch devices/ports/cables/requirements/bundles.
- Reserve ports used by manual cables (auto=0) so the solver can't
reuse them.
- For each requirement (must_connect DESC, id ASC):
* Resolve cable type: preferred, or T = port-types(from) ∩
port-types(to). |T|==0 → unsatisfied "no compat type"; |T|>1 →
"ambiguous"; |T|==1 → that one.
* Pick lowest-id free port on each side. None → unsatisfied with
WhichSide hint + cable-type name.
- Endpoint-pair bundle: ≥2 staged cables between the same device pair
→ auto bundle.
- Diff against existing auto cables by (type_id, MIN(from,to), MAX(from,to))
signature. Matched = kept; new = added; orphans = removed.
- Preview returns the diff without writing; Apply runs in a single tx
that wipes auto bundles, deletes orphan auto cables, inserts new
ones, and rebuilds bundles.
- PortsAndResolve: combo helper for the inspector quick-fix —
inserts a port + re-runs Solve.
Setup-templates store (setup_templates.go):
- List/Get with hydrated devices + requirements.
- ApplyTemplate(projectID, templateID, opts) seeds devices + requirements
in one tx. Per-device name overrides + opt-out. Name collisions skip
the device (skipped_devices); requirements whose endpoints both fail
are also skipped (requirements_skipped). UNIQUE-collision on an
existing requirement is non-fatal; logged in requirements_skipped.
Snapshot: cables + bundles fields tightened to []Cable / []Bundle and
populated from the store.
11 new tests (solver_test.go), all green with -race:
- Basic NAS↔Switch (RJ45) → 1 cable, auto=true
- Ambiguous cable type → unsatisfied
- No free port → unsatisfied with side hint
- Preview doesn't write
- Apply then re-apply → idempotent (kept=N, added=0)
- Manual cable reserves its port → solver can't claim it
- ApplyTemplate Living Room → 3 devices + 2 requirements + 7 ports
(from the device-type port seeder)
- Home Office template then Solve → 3 cables, 0 unsatisfied
- Name-collision pre-existing device → skipped + req-pair skipped
352 lines
9.6 KiB
Go
352 lines
9.6 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
|
|
}
|
|
return &Snapshot{
|
|
Project: *p,
|
|
Frames: frames,
|
|
Devices: devices,
|
|
Ports: ports,
|
|
Cables: cables,
|
|
IOMarkers: ios,
|
|
Bundles: bundles,
|
|
CableTypes: types,
|
|
ConnectionRequirements: reqs,
|
|
}, 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")
|
|
}
|