Catalog: 11 built-ins from §2.2 + the v4.1 trio (Screen, Keyboard, Mouse)
seeded in migration 002, totalling 16 built-in types.
Store layer:
- internal/db/device_types.go — CRUD for device_types. Built-ins
(project_id NULL) reject PATCH/DELETE with new ErrForbidden sentinel
(handler maps to HTTP 403). Project-custom types accept full CRUD;
cross-project access returns ErrNotFound. Replacing the port profile
on UPDATE is one transaction.
- internal/db/ports.go — ListPortsForProject for the snapshot loader +
seedPortsFromType(tx, …) used by CreateDevice. Layout is "evenly spaced
along the configured edge", per-edge group ordering by sort_order +
id. Labels are "<prefix>" for count==1 and "<prefix> N" 1-indexed for
count>1.
- Device gains a nullable TypeID + tri-state on UpdateDevice. CreateDevice
validates the type is built-in or a project-custom row of the same
project, then seeds the device's ports in the same transaction.
Snapshot now populates Ports from the store; field type tightened to
[]Port.
Tests (15 new, all green with -race):
- 16 built-ins seeded with correct names + project_id=NULL + built_in=1
- Port-profile totals match the §2.2 table for every built-in type
- Project-custom create + name-collision-with-built-in → 409 (ErrConflict)
- Per-project name UNIQUE — same custom name across projects is fine
- PATCH/DELETE built-in → ErrForbidden
- Cross-project custom PATCH → ErrNotFound
- CreateDevice with NAS type → 2 ports along bottom edge, evenly spaced,
labels set
- CreateDevice with PC type → 5 ports incl. "USB 1" + "USB 2"
- CreateDevice without type_id → 0 ports (freeform fallback)
- Cross-project custom type on CreateDevice → ErrInvalidInput
- Snapshot includes the seeded ports
Snapshot now populates frames + devices from the DB (slice 1 left them as
empty arrays).
Frame store:
- CreateFrame requires positive width/height; rejects empty name; UNIQUE
(project_id, name) collisions surface as ErrConflict via mapWriteErr.
- GetFrame is project-scoped — wrong-project read returns ErrNotFound.
- UpdateFrame applies a partial; project_id is not exposed (moving a
frame across projects would orphan its devices).
- DeleteFrame relies on the schema's ON DELETE SET NULL to drop
devices' frame_id refs cleanly; verified by test.
Device store:
- CreateDevice defaults color to #1e1e1e if blank; rejects empty name,
non-positive size; validates frame_id is in the same project (returns
ErrInvalidInput on cross-project ref).
- UpdateDevice uses a FrameRef tri-state for frame_id so callers can
distinguish "leave alone" from "clear to NULL" from "move to frame X".
- Cross-project frame_id on PATCH is rejected with ErrInvalidInput.
- ListDevices supports an optional frame_id filter.
13 new table-driven tests, all green with -race.