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
510 lines
14 KiB
Go
510 lines
14 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"sort"
|
|
)
|
|
|
|
// Solve runs the v0 algorithm (design v4.1 §5b.2) against the project.
|
|
// If preview is true, no DB writes happen — the function returns the
|
|
// diff it WOULD apply. If preview is false, the diff is applied in a
|
|
// single transaction.
|
|
//
|
|
// Algorithm:
|
|
// 1. Read all auto cables, manual cables, ports, requirements.
|
|
// 2. Reserve ports used by manual cables (auto=0) so the solver
|
|
// doesn't reuse them.
|
|
// 3. For each requirement (must_connect DESC, id ASC):
|
|
// - Resolve cable type: preferred, or T = port-types(from) ∩
|
|
// port-types(to). |T|==1 → that. |T|>1 → unsatisfied (ambiguous).
|
|
// |T|==0 → unsatisfied (no compat type).
|
|
// - Find lowest-id free port on each side. None → unsatisfied
|
|
// (no free port). Reserve both.
|
|
// - Stage an "add cable {from_port, to_port, type, auto=1}".
|
|
// 4. Endpoint-pair bundle: any pair of device endpoints with ≥ 2
|
|
// staged cables becomes an auto bundle.
|
|
// 5. Diff against existing auto cables/bundles: removed = existing
|
|
// auto rows not in the staged set; kept = those that match by
|
|
// (from_port, to_port, type); add = remaining staged rows.
|
|
func (s *Store) Solve(projectID int64, preview bool) (*SolveResult, error) {
|
|
res := &SolveResult{
|
|
CablesAdded: []Cable{},
|
|
CablesKept: []int64{},
|
|
CablesRemoved: []int64{},
|
|
BundlesAdded: []Bundle{},
|
|
BundlesRemoved: []int64{},
|
|
Unsatisfied: []UnsatisfiedReq{},
|
|
Warnings: []string{},
|
|
}
|
|
|
|
if _, err := s.GetProject(projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
devices, err := s.ListDevices(projectID, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ports, err := s.ListPortsForProject(projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cables, err := s.ListCables(projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
reqs, err := s.ListConnectionRequirements(projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bundles, err := s.ListBundles(projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Index ports by (device_id, type_id), sorted by id (deterministic).
|
|
portsByDevice := map[int64][]Port{}
|
|
for _, p := range ports {
|
|
portsByDevice[p.DeviceID] = append(portsByDevice[p.DeviceID], p)
|
|
}
|
|
for did := range portsByDevice {
|
|
sort.SliceStable(portsByDevice[did], func(i, j int) bool {
|
|
return portsByDevice[did][i].ID < portsByDevice[did][j].ID
|
|
})
|
|
}
|
|
deviceByID := map[int64]Device{}
|
|
for _, d := range devices {
|
|
deviceByID[d.ID] = d
|
|
}
|
|
|
|
// Reserve ports used by manual cables.
|
|
usedPorts := map[int64]bool{}
|
|
autoCablesByID := map[int64]Cable{}
|
|
for _, c := range cables {
|
|
if c.Auto {
|
|
autoCablesByID[c.ID] = c
|
|
continue
|
|
}
|
|
if c.FromPortID != nil {
|
|
usedPorts[*c.FromPortID] = true
|
|
}
|
|
if c.ToPortID != nil {
|
|
usedPorts[*c.ToPortID] = true
|
|
}
|
|
}
|
|
|
|
// Sort requirements: must_connect DESC, id ASC.
|
|
rs := append([]ConnectionRequirement{}, reqs...)
|
|
sort.SliceStable(rs, func(i, j int) bool {
|
|
if rs[i].MustConnect != rs[j].MustConnect {
|
|
return rs[i].MustConnect
|
|
}
|
|
return rs[i].ID < rs[j].ID
|
|
})
|
|
|
|
type staged struct {
|
|
typeID int64
|
|
fromPortID int64
|
|
toPortID int64
|
|
fromDeviceID int64
|
|
toDeviceID int64
|
|
}
|
|
var staging []staged
|
|
|
|
for _, r := range rs {
|
|
_, fromOK := deviceByID[r.FromDeviceID]
|
|
_, toOK := deviceByID[r.ToDeviceID]
|
|
if !fromOK || !toOK {
|
|
// Shouldn't happen (FK CASCADE removes the row when a device
|
|
// goes), but be defensive.
|
|
continue
|
|
}
|
|
|
|
// Resolve cable type.
|
|
var typeID int64
|
|
if r.PreferredCableTypeID != nil {
|
|
typeID = *r.PreferredCableTypeID
|
|
} else {
|
|
fromTypes := map[int64]bool{}
|
|
for _, p := range portsByDevice[r.FromDeviceID] {
|
|
fromTypes[p.TypeID] = true
|
|
}
|
|
candidates := []int64{}
|
|
for _, p := range portsByDevice[r.ToDeviceID] {
|
|
if fromTypes[p.TypeID] {
|
|
// Add unique.
|
|
already := false
|
|
for _, c := range candidates {
|
|
if c == p.TypeID {
|
|
already = true
|
|
break
|
|
}
|
|
}
|
|
if !already {
|
|
candidates = append(candidates, p.TypeID)
|
|
}
|
|
}
|
|
}
|
|
if len(candidates) == 0 {
|
|
if r.MustConnect {
|
|
res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{
|
|
RequirementID: r.ID,
|
|
Reason: "no compatible cable type — devices share no port-type",
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
if len(candidates) > 1 {
|
|
if r.MustConnect {
|
|
res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{
|
|
RequirementID: r.ID,
|
|
Reason: "ambiguous cable type — specify preferred_cable_type_id",
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
typeID = candidates[0]
|
|
}
|
|
|
|
// Pick lowest-id free port of `typeID` on each side.
|
|
pickFree := func(deviceID, t int64) *int64 {
|
|
for _, p := range portsByDevice[deviceID] {
|
|
if p.TypeID != t {
|
|
continue
|
|
}
|
|
if usedPorts[p.ID] {
|
|
continue
|
|
}
|
|
return &p.ID
|
|
}
|
|
return nil
|
|
}
|
|
fromPort := pickFree(r.FromDeviceID, typeID)
|
|
toPort := pickFree(r.ToDeviceID, typeID)
|
|
if fromPort == nil || toPort == nil {
|
|
if r.MustConnect {
|
|
side := ""
|
|
if fromPort == nil && toPort == nil {
|
|
side = ""
|
|
} else if fromPort == nil {
|
|
side = "from"
|
|
} else {
|
|
side = "to"
|
|
}
|
|
typeName := ""
|
|
if ct, err := s.GetCableType(typeID); err == nil {
|
|
typeName = ct.Name
|
|
}
|
|
res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{
|
|
RequirementID: r.ID,
|
|
Reason: fmt.Sprintf("no free %s port", typeName),
|
|
WhichSide: side,
|
|
CableType: typeName,
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
usedPorts[*fromPort] = true
|
|
usedPorts[*toPort] = true
|
|
staging = append(staging, staged{
|
|
typeID: typeID, fromPortID: *fromPort, toPortID: *toPort,
|
|
fromDeviceID: r.FromDeviceID, toDeviceID: r.ToDeviceID,
|
|
})
|
|
}
|
|
|
|
// Match staged → existing auto cables by (typeID, fromPortID, toPortID)
|
|
// or its reverse. Anything matched is "kept"; the rest of auto cables
|
|
// is "removed". Unmatched staged entries become "added".
|
|
type sigKey struct{ typeID, a, b int64 }
|
|
matched := map[int64]bool{} // existing auto cable IDs that match
|
|
sigToAuto := map[sigKey]int64{}
|
|
for id, c := range autoCablesByID {
|
|
if c.FromPortID == nil || c.ToPortID == nil {
|
|
continue
|
|
}
|
|
a, b := *c.FromPortID, *c.ToPortID
|
|
if a > b {
|
|
a, b = b, a
|
|
}
|
|
sigToAuto[sigKey{c.TypeID, a, b}] = id
|
|
}
|
|
var toAdd []staged
|
|
for _, st := range staging {
|
|
a, b := st.fromPortID, st.toPortID
|
|
if a > b {
|
|
a, b = b, a
|
|
}
|
|
if existingID, ok := sigToAuto[sigKey{st.typeID, a, b}]; ok {
|
|
matched[existingID] = true
|
|
res.CablesKept = append(res.CablesKept, existingID)
|
|
continue
|
|
}
|
|
toAdd = append(toAdd, st)
|
|
}
|
|
for id := range autoCablesByID {
|
|
if !matched[id] {
|
|
res.CablesRemoved = append(res.CablesRemoved, id)
|
|
}
|
|
}
|
|
sort.Slice(res.CablesKept, func(i, j int) bool { return res.CablesKept[i] < res.CablesKept[j] })
|
|
sort.Slice(res.CablesRemoved, func(i, j int) bool { return res.CablesRemoved[i] < res.CablesRemoved[j] })
|
|
|
|
// Endpoint-pair bundling for the final set of auto cables (kept + added).
|
|
// Group by unordered (deviceA, deviceB). Build the map of port_id → device_id
|
|
// for fast lookup.
|
|
portToDevice := map[int64]int64{}
|
|
for _, p := range ports {
|
|
portToDevice[p.ID] = p.DeviceID
|
|
}
|
|
type pairKey struct{ a, b int64 }
|
|
pairGroup := map[pairKey][]string{} // staged-or-kept tags (we just count)
|
|
pairOrder := []pairKey{} // first-seen order
|
|
|
|
// We'll need the final list of cables-after-apply (with their IDs) to
|
|
// build bundles. For preview, kept IDs are real, added IDs are zero;
|
|
// for apply, we'll re-bundle after inserts.
|
|
|
|
if preview {
|
|
// In preview mode, "kept" IDs are real cables; "added" are
|
|
// staged. We still compute bundles_added so the UI can show
|
|
// which cable groups will be bundled. Bundles_added carry
|
|
// `CableIDs: []` for the staged entries because they don't
|
|
// have IDs yet — the UI maps by position. cables_kept that
|
|
// belong to a bundle group also list their existing ids.
|
|
// In short, slot every staged cable into the same pair bucket
|
|
// + the kept cables.
|
|
for _, st := range staging {
|
|
da, db := st.fromDeviceID, st.toDeviceID
|
|
if da > db {
|
|
da, db = db, da
|
|
}
|
|
pk := pairKey{da, db}
|
|
if _, ok := pairGroup[pk]; !ok {
|
|
pairOrder = append(pairOrder, pk)
|
|
}
|
|
pairGroup[pk] = append(pairGroup[pk], "")
|
|
}
|
|
// Materialise preview-shape Cable structs for the added rows.
|
|
for _, st := range toAdd {
|
|
c := Cable{
|
|
ProjectID: projectID,
|
|
TypeID: st.typeID,
|
|
FromPortID: ptr(st.fromPortID),
|
|
ToPortID: ptr(st.toPortID),
|
|
Auto: true,
|
|
}
|
|
res.CablesAdded = append(res.CablesAdded, c)
|
|
}
|
|
for _, pk := range pairOrder {
|
|
if len(pairGroup[pk]) < 2 {
|
|
continue
|
|
}
|
|
a := deviceByID[pk.a].Name
|
|
b := deviceByID[pk.b].Name
|
|
res.BundlesAdded = append(res.BundlesAdded, Bundle{
|
|
ProjectID: projectID,
|
|
Name: a + " ↔ " + b,
|
|
Auto: true,
|
|
CableIDs: nil, // post-apply only
|
|
})
|
|
}
|
|
// Existing auto bundles all "would be removed" since we rebuild
|
|
// from scratch each solve (slice-6 v0 is wholesale-replace).
|
|
for _, b := range bundles {
|
|
if b.Auto {
|
|
res.BundlesRemoved = append(res.BundlesRemoved, b.ID)
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// Apply mode: open a transaction, delete removed auto cables + auto
|
|
// bundles, insert added cables, re-bundle by endpoint pair.
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Delete obsolete auto bundles (we'll rebuild).
|
|
if _, err := tx.Exec(
|
|
`DELETE FROM bundles WHERE project_id = ? AND auto = 1`, projectID,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, b := range bundles {
|
|
if b.Auto {
|
|
res.BundlesRemoved = append(res.BundlesRemoved, b.ID)
|
|
}
|
|
}
|
|
|
|
// Delete removed auto cables.
|
|
for _, id := range res.CablesRemoved {
|
|
if _, err := tx.Exec(
|
|
`DELETE FROM cables WHERE id = ? AND project_id = ?`, id, projectID,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Insert added cables. Track new ids by their staged signature for
|
|
// bundle wiring.
|
|
type addedRow struct {
|
|
id int64
|
|
staged staged
|
|
}
|
|
addedRows := []addedRow{}
|
|
for _, st := range toAdd {
|
|
c, err := s.createCable(tx, projectID, CableCreate{
|
|
TypeID: st.typeID,
|
|
From: CableEndpoint{PortID: &st.fromPortID},
|
|
To: CableEndpoint{PortID: &st.toPortID},
|
|
Auto: true,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res.CablesAdded = append(res.CablesAdded, *c)
|
|
addedRows = append(addedRows, addedRow{id: c.ID, staged: st})
|
|
}
|
|
|
|
// Re-bundle: all auto cables (kept + added) grouped by endpoint pair.
|
|
// First, collect cable IDs per (deviceA, deviceB) — both kept (from
|
|
// matched map) and added.
|
|
groups := map[pairKey][]int64{}
|
|
order := []pairKey{}
|
|
addToGroup := func(da, db, cid int64) {
|
|
if da > db {
|
|
da, db = db, da
|
|
}
|
|
pk := pairKey{da, db}
|
|
if _, ok := groups[pk]; !ok {
|
|
order = append(order, pk)
|
|
}
|
|
groups[pk] = append(groups[pk], cid)
|
|
}
|
|
for id, c := range autoCablesByID {
|
|
if !matched[id] {
|
|
continue
|
|
}
|
|
if c.FromPortID == nil || c.ToPortID == nil {
|
|
continue
|
|
}
|
|
da := portToDevice[*c.FromPortID]
|
|
db := portToDevice[*c.ToPortID]
|
|
if da == 0 || db == 0 {
|
|
continue
|
|
}
|
|
addToGroup(da, db, id)
|
|
}
|
|
for _, ar := range addedRows {
|
|
addToGroup(ar.staged.fromDeviceID, ar.staged.toDeviceID, ar.id)
|
|
}
|
|
|
|
for _, pk := range order {
|
|
ids := groups[pk]
|
|
if len(ids) < 2 {
|
|
continue
|
|
}
|
|
a := deviceByID[pk.a].Name
|
|
b := deviceByID[pk.b].Name
|
|
bundle, err := s.createBundle(tx, projectID, BundleCreate{
|
|
Name: a + " ↔ " + b,
|
|
CableIDs: ids,
|
|
Auto: true,
|
|
}, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res.BundlesAdded = append(res.BundlesAdded, *bundle)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
// PortsAndResolve adds a port to a device + re-runs Solve in one tx.
|
|
// Used by the inspector's "+ Add <type> port and re-solve" quick-fix.
|
|
type PortsAndResolveResult struct {
|
|
Port Port `json:"port"`
|
|
Solve *SolveResult `json:"solve"`
|
|
}
|
|
|
|
func (s *Store) PortsAndResolve(projectID, deviceID int64, typeID int64, label string, xOff, yOff float64) (*PortsAndResolveResult, error) {
|
|
d, err := s.GetDevice(projectID, deviceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := s.GetCableType(typeID); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, typeID)
|
|
}
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
// Default the new port to the bottom edge at the right-most existing offset.
|
|
if xOff == 0 && yOff == 0 {
|
|
xOff = d.Width / 2
|
|
yOff = d.Height
|
|
}
|
|
var labelArg any
|
|
if label != "" {
|
|
labelArg = label
|
|
}
|
|
res, err := tx.Exec(
|
|
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
projectID, deviceID, typeID, labelArg, xOff, yOff,
|
|
)
|
|
if err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
portID, _ := res.LastInsertId()
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
// Now re-solve outside the tx — Solve manages its own tx for the
|
|
// apply path. This is a slight relaxation of "single round-trip" — if
|
|
// the solver run fails the port stays, but that's fine; the port is
|
|
// what m wanted regardless.
|
|
solveRes, err := s.Solve(projectID, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Re-fetch the port row to return its full shape.
|
|
port, err := s.getPortByID(portID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &PortsAndResolveResult{Port: *port, Solve: solveRes}, nil
|
|
}
|
|
|
|
func (s *Store) getPortByID(id int64) (*Port, error) {
|
|
var p Port
|
|
var label, ex sql.NullString
|
|
err := s.db.QueryRow(
|
|
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
|
|
excalidraw_id, created_at, updated_at
|
|
FROM ports WHERE id = ?`, id,
|
|
).Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
|
|
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if label.Valid {
|
|
v := label.String
|
|
p.Label = &v
|
|
}
|
|
if ex.Valid {
|
|
p.ExcalidrawID = &ex.String
|
|
}
|
|
return &p, nil
|
|
}
|