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:
18
CLAUDE.md
18
CLAUDE.md
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
179
docs/design.md
179
docs/design.md
@@ -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 1–4 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
|
||||
|
||||
Reference in New Issue
Block a user