feat(sessions): edit session acquire, release, and query endpoints #163

Open
opened 2026-03-01 15:37:12 +00:00 by forbes · 0 comments
Owner

Context

Sub-issue of #125 (Context-Aware Part Subscription System).

Edit sessions are the server-side representation of a user's active editing context in Kindred Create. When a user enters Sketcher, PartDesign, or Assembly editing mode, the client acquires a session. When they exit (upward context transition), the client releases it.

This issue implements the core acquire/release/query lifecycle. Interference detection is a separate issue.

1. Database Migration

Create migrations/023_edit_sessions.sql:

CREATE TABLE edit_sessions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    item_id         UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
    user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    workstation_id  UUID NOT NULL REFERENCES workstations(id) ON DELETE CASCADE,
    context_level   TEXT NOT NULL
                    CHECK (context_level IN ('sketch', 'partdesign', 'assembly')),
    object_id       TEXT,
    dependency_cone TEXT[],
    acquired_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_heartbeat  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_edit_sessions_item ON edit_sessions(item_id);
CREATE INDEX idx_edit_sessions_user ON edit_sessions(user_id);
CREATE UNIQUE INDEX idx_edit_sessions_active
    ON edit_sessions(item_id, context_level, object_id);

The unique index on (item_id, context_level, object_id) enforces the hard interference constraint at the database level.

2. Context Levels

Create Context context_level Typical object_id
Sketcher sketch Sketch001
PartDesign (Body editing) partdesign Body or feature name
Assembly assembly Assembly container name

3. Database Layer

Create internal/db/edit_sessions.go:

type EditSession struct {
    ID             string
    ItemID         string
    UserID         string
    WorkstationID  string
    ContextLevel   string
    ObjectID       *string
    DependencyCone []string
    AcquiredAt     time.Time
    LastHeartbeat  time.Time
}

Methods:

  • Acquire(ctx, session) — INSERT, returns error on unique constraint violation (hard interference)
  • Release(ctx, sessionID) — DELETE by ID
  • ReleaseForWorkstation(ctx, workstationID) — DELETE all sessions for a workstation (disconnect cleanup)
  • GetByID(ctx, id) — single lookup
  • ListForItem(ctx, itemID) — all active sessions on an item
  • ListForUser(ctx, userID) — all active sessions for a user
  • TouchHeartbeat(ctx, workstationID) — UPDATE last_heartbeat for all sessions of a workstation
  • ExpireStale(ctx, timeout) — DELETE WHERE last_heartbeat < now() - timeout, return released sessions

4. API Endpoints

Create internal/api/session_handlers.go:

Method Path Auth Description
POST /api/items/{partNumber}/edit-sessions editor Acquire session
DELETE /api/items/{partNumber}/edit-sessions/{id} editor Release session (owner or admin)
GET /api/items/{partNumber}/edit-sessions viewer List active sessions for item
GET /api/edit-sessions viewer List current user's active sessions

Acquire Request

{
  "workstation_id": "uuid",
  "context_level": "sketch",
  "object_id": "Sketch001",
  "dependency_cone": ["Sketch001", "Pad003", "Fillet005"]
}

dependency_cone is optional. If omitted and the DAG module is enabled, the server can compute it from the stored DAG using GetForwardCone(). If no DAG exists, store empty (interference detection degrades to context-level-only).

Acquire Response (success)

{
  "session_id": "uuid",
  "interference": "none",
  "conflicts": []
}

For this issue, interference is always "none" on success or a DB unique constraint error (hard block, 409). Soft interference detection comes in a follow-up issue.

Acquire Response (hard block)

HTTP 409:

{
  "error": "hard_interference",
  "holder": {
    "user": "alice",
    "workstation": "alice-desktop",
    "context_level": "sketch",
    "object_id": "Sketch001",
    "acquired_at": "2026-02-16T14:00:00Z"
  },
  "message": "alice is currently editing Sketch001"
}

Release

On release, broadcast edit.session_released SSE event via PublishToItem().
On acquire, broadcast edit.session_acquired SSE event via PublishToItem().

The release endpoint also adds the client to the item's SSE watch list on acquire, and removes on release.

5. SSE Events

Event Delivery Payload
edit.session_acquired Item-scoped {item_id, part_number, user, workstation, context_level, object_id}
edit.session_released Item-scoped {item_id, part_number, user, context_level, object_id}

6. Routes

r.Route("/edit-sessions", func(r chi.Router) {
    r.Use(server.RequireModule("sessions"))
    r.Get("/", server.HandleListUserEditSessions)
})

// Under /api/items/{partNumber}:
r.Route("/edit-sessions", func(r chi.Router) {
    r.Use(server.RequireModule("sessions"))
    r.Get("/", server.HandleListItemEditSessions)
    r.Group(func(r chi.Router) {
        r.Use(server.RequireWritable)
        r.Use(server.RequireRole(auth.RoleEditor))
        r.Post("/", server.HandleAcquireEditSession)
        r.Delete("/{sessionID}", server.HandleReleaseEditSession)
    })
})

Acceptance Criteria

  • Migration creates edit_sessions table with unique index
  • POST /edit-sessions acquires session, returns session_id
  • Duplicate acquire (same item + context_level + object_id) returns 409 with holder info
  • DELETE /edit-sessions/{id} releases session (owner or admin)
  • GET /items/{pn}/edit-sessions lists active sessions for an item
  • GET /edit-sessions lists current user's sessions across all items
  • edit.session_acquired SSE event published on acquire (item-scoped)
  • edit.session_released SSE event published on release (item-scoped)
  • Sessions store optional dependency_cone array

Depends On

  • #161 (workstations table)
  • #162 (SSE per-connection filtering)

Part Of

#125

## Context Sub-issue of #125 (Context-Aware Part Subscription System). Edit sessions are the server-side representation of a user's active editing context in Kindred Create. When a user enters Sketcher, PartDesign, or Assembly editing mode, the client acquires a session. When they exit (upward context transition), the client releases it. This issue implements the core acquire/release/query lifecycle. Interference detection is a separate issue. ## 1. Database Migration Create `migrations/023_edit_sessions.sql`: ```sql CREATE TABLE edit_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, workstation_id UUID NOT NULL REFERENCES workstations(id) ON DELETE CASCADE, context_level TEXT NOT NULL CHECK (context_level IN ('sketch', 'partdesign', 'assembly')), object_id TEXT, dependency_cone TEXT[], acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_heartbeat TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_edit_sessions_item ON edit_sessions(item_id); CREATE INDEX idx_edit_sessions_user ON edit_sessions(user_id); CREATE UNIQUE INDEX idx_edit_sessions_active ON edit_sessions(item_id, context_level, object_id); ``` The unique index on `(item_id, context_level, object_id)` enforces the hard interference constraint at the database level. ## 2. Context Levels | Create Context | `context_level` | Typical `object_id` | |---------------|----------------|---------------------| | Sketcher | `sketch` | `Sketch001` | | PartDesign (Body editing) | `partdesign` | `Body` or feature name | | Assembly | `assembly` | Assembly container name | ## 3. Database Layer Create `internal/db/edit_sessions.go`: ```go type EditSession struct { ID string ItemID string UserID string WorkstationID string ContextLevel string ObjectID *string DependencyCone []string AcquiredAt time.Time LastHeartbeat time.Time } ``` Methods: - `Acquire(ctx, session)` — INSERT, returns error on unique constraint violation (hard interference) - `Release(ctx, sessionID)` — DELETE by ID - `ReleaseForWorkstation(ctx, workstationID)` — DELETE all sessions for a workstation (disconnect cleanup) - `GetByID(ctx, id)` — single lookup - `ListForItem(ctx, itemID)` — all active sessions on an item - `ListForUser(ctx, userID)` — all active sessions for a user - `TouchHeartbeat(ctx, workstationID)` — UPDATE last_heartbeat for all sessions of a workstation - `ExpireStale(ctx, timeout)` — DELETE WHERE last_heartbeat < now() - timeout, return released sessions ## 4. API Endpoints Create `internal/api/session_handlers.go`: | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/api/items/{partNumber}/edit-sessions` | editor | Acquire session | | DELETE | `/api/items/{partNumber}/edit-sessions/{id}` | editor | Release session (owner or admin) | | GET | `/api/items/{partNumber}/edit-sessions` | viewer | List active sessions for item | | GET | `/api/edit-sessions` | viewer | List current user's active sessions | ### Acquire Request ```json { "workstation_id": "uuid", "context_level": "sketch", "object_id": "Sketch001", "dependency_cone": ["Sketch001", "Pad003", "Fillet005"] } ``` `dependency_cone` is optional. If omitted and the DAG module is enabled, the server can compute it from the stored DAG using `GetForwardCone()`. If no DAG exists, store empty (interference detection degrades to context-level-only). ### Acquire Response (success) ```json { "session_id": "uuid", "interference": "none", "conflicts": [] } ``` For this issue, interference is always "none" on success or a DB unique constraint error (hard block, 409). Soft interference detection comes in a follow-up issue. ### Acquire Response (hard block) HTTP 409: ```json { "error": "hard_interference", "holder": { "user": "alice", "workstation": "alice-desktop", "context_level": "sketch", "object_id": "Sketch001", "acquired_at": "2026-02-16T14:00:00Z" }, "message": "alice is currently editing Sketch001" } ``` ### Release On release, broadcast `edit.session_released` SSE event via `PublishToItem()`. On acquire, broadcast `edit.session_acquired` SSE event via `PublishToItem()`. The release endpoint also adds the client to the item's SSE watch list on acquire, and removes on release. ## 5. SSE Events | Event | Delivery | Payload | |-------|----------|---------| | `edit.session_acquired` | Item-scoped | `{item_id, part_number, user, workstation, context_level, object_id}` | | `edit.session_released` | Item-scoped | `{item_id, part_number, user, context_level, object_id}` | ## 6. Routes ```go r.Route("/edit-sessions", func(r chi.Router) { r.Use(server.RequireModule("sessions")) r.Get("/", server.HandleListUserEditSessions) }) // Under /api/items/{partNumber}: r.Route("/edit-sessions", func(r chi.Router) { r.Use(server.RequireModule("sessions")) r.Get("/", server.HandleListItemEditSessions) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Post("/", server.HandleAcquireEditSession) r.Delete("/{sessionID}", server.HandleReleaseEditSession) }) }) ``` ## Acceptance Criteria - [ ] Migration creates `edit_sessions` table with unique index - [ ] `POST /edit-sessions` acquires session, returns session_id - [ ] Duplicate acquire (same item + context_level + object_id) returns 409 with holder info - [ ] `DELETE /edit-sessions/{id}` releases session (owner or admin) - [ ] `GET /items/{pn}/edit-sessions` lists active sessions for an item - [ ] `GET /edit-sessions` lists current user's sessions across all items - [ ] `edit.session_acquired` SSE event published on acquire (item-scoped) - [ ] `edit.session_released` SSE event published on release (item-scoped) - [ ] Sessions store optional `dependency_cone` array ## Depends On - #161 (workstations table) - #162 (SSE per-connection filtering) ## Part Of #125
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kindred/silo#163