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
223 lines
8.9 KiB
Go
223 lines
8.9 KiB
Go
package db
|
|
|
|
// Project is the top-level entity. One project ↔ one .excalidraw drawing.
|
|
type Project struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
DrawingName string `json:"drawing_name"`
|
|
Description string `json:"description"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// CableType is global. Renaming/recolouring affects every project.
|
|
type CableType struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// Frame is a sub-zone inside a project (`desk`, `rack`, …).
|
|
type Frame struct {
|
|
ID int64 `json:"id"`
|
|
ProjectID int64 `json:"project_id"`
|
|
Name string `json:"name"`
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
Width float64 `json:"width"`
|
|
Height float64 `json:"height"`
|
|
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// Device is a hardware item inside a project, optionally inside a frame.
|
|
// v4: type_id (nullable) lets a device inherit its port profile from a
|
|
// device_types catalog row.
|
|
type Device struct {
|
|
ID int64 `json:"id"`
|
|
ProjectID int64 `json:"project_id"`
|
|
FrameID *int64 `json:"frame_id"` // nullable: device "outside" any frame
|
|
TypeID *int64 `json:"type_id"` // nullable: freeform device when null
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
Width float64 `json:"width"`
|
|
Height float64 `json:"height"`
|
|
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// DeviceType is a catalog row. Built-in rows have ProjectID nil and
|
|
// BuiltIn true. Project-custom rows have ProjectID set.
|
|
type DeviceType struct {
|
|
ID int64 `json:"id"`
|
|
ProjectID *int64 `json:"project_id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
Icon *string `json:"icon,omitempty"`
|
|
Description string `json:"description"`
|
|
BuiltIn bool `json:"built_in"`
|
|
Ports []DeviceTypePort `json:"ports"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// DeviceTypePort is a row of a type's port profile. The seeder uses
|
|
// (cable_type_id, count, label_prefix, edge, sort_order) to lay out
|
|
// concrete ports on a freshly-created device.
|
|
type DeviceTypePort struct {
|
|
ID int64 `json:"id"`
|
|
DeviceTypeID int64 `json:"device_type_id"`
|
|
CableTypeID int64 `json:"cable_type_id"`
|
|
LabelPrefix string `json:"label_prefix"`
|
|
Count int `json:"count"`
|
|
Edge string `json:"edge"`
|
|
SortOrder int `json:"sort_order"`
|
|
}
|
|
|
|
// Port is a connector on a device. cable_type colour drives the visual
|
|
// rendering; ports are instance-owned even when seeded from a type.
|
|
type Port struct {
|
|
ID int64 `json:"id"`
|
|
ProjectID int64 `json:"project_id"`
|
|
DeviceID int64 `json:"device_id"`
|
|
TypeID int64 `json:"type_id"` // cable type
|
|
Label *string `json:"label"`
|
|
XOffset float64 `json:"x_offset"`
|
|
YOffset float64 `json:"y_offset"`
|
|
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// ConnectionRequirement is the solver's per-project input.
|
|
// pair_lo/pair_hi are the ordered (MIN,MAX) of (from, to) so the
|
|
// UNIQUE on (project_id, pair_lo, pair_hi, preferred_cable_type_id)
|
|
// prevents (A,B,T) AND (B,A,T) from coexisting.
|
|
type ConnectionRequirement struct {
|
|
ID int64 `json:"id"`
|
|
ProjectID int64 `json:"project_id"`
|
|
FromDeviceID int64 `json:"from_device_id"`
|
|
ToDeviceID int64 `json:"to_device_id"`
|
|
PreferredCableTypeID *int64 `json:"preferred_cable_type_id"`
|
|
MustConnect bool `json:"must_connect"`
|
|
Notes string `json:"notes"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// Cable is a typed connection. Each endpoint is exactly one of
|
|
// (port, device, io-marker). Auto=true means the solver placed it.
|
|
type Cable struct {
|
|
ID int64 `json:"id"`
|
|
ProjectID int64 `json:"project_id"`
|
|
TypeID int64 `json:"type_id"`
|
|
Label *string `json:"label"`
|
|
FromPortID *int64 `json:"from_port_id"`
|
|
FromDeviceID *int64 `json:"from_device_id"`
|
|
FromIOID *int64 `json:"from_io_id"`
|
|
ToPortID *int64 `json:"to_port_id"`
|
|
ToDeviceID *int64 `json:"to_device_id"`
|
|
ToIOID *int64 `json:"to_io_id"`
|
|
Auto bool `json:"auto"`
|
|
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// Bundle is a named group of cables that physically run together.
|
|
type Bundle struct {
|
|
ID int64 `json:"id"`
|
|
ProjectID int64 `json:"project_id"`
|
|
Name string `json:"name"`
|
|
Auto bool `json:"auto"`
|
|
CableIDs []int64 `json:"cable_ids"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// SetupTemplate is a named recipe of device-types + requirements.
|
|
type SetupTemplate struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
BuiltIn bool `json:"built_in"`
|
|
Devices []SetupTemplateDevice `json:"devices"`
|
|
Requirements []SetupTemplateRequirement `json:"requirements"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type SetupTemplateDevice struct {
|
|
ID int64 `json:"id"`
|
|
TemplateID int64 `json:"template_id"`
|
|
DeviceTypeID int64 `json:"device_type_id"`
|
|
DeviceType *DeviceType `json:"device_type,omitempty"`
|
|
SuggestedName *string `json:"suggested_name"`
|
|
SortOrder int `json:"sort_order"`
|
|
}
|
|
|
|
type SetupTemplateRequirement struct {
|
|
ID int64 `json:"id"`
|
|
TemplateID int64 `json:"template_id"`
|
|
FromTemplateDeviceID int64 `json:"from_template_device_id"`
|
|
ToTemplateDeviceID int64 `json:"to_template_device_id"`
|
|
PreferredCableTypeID *int64 `json:"preferred_cable_type_id"`
|
|
MustConnect bool `json:"must_connect"`
|
|
}
|
|
|
|
// SolveResult is the response shape from POST /api/projects/:pid/solve.
|
|
type SolveResult struct {
|
|
CablesAdded []Cable `json:"cables_added"`
|
|
CablesKept []int64 `json:"cables_kept"`
|
|
CablesRemoved []int64 `json:"cables_removed"`
|
|
BundlesAdded []Bundle `json:"bundles_added"`
|
|
BundlesRemoved []int64 `json:"bundles_removed"`
|
|
Unsatisfied []UnsatisfiedReq `json:"unsatisfied"`
|
|
Warnings []string `json:"warnings"`
|
|
}
|
|
|
|
type UnsatisfiedReq struct {
|
|
RequirementID int64 `json:"requirement_id"`
|
|
Reason string `json:"reason"`
|
|
WhichSide string `json:"which_side,omitempty"` // "from" | "to" | "" when both/neither
|
|
CableType string `json:"cable_type,omitempty"` // when known
|
|
}
|
|
|
|
// ApplyTemplateResult is the response from POST /apply-template.
|
|
type ApplyTemplateResult struct {
|
|
DevicesAdded []Device `json:"devices_added"`
|
|
RequirementsAdded []ConnectionRequirement `json:"requirements_added"`
|
|
SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"`
|
|
RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"`
|
|
}
|
|
|
|
type SkippedTemplateDevice struct {
|
|
TemplateDeviceID int64 `json:"template_device_id"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
type SkippedTemplateReq struct {
|
|
TemplateRequirementID int64 `json:"template_requirement_id"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// Snapshot is the editor's one-shot loader payload for a single project.
|
|
// Arrays for collections still gated by future slices stay non-nil [] so
|
|
// JSON encodes as [] not null.
|
|
type Snapshot struct {
|
|
Project Project `json:"project"`
|
|
Frames []Frame `json:"frames"`
|
|
Devices []Device `json:"devices"`
|
|
Ports []Port `json:"ports"`
|
|
Cables []Cable `json:"cables"`
|
|
IOMarkers []IOMarker `json:"io_markers"`
|
|
Bundles []Bundle `json:"bundles"`
|
|
CableTypes []CableType `json:"cable_types"`
|
|
ConnectionRequirements []ConnectionRequirement `json:"connection_requirements"`
|
|
}
|