Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions apps/server/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,8 @@ paths:
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'423':
$ref: '#/components/responses/Held'
delete:
operationId: unjudgeRecording
security:
Expand Down Expand Up @@ -1278,6 +1280,8 @@ paths:
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'423':
$ref: '#/components/responses/Held'
/projects/{slug}/cases/{caseId}:
parameters:
- name: slug
Expand Down Expand Up @@ -1341,6 +1345,64 @@ paths:
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/projects/{slug}/cases/{caseId}/lock:
parameters:
- name: slug
in: path
required: true
schema:
type: string
- name: caseId
in: path
required: true
schema:
type: string
post:
operationId: claimCase
security:
- session: []
summary: Hold the case, or keep holding it
description: |
Claiming is also the heartbeat (ADR 0005, #95): the same call takes a free
or expired lock and renews the caller's own. Somebody else's live hold
answers 423, naming them. Occupancy is never a state — the case reads the
same before and after.
tags:
- reviews
responses:
'200':
description: The caller holds the case.
content:
application/json:
schema:
$ref: '#/components/schemas/Hold'
'401':
$ref: '#/components/responses/Unauthenticated'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'423':
$ref: '#/components/responses/Held'
delete:
operationId: releaseCase
security:
- session: []
summary: Let the case go
description: |
Releasing a lock nobody holds, or somebody else's, changes nothing
(ADR 0005).
tags:
- reviews
responses:
'204':
description: The caller no longer holds the case.
'401':
$ref: '#/components/responses/Unauthenticated'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/projects/{slug}/cases/{caseId}/captures:
parameters:
- name: slug
Expand Down Expand Up @@ -1648,6 +1710,8 @@ paths:
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/MoveRefused'
'423':
$ref: '#/components/responses/Held'
post:
operationId: judgeComment
security:
Expand Down Expand Up @@ -1699,6 +1763,8 @@ paths:
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/MoveRefused'
'423':
$ref: '#/components/responses/Held'
/projects/{slug}/cases/{caseId}/reviews:
parameters:
- name: slug
Expand Down Expand Up @@ -1748,6 +1814,8 @@ paths:
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'423':
$ref: '#/components/responses/Held'
/projects/{slug}/cases/{caseId}/archive:
parameters:
- name: slug
Expand Down Expand Up @@ -2049,6 +2117,21 @@ components:
description: |
Who holds the ball. Computed by the server from the case's comments; no
endpoint accepts it as an argument.
Hold:
type: object
required:
- by
- name
- since
description: Who holds the case, and since when. Occupancy, never a state (ADR 0005).
properties:
by:
type: string
name:
type: string
since:
type: string
format: date-time
CaptureCounts:
type: object
required:
Expand Down Expand Up @@ -2092,6 +2175,10 @@ components:
description:
type: string
nullable: true
held:
description: Present while a reviewer holds the case (ADR 0005,
allOf:
- $ref: '#/components/schemas/Hold'
state:
$ref: '#/components/schemas/CaseState'
archived:
Expand Down Expand Up @@ -2882,6 +2969,14 @@ components:
application/problem+json:
schema:
$ref: '#/components/schemas/problem'
Held:
description: |
Somebody else holds this case (ADR 0005, #95): a held case takes no
verdict but its holder's. The detail names them.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/problem'
MoveApplied:
description: Where the comment and its case now stand.
content:
Expand Down
9 changes: 9 additions & 0 deletions apps/server/api/src/components/responses.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ BadRequest:
schema:
$ref: "./problem.yaml"

Held:
description: |
Somebody else holds this case (ADR 0005, #95): a held case takes no
verdict but its holder's. The detail names them.
content:
application/problem+json:
schema:
$ref: "./problem.yaml"

NotFound:
description: No such resource.
content:
Expand Down
13 changes: 13 additions & 0 deletions apps/server/api/src/components/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ Case:
categoryId: { type: string, nullable: true }
title: { type: string }
description: { type: string, nullable: true }
held:
description: Present while a reviewer holds the case (ADR 0005, #95).
allOf:
- $ref: "#/Hold"
state:
$ref: "#/CaseState"
archived:
Expand All @@ -78,6 +82,15 @@ NewCase:
description: { type: string }
categoryId: { type: string, minLength: 1 }

Hold:
type: object
required: [by, name, since]
description: Who holds the case, and since when. Occupancy, never a state (ADR 0005).
properties:
by: { type: string }
name: { type: string }
since: { type: string, format: date-time }

CaseUpdate:
type: object
required: [title]
Expand Down
2 changes: 2 additions & 0 deletions apps/server/api/src/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ paths:
$ref: "./paths/recording-judgment.yaml"
/projects/{slug}/cases/{caseId}:
$ref: "./paths/case.yaml"
/projects/{slug}/cases/{caseId}/lock:
$ref: "./paths/case-lock.yaml"
/projects/{slug}/cases/{caseId}/captures:
$ref: "./paths/case-captures.yaml"
/projects/{slug}/cases/{caseId}/comments:
Expand Down
55 changes: 55 additions & 0 deletions apps/server/api/src/paths/case-lock.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
parameters:
- name: slug
in: path
required: true
schema: { type: string }
- name: caseId
in: path
required: true
schema: { type: string }

post:
operationId: claimCase
security:
- session: []
summary: Hold the case, or keep holding it
description: |
Claiming is also the heartbeat (ADR 0005, #95): the same call takes a free
or expired lock and renews the caller's own. Somebody else's live hold
answers 423, naming them. Occupancy is never a state — the case reads the
same before and after.
tags: [reviews]
responses:
"200":
description: The caller holds the case.
content:
application/json:
schema:
$ref: "../components/schemas.yaml#/Hold"
'401':
$ref: "../components/responses.yaml#/Unauthenticated"
'403':
$ref: "../components/responses.yaml#/Forbidden"
"404":
$ref: "../components/responses.yaml#/NotFound"
"423":
$ref: "../components/responses.yaml#/Held"

delete:
operationId: releaseCase
security:
- session: []
summary: Let the case go
description: |
Releasing a lock nobody holds, or somebody else's, changes nothing
(ADR 0005).
tags: [reviews]
responses:
"204":
description: The caller no longer holds the case.
'401':
$ref: "../components/responses.yaml#/Unauthenticated"
'403':
$ref: "../components/responses.yaml#/Forbidden"
"404":
$ref: "../components/responses.yaml#/NotFound"
2 changes: 2 additions & 0 deletions apps/server/api/src/paths/case-reviews.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,7 @@ post:
$ref: "../components/review.yaml#/ReviewOutcome"
"400":
$ref: "../components/responses.yaml#/BadRequest"
"423":
$ref: "../components/responses.yaml#/Held"
"404":
$ref: "../components/responses.yaml#/NotFound"
4 changes: 4 additions & 0 deletions apps/server/api/src/paths/comment-moves.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ Judgment:
$ref: "#/MoveApplied"
"400":
$ref: "../components/responses.yaml#/BadRequest"
"423":
$ref: "../components/responses.yaml#/Held"
"404":
$ref: "../components/responses.yaml#/NotFound"
"409":
Expand Down Expand Up @@ -252,6 +254,8 @@ Judgment:
$ref: "#/MoveApplied"
"400":
$ref: "../components/responses.yaml#/BadRequest"
"423":
$ref: "../components/responses.yaml#/Held"
"404":
$ref: "../components/responses.yaml#/NotFound"
"409":
Expand Down
4 changes: 4 additions & 0 deletions apps/server/api/src/paths/recording-judgment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ post:
$ref: "../components/responses.yaml#/Unauthenticated"
'403':
$ref: "../components/responses.yaml#/Forbidden"
"423":
$ref: "../components/responses.yaml#/Held"
"404":
$ref: "../components/responses.yaml#/NotFound"

Expand Down Expand Up @@ -78,5 +80,7 @@ delete:
$ref: "../components/responses.yaml#/Unauthenticated"
'403':
$ref: "../components/responses.yaml#/Forbidden"
"423":
$ref: "../components/responses.yaml#/Held"
"404":
$ref: "../components/responses.yaml#/NotFound"
14 changes: 14 additions & 0 deletions apps/server/db/migrations/00021_a_case_is_held.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- +goose Up
-- Occupancy is its own axis (ADR 0005, #95): who is reviewing a case right
-- now, never folded into the cycle state. One live row per case; expiry is
-- read, not written — a lock whose heartbeat went silent past the window
-- simply stops counting.
CREATE TABLE case_locks (
case_id text PRIMARY KEY REFERENCES cases(id) ON DELETE CASCADE,
account_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
claimed_at timestamptz NOT NULL DEFAULT now(),
beaten_at timestamptz NOT NULL DEFAULT now()
);

-- +goose Down
DROP TABLE case_locks;
32 changes: 32 additions & 0 deletions apps/server/db/queries/aggregates.sql
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,35 @@ SET capture_id = (
LIMIT 1
)
WHERE cv.comment_id = @comment_id AND cv.variant_id = @variant_id;

-- Claiming is also renewing (ADR 0005, #95): one atomic statement takes a
-- free or expired lock, or beats the caller's own. Somebody else's live lock
-- makes the upsert a no-op — no row comes back, and the caller reads who
-- holds it instead. The window rides in as seconds so expiry is read, never
-- written.
-- name: ClaimCaseLock :one
INSERT INTO case_locks (case_id, account_id)
VALUES (@case_id, @account_id)
ON CONFLICT (case_id) DO UPDATE
SET account_id = EXCLUDED.account_id,
claimed_at = CASE
WHEN case_locks.account_id = EXCLUDED.account_id
AND case_locks.beaten_at > now() - make_interval(secs => @window_seconds::int)
THEN case_locks.claimed_at
ELSE now()
END,
beaten_at = now()
WHERE case_locks.account_id = EXCLUDED.account_id
OR case_locks.beaten_at < now() - make_interval(secs => @window_seconds::int)
RETURNING case_id, account_id, claimed_at;

-- name: ReadCaseLock :one
SELECT l.account_id, l.claimed_at, u.name AS holder_name
FROM case_locks l
JOIN users u ON u.id = l.account_id
WHERE l.case_id = @case_id
AND l.beaten_at > now() - make_interval(secs => @window_seconds::int);

-- Releasing somebody else's lock, or one nobody holds, changes nothing.
-- name: ReleaseCaseLock :exec
DELETE FROM case_locks WHERE case_id = $1 AND account_id = $2;
10 changes: 9 additions & 1 deletion apps/server/internal/adapters/postgres/catalogue.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,15 @@ func (r *Repository) CaseByID(ctx context.Context, slug, id string) (catalogue.C
if err != nil {
return catalogue.Case{}, translate("reading the case", err)
}
return toCase(row), nil
out := toCase(row)
// The holder rides along (ADR 0005): occupancy shown, never stored in
// the state.
held, err := r.holderOf(ctx, r.q, row.ID)
if err != nil {
return catalogue.Case{}, err
}
out.Held = held
return out, nil
}

func (r *Repository) ListCases(ctx context.Context, projectID string, state, categoryID *string) ([]catalogue.Case, error) {
Expand Down
9 changes: 9 additions & 0 deletions apps/server/internal/adapters/postgres/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,15 @@ func (r *Repository) move(
return appcomment.Outcome{}, translate("reading the comment", err)
}

// A held case takes no verdict but its holder's (ADR 0005, #95). The
// dev's moves — tracking, delivering, discarding — are not verdicts and
// pass untouched.
if m == review.MoveAccept || m == review.MoveRefuse || m == review.MoveUnjudge {
if err := r.refuseHeld(ctx, q, comment.CaseID, by); err != nil {
return appcomment.Outcome{}, err
}
}

// Each move decides for itself: discard consults the comment machine, the
// ref moves consult the ref machine and derive the comment from its refs.
// A refused move is the domain's answer, not a database failure.
Expand Down
Loading
Loading