docs: design v3 — global cable_types, device UNIQUE, delete guardrail

Tight pass on round-4 answers (single commit per head's request):

- cable_types is GLOBAL — drop project_id, UNIQUE(name). Migration 001
  seeds the 5 defaults once; POST /api/projects no longer seeds them.
  API moves to top-level /api/cable-types. Renaming/recolouring affects
  every project. CASCADE from projects does not touch cable_types.
- devices: UNIQUE (project_id, name) added.
- projects: drawing_name defaults to "<name>.excalidraw" server-side
  on POST when omitted; editable via PATCH.
- DELETE /api/projects/:pid requires ?confirm=<name>; server checks
  name match, returns 400 if missing or mismatched.
- io_markers: no type_id (Power-by-convention, UI soft-warn). Confirmed
  v0 stance.
- Bundles ignored on export — carries over from v2.
- §0 changelog rewritten as "what changed in v3 / what carried over".
- §2 schema rewritten; FK-shape paragraph updated to call out the one
  global table.
- §3 endpoints: cable-types moved to top level; POST/DELETE projects
  show new defaults + guardrail semantics.
- §4 export table notes cable_types pulled from global.
- §7 "edit cable type" flow gains the cross-project-effect banner +
  ON DELETE RESTRICT inline-error UX.
- §8 slice 1 rewritten: no per-project seeding; legend reads global.
- §9 all six v2 questions marked resolved with the v3 answer per item.
- Trailer changes to "DESIGN v3 READY — coder shift gated".
- CLAUDE.md mirrors: global cable_types, device UNIQUE per project,
  drawing_name default, delete guardrail.
This commit is contained in:
mAi
2026-05-15 16:32:20 +02:00
parent 023bf82dbd
commit c690113ea1
2 changed files with 113 additions and 84 deletions

View File

@@ -40,13 +40,21 @@ interface. The backend serves the UI and the API; there is no
- **Project** (`projects` table) is the top-level concept. LOFT, OFFICE,
HOMELAB, … are separate projects. One project ↔ one `.excalidraw`
drawing in mExDraw.
drawing in mExDraw. `projects.drawing_name` defaults to
`<name>.excalidraw` server-side when omitted on create; editable later.
- **Frames** sub-divide a project (LOFT has `desk`, `rack`, `media`;
OFFICE has `desk`, `server`). Frames are not projects — they're zones
within one drawing.
- Every device, port, cable, cable type, IO marker, and bundle is
project-scoped (`project_id` denormalised onto every row, with
`ON DELETE CASCADE` from `projects`).
- Every device, port, cable, IO marker, and bundle is **project-scoped**
(`project_id` denormalised onto every row, with `ON DELETE CASCADE` from
`projects`). `UNIQUE (project_id, devices.name)` — no two devices in
one project share a name.
- **Cable types are global.** A single shared `cable_types` table —
no `project_id`. The five defaults (Power/USB/HDMI/DP/RJ45) are seeded
by migration 001 once, not per project. Renaming or recolouring a type
affects every project's legend immediately.
- **Project deletion guardrail.** `DELETE /api/projects/:pid` requires
`?confirm=<name>` matching the project's current name. 400 otherwise.
## Branch Strategy
@@ -106,7 +114,7 @@ its visual grammar:
- **Lines** = decorative only (legend separators in the seed). Ignored on
export.
Legend colours (per-project default seed when a project is created):
Legend colours (global, seeded once by migration 001):
| Type | Hex |
|---|---|

View File

@@ -1,25 +1,36 @@
# mCables — Design v2
# mCables — Design v3
Cable-management **framework** for m's setup. Inventor shift 1 design,
revised after m's answers (2026-05-15) — for m's review.
revised after m's round-4 answers (2026-05-15) — for m's review.
Sources read for v2: the live `Cable-Management.excalidraw` on
mxdrw.msbls.de (used as the *visual-grammar reference*, not as a bootstrap
import target), `mai-memory` (`mcables`, `m`), and a live survey of mDock
services for the deploy conventions (§10).
Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as
the *visual-grammar reference*, not as a bootstrap import target),
`mai-memory` (`mcables`, `m`), and a live survey of mDock services for the
deploy conventions (§10).
> **What changed in v2**
> - mCables is a **framework**: a top-level `projects` table; LOFT and OFFICE
> are *separate* mCables projects, each backed by **one** drawing.
> - **No runtime importer.** The seed drawing is reference material only; m
> rebuilds LOFT and OFFICE from scratch in the tool. Only `POST /api/sync/export`
> stays in the MVP API.
> - **IO diamonds are wall-outlet terminators** (type=Power), not inter-frame
> bridges. `paired_with_id` is gone.
> - **No cable inventory metadata.** Purely visual structure for v0.
> - **DB**: `./data/mcables.db` (project-local, gitignored).
> - **Deploy**: raw docker / docker-compose on mDock — *not* Dokploy.
> - **Bind**: `0.0.0.0:7777` on the LAN, no auth.
> **What changed in v3** (mechanical deltas on top of v2)
> - `cable_types` is now a **global** table — one set shared across all
> projects. Migration 001 seeds the 5 defaults once. `POST /api/projects`
> no longer seeds types. API moved to top-level `/api/cable-types`.
> Renaming/recolouring a type affects every project.
> - `devices` gains `UNIQUE (project_id, name)` — no two devices in the
> same project can share a name.
> - `projects.drawing_name` is auto-filled `<name>.excalidraw` server-side
> when omitted on POST; editable via PATCH.
> - `DELETE /api/projects/:pid` requires `?confirm=<name>` query param;
> server checks it matches the project's current name. 400 otherwise.
>
> **What carried over from v2**
> - mCables is a framework: top-level `projects` table; LOFT and OFFICE
> are separate projects, each backed by one drawing.
> - No runtime importer. The seed drawing is reference material only.
> `/api/sync/import` is out of MVP; only `POST .../sync/export` ships.
> - IO diamonds are wall-outlet terminators (type=Power by convention,
> not enforced in schema). UI soft-warns on non-Power cables to an IO.
> - No cable inventory metadata. Purely visual structure for v0.
> - DB at `./data/mcables.db` (project-local, gitignored).
> - Deploy: raw docker / docker-compose on mDock (not Dokploy).
> - Bind `0.0.0.0:7777` on the LAN, no auth.
---
@@ -111,18 +122,17 @@ CREATE TABLE projects (
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Cable types: legend rows. Project-scoped so LOFT and OFFICE can diverge
-- (e.g. add Audio-jack only in LOFT). The five seed types are inserted
-- when a project is created — not as a global table.
-- Cable types: GLOBAL legend, one set shared across all projects.
-- Migration 001 seeds the 5 defaults (Power/USB/HDMI/DP/RJ45) once.
-- Renaming or recolouring a type from anywhere in the UI propagates to
-- every project's legend and to every cable already typed as it.
CREATE TABLE cable_types (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- "Power", "USB", …
name TEXT NOT NULL UNIQUE, -- "Power", "USB", "HDMI", "DP", "RJ45"
color TEXT NOT NULL, -- "#e03131"
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name)
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX cable_types_project_idx ON cable_types(project_id);
-- A frame is a named container *inside* a project: 'desk', 'rack', 'media'.
CREATE TABLE frames (
@@ -157,6 +167,7 @@ CREATE TABLE devices (
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name), -- no two devices in one project share a name
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX devices_project_idx ON devices(project_id);
@@ -251,19 +262,25 @@ CREATE TABLE bundle_cables (
CREATE INDEX bundle_cables_cable_idx ON bundle_cables(cable_id);
```
**FK shape — why `project_id` on every row, not just transitively:**
**FK shape — why `project_id` on every project-scoped row, not just transitively:**
The structural truth is `cable → port → device → frame → project`. But
project-scoped queries ("give me all cables in OFFICE") would otherwise need
three joins. Denormalising `project_id` onto every row is a small,
load-bearing pragma: `cables WHERE project_id=?` is a one-column index hit.
The cost: code must keep `project_id` consistent with `frame_id` /
`device_id` on insert+update. That's enforced at the Go layer
three joins. Denormalising `project_id` onto every project-scoped row is a
small, load-bearing pragma: `cables WHERE project_id=?` is a one-column
index hit. The cost: code must keep `project_id` consistent with `frame_id`
/ `device_id` on insert+update. That's enforced at the Go layer
(`internal/db/store.go` setter functions), not by SQL — `CHECK` constraints
in SQLite can't reference another table.
`cable_types` is the **one global table** — it has no `project_id`.
Cables reference it cross-project. Renaming or recolouring a type updates
the legend everywhere immediately and re-renders every cable of that type
on the next paint.
`ON DELETE CASCADE` from `projects` cleanly wipes a project's whole subgraph
in one statement, which is what we want when m says "delete OFFICE".
in one statement, which is what we want when m says "delete OFFICE". The
cascade does **not** touch `cable_types` (no FK to projects).
---
@@ -280,14 +297,29 @@ GET /api/healthz → 200 ok
# Projects — top-level
GET /api/projects → [Project, …]
POST /api/projects ← {name, drawing_name, description?}
(seeds the 5 default cable types)
POST /api/projects ← {name, drawing_name?, description?}
If drawing_name is omitted, server defaults to
"<name>.excalidraw". No cable-type seeding —
cable_types is global (see /api/cable-types).
GET /api/projects/:pid → full snapshot
{project, frames, devices, ports, cables,
cable_types, io_markers, bundles}
— editor's one-shot loader
io_markers, bundles}
Plus the global cable_types (clients can also
fetch them via /api/cable-types). Editor's
one-shot loader.
PATCH /api/projects/:pid ← partial {name, drawing_name, description}
DELETE /api/projects/:pid (cascades through all child rows)
DELETE /api/projects/:pid?confirm=<name> Confirmation guardrail — the query param must
equal the project's current name. 400 if missing
or mismatched. Cascades through all child rows
(frames, devices, ports, cables, io_markers,
bundles, bundle_cables). Does NOT touch
cable_types.
# Cable types — GLOBAL, NOT under a project
GET /api/cable-types → [CableType, …]
POST /api/cable-types ← {name, color} # name must be unique globally
PATCH /api/cable-types/:id ← {name?, color?} # affects every project's legend + every cable using this type
DELETE /api/cable-types/:id # blocked if any cable still references it (ON DELETE RESTRICT)
# Inside a project — everything below scoped under :pid
GET /api/projects/:pid/frames
@@ -311,11 +343,6 @@ POST /api/projects/:pid/cables ← {type_id, from_{port|dev
PATCH /api/projects/:pid/cables/:id
DELETE /api/projects/:pid/cables/:id
GET /api/projects/:pid/cable-types
POST /api/projects/:pid/cable-types ← {name, color}
PATCH /api/projects/:pid/cable-types/:id
DELETE /api/projects/:pid/cable-types/:id
GET /api/projects/:pid/io-markers
POST /api/projects/:pid/io-markers ← {frame_id?, label, x, y}
PATCH /api/projects/:pid/io-markers/:id
@@ -362,7 +389,7 @@ drawing's grammar is the contract.
| `ports` | `type=ellipse`, ~12×9 | `strokeColor=type.color`, absolute pos = `(device.x + port.x_offset, device.y + port.y_offset)`, no containerId binding (matches seed) |
| `io_markers` | `type=diamond` with bound `text=label` | small (~30×30), `strokeColor` = the Power cable type's colour |
| `cables` | `type=arrow` | `strokeColor=type.color`, `startBinding.elementId` = port/device/io excalidraw_id, same for end |
| `cable_types` legend | 5 `type=text` rows in top-left of the project's first frame | `strokeColor=color`, `text=name`. Regenerated each export. |
| `cable_types` legend (global) | one `type=text` row per `cable_types` row, top-left of the project's first frame | `strokeColor=color`, `text=name`. Pulled from the global table, regenerated each export. |
| `bundles` | (rendering open question — see §5) | post-MVP: render as a thick path; v0: ignored on export |
### 4.2 Element IDs are stable across exports
@@ -528,9 +555,18 @@ that — the UI shows a soft warning if m draws a non-Power cable to an IO.
### Flow: pick / edit a cable type
Legend on the left is interactive: click a row → that type becomes the
active "drawing type". Drag the swatch → colour picker → updates
`cable_types.color`. `+ Type` at the bottom → "new cable type" modal.
Legend on the left is interactive and **global** (the same legend shows up
in every project). Click a row → that type becomes the active "drawing
type" for the current project's session. Drag the swatch → colour picker →
updates `cable_types.color` via `PATCH /api/cable-types/:id`. `+ Type` at
the bottom → "new cable type" modal — `POST /api/cable-types`. Names are
globally unique.
The modal for editing / adding shows a banner:
*"Cable types are shared across all projects. Renaming or recolouring
affects every project that uses this type."* Deleting a type that's still
in use by any cable returns a 400 with the offending cable count — the
client surfaces it as an inline error in the modal.
### Flow: drag a device
@@ -561,7 +597,7 @@ slices 14 as the MVP; slice 5 (export) is the round-trip end.
| # | Slice | What's shipped |
|---|---|---|
| 1 | **Bootstrap + project CRUD** | `cmd/mcables` Go binary, SQLite migrations, `internal/db` store. `POST /api/projects` creates a project and seeds 5 cable types. `GET /api/projects` lists them. `GET /api/projects/:pid` returns a (mostly empty) snapshot. Frontend `index.html` + `main.js` shows the project picker, a "+ New Project" modal, and an empty SVG canvas with the legend rendered from `cable_types`. m can create LOFT, see it picked, see no devices. |
| 1 | **Bootstrap + project CRUD** | `cmd/mcables` Go binary, SQLite migrations. Migration 001 seeds the 5 default cable types (Power/USB/HDMI/DP/RJ45) **globally, once**. `internal/db` store. `POST /api/projects` auto-fills `drawing_name = <name>.excalidraw` when omitted. `DELETE /api/projects/:pid?confirm=<name>` with name-match guardrail. `GET /api/projects` lists them. `GET /api/projects/:pid` returns a (mostly empty) snapshot. `GET /api/cable-types` returns the 5 seeded rows. Frontend `index.html` + `main.js` shows the project picker, a "+ New Project" modal, and an empty SVG canvas with the legend rendered from the global `cable_types` table. m can create LOFT, see it picked, see no devices. |
| 2 | **Add frame, add device, drag-to-position** | `+ Frm` and `+ Dev` tools work. Devices and frames persist. Drag-to-position writes back to DB on `pointerup`. Reload returns to the same layout. m builds LOFT's `desk` and `rack` frames and drops in his first devices. |
| 3 | **Add port, draw cable** | `+ Port` (with a device selected) places type-coloured ports on device edges with offsets. Click-port → click-port creates a cable. Cables auto-route as straight lines. Inspector shows the cable's type, endpoints, label. m wires up the first end-to-end cable. |
| 4 | **IO markers + cable-type editing** | `+ IO` places a wall-outlet diamond. Cable-from-port → IO commits as `to_io_id`. Legend swatch is a colour picker; renaming a type updates the legend on the fly. `+ Type` adds new types. m can fully recreate LOFT's visual model from scratch. |
@@ -574,40 +610,25 @@ metadata (length/SKU) if m later wants it; dark mode.
---
## 9. Open questions for m
## 9. Open questions for m — all resolved in v3
Below are only the *new* questions raised by the rescope. Everything
m already answered (stack, DB path, auth, sync direction, inventory
fields, big red rectangles) is locked in.
All six v2 questions are now answered. Locked answers:
1. **Drawing-name policy.** Should mCables enforce
`projects.drawing_name == projects.name + ".excalidraw"`, or let m set
them independently? I default to "enforce on create, editable on update"
— fastest to use, still escapeable.
2. **One project per device-name uniqueness?** Two LOFTs is impossible by
`UNIQUE(projects.name)`, but two devices named "PC" within OFFICE — fine
(one is m's, one is the work PC)? I'm not enforcing `UNIQUE(project_id,
devices.name)` for that reason — confirm.
3. **Non-Power IO markers.** I'm modelling IO markers as "wall outlets,
typically Power", with the soft-warning UI rule that non-Power cables to
an IO are unusual. Future-proofing question: should I add a `type_id`
nullable column to `io_markers` now ("this wall outlet is a network
jack"), or wait until you actually want to model network outlets?
4. **Bundle render in export v1.** v0 ignores bundles on export. Eventually
you'll want the bundle visualised in the `.excalidraw` too (thick path,
coloured fan-out). Are you happy waiting for slice 6+, or want a
placeholder rendering (e.g. one heavy black arrow with a bundle label)
in the v1 export?
5. **Cross-project cable types?** Right now each project has its own
`cable_types`. Means renaming "HDMI" to "HDMI-2.1" in LOFT doesn't touch
OFFICE. Future-proof, but also means if you want to change Power-red
across both projects you do it twice. Fine, or should there be a
"global defaults" template that new projects copy from but updates
don't propagate?
6. **Project deletion guardrail.** `DELETE /api/projects/:pid` cascades
through everything. Want a confirmation token in the API
(`?confirm=<name>`) before it accepts the delete? Cheap to add, hard
to recover from if it isn't there.
1. **Drawing-name policy** → server-side default `<name>.excalidraw` on
POST when omitted; editable via PATCH. (§3)
2. **Device-name uniqueness within a project** → `UNIQUE (project_id,
devices.name)` enforced at the schema level. (§2)
3. **Non-Power IO markers** → no `type_id` on `io_markers` for v0.
Power-by-convention; UI soft-warns on non-Power cables to an IO. (§2, §7)
4. **Bundle render in export v1** → bundles ignored on export until slice
6+. (§4, §5)
5. **Cross-project cable types** → `cable_types` is fully **global**. One
shared legend; renaming/recolouring affects every project. (§2, §3, §7)
6. **Project deletion guardrail** → `DELETE /api/projects/:pid?confirm=<name>`
required; server validates name match, returns 400 otherwise. (§3)
No open design questions remain. The coder shift is gated on m's
go/no-go for v3 — not on any unanswered design question from picasso.
---
@@ -756,4 +777,4 @@ gitignored.
---
DESIGN v2 READY FOR REVIEW
DESIGN v3 READY — coder shift gated