From cb0df5728c0ed3ae401e121452d50583921cd26c Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Mon, 17 Aug 2026 14:28:52 -0600 Subject: [PATCH] Add basic breadboard simulator --- Media.JoshHeaps.Net/Api/BreadboardApi.cs | 141 +++ .../Database/031_breadboard.sql | 26 + .../Models/BreadboardProject.cs | 24 + .../Models/BreadboardResult.cs | 35 + Media.JoshHeaps.Net/Pages/Breadboard.cshtml | 176 ++++ .../Pages/Breadboard.cshtml.cs | 26 + .../Pages/BreadboardEditor.cshtml | 24 + .../Pages/BreadboardEditor.cshtml.cs | 34 + Media.JoshHeaps.Net/Pages/Landing.cshtml | 29 + Media.JoshHeaps.Net/Program.cs | 2 + .../Services/BreadboardService.cs | 293 ++++++ .../Services/BreadboardValidator.cs | 923 ++++++++++++++++++ .../wwwroot/css/breadboard-projects.css | 250 +++++ .../wwwroot/css/breadboard.css | 590 +++++++++++ .../wwwroot/js/breadboard/editor/api.js | 130 +++ .../wwwroot/js/breadboard/editor/board-art.js | 243 +++++ .../js/breadboard/editor/component-art.js | 443 +++++++++ .../wwwroot/js/breadboard/editor/dom.js | 116 +++ .../js/breadboard/editor/editor-state.js | 380 +++++++ .../wwwroot/js/breadboard/editor/main.js | 440 +++++++++ .../wwwroot/js/breadboard/editor/palette.js | 126 +++ .../js/breadboard/editor/properties.js | 248 +++++ .../wwwroot/js/breadboard/editor/renderer.js | 412 ++++++++ .../js/breadboard/editor/server-errors.js | 74 ++ .../js/breadboard/editor/sim-client.js | 307 ++++++ .../wwwroot/js/breadboard/editor/status.js | 163 ++++ .../js/breadboard/editor/theme-colors.js | 138 +++ .../wwwroot/js/breadboard/editor/toolbar.js | 122 +++ .../wwwroot/js/breadboard/editor/tools.js | 511 ++++++++++ .../wwwroot/js/breadboard/editor/viewport.js | 136 +++ .../wwwroot/js/breadboard/engine/constants.js | 96 ++ .../wwwroot/js/breadboard/engine/drive.js | 128 +++ .../js/breadboard/engine/event-queue.js | 141 +++ .../js/breadboard/engine/models/index.js | 15 + .../js/breadboard/engine/models/led.js | 130 +++ .../js/breadboard/engine/models/logic-ic.js | 186 ++++ .../js/breadboard/engine/models/logic.js | 80 ++ .../js/breadboard/engine/models/registry.js | 146 +++ .../js/breadboard/engine/models/resistor.js | 78 ++ .../js/breadboard/engine/models/supply.js | 36 + .../js/breadboard/engine/models/switches.js | 157 +++ .../js/breadboard/engine/net-builder.js | 110 +++ .../wwwroot/js/breadboard/engine/net-state.js | 221 +++++ .../js/breadboard/engine/simulation.js | 661 +++++++++++++ .../js/breadboard/engine/union-find.js | 90 ++ .../wwwroot/js/breadboard/engine/worker.js | 428 ++++++++ .../js/breadboard/shared/board-geometry.js | 353 +++++++ .../js/breadboard/shared/circuit-schema.js | 729 ++++++++++++++ .../js/breadboard/shared/component-pins.js | 239 +++++ .../breadboard/shared/component-registry.js | 391 ++++++++ 50 files changed, 10977 insertions(+) create mode 100644 Media.JoshHeaps.Net/Api/BreadboardApi.cs create mode 100644 Media.JoshHeaps.Net/Database/031_breadboard.sql create mode 100644 Media.JoshHeaps.Net/Models/BreadboardProject.cs create mode 100644 Media.JoshHeaps.Net/Models/BreadboardResult.cs create mode 100644 Media.JoshHeaps.Net/Pages/Breadboard.cshtml create mode 100644 Media.JoshHeaps.Net/Pages/Breadboard.cshtml.cs create mode 100644 Media.JoshHeaps.Net/Pages/BreadboardEditor.cshtml create mode 100644 Media.JoshHeaps.Net/Pages/BreadboardEditor.cshtml.cs create mode 100644 Media.JoshHeaps.Net/Services/BreadboardService.cs create mode 100644 Media.JoshHeaps.Net/Services/BreadboardValidator.cs create mode 100644 Media.JoshHeaps.Net/wwwroot/css/breadboard-projects.css create mode 100644 Media.JoshHeaps.Net/wwwroot/css/breadboard.css create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/api.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/board-art.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/component-art.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/dom.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/editor-state.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/main.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/palette.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/properties.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/renderer.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/server-errors.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/sim-client.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/status.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/theme-colors.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/toolbar.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/tools.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/viewport.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/constants.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/drive.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/event-queue.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/index.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/led.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/logic-ic.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/logic.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/registry.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/resistor.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/supply.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/switches.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/net-builder.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/net-state.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/simulation.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/union-find.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/worker.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/board-geometry.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/circuit-schema.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/component-pins.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/component-registry.js diff --git a/Media.JoshHeaps.Net/Api/BreadboardApi.cs b/Media.JoshHeaps.Net/Api/BreadboardApi.cs new file mode 100644 index 0000000..b42fd6d --- /dev/null +++ b/Media.JoshHeaps.Net/Api/BreadboardApi.cs @@ -0,0 +1,141 @@ +using System.Security.Claims; +using System.Text.Json; +using Media.JoshHeaps.Net.Models; +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Media.JoshHeaps.Net.Api; + +[ApiController] +[Route("api/breadboard")] +public class BreadboardApi(BreadboardService breadboardService) : ControllerBase +{ + /// + /// Pipeline-level guard so an oversized body is rejected before it is buffered into a + /// string. The validator's 2 MB circuit cap is the real limit; the extra megabyte is + /// headroom for the JSON envelope around it. + /// + private const long MaxRequestBodyBytes = 3L * 1024 * 1024; + + [HttpGet("projects")] + public async Task ListProjects() + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(Problems("Not authenticated")); + } + + var projects = await breadboardService.GetProjectsAsync(userId.Value); + return Ok(projects); + } + + [HttpPost("projects")] + [RequestSizeLimit(MaxRequestBodyBytes)] + public async Task CreateProject([FromBody] CreateBreadboardProjectRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(Problems("Not authenticated")); + } + + var result = await breadboardService.CreateProjectAsync(userId.Value, request.Name, request.Description); + return MapResult(result); + } + + [HttpGet("projects/{projectId:long}")] + public async Task GetProject(long projectId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(Problems("Not authenticated")); + } + + var project = await breadboardService.GetProjectAsync(projectId, userId.Value); + if (project == null) + { + return NotFound(Problems(BreadboardResult.NotFoundMessage)); + } + + return Ok(project); + } + + [HttpPut("projects/{projectId:long}")] + [RequestSizeLimit(MaxRequestBodyBytes)] + public async Task UpdateProject(long projectId, [FromBody] UpdateBreadboardProjectRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(Problems("Not authenticated")); + } + + var result = await breadboardService.UpdateProjectAsync( + projectId, + userId.Value, + request.Name, + request.Description, + RawCircuit(request.Circuit)); + + return MapResult(result); + } + + [HttpDelete("projects/{projectId:long}")] + public async Task DeleteProject(long projectId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(Problems("Not authenticated")); + } + + var result = await breadboardService.DeleteProjectAsync(projectId, userId.Value); + return MapResult(result); + } + + /// A project that belongs to someone else is reported as missing, never as forbidden. + private IActionResult MapResult(BreadboardResult result) => result.Outcome switch + { + BreadboardOutcome.Success => result.Project is null ? NoContent() : Ok(result.Project), + BreadboardOutcome.NotFound => NotFound(new { errors = result.Errors }), + BreadboardOutcome.Invalid => BadRequest(new { errors = result.Errors }), + _ => StatusCode(StatusCodes.Status500InternalServerError, new { errors = result.Errors }) + }; + + private static object Problems(string message) => new { errors = new[] { message } }; + + /// + /// An omitted circuit and an explicit JSON null both mean "leave the circuit alone". + /// Anything else is handed to the service as raw text — the server never reshapes the document. + /// + private static string? RawCircuit(JsonElement? circuit) => + circuit is { ValueKind: not JsonValueKind.Undefined and not JsonValueKind.Null } element + ? element.GetRawText() + : null; + + /// + /// JWT first, then the session cookie — the repo-wide pattern. + /// CSRF note: the session path carries no antiforgery token, and is safe today only + /// because of three framework defaults — session cookies are SameSite=Lax, no CORS policy + /// is registered, and an application/json body forces a preflight. Adding a permissive + /// CORS policy or SameSite=None anywhere in this app makes these writes CSRF-able. + /// + private long? GetUserIdFromAuth() + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId)) + return jwtUserId; + + var userIdString = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId)) + return sessionUserId; + + return null; + } +} + +public record CreateBreadboardProjectRequest(string? Name, string? Description); + +public record UpdateBreadboardProjectRequest(string? Name, string? Description, JsonElement? Circuit); diff --git a/Media.JoshHeaps.Net/Database/031_breadboard.sql b/Media.JoshHeaps.Net/Database/031_breadboard.sql new file mode 100644 index 0000000..f05f169 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/031_breadboard.sql @@ -0,0 +1,26 @@ +-- Breadboard circuit simulator. +-- A project owns one circuit document (schema v1) stored as JSONB: boards, +-- components and wires laid out on full-size 830-point breadboards. +-- Memory images hold the contents of memory components keyed by the component's +-- uid within the circuit document; no endpoints use them yet. + +CREATE TABLE IF NOT EXISTS app.breadboard_projects ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app.users(id), + name TEXT NOT NULL, + description TEXT, + circuit JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_breadboard_projects_user_id ON app.breadboard_projects(user_id); + +CREATE TABLE IF NOT EXISTS app.breadboard_memory_images ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES app.breadboard_projects(id) ON DELETE CASCADE, + component_uid TEXT NOT NULL, + data BYTEA NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_breadboard_memory_images UNIQUE (project_id, component_uid) +); diff --git a/Media.JoshHeaps.Net/Models/BreadboardProject.cs b/Media.JoshHeaps.Net/Models/BreadboardProject.cs new file mode 100644 index 0000000..655475b --- /dev/null +++ b/Media.JoshHeaps.Net/Models/BreadboardProject.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Nodes; + +namespace Media.JoshHeaps.Net.Models; + +/// Row shape for the project list — deliberately excludes the circuit document. +public sealed record BreadboardProjectSummary( + long Id, + string Name, + string? Description, + DateTime CreatedAt, + DateTime UpdatedAt); + +/// +/// A single project including its circuit document. The circuit is opaque to C# — +/// it is stored and validated as text and only parsed here so the API emits it as a +/// real JSON object rather than a JSON-encoded string. +/// +public sealed record BreadboardProject( + long Id, + string Name, + string? Description, + JsonNode? Circuit, + DateTime CreatedAt, + DateTime UpdatedAt); diff --git a/Media.JoshHeaps.Net/Models/BreadboardResult.cs b/Media.JoshHeaps.Net/Models/BreadboardResult.cs new file mode 100644 index 0000000..fd19c5f --- /dev/null +++ b/Media.JoshHeaps.Net/Models/BreadboardResult.cs @@ -0,0 +1,35 @@ +namespace Media.JoshHeaps.Net.Models; + +public enum BreadboardOutcome +{ + Success, + NotFound, + Invalid, + Failed +} + +/// +/// Outcome of a write against a breadboard project. The API layer maps the outcome to a +/// status code; every rule that produces lives in +/// the service (or the validator it delegates to), never in the controller. +/// +public sealed record BreadboardResult( + BreadboardOutcome Outcome, + IReadOnlyList Errors, + BreadboardProject? Project) +{ + /// The single wording for "gone or never yours" — read and write paths share it. + public const string NotFoundMessage = "Project not found"; + + public static BreadboardResult Succeeded(BreadboardProject? project = null) => + new(BreadboardOutcome.Success, [], project); + + public static BreadboardResult Missing() => + new(BreadboardOutcome.NotFound, [NotFoundMessage], null); + + public static BreadboardResult Invalid(IReadOnlyList errors) => + new(BreadboardOutcome.Invalid, errors, null); + + public static BreadboardResult Failed(string error) => + new(BreadboardOutcome.Failed, [error], null); +} diff --git a/Media.JoshHeaps.Net/Pages/Breadboard.cshtml b/Media.JoshHeaps.Net/Pages/Breadboard.cshtml new file mode 100644 index 0000000..68b6b43 --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/Breadboard.cshtml @@ -0,0 +1,176 @@ +@page +@model Media.JoshHeaps.Net.Pages.BreadboardModel +@{ + ViewData["Title"] = "Breadboard Simulator"; + Layout = "_Layout"; +} + +@section Styles { + +} + +@* Class names are this page's own (bbp- prefix, styled in breadboard-projects.css). + This app never links Bootstrap's stylesheet, so Bootstrap class names here would imply + styling that does not exist. Colours come from the site.css custom properties, so the + page follows the theme toggle without any theme-specific rules. *@ +
+
+
+ + + + + + +

Breadboard Simulator

+
+ Logout +
+ + + +
+

New project

+
+
+ + +
+
+ + +
+ +
+
+ + @if (Model.Projects.Count == 0) + { +

No projects yet. Create one above to start wiring.

+ } + else + { +
+ @foreach (var project in Model.Projects) + { +
+
+ @project.Name + @if (!string.IsNullOrWhiteSpace(project.Description)) + { +
@project.Description
+ } + @* Rendered in the viewer's timezone by the module script below, not the server's. *@ +
+
+ +
+ } +
+ } +
+ +@section Scripts { + +} diff --git a/Media.JoshHeaps.Net/Pages/Breadboard.cshtml.cs b/Media.JoshHeaps.Net/Pages/Breadboard.cshtml.cs new file mode 100644 index 0000000..21f2fd6 --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/Breadboard.cshtml.cs @@ -0,0 +1,26 @@ +using Media.JoshHeaps.Net.Models; +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Media.JoshHeaps.Net.Pages; + +public class BreadboardModel(BreadboardService breadboardService) : AuthenticatedPageModel +{ + public List Projects { get; private set; } = []; + + public async Task OnGetAsync() + { + RequireAuthentication(); + LoadUserSession(); + + // RequireAuthentication only queues a redirect, so bail out explicitly rather than + // rendering the page against a zero user id. + if (UserId == 0) + { + return Redirect("/Login"); + } + + Projects = await breadboardService.GetProjectsAsync(UserId); + return Page(); + } +} diff --git a/Media.JoshHeaps.Net/Pages/BreadboardEditor.cshtml b/Media.JoshHeaps.Net/Pages/BreadboardEditor.cshtml new file mode 100644 index 0000000..2f7d88c --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/BreadboardEditor.cshtml @@ -0,0 +1,24 @@ +@page +@model Media.JoshHeaps.Net.Pages.BreadboardEditorModel +@{ + ViewData["Title"] = $"Breadboard - {Model.ProjectName}"; + Layout = "_Layout"; +} + +@section Styles { + +} + +@* Shell only. Everything inside #breadboard-editor is built by the editor module, so the + component palette can grow without a Razor edit. Contract agreed with frontend-impl: + the root id and the three data-attributes below are the entire server-to-editor surface. *@ +
+
+
+ +@section Scripts { + +} diff --git a/Media.JoshHeaps.Net/Pages/BreadboardEditor.cshtml.cs b/Media.JoshHeaps.Net/Pages/BreadboardEditor.cshtml.cs new file mode 100644 index 0000000..a1384ff --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/BreadboardEditor.cshtml.cs @@ -0,0 +1,34 @@ +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Media.JoshHeaps.Net.Pages; + +public class BreadboardEditorModel(BreadboardService breadboardService) : AuthenticatedPageModel +{ + public long ProjectId { get; private set; } + public string ProjectName { get; private set; } = string.Empty; + + public async Task OnGetAsync([FromQuery] long projectId) + { + RequireAuthentication(); + LoadUserSession(); + + if (UserId == 0) + { + return Redirect("/Login"); + } + + // Ownership check lives in SQL, so someone else's project is simply not found. The + // summary lookup deliberately skips the circuit — the editor module fetches the + // document itself, and pulling it here would parse a multi-megabyte payload to throw away. + var project = await breadboardService.GetProjectSummaryAsync(projectId, UserId); + if (project == null) + { + return NotFound(); + } + + ProjectId = project.Id; + ProjectName = project.Name; + return Page(); + } +} diff --git a/Media.JoshHeaps.Net/Pages/Landing.cshtml b/Media.JoshHeaps.Net/Pages/Landing.cshtml index 6052410..ce6073e 100644 --- a/Media.JoshHeaps.Net/Pages/Landing.cshtml +++ b/Media.JoshHeaps.Net/Pages/Landing.cshtml @@ -93,6 +93,35 @@ + +
+
+
+ + + + + + + +
+
+
+

Breadboard Simulator

+

Build and simulate logic circuits on a virtual solderless breadboard

+
+
+ +
+ @if (Model.IsAdmin) { diff --git a/Media.JoshHeaps.Net/Program.cs b/Media.JoshHeaps.Net/Program.cs index 3efb09a..3af69d8 100644 --- a/Media.JoshHeaps.Net/Program.cs +++ b/Media.JoshHeaps.Net/Program.cs @@ -21,6 +21,8 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpClient(); // Add session support diff --git a/Media.JoshHeaps.Net/Services/BreadboardService.cs b/Media.JoshHeaps.Net/Services/BreadboardService.cs new file mode 100644 index 0000000..bf8efac --- /dev/null +++ b/Media.JoshHeaps.Net/Services/BreadboardService.cs @@ -0,0 +1,293 @@ +using System.Text.Json.Nodes; +using Media.JoshHeaps.Net.Models; +using Npgsql; + +namespace Media.JoshHeaps.Net.Services; + +/// +/// Data access and write rules for breadboard projects. The circuit document is opaque +/// here: it travels as JSON text, is checked by before +/// it ever reaches the database, and is only parsed on the way out so the API can emit it +/// as a JSON object. Ownership is enforced in SQL — every statement is scoped by user_id, +/// so a project belonging to someone else is indistinguishable from one that never existed. +/// +public class BreadboardService(DbExecutor db, BreadboardValidator validator, ILogger logger) +{ + public const int MaxNameLength = 200; + public const int MaxDescriptionLength = 2000; + public const int MaxProjectsPerUser = 200; + + private const string EmptyCircuitJson = """{"version":1,"boards":[],"components":[],"wires":[]}"""; + + public async Task> GetProjectsAsync(long userId) + { + try + { + var query = @" + SELECT id, name, description, created_at, updated_at + FROM app.breadboard_projects + WHERE user_id = @userId + ORDER BY updated_at DESC"; + + return await db.ExecuteListReaderAsync(query, MapSummary, new { userId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to list breadboard projects for user {UserId}", userId); + return []; + } + } + + /// + /// Ownership check plus display fields, without dragging the circuit document along. + /// Pages that only need to know "is this mine, and what is it called" use this so the + /// document is fetched exactly once, by the editor module over the API. + /// + public async Task GetProjectSummaryAsync(long projectId, long userId) + { + var query = @" + SELECT id, name, description, created_at, updated_at + FROM app.breadboard_projects + WHERE id = @projectId AND user_id = @userId"; + + return await db.ExecuteReaderAsync(query, MapSummary, new { projectId, userId }); + } + + /// + /// Returns null only when the project does not exist or is not this user's. Database + /// failures deliberately propagate — a caller must never turn an outage into a 404. + /// + public async Task GetProjectAsync(long projectId, long userId) + { + var query = @" + SELECT id, name, description, circuit::text, created_at, updated_at + FROM app.breadboard_projects + WHERE id = @projectId AND user_id = @userId"; + + return await db.ExecuteReaderAsync(query, MapProject, new { projectId, userId }); + } + + public async Task CreateProjectAsync(long userId, string? name, string? description) + { + var errors = new List(); + + var trimmedName = ValidateName(name, errors); + var trimmedDescription = ValidateDescription(description, errors); + + if (errors.Count > 0) + { + return BreadboardResult.Invalid(errors); + } + + // Even the starter document goes through the validator — there is no trusted path by + // which a circuit reaches the database unchecked. It is server-authored though, so a + // rejection means the seed and the validator have drifted: that is our bug, not the + // caller's, and it must not surface as a 400 blaming their input. + var circuitCheck = validator.Validate(EmptyCircuitJson); + if (!circuitCheck.IsValid) + { + logger.LogError( + "Seed breadboard circuit document was rejected by the validator: {Errors}", + string.Join(", ", circuitCheck.Errors)); + return BreadboardResult.Failed("Failed to create project"); + } + + try + { + // An authenticated user can otherwise grow the table without bound, 2 MB at a time. + var projectCount = await db.ExecuteAsync( + "SELECT COUNT(*) FROM app.breadboard_projects WHERE user_id = @userId", + new { userId }); + + if (projectCount >= MaxProjectsPerUser) + { + return BreadboardResult.Invalid([$"Project limit reached ({MaxProjectsPerUser} per account)"]); + } + + var query = @" + INSERT INTO app.breadboard_projects (user_id, name, description, circuit, created_at, updated_at) + VALUES (@userId, @name, @description, @circuit::jsonb, NOW(), NOW()) + RETURNING id, name, description, circuit::text, created_at, updated_at"; + + var project = await db.ExecuteReaderAsync(query, MapProject, new + { + userId, + name = trimmedName, + description = trimmedDescription, + circuit = EmptyCircuitJson + }); + + return project is null + ? BreadboardResult.Failed("Failed to create project") + : BreadboardResult.Succeeded(project); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create breadboard project for user {UserId}", userId); + return BreadboardResult.Failed("Failed to create project"); + } + } + + /// + /// Partial update: a null argument means "leave unchanged". An explicitly blank + /// description clears the column. + /// + public async Task UpdateProjectAsync( + long projectId, + long userId, + string? name, + string? description, + string? circuitJson) + { + var errors = new List(); + + var setName = name is not null; + var trimmedName = setName ? ValidateName(name, errors) : null; + + var setDescription = description is not null; + var trimmedDescription = setDescription ? ValidateDescription(description, errors) : null; + + var setCircuit = circuitJson is not null; + if (setCircuit) + { + var circuitCheck = validator.Validate(circuitJson!); + if (!circuitCheck.IsValid) + { + errors.AddRange(circuitCheck.Errors); + } + } + + if (errors.Count > 0) + { + return BreadboardResult.Invalid(errors); + } + + try + { + var query = @" + UPDATE app.breadboard_projects + SET name = CASE WHEN @setName THEN @name ELSE name END, + -- NULLIF keeps @description a non-null text parameter, so the server never has + -- to infer a type for an untyped NULL, and clearing still works. + description = CASE WHEN @setDescription THEN NULLIF(@description, '') ELSE description END, + circuit = CASE WHEN @setCircuit THEN @circuit::jsonb ELSE circuit END, + -- A no-op save must not reorder the project list, which sorts on updated_at. + updated_at = CASE WHEN @setName OR @setDescription OR @setCircuit THEN NOW() ELSE updated_at END + WHERE id = @projectId AND user_id = @userId"; + + var rows = await db.ExecuteNonQueryAsync(query, new + { + projectId, + userId, + setName, + // Guarded by @setName — the CASE is what keeps this placeholder off the column. + name = trimmedName ?? string.Empty, + setDescription, + description = trimmedDescription ?? string.Empty, + setCircuit, + // Guarded by @setCircuit, but the ::jsonb cast still parses it, so it must be + // valid JSON even on the branch the CASE discards. + circuit = circuitJson ?? EmptyCircuitJson + }); + + return rows == 0 ? BreadboardResult.Missing() : BreadboardResult.Succeeded(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update breadboard project {ProjectId} for user {UserId}", projectId, userId); + return BreadboardResult.Failed("Failed to update project"); + } + } + + public async Task DeleteProjectAsync(long projectId, long userId) + { + try + { + var query = "DELETE FROM app.breadboard_projects WHERE id = @projectId AND user_id = @userId"; + var rows = await db.ExecuteNonQueryAsync(query, new { projectId, userId }); + + return rows == 0 ? BreadboardResult.Missing() : BreadboardResult.Succeeded(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete breadboard project {ProjectId} for user {UserId}", projectId, userId); + return BreadboardResult.Failed("Failed to delete project"); + } + } + + private static BreadboardProjectSummary MapSummary(NpgsqlDataReader reader) => new( + reader.GetInt64(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.GetDateTime(3), + reader.GetDateTime(4)); + + private BreadboardProject MapProject(NpgsqlDataReader reader) => new( + reader.GetInt64(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + ParseCircuit(reader.GetString(3), reader.GetInt64(0)), + reader.GetDateTime(4), + reader.GetDateTime(5)); + + private JsonNode? ParseCircuit(string circuitJson, long projectId) + { + try + { + return JsonNode.Parse(circuitJson); + } + catch (Exception ex) + { + logger.LogError(ex, "Stored circuit for breadboard project {ProjectId} is not parseable JSON", projectId); + return null; + } + } + + private static string ValidateName(string? name, List errors) + { + var trimmed = name?.Trim() ?? string.Empty; + + if (trimmed.Length == 0) + { + errors.Add("Name is required"); + } + else if (trimmed.Length > MaxNameLength) + { + errors.Add($"Name must be {MaxNameLength} characters or fewer"); + } + else if (trimmed.Any(char.IsControl)) + { + // PostgreSQL rejects NUL in text outright; catching it here makes it a 400 rather + // than a generic 500, and the rest of the control range has no business in a name. + errors.Add("Name must not contain control characters"); + } + + return trimmed; + } + + private static string? ValidateDescription(string? description, List errors) + { + var trimmed = description?.Trim(); + + if (string.IsNullOrEmpty(trimmed)) + { + return null; + } + + if (trimmed.Length > MaxDescriptionLength) + { + errors.Add($"Description must be {MaxDescriptionLength} characters or fewer"); + } + else if (trimmed.Any(c => char.IsControl(c) && c is not ('\n' or '\r' or '\t'))) + { + // Line breaks and tabs are legitimate in free text — CR is in the list because a + //