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
368 lines
10 KiB
Go
368 lines
10 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// ListSetupTemplates returns every template with its devices +
|
|
// requirements hydrated.
|
|
func (s *Store) ListSetupTemplates() ([]SetupTemplate, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, name, description, built_in, created_at, updated_at
|
|
FROM setup_templates ORDER BY id`,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []SetupTemplate{}
|
|
for rows.Next() {
|
|
var t SetupTemplate
|
|
var built int
|
|
if err := rows.Scan(&t.ID, &t.Name, &t.Description, &built,
|
|
&t.CreatedAt, &t.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
t.BuiltIn = built != 0
|
|
out = append(out, t)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range out {
|
|
devs, err := s.listTemplateDevices(out[i].ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[i].Devices = devs
|
|
reqs, err := s.listTemplateRequirements(out[i].ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[i].Requirements = reqs
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetSetupTemplate is a one-template variant of List.
|
|
func (s *Store) GetSetupTemplate(id int64) (*SetupTemplate, error) {
|
|
var t SetupTemplate
|
|
var built int
|
|
err := s.db.QueryRow(
|
|
`SELECT id, name, description, built_in, created_at, updated_at
|
|
FROM setup_templates WHERE id = ?`, id,
|
|
).Scan(&t.ID, &t.Name, &t.Description, &built, &t.CreatedAt, &t.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.BuiltIn = built != 0
|
|
t.Devices, err = s.listTemplateDevices(t.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.Requirements, err = s.listTemplateRequirements(t.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
func (s *Store) listTemplateDevices(templateID int64) ([]SetupTemplateDevice, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, template_id, device_type_id, suggested_name, sort_order
|
|
FROM setup_template_devices WHERE template_id = ? ORDER BY sort_order, id`,
|
|
templateID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []SetupTemplateDevice{}
|
|
for rows.Next() {
|
|
var d SetupTemplateDevice
|
|
var sn sql.NullString
|
|
if err := rows.Scan(&d.ID, &d.TemplateID, &d.DeviceTypeID, &sn, &d.SortOrder); err != nil {
|
|
return nil, err
|
|
}
|
|
if sn.Valid {
|
|
v := sn.String
|
|
d.SuggestedName = &v
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
// Hydrate the device_type for the UI's optgroup labels.
|
|
for i := range out {
|
|
dt, err := s.GetDeviceType(out[i].DeviceTypeID)
|
|
if err == nil {
|
|
out[i].DeviceType = dt
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) listTemplateRequirements(templateID int64) ([]SetupTemplateRequirement, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, template_id, from_template_device_id, to_template_device_id,
|
|
preferred_cable_type_id, must_connect
|
|
FROM setup_template_requirements WHERE template_id = ? ORDER BY id`,
|
|
templateID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []SetupTemplateRequirement{}
|
|
for rows.Next() {
|
|
var r SetupTemplateRequirement
|
|
var pct sql.NullInt64
|
|
var must int
|
|
if err := rows.Scan(&r.ID, &r.TemplateID, &r.FromTemplateDeviceID, &r.ToTemplateDeviceID,
|
|
&pct, &must); err != nil {
|
|
return nil, err
|
|
}
|
|
if pct.Valid {
|
|
v := pct.Int64
|
|
r.PreferredCableTypeID = &v
|
|
}
|
|
r.MustConnect = must != 0
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ApplyTemplateOptions controls per-device name overrides + opt-outs.
|
|
type ApplyTemplateOptions struct {
|
|
NameOverrides map[int64]string // template_device_id → custom name
|
|
SkipDevices map[int64]bool // template_device_id → skip
|
|
// Layout: where to place the first device in the cluster on the canvas.
|
|
OriginX, OriginY float64
|
|
}
|
|
|
|
// ApplyTemplate seeds devices + requirements from the template into
|
|
// projectID in a single transaction. Name collisions skip the device
|
|
// (recorded in skipped_devices); requirements whose endpoints both fail
|
|
// to land are also skipped.
|
|
func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOptions) (*ApplyTemplateResult, error) {
|
|
tmpl, err := s.GetSetupTemplate(templateID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := s.GetProject(projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := &ApplyTemplateResult{
|
|
DevicesAdded: []Device{},
|
|
RequirementsAdded: []ConnectionRequirement{},
|
|
SkippedDevices: []SkippedTemplateDevice{},
|
|
RequirementsSkipped: []SkippedTemplateReq{},
|
|
}
|
|
|
|
if opts.OriginX == 0 && opts.OriginY == 0 {
|
|
opts.OriginX, opts.OriginY = 200, 200
|
|
}
|
|
|
|
// Pull existing device names in the project so we can pre-check
|
|
// collisions without aborting the whole transaction.
|
|
existing, err := s.ListDevices(projectID, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nameTaken := map[string]bool{}
|
|
for _, d := range existing {
|
|
nameTaken[d.Name] = true
|
|
}
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Map: template_device_id → newly-created device_id (or 0 if skipped).
|
|
tmplToDevice := map[int64]int64{}
|
|
|
|
for i, td := range tmpl.Devices {
|
|
if opts.SkipDevices[td.ID] {
|
|
out.SkippedDevices = append(out.SkippedDevices, SkippedTemplateDevice{
|
|
TemplateDeviceID: td.ID, Reason: "skip requested",
|
|
})
|
|
tmplToDevice[td.ID] = 0
|
|
continue
|
|
}
|
|
name := opts.NameOverrides[td.ID]
|
|
if name == "" && td.SuggestedName != nil {
|
|
name = *td.SuggestedName
|
|
}
|
|
if name == "" {
|
|
name = fmt.Sprintf("Device %d", td.ID)
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if nameTaken[name] {
|
|
out.SkippedDevices = append(out.SkippedDevices, SkippedTemplateDevice{
|
|
TemplateDeviceID: td.ID,
|
|
Reason: fmt.Sprintf("name %q already used in project", name),
|
|
})
|
|
tmplToDevice[td.ID] = 0
|
|
continue
|
|
}
|
|
// Lay out devices in a horizontal row near the origin, 150 px apart.
|
|
x := opts.OriginX + float64(i)*150
|
|
y := opts.OriginY
|
|
// Use createDeviceTx so the port-seeding share the same transaction.
|
|
d, err := s.createDeviceTx(tx, projectID, DeviceCreate{
|
|
Name: name,
|
|
TypeID: &td.DeviceTypeID,
|
|
X: x,
|
|
Y: y,
|
|
Width: 100,
|
|
Height: 35,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("seed %s: %w", name, err)
|
|
}
|
|
nameTaken[name] = true
|
|
tmplToDevice[td.ID] = d.ID
|
|
out.DevicesAdded = append(out.DevicesAdded, *d)
|
|
}
|
|
|
|
for _, tr := range tmpl.Requirements {
|
|
fromID := tmplToDevice[tr.FromTemplateDeviceID]
|
|
toID := tmplToDevice[tr.ToTemplateDeviceID]
|
|
if fromID == 0 || toID == 0 {
|
|
out.RequirementsSkipped = append(out.RequirementsSkipped, SkippedTemplateReq{
|
|
TemplateRequirementID: tr.ID,
|
|
Reason: "one or both endpoint devices were skipped",
|
|
})
|
|
continue
|
|
}
|
|
// Normalise pair_lo/pair_hi, mirror what CreateConnectionRequirement does.
|
|
lo, hi := fromID, toID
|
|
if lo > hi {
|
|
lo, hi = hi, lo
|
|
}
|
|
must := 0
|
|
if tr.MustConnect {
|
|
must = 1
|
|
}
|
|
var ctArg any
|
|
if tr.PreferredCableTypeID != nil {
|
|
ctArg = *tr.PreferredCableTypeID
|
|
}
|
|
res, err := tx.Exec(
|
|
`INSERT INTO connection_requirements
|
|
(project_id, from_device_id, to_device_id, preferred_cable_type_id,
|
|
must_connect, notes, pair_lo, pair_hi)
|
|
VALUES (?, ?, ?, ?, ?, '', ?, ?)`,
|
|
projectID, fromID, toID, ctArg, must, lo, hi,
|
|
)
|
|
if err != nil {
|
|
// A UNIQUE collision (project already has the same requirement)
|
|
// is non-fatal — record as skipped, continue.
|
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
|
out.RequirementsSkipped = append(out.RequirementsSkipped, SkippedTemplateReq{
|
|
TemplateRequirementID: tr.ID,
|
|
Reason: "requirement already exists in project",
|
|
})
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
rid, _ := res.LastInsertId()
|
|
out.RequirementsAdded = append(out.RequirementsAdded, ConnectionRequirement{
|
|
ID: rid,
|
|
ProjectID: projectID,
|
|
FromDeviceID: fromID,
|
|
ToDeviceID: toID,
|
|
PreferredCableTypeID: tr.PreferredCableTypeID,
|
|
MustConnect: tr.MustConnect,
|
|
})
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// createDeviceTx is a tx-aware variant of CreateDevice used by
|
|
// ApplyTemplate so seeding the template's devices + their ports stays
|
|
// inside one atomic apply.
|
|
//
|
|
// Validation is intentionally lighter than CreateDevice: callers (only
|
|
// ApplyTemplate today) hold a tx on the single SQLite connection, so
|
|
// any "validate by reading from s.db" call would deadlock. The template's
|
|
// device_type_id + frame_id come from already-validated template rows,
|
|
// and SQLite FK constraints catch any genuine corruption on INSERT
|
|
// (mapped to ErrInvalidInput by mapWriteErr).
|
|
func (s *Store) createDeviceTx(tx *sql.Tx, projectID int64, d DeviceCreate) (*Device, error) {
|
|
name := strings.TrimSpace(d.Name)
|
|
if name == "" {
|
|
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
|
}
|
|
if d.Width <= 0 || d.Height <= 0 {
|
|
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
|
|
}
|
|
color := strings.TrimSpace(d.Color)
|
|
if color == "" {
|
|
color = "#1e1e1e"
|
|
}
|
|
res, err := tx.Exec(
|
|
`INSERT INTO devices (project_id, frame_id, type_id, name, color, x, y, width, height)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
projectID, nullableInt64(d.FrameID), nullableInt64(d.TypeID),
|
|
name, color, d.X, d.Y, d.Width, d.Height,
|
|
)
|
|
if err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
deviceID, _ := res.LastInsertId()
|
|
if d.TypeID != nil {
|
|
if err := s.seedPortsFromType(tx, projectID, deviceID, *d.TypeID, d.Width, d.Height); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// Read back via the public store path is fine — the row exists in
|
|
// the in-flight tx and SQLite sees its own writes within the tx.
|
|
// Use a sub-helper that takes the tx executor for clean isolation.
|
|
return s.readDeviceTx(tx, projectID, deviceID)
|
|
}
|
|
|
|
func (s *Store) readDeviceTx(ex execer, projectID, id int64) (*Device, error) {
|
|
var d Device
|
|
var frame, typeID sql.NullInt64
|
|
var ex2 sql.NullString
|
|
err := ex.QueryRow(
|
|
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
|
FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
|
|
).Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
|
&ex2, &d.CreatedAt, &d.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if frame.Valid {
|
|
v := frame.Int64
|
|
d.FrameID = &v
|
|
}
|
|
if typeID.Valid {
|
|
v := typeID.Int64
|
|
d.TypeID = &v
|
|
}
|
|
if ex2.Valid {
|
|
d.ExcalidrawID = &ex2.String
|
|
}
|
|
return &d, nil
|
|
}
|