Skip to content

feat(db): add room_create rpc and participants table - #61

Merged
flyingrobots merged 18 commits into
mainfrom
feat/room-create
Sep 29, 2025
Merged

feat(db): add room_create rpc and participants table#61
flyingrobots merged 18 commits into
mainfrom
feat/room-create

Conversation

@flyingrobots

Copy link
Copy Markdown
Owner

Summary

  • add participants table and room_create RPC

Changes

  • seed participants and round0 when creating rooms
  • pgTAP invariants for participants table and room_create behavior
  • docs: show how to call room_create locally

Tests

  • CI=true npm test
  • npm test

Next

  • wire server/CLI to consume room_create once API contracts stabilize

@coderabbitai

coderabbitai Bot commented Sep 29, 2025

Copy link
Copy Markdown

Summary by CodeRabbit

  • New Features
    • Added an API to create a room with configurable settings and automatically seed participants and the first round.
    • Idempotent operations via client nonces for room creation, submissions, and votes to prevent duplicates.
  • Bug Fixes
    • More robust vote tallies with null-safe aggregation.
    • Consistent time and numeric types to avoid edge-case errors.
  • Documentation
    • New guide for creating rooms and seeding participants.
    • Updated API docs to reflect the simplified voting endpoint.
  • Tests
    • Expanded coverage for new workflows, constraints, and RPCs.

Walkthrough

Adds a participants table, client_nonce to rooms, richer submissions and votes schemas with cascading FKs and dedupe nonces, implements room_create(...) to idempotently create/resolve rooms and seed participants, adjusts vote_submit signature, and expands tests/docs for these behaviors.

Changes

Cohort / File(s) Summary
Schema & DDL
db/schema.sql
Adds participants table (uuid PK, room_id FK → rooms(id) ON DELETE CASCADE, anon_name, role CHECK('debater'
RPCs & Stored Procs
db/rpc.sql
Adds room_create(p_topic text, p_cfg jsonb DEFAULT '{}'::jsonb, p_client_nonce text DEFAULT NULL) RETURNS uuid — validates cfg (participant_count 1–64, submit_minutes 1–1440), computes submit deadline, resolves-or-creates room by client_nonce (idempotent), updates title when provided, inserts initial submit round, seeds anonymous participants agent_1..N. Changes vote_submit signature: removes room_id param (now: p_round_id, p_voter_id, p_kind, p_ballot, p_client_nonce) and adjusts INSERT/ON CONFLICT logic; makes time/deadline arithmetic and tallies NULL-safe using bigint and COALESCE.
Tests
db/test/10_rooms_rounds.pgtap, db/test/20_submissions_votes.pgtap, db/test/30_rpcs.pgtap
Expanded test plans and assertions: new checks for participants table, constraints, indexes; updated canonical_sha256 test value; RPC existence and behavior tests added for room_create, submission_upsert, vote_submit, round_publish_due, round_open_next; idempotence and nonce tests for submissions/votes/room_create; plan counts increased.
Server & Integration
server/rpc.js, server/test/rpc.db.postgres.test.js, server/test/rpc.db.integration.test.js
Adjusted server RPC callsites and test fakes to use updated vote_submit signature (drop room_id arg). Tests insert seeded participants; TRUNCATE cleanup updated to include submission_flags, submissions, votes. FakePool adjusted to destructure 5 params for vote calls.
Docs
docs/LocalDB.md, docs/Architecture.md, docs/Features.md
Adds "Create a Room + Seed Participants" guide for room_create with defaults and idempotency examples; documents vote_submit signature change and related API/schema text edits; content additions describing milestones, voting changes, and other editorial updates.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Client
  participant DB as Postgres
  participant RPC as room_create()
  participant Rooms as rooms
  participant Rounds as rounds
  participant Parts as participants

  Client->>DB: CALL room_create(topic,cfg,client_nonce)
  DB->>RPC: execute
  rect rgb(255,245,230)
    RPC->>Rooms: SELECT ... WHERE client_nonce = $nonce
    alt found
      RPC->>Rooms: UPDATE title if provided
    else not found
      RPC->>Rooms: INSERT room (client_nonce,title,...) -> room_id
      RPC->>Rounds: INSERT initial round (phase='submit', deadline)
    end
  end
  rect rgb(230,245,255)
    RPC->>Parts: INSERT agent_1..agent_N (room_id, anon_name, role='debater') ON CONFLICT DO NOTHING
  end
  RPC-->>Client: RETURN room_id
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

A room appears, a nonce locks out the noise,
Agent_1..N shuffle in, all nameless little toys.
Deadlines set in bigint, tallies COALESCE clean and bright,
Tests grow teeth — review it hard, and get this ship to right!

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The title “feat(db): add room_create rpc and participants table” is concise and directly reflects the two primary additions—the new RPC and the participants table—so it satisfies the requirement for clarity and relevance.
Description Check ✅ Passed The description clearly outlines the addition of the participants table and the room_create RPC along with related seeding, tests, and documentation, making it directly relevant to the changeset.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/room-create

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
db/schema.sql (2)

41-58: Submissions without FKs? Enjoy your orphaned garbage. Add the constraints.

You reference rounds/participants by UUID with zero enforcement. That’s sloppy.

Patch:

 CREATE TABLE IF NOT EXISTS submissions (
   id                  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-  round_id            uuid        NOT NULL,
-  author_id           uuid        NOT NULL,
+  round_id            uuid        NOT NULL REFERENCES rounds(id) ON DELETE CASCADE,
+  author_id           uuid        NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
   content             text        NOT NULL,
   ...
   UNIQUE (round_id, author_id, client_nonce)
 );

Add a covering index for the idempotency key:

CREATE INDEX IF NOT EXISTS idx_submissions_nonce ON submissions (round_id, author_id, client_nonce);

62-76: Same problem in votes. Tie it to reality with FKs or this schema will rot.

Room/round/voter must be coherent. At minimum, enforce round_id and voter_id.

Patch:

 CREATE TABLE IF NOT EXISTS votes (
   id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-  room_id       uuid        NOT NULL,
-  round_id      uuid        NOT NULL,
-  voter_id      uuid        NOT NULL,
+  room_id       uuid        NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
+  round_id      uuid        NOT NULL REFERENCES rounds(id) ON DELETE CASCADE,
+  voter_id      uuid        NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
   kind          text        NOT NULL, -- e.g., 'continue'
   ballot        jsonb       NOT NULL DEFAULT '{}'::jsonb,
   client_nonce  text        NOT NULL,
   created_at    timestamptz NOT NULL DEFAULT now(),
   UNIQUE (round_id, voter_id, kind, client_nonce)
 );

Optionally enforce (room_id, round_id) coherence with a trigger or drop room_id from votes and derive via joins.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 05003ad and f97f579.

📒 Files selected for processing (5)
  • db/rpc.sql (1 hunks)
  • db/schema.sql (1 hunks)
  • db/test/10_rooms_rounds.pgtap (2 hunks)
  • db/test/30_rpcs.pgtap (1 hunks)
  • docs/LocalDB.md (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions
📚 Learning: 2025-09-29T10:17:53.577Z
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions

Applied to files:

  • docs/LocalDB.md
  • db/test/10_rooms_rounds.pgtap
  • db/schema.sql
  • db/rpc.sql
  • db/test/30_rpcs.pgtap

Comment thread db/rpc.sql
Comment thread db/rpc.sql Outdated
Comment thread db/rpc.sql
Comment thread db/schema.sql
Comment thread db/test/10_rooms_rounds.pgtap Outdated
Comment thread db/test/30_rpcs.pgtap Outdated
Comment thread docs/LocalDB.md
@flyingrobots

Copy link
Copy Markdown
Owner Author

Added FK constraints for submissions/votes, created nonce index, and updated pgTAP fixtures to seed the requisite rooms/participants so the tests cover the new constraints.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
db/test/30_rpcs.pgtap (3)

24-28: Same schema qualification problem here. Fix it.

-  to_regprocedure('submission_upsert(uuid,uuid,text,jsonb,jsonb,text,text)') IS NOT NULL,
+  to_regprocedure('public.submission_upsert(uuid,uuid,text,jsonb,jsonb,text,text)') IS NOT NULL,

53-56: Qualify the vote RPC too.

-  to_regprocedure('vote_submit(uuid,uuid,uuid,text,jsonb,text)') IS NOT NULL,
+  to_regprocedure('public.vote_submit(uuid,uuid,uuid,text,jsonb,text)') IS NOT NULL,

81-83: Existence checks for round ops are the floor, not the ceiling.

At minimum, assert they “live” without exceptions using pgTAP’s lives_ok instead of a generic DO/pass block. Even better: seed a due round and verify publish/open transitions.

-SELECT ok(to_regprocedure('round_publish_due()') IS NOT NULL, 'round_publish_due exists');
-SELECT ok(to_regprocedure('round_open_next()') IS NOT NULL, 'round_open_next exists');
+SELECT ok(to_regprocedure('public.round_publish_due()') IS NOT NULL, 'round_publish_due exists');
+SELECT ok(to_regprocedure('public.round_open_next()') IS NOT NULL, 'round_open_next exists');

Optionally replace the DO/pass with:

SELECT lives_ok($$ SELECT public.round_publish_due() $$, 'round_publish_due lives');
SELECT lives_ok($$ SELECT public.round_open_next() $$, 'round_open_next lives');

Remember to adjust the plan if you change assertion counts.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bb203e4 and 055d75b.

📒 Files selected for processing (6)
  • db/rpc.sql (1 hunks)
  • db/schema.sql (2 hunks)
  • db/test/10_rooms_rounds.pgtap (2 hunks)
  • db/test/30_rpcs.pgtap (3 hunks)
  • docs/LocalDB.md (1 hunks)
  • server/test/rpc.db.postgres.test.js (2 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
@(server|web|cli)/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

Validate external inputs with Zod at the edges (server/controllers, web handlers/forms, CLI parsing)

Files:

  • server/test/rpc.db.postgres.test.js
**/*.{test,spec}.js

📄 CodeRabbit inference engine (AGENTS.md)

Write minimal Vitest tests for canonical JSON and RPC schema validation

Files:

  • server/test/rpc.db.postgres.test.js
server/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

Do not access process.env directly in server code; use SecretSource and ConfigBuilder

Files:

  • server/test/rpc.db.postgres.test.js
🧠 Learnings (2)
📓 Common learnings
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions
📚 Learning: 2025-09-29T10:17:53.577Z
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions

Applied to files:

  • db/schema.sql
  • db/rpc.sql
  • db/test/30_rpcs.pgtap
  • server/test/rpc.db.postgres.test.js
  • db/test/10_rooms_rounds.pgtap
  • docs/LocalDB.md
🪛 GitHub Actions: ci
server/test/rpc.db.postgres.test.js

[error] 51-51: npx vitest run server/test/rpc.db.postgres.test.js failed. Cannot truncate a table referenced in a foreign key constraint while executing: TRUNCATE submissions, votes RESTART IDENTITY;.

🔇 Additional comments (9)
db/schema.sql (3)

39-39: Index looks right.

idx_participants_room matches the expected access pattern. Good.


44-46: FKs + ON DELETE CASCADE: correct.

Linking submissions/votes to participants (and rooms for votes) with CASCADE is the right call for cleanup and integrity. Keep it.

Also applies to: 60-61, 67-70


27-37: Constrain role. Stop letting garbage in.

participants.role is a free-for-all. Add a CHECK (or enum) so you don’t end up with “rabid_ferret” as a role.

Apply:

 CREATE TABLE IF NOT EXISTS participants (
   id               uuid PRIMARY KEY DEFAULT gen_random_uuid(),
   room_id          uuid        NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
   anon_name        text        NOT NULL,
-  role             text        NOT NULL DEFAULT 'debater',
+  role             text        NOT NULL DEFAULT 'debater' CHECK (role IN ('debater','host','judge')),
   jwt_sub          text,
   ssh_fingerprint  text,
   created_at       timestamptz NOT NULL DEFAULT now(),
   UNIQUE (room_id, anon_name)
 );
server/test/rpc.db.postgres.test.js (1)

36-42: Seeding participants: fine.

Deterministic IDs + roles set. Works with FKs.

db/test/10_rooms_rounds.pgtap (1)

2-2: Your plan is lying. Test the constraints you added.

You added a table but never asserted its unique and index invariants. Bump the plan and add checks.

Apply:

-SELECT plan(7);
+SELECT plan(9);
@@
 -- participants table exists
 SELECT ok(
   EXISTS (
     SELECT 1 FROM information_schema.tables
     WHERE table_schema = 'public' AND table_name = 'participants'
   ),
   'participants table exists'
 );
+
+-- participants unique (room_id, anon_name) exists
+SELECT ok(
+  EXISTS (
+    SELECT 1
+    FROM pg_constraint c
+    JOIN pg_class t ON t.oid = c.conrelid
+    JOIN pg_namespace n ON n.oid = t.relnamespace
+    WHERE n.nspname = 'public'
+      AND t.relname = 'participants'
+      AND c.contype = 'u'
+      AND pg_get_constraintdef(c.oid) ILIKE '%(room_id, anon_name)%'
+  ),
+  'participants unique (room_id, anon_name) exists'
+);
+
+-- idx_participants_room exists
+SELECT ok(
+  EXISTS (
+    SELECT 1
+    FROM pg_indexes
+    WHERE schemaname = 'public'
+      AND tablename = 'participants'
+      AND indexname = 'idx_participants_room'
+  ),
+  'idx_participants_room exists'
+);

Also applies to: 22-29

docs/LocalDB.md (1)

25-34: Document the right damn signature.

You wrote room_create(topic json). The code is room_create(topic text, cfg jsonb DEFAULT '{}'::jsonb). Mismatch confuses everyone.

Apply:

-### Create a Room + Seed Participants
-
-The new `room_create(topic json)` RPC seeds a room, round 0, and a roster of anonymous participants. Example:
+### Create a Room + Seed Participants
+
+The new `room_create(topic text, cfg jsonb DEFAULT '{}'::jsonb)` RPC seeds a room, round 0, and a roster of anonymous participants.
+Defaults: `participant_count=4`, `submit_minutes=5`.
+Examples:
+
+-- simplest
+psql ... -c "select room_create('Demo Topic');"
 

-psql postgresql://postgres:test@localhost:54329/db8 -c "select room_create('Demo Topic', '{"participant_count":4,"submit_minutes":2}'::jsonb);"
+psql postgresql://postgres:test@localhost:54329/db8 -c "select room_create('Demo Topic', '{"participant_count":4,"submit_minutes":2}'::jsonb);"


The function returns the `room_id` you can plug into API calls or the CLI.
db/rpc.sql (1)

3-29: No bounds. No idempotency. No mercy. Fix both.

You accept absurd inputs and you’ll mint duplicate rooms on retries. Validate and make room creation idempotent. Also stop hiding bugs with GREATEST in generate_series.

Apply:

 CREATE OR REPLACE FUNCTION room_create(
-  p_topic text,
-  p_cfg jsonb DEFAULT '{}'::jsonb
+  p_topic text,
+  p_cfg jsonb DEFAULT '{}'::jsonb,
+  p_client_nonce text DEFAULT NULL
 ) RETURNS uuid
 LANGUAGE plpgsql
 AS $$
 DECLARE
   v_room_id uuid;
-  v_participants integer := COALESCE((p_cfg->>'participant_count')::int, 4);
-  v_submit_minutes integer := COALESCE((p_cfg->>'submit_minutes')::int, 5);
-  v_now integer := extract(epoch from now())::int;
-  v_submit_deadline integer := v_now + GREATEST(v_submit_minutes, 1) * 60;
+  v_participants integer := COALESCE((p_cfg->>'participant_count')::int, 4);
+  v_submit_minutes integer := COALESCE((p_cfg->>'submit_minutes')::int, 5);
+  v_now integer := extract(epoch from now())::int;
+  v_submit_deadline integer;
 BEGIN
-  INSERT INTO rooms (title)
-  VALUES (NULLIF(p_topic, ''))
-  RETURNING id INTO v_room_id;
+  -- bounds: participants [1..64], submit_minutes [1..1440]
+  IF v_participants IS NULL OR v_participants < 1 OR v_participants > 64 THEN
+    RAISE EXCEPTION 'participant_count out of range [1..64]: %', v_participants USING ERRCODE = '22023';
+  END IF;
+  IF v_submit_minutes IS NULL OR v_submit_minutes < 1 OR v_submit_minutes > 1440 THEN
+    RAISE EXCEPTION 'submit_minutes out of range [1..1440]: %', v_submit_minutes USING ERRCODE = '22023';
+  END IF;
+  v_submit_deadline := v_now + v_submit_minutes * 60;
+
+  -- idempotent room creation via client nonce
+  INSERT INTO rooms (title, client_nonce)
+  VALUES (NULLIF(p_topic, ''), NULLIF(p_client_nonce, ''))
+  ON CONFLICT (client_nonce)
+  DO UPDATE SET title = COALESCE(rooms.title, EXCLUDED.title)
+  RETURNING id INTO v_room_id;
 
   INSERT INTO rounds (room_id, idx, phase, submit_deadline_unix)
   VALUES (v_room_id, 0, 'submit', v_submit_deadline);
 
   INSERT INTO participants (room_id, anon_name, role)
   SELECT v_room_id, format('agent_%s', gs), 'debater'
-  FROM generate_series(1, GREATEST(v_participants, 1)) AS gs;
+  FROM generate_series(1, v_participants) AS gs;
 
   RETURN v_room_id;
 END;
 $$;

And update schema to support nonce (rooms table):

-- db/schema.sql (rooms)
ALTER TABLE rooms
  ADD COLUMN IF NOT EXISTS client_nonce text UNIQUE;

Update docs/tests to pass p_client_nonce where you need idempotency (e.g., CLI/HTTP clients).

db/test/30_rpcs.pgtap (2)

2-2: Your plan will be wrong the moment you test the real contract. Bump it.

You’re adding assertions (see below). Update the plan accordingly instead of shipping a lie.

Apply once you add the two missing ok() in room_create:

-SELECT plan(10);
+SELECT plan(12);

10-22: You still don’t assert phase/idx or participant role defaults. Finish the job.

We asked for this already. Test the damned contract, not vibes.

Apply this patch to the DO block and bump the plan to 12 as shown earlier:

 DO $$
 DECLARE
   new_room uuid;
   participant_count integer;
   round_deadline integer;
+  round_phase text;
+  round_idx integer;
+  role_count integer;
 BEGIN
   SELECT room_create('Test Room', '{"participant_count":3,"submit_minutes":1}'::jsonb) INTO new_room;
   SELECT count(*) INTO participant_count FROM participants WHERE room_id = new_room;
-  SELECT submit_deadline_unix INTO round_deadline FROM rounds WHERE room_id = new_room AND idx = 0;
+  SELECT submit_deadline_unix, phase, idx
+    INTO round_deadline, round_phase, round_idx
+    FROM rounds WHERE room_id = new_room AND idx = 0;
+  SELECT count(*) INTO role_count FROM participants WHERE room_id = new_room AND role = 'debater';
   PERFORM ok(participant_count = 3, 'room_create seeds configured participants');
   PERFORM ok(round_deadline > extract(epoch from now())::int, 'room_create schedules submit deadline in future');
+  PERFORM ok(round_phase = 'submit' AND round_idx = 0, 'room_create opens round 0 in submit phase');
+  PERFORM ok(role_count = 3, 'participants default role = debater');
 END$$;

Based on learnings.

Comment thread db/test/30_rpcs.pgtap Outdated
Comment thread db/test/30_rpcs.pgtap
Comment thread db/test/30_rpcs.pgtap
Comment thread server/test/rpc.db.postgres.test.js Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 055d75b and 42c828e.

📒 Files selected for processing (6)
  • db/rpc.sql (1 hunks)
  • db/schema.sql (2 hunks)
  • db/test/10_rooms_rounds.pgtap (2 hunks)
  • db/test/30_rpcs.pgtap (3 hunks)
  • docs/LocalDB.md (1 hunks)
  • server/test/rpc.db.postgres.test.js (2 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
@(server|web|cli)/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

Validate external inputs with Zod at the edges (server/controllers, web handlers/forms, CLI parsing)

Files:

  • server/test/rpc.db.postgres.test.js
**/*.{test,spec}.js

📄 CodeRabbit inference engine (AGENTS.md)

Write minimal Vitest tests for canonical JSON and RPC schema validation

Files:

  • server/test/rpc.db.postgres.test.js
server/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

Do not access process.env directly in server code; use SecretSource and ConfigBuilder

Files:

  • server/test/rpc.db.postgres.test.js
🧠 Learnings (2)
📓 Common learnings
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions
📚 Learning: 2025-09-29T10:17:53.577Z
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions

Applied to files:

  • docs/LocalDB.md
  • db/rpc.sql
  • db/schema.sql
  • db/test/10_rooms_rounds.pgtap
  • db/test/30_rpcs.pgtap
  • server/test/rpc.db.postgres.test.js
🔇 Additional comments (9)
db/test/10_rooms_rounds.pgtap (1)

2-29: STOP HAND-WAVING THE INVARIANTS.

Line 2: You “planned” seven tests and still refuse to assert the unique constraint or the participants index you just introduced. We’ve already been over this. Bump the plan, add the checks, and make the tests prove the structure exists. Based on learnings.

-SELECT plan(7);
+SELECT plan(9);

Append after the participants existence block:

-- participants unique (room_id, anon_name) exists
SELECT ok(
  EXISTS (
    SELECT 1
    FROM pg_constraint c
    JOIN pg_class t ON t.oid = c.conrelid
    JOIN pg_namespace n ON n.oid = t.relnamespace
    WHERE n.nspname = 'public'
      AND t.relname = 'participants'
      AND c.contype = 'u'
      AND pg_get_constraintdef(c.oid) ILIKE '%(room_id, anon_name)%'
  ),
  'participants unique (room_id, anon_name) exists'
);

-- idx_participants_room exists
SELECT ok(
  EXISTS (
    SELECT 1
    FROM pg_indexes
    WHERE schemaname = 'public'
      AND tablename = 'participants'
      AND indexname = 'idx_participants_room'
  ),
  'idx_participants_room exists'
);
docs/LocalDB.md (1)

25-33: FIX THE LIE ABOUT room_create’s SIGNATURE.

Line 27: The function takes (topic text, cfg jsonb DEFAULT '{}'::jsonb) but you’re still telling everyone it’s topic json. That misinformation sends users straight into syntax errors. Update the docs to match reality and mention the defaults while you’re there.

-### Create a Room + Seed Participants
-
-The new `room_create(topic json)` RPC seeds a room, round 0, and a roster of anonymous participants. Example:
+### Create a Room + Seed Participants
+
+The new `room_create(topic text, cfg jsonb DEFAULT '{}'::jsonb)` RPC seeds a room, round 0, and a roster of anonymous participants. Defaults: `participant_count=4`, `submit_minutes=5`. Example:
db/schema.sql (1)

32-37: LOCK DOWN participants.role BEFORE IT POISONS THE DB.

Line 32: You’re letting anything slip into participants.role. That’s amateur hour. The rest of the system clearly expects a small set of roles, yet you give callers a blank text field and pray. Enforce the contract with a check (or enum) and document it so junk data can’t leak in. Fix it now.

 CREATE TABLE IF NOT EXISTS participants (
   id               uuid PRIMARY KEY DEFAULT gen_random_uuid(),
   room_id          uuid        NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
   anon_name        text        NOT NULL,
-  role             text        NOT NULL DEFAULT 'debater',
+  role             text        NOT NULL DEFAULT 'debater' CHECK (role IN ('debater','host','judge')),
   jwt_sub          text,
   ssh_fingerprint  text,
   created_at       timestamptz NOT NULL DEFAULT now(),
   UNIQUE (room_id, anon_name)
 );
db/rpc.sql (2)

11-25: VALIDATE YOUR INPUTS BEFORE THEY DDOS THE DATABASE.

Lines 11-25: You blindly coerce participant_count and submit_minutes, slap GREATEST(...,1) on them, and call it a day. A malicious client can ask for 10 million participants or a 200-year deadline and you’ll happily queue that work. Validate the bounds, throw when they’re garbage, and only then compute the deadline and generate_series. Lose the GREATEST hack once you’ve grown up.

DECLARE
   v_room_id uuid;
-  v_participants integer := COALESCE((p_cfg->>'participant_count')::int, 4);
-  v_submit_minutes integer := COALESCE((p_cfg->>'submit_minutes')::int, 5);
-  v_now integer := extract(epoch from now())::int;
-  v_submit_deadline integer := v_now + GREATEST(v_submit_minutes, 1) * 60;
+  v_participants integer := COALESCE((p_cfg->>'participant_count')::int, 4);
+  v_submit_minutes integer := COALESCE((p_cfg->>'submit_minutes')::int, 5);
+  v_now integer := extract(epoch from now())::int;
+  v_submit_deadline integer;
 BEGIN
+  IF v_participants IS NULL OR v_participants < 1 OR v_participants > 64 THEN
+    RAISE EXCEPTION 'participant_count out of range [1..64]: %', v_participants USING ERRCODE = '22023';
+  END IF;
+  IF v_submit_minutes IS NULL OR v_submit_minutes < 1 OR v_submit_minutes > 1440 THEN
+    RAISE EXCEPTION 'submit_minutes out of range [1..1440]: %', v_submit_minutes USING ERRCODE = '22023';
+  END IF;
+  v_submit_deadline := v_now + v_submit_minutes * 60;
 ...
-  FROM generate_series(1, GREATEST(v_participants, 1)) AS gs;
+  FROM generate_series(1, v_participants) AS gs;

16-28: ADD A CLIENT NONCE OR ENJOY DUPLICATE ROOMS ON EVERY RETRY.

Lines 16-28: This RPC is not idempotent. The moment the caller retries (which happens all the time in the real world) you mint another room, another round, another participant roster. You need a client_nonce column on rooms, a UNIQUE constraint, and an extra parameter in this function so retries return the existing room instead of detonating duplicates. Update the schema, the insert, and the call sites accordingly. No excuses.

-CREATE OR REPLACE FUNCTION room_create(
-  p_topic text,
-  p_cfg jsonb DEFAULT '{}'::jsonb
-) RETURNS uuid
+CREATE OR REPLACE FUNCTION room_create(
+  p_topic text,
+  p_cfg jsonb DEFAULT '{}'::jsonb,
+  p_client_nonce text DEFAULT NULL
+) RETURNS uuid
 ...
-  INSERT INTO rooms (title)
-  VALUES (NULLIF(p_topic, ''))
-  RETURNING id INTO v_room_id;
+  INSERT INTO rooms (title, client_nonce)
+  VALUES (NULLIF(p_topic, ''), NULLIF(p_client_nonce, ''))
+  ON CONFLICT (client_nonce) DO UPDATE SET title = COALESCE(rooms.title, EXCLUDED.title)
+  RETURNING id INTO v_room_id;

And don’t forget to add client_nonce text UNIQUE to rooms in db/schema.sql, plus migrations/tests/docs to match.

db/test/30_rpcs.pgtap (4)

6-6: QUALIFY THE DAMN FUNCTION LOOKUP.
If you don’t pin the schema, a stray search_path masks missing RPCs. Fix it.

-  to_regprocedure('room_create(text,jsonb)') IS NOT NULL,
+  to_regprocedure('public.room_create(text,jsonb)') IS NOT NULL,

33-50: FIX submission_upsert TEST TO PROVE IDEMPOTENCE.
Counting on UUID equality alone is lazy. Show that the table holds a single row for a repeated nonce and that a different nonce yields a new row.

 DECLARE
   room uuid := '00000000-0000-0000-0000-0000000000a0';
   round uuid := '00000000-0000-0000-0000-0000000000aa';
   author uuid := '00000000-0000-0000-0000-0000000000bb';
   n text := 'nonce-sub-1';
-  id1 uuid; id2 uuid;
+  n2 text := 'nonce-sub-2';
+  id1 uuid; id2 uuid; id3 uuid;
+  sub_count integer;
 BEGIN
@@
   SELECT submission_upsert(round, author, 'hello', '[]'::jsonb, '[]'::jsonb, 'deadbeef', n) INTO id1;
   SELECT submission_upsert(round, author, 'hello', '[]'::jsonb, '[]'::jsonb, 'deadbeef', n) INTO id2;
   PERFORM ok(id1 = id2, 'submission_upsert idempotent for same nonce');
+  SELECT count(*) INTO sub_count FROM submissions WHERE round_id = round AND author_id = author;
+  PERFORM ok(sub_count = 1, 'submission_upsert inserts one row for repeated nonce');
+  SELECT submission_upsert(round, author, 'hello', '[]'::jsonb, '[]'::jsonb, 'deadbeef', n2) INTO id3;
+  PERFORM ok(id3 <> id1, 'submission_upsert creates a new row for different nonce');
 END$$;

61-77: APPLY THE SAME RIGOR TO vote_submit.
You learned nothing from the submissions block. Prove the nonce uniqueness with counts and a different nonce.

 DECLARE
   v_room uuid := '00000000-0000-0000-0000-0000000000ac';
   v_round uuid := '00000000-0000-0000-0000-0000000000ad';
   voter uuid := '00000000-0000-0000-0000-0000000000ae';
   n text := 'nonce-vote-1';
-  id1 uuid; id2 uuid;
+  n2 text := 'nonce-vote-2';
+  id1 uuid; id2 uuid; id3 uuid;
+  votes_count integer;
 BEGIN
@@
   SELECT vote_submit(v_room, v_round, voter, 'continue', '{}'::jsonb, n) INTO id1;
   SELECT vote_submit(v_room, v_round, voter, 'continue', '{}'::jsonb, n) INTO id2;
   PERFORM ok(id1 = id2, 'vote_submit idempotent for same nonce');
+  SELECT count(*) INTO votes_count FROM votes WHERE round_id = v_round AND voter_id = voter AND kind = 'continue';
+  PERFORM ok(votes_count = 1, 'vote_submit inserts one row for repeated nonce');
+  SELECT vote_submit(v_room, v_round, voter, 'continue', '{}'::jsonb, n2) INTO id3;
+  PERFORM ok(id3 <> id1, 'vote_submit creates a new row for different nonce');
 END$$;

2-21: STOP SHIPPING HALF-TESTED room_create()!
You were already told to prove the round opens in submit phase and that the seeded bodies are debaters. Still missing. Add the proper selects, assert the phase/idx/role, and bump the plan after you stack the other missing checks — this sloppy coverage is unacceptable. Based on learnings.

-SELECT plan(10);
+SELECT plan(16);

 DO $$
 DECLARE
   new_room uuid;
   participant_count integer;
   round_deadline integer;
+  round_phase text;
+  round_idx integer;
+  role_count integer;
 BEGIN
   SELECT room_create('Test Room', '{"participant_count":3,"submit_minutes":1}'::jsonb) INTO new_room;
   SELECT count(*) INTO participant_count FROM participants WHERE room_id = new_room;
-  SELECT submit_deadline_unix INTO round_deadline FROM rounds WHERE room_id = new_room AND idx = 0;
+  SELECT submit_deadline_unix, phase, idx
+    INTO round_deadline, round_phase, round_idx
+    FROM rounds WHERE room_id = new_room AND idx = 0;
+  SELECT count(*) INTO role_count FROM participants WHERE room_id = new_room AND role = 'debater';
   PERFORM ok(participant_count = 3, 'room_create seeds configured participants');
   PERFORM ok(round_deadline > extract(epoch from now())::int, 'room_create schedules submit deadline in future');
+  PERFORM ok(round_phase = 'submit' AND round_idx = 0, 'room_create opens round 0 in submit phase');
+  PERFORM ok(role_count = participant_count, 'participants default role = debater');
 END$$;

Comment thread db/schema.sql Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
db/schema.sql (1)

20-22: 2038 called. It wants your 32‑bit epoch back. Use BIGINT.

Integer epochs will overflow. Make unix timestamps BIGINT now instead of shipping a time bomb.

-  submit_deadline_unix      integer     NOT NULL DEFAULT 0,
-  published_at_unix         integer,
-  continue_vote_close_unix  integer,
+  submit_deadline_unix      bigint      NOT NULL DEFAULT 0,
+  published_at_unix         bigint,
+  continue_vote_close_unix  bigint,
db/test/30_rpcs.pgtap (3)

41-44: Schema‑qualify or use pgTAP helpers. Don’t play search_path roulette.

Qualify submission_upsert with public. Or better, use has_function.

-SELECT ok(
-  to_regprocedure('submission_upsert(uuid,uuid,text,jsonb,jsonb,text,text)') IS NOT NULL,
+SELECT ok(
+  to_regprocedure('public.submission_upsert(uuid,uuid,text,jsonb,jsonb,text,text)') IS NOT NULL,
   'submission_upsert exists'
 );

Or:

SELECT has_function('public', 'submission_upsert', ARRAY['uuid','uuid','text','jsonb','jsonb','text','text']);

75-78: Qualify vote_submit as well. Consistency matters.

Same issue: add public. or switch to has_function.

-  to_regprocedure('vote_submit(uuid,uuid,uuid,text,jsonb,text)') IS NOT NULL,
+  to_regprocedure('public.vote_submit(uuid,uuid,uuid,text,jsonb,text)') IS NOT NULL,

109-110: And the round ops. Finish the job.

Qualify round_publish_due and round_open_next too.

-SELECT ok(to_regprocedure('round_publish_due()') IS NOT NULL, 'round_publish_due exists');
-SELECT ok(to_regprocedure('round_open_next()') IS NOT NULL, 'round_open_next exists');
+SELECT ok(to_regprocedure('public.round_publish_due()') IS NOT NULL, 'round_publish_due exists');
+SELECT ok(to_regprocedure('public.round_open_next()') IS NOT NULL, 'round_open_next exists');
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 42c828e and 5d5a6e9.

📒 Files selected for processing (6)
  • db/rpc.sql (1 hunks)
  • db/schema.sql (3 hunks)
  • db/test/10_rooms_rounds.pgtap (2 hunks)
  • db/test/30_rpcs.pgtap (3 hunks)
  • docs/LocalDB.md (1 hunks)
  • server/test/rpc.db.postgres.test.js (2 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
@(server|web|cli)/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

Validate external inputs with Zod at the edges (server/controllers, web handlers/forms, CLI parsing)

Files:

  • server/test/rpc.db.postgres.test.js
**/*.{test,spec}.js

📄 CodeRabbit inference engine (AGENTS.md)

Write minimal Vitest tests for canonical JSON and RPC schema validation

Files:

  • server/test/rpc.db.postgres.test.js
server/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

Do not access process.env directly in server code; use SecretSource and ConfigBuilder

Files:

  • server/test/rpc.db.postgres.test.js
🧠 Learnings (2)
📓 Common learnings
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions
📚 Learning: 2025-09-29T10:17:53.577Z
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions

Applied to files:

  • db/test/30_rpcs.pgtap
  • db/rpc.sql
  • db/schema.sql
  • docs/LocalDB.md
  • server/test/rpc.db.postgres.test.js
  • db/test/10_rooms_rounds.pgtap
🔇 Additional comments (10)
db/test/30_rpcs.pgtap (1)

22-38: Good. This actually tests the contract.

Solid assertions for participant seeding, phase/idx, and nonce idempotency. Keep it.

server/test/rpc.db.postgres.test.js (1)

36-42: Seeding participants up front is the right move.

This makes the RPC tests deterministic. Good.

db/schema.sql (2)

28-38: Stop letting garbage “roles” leak in. Constrain it.

You added participants but left role as free‑text. That’s how bad data creeps in. Lock it down with a CHECK (or enum).

Apply:

 CREATE TABLE IF NOT EXISTS participants (
   id               uuid PRIMARY KEY DEFAULT gen_random_uuid(),
   room_id          uuid        NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
   anon_name        text        NOT NULL,
-  role             text        NOT NULL DEFAULT 'debater',
+  role             text        NOT NULL DEFAULT 'debater'
+                   CHECK (role IN ('debater','host','judge')),
   jwt_sub          text,
   ssh_fingerprint  text,
   created_at       timestamptz NOT NULL DEFAULT now(),
   UNIQUE (room_id, anon_name)
 );

61-62: Drop the redundant nonce index. You already have it via UNIQUE.

The UNIQUE (round_id, author_id, client_nonce) builds an index. Your extra idx_submissions_nonce is pointless bloat.

-CREATE INDEX IF NOT EXISTS idx_submissions_nonce ON submissions (round_id, author_id, client_nonce);
db/test/10_rooms_rounds.pgtap (1)

2-2: Your plan is undercounting the invariants you should be asserting. Add them and bump the plan.

You added participants but didn’t verify its unique and index. Test the schema, not your wishful thinking.

Apply:

-SELECT plan(7);
+SELECT plan(9);

Append after “participants table exists”:

-- participants unique (room_id, anon_name) exists
SELECT ok(
  EXISTS (
    SELECT 1
    FROM pg_constraint c
    JOIN pg_class t ON t.oid = c.conrelid
    JOIN pg_namespace n ON n.oid = t.relnamespace
    WHERE n.nspname = 'public'
      AND t.relname = 'participants'
      AND c.contype = 'u'
      AND pg_get_constraintdef(c.oid) ILIKE '%(room_id, anon_name)%'
  ),
  'participants unique (room_id, anon_name) exists'
);

-- idx_participants_room exists
SELECT ok(
  EXISTS (
    SELECT 1
    FROM pg_indexes
    WHERE schemaname = 'public'
      AND tablename = 'participants'
      AND indexname = 'idx_participants_room'
  ),
  'idx_participants_room exists'
);
docs/LocalDB.md (1)

25-34: Docs are lying about the RPC signature. Fix it.

You wrote room_create(topic json). The code is room_create(text, jsonb DEFAULT '{}'::jsonb, text DEFAULT NULL). Don’t mislead users.

Apply:

-### Create a Room + Seed Participants
-
-The new `room_create(topic json)` RPC seeds a room, round 0, and a roster of anonymous participants. Example:
+### Create a Room + Seed Participants
+
+The new `room_create(topic text, cfg jsonb DEFAULT '{}'::jsonb, p_client_nonce text DEFAULT NULL)` RPC seeds a room, round 0, and a roster of anonymous participants.
+Defaults: `participant_count=4`, `submit_minutes=5`.
+Examples:
+
+-- simplest
+psql postgresql://postgres:test@localhost:54329/db8 -c "select room_create('Demo Topic');"
 

-psql postgresql://postgres:test@localhost:54329/db8 -c "select room_create('Demo Topic', '{"participant_count":4,"submit_minutes":2}'::jsonb, 'demo-room-nonce');"
+psql postgresql://postgres:test@localhost:54329/db8 -c "select room_create('Demo Topic', '{"participant_count":4,"submit_minutes":2}'::jsonb, 'demo-room-nonce');"


-The function returns the `room_id` you can plug into API calls or the CLI. Repeating the call with the same nonce reuses the existing room.
+The function returns the `room_id` you can plug into API calls or the CLI. Repeating the call with the same nonce reuses the existing room.
db/rpc.sql (4)

12-16: Stop letting clients DOS your DB. Validate inputs and compute deadline after checks.

You’re blindly coercing JSON and then hiding bad values with GREATEST(...). That’s sloppy and dangerous. Enforce sane bounds and fail fast; then remove the GREATEST in generate_series.

Apply this patch:

-  v_participants integer := COALESCE((p_cfg->>'participant_count')::int, 4);
-  v_submit_minutes integer := COALESCE((p_cfg->>'submit_minutes')::int, 5);
+  -- parse with defaults; validate below
+  v_participants integer := COALESCE(NULLIF(p_cfg->>'participant_count','')::int, 4);
+  v_submit_minutes integer := COALESCE(NULLIF(p_cfg->>'submit_minutes','')::int, 5);
   v_now integer := extract(epoch from now())::int;
-  v_submit_deadline integer := v_now + GREATEST(v_submit_minutes, 1) * 60;
+  v_submit_deadline integer;
   v_client_nonce text := NULLIF(p_client_nonce, '');
   v_created boolean := false;
 BEGIN
+  -- hard bounds; fail fast
+  IF v_participants IS NULL OR v_participants < 1 OR v_participants > 64 THEN
+    RAISE EXCEPTION 'participant_count out of range [1..64]: %', v_participants USING ERRCODE = '22023';
+  END IF;
+  IF v_submit_minutes IS NULL OR v_submit_minutes < 1 OR v_submit_minutes > 1440 THEN
+    RAISE EXCEPTION 'submit_minutes out of range [1..1440]: %', v_submit_minutes USING ERRCODE = '22023';
+  END IF;
+  v_submit_deadline := v_now + v_submit_minutes * 60;
@@
-    FROM generate_series(1, GREATEST(v_participants, 1)) AS gs
+    FROM generate_series(1, v_participants) AS gs

Also consider guarding casts to int with a regex if you want to default instead of erroring on non‑numeric strings. Based on learnings.

Also applies to: 41-41


19-27: Your “idempotency” is racy. Two retries and you 500. Use ON CONFLICT or the two-step safe pattern.

Pre‑selecting by nonce and then inserting is a TOCTOU bug. Concurrent calls with the same nonce will collide on the UNIQUE constraint and error, instead of cleanly returning the same room_id. Fix it.

Apply this race‑free pattern:

-  IF v_client_nonce IS NOT NULL THEN
-    SELECT id INTO v_room_id FROM rooms WHERE client_nonce = v_client_nonce;
-  END IF;
-
-  IF v_room_id IS NULL THEN
-    INSERT INTO rooms (title, client_nonce)
-    VALUES (NULLIF(p_topic, ''), v_client_nonce)
-    RETURNING id INTO v_room_id;
-    v_created := true;
-  ELSE
-    UPDATE rooms
-       SET title = COALESCE(title, NULLIF(p_topic, ''))
-     WHERE id = v_room_id;
-  END IF;
+  -- Try insert; if conflict on nonce, fall back to select. This is idempotent and race-safe.
+  v_room_id := NULL;
+  INSERT INTO rooms (title, client_nonce)
+  VALUES (NULLIF(p_topic, ''), v_client_nonce)
+  ON CONFLICT (client_nonce) DO NOTHING
+  RETURNING id INTO v_room_id;
+
+  IF v_room_id IS NULL AND v_client_nonce IS NOT NULL THEN
+    SELECT id INTO v_room_id FROM rooms WHERE client_nonce = v_client_nonce;
+  ELSE
+    v_created := true;
+  END IF;
+
+  UPDATE rooms
+     SET title = COALESCE(title, NULLIF(p_topic, ''))
+   WHERE id = v_room_id;

This delivers true idempotency under retries. If you insist on a single statement, use ON CONFLICT ... DO UPDATE RETURNING, but then you’ll need a reliable way to detect “inserted vs conflicted” if you want to gate seeding. Based on learnings.


41-41: Kill the GREATEST(...) in generate_series. It masks bugs.

After proper bounds checks, use the validated value directly. Keeping GREATEST here hides upstream errors.

-    FROM generate_series(1, GREATEST(v_participants, 1)) AS gs
+    FROM generate_series(1, v_participants) AS gs

34-43: Seeding guard confirmed: v_created only set on initial insert
v_created is assigned true only in the INSERT branch and remains false on retries (SELECT/UPDATE path), so rounds/participants won’t be re-seeded.

Comment thread db/rpc.sql
Comment thread db/rpc.sql Outdated
Comment on lines +12 to +16
v_participants integer := COALESCE((p_cfg->>'participant_count')::int, 4);
v_submit_minutes integer := COALESCE((p_cfg->>'submit_minutes')::int, 5);
v_now integer := extract(epoch from now())::int;
v_submit_deadline integer := v_now + GREATEST(v_submit_minutes, 1) * 60;
v_client_nonce text := NULLIF(p_client_nonce, '');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Non-numeric JSON will blow up your casts. Decide: strict error vs graceful default.

(p_cfg->>'…')::int throws on “3min” or "04a". If that’s not desired, guard with a regex and default. If strict is desired, add explicit ERROR CODE and message so clients get a sane 400-class mapping.

I can patch to:

v_participants := COALESCE(CASE WHEN (p_cfg->>'participant_count') ~ '^\d+$' THEN (p_cfg->>'participant_count')::int END, 4);

Confirm preference.

🤖 Prompt for AI Agents
In db/rpc.sql around lines 12–16, the JSON-to-int casts will raise on
non-numeric strings; switch to a guarded parse with a regex fallback so
malformed values gracefully default (participants -> 4, submit_minutes -> 5) and
keep v_now/v_submit_deadline logic unchanged. Replace each (p_cfg->>'...')::int
with a CASE/WHEN that checks the value matches '^\d+$' before casting, then
COALESCE to the default; keep v_client_nonce NULLIF behavior. Ensure both
participant_count and submit_minutes use the same guarded pattern.

Comment thread db/schema.sql
Comment on lines +11 to 12
client_nonce text UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Decide your nonce semantics and make the DDL match.

UNIQUE on client_nonce allows multiple NULLs. If room_create defaults p_client_nonce to NULL (docs say it can), you’ll happily create duplicate rooms. Either:

  • Enforce NOT NULL and make callers pass a nonce, or
  • Keep NULLs allowed but make uniqueness explicit with a partial unique index.

If you want “unique when present,” change to:

-  client_nonce  text UNIQUE,
+  client_nonce  text,

and add after the table:

CREATE UNIQUE INDEX IF NOT EXISTS uniq_rooms_client_nonce
  ON rooms (client_nonce)
  WHERE client_nonce IS NOT NULL;

If you want strict idempotency always, mark client_nonce NOT NULL and keep the UNIQUE.

🤖 Prompt for AI Agents
In db/schema.sql around lines 11-12, the client_nonce column is declared UNIQUE
but allows NULLs so multiple rows can have NULL and break idempotency; decide
desired semantics and update the DDL accordingly: if you want uniqueness only
when a nonce is provided, keep the column nullable and add a partial unique
index on client_nonce WHERE client_nonce IS NOT NULL; if you want strict
idempotency, change the column to NOT NULL (and leave UNIQUE) and ensure callers
always supply a nonce.

Comment thread server/test/rpc.db.postgres.test.js Outdated
@flyingrobots flyingrobots changed the title feat(db): add room_create rpc feat(db): add room_create rpc and participants table Sep 29, 2025
@flyingrobots

Copy link
Copy Markdown
Owner Author

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)

db/schema.sql (2)> 41-58: Submissions without FKs? Enjoy your orphaned garbage. Add the constraints.

You reference rounds/participants by UUID with zero enforcement. That’s sloppy.
Patch:

 CREATE TABLE IF NOT EXISTS submissions (
   id                  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-  round_id            uuid        NOT NULL,
-  author_id           uuid        NOT NULL,
+  round_id            uuid        NOT NULL REFERENCES rounds(id) ON DELETE CASCADE,
+  author_id           uuid        NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
   content             text        NOT NULL,
   ...
   UNIQUE (round_id, author_id, client_nonce)
 );

Add a covering index for the idempotency key:

CREATE INDEX IF NOT EXISTS idx_submissions_nonce ON submissions (round_id, author_id, client_nonce);

62-76: Same problem in votes. Tie it to reality with FKs or this schema will rot.
Room/round/voter must be coherent. At minimum, enforce round_id and voter_id.
Patch:

 CREATE TABLE IF NOT EXISTS votes (
   id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-  room_id       uuid        NOT NULL,
-  round_id      uuid        NOT NULL,
-  voter_id      uuid        NOT NULL,
+  room_id       uuid        NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
+  round_id      uuid        NOT NULL REFERENCES rounds(id) ON DELETE CASCADE,
+  voter_id      uuid        NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
   kind          text        NOT NULL, -- e.g., 'continue'
   ballot        jsonb       NOT NULL DEFAULT '{}'::jsonb,
   client_nonce  text        NOT NULL,
   created_at    timestamptz NOT NULL DEFAULT now(),
   UNIQUE (round_id, voter_id, kind, client_nonce)
 );

Optionally enforce (room_id, round_id) coherence with a trigger or drop room_id from votes and derive via joins.

📜 Review details
Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits
📒 Files selected for processing (5)
🧰 Additional context used

Added the FK constraints and nonce index (see commit c34e2b0); integration tests now seed participants/
rounds so the DB path is exercised.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
db/rpc.sql (3)

97-109: Make vote_submit one statement; stop round-tripping.

INSERT … ON CONFLICT DO NOTHING + SELECT is clumsy. Return the id directly via DO UPDATE RETURNING.

-  INSERT INTO votes (room_id, round_id, voter_id, kind, ballot, client_nonce)
-  VALUES (p_room_id, p_round_id, p_voter_id, p_kind, p_ballot, p_client_nonce)
-  ON CONFLICT (round_id, voter_id, kind, client_nonce)
-  DO NOTHING
-  RETURNING id INTO v_id;
-  IF v_id IS NULL THEN
-    SELECT id INTO v_id FROM votes
-    WHERE round_id = p_round_id AND voter_id = p_voter_id AND kind = p_kind AND client_nonce = p_client_nonce
-    LIMIT 1;
-  END IF;
+  INSERT INTO votes (room_id, round_id, voter_id, kind, ballot, client_nonce)
+  VALUES (p_room_id, p_round_id, p_voter_id, p_kind, p_ballot, p_client_nonce)
+  ON CONFLICT (round_id, voter_id, kind, client_nonce)
+  DO UPDATE SET ballot = votes.ballot
+  RETURNING id INTO v_id;

Also, validate p_kind against the allowed set (CHECK or enum). Add a test.


131-153: Rounds get stuck forever when there are zero votes. Your SUMs return NULL. Fix it.

With no votes, yes/no are NULL; all your comparisons short-circuit and nothing advances/finalizes. COALESCE to 0 and simplify.

   ), tallied AS (
     SELECT d.room_id, d.id as round_id,
-           SUM(CASE WHEN v.kind='continue' AND (v.ballot->>'choice')='continue' THEN 1 ELSE 0 END) AS yes,
-           SUM(CASE WHEN v.kind='continue' AND (v.ballot->>'choice')='end' THEN 1 ELSE 0 END) AS no
+           COALESCE(SUM(CASE WHEN v.kind='continue' AND (v.ballot->>'choice')='continue' THEN 1 ELSE 0 END), 0) AS yes,
+           COALESCE(SUM(CASE WHEN v.kind='continue' AND (v.ballot->>'choice')='end' THEN 1 ELSE 0 END), 0) AS no
     FROM due d
     LEFT JOIN votes v ON v.round_id = d.id
     GROUP BY d.room_id, d.id
   ), winners AS (
     SELECT t.*, r.idx FROM tallied t JOIN rounds r ON r.id = t.round_id
   )
-  UPDATE rounds r SET phase = CASE WHEN w.yes > w.no THEN r.phase ELSE 'final' END
-  FROM winners w
-  WHERE r.id = w.round_id AND w.yes <= w.no;
+  UPDATE rounds r
+  SET phase = 'final'
+  FROM winners w
+  WHERE r.id = w.round_id AND w.yes <= w.no;
@@
-  WHERE w.yes > w.no
+  WHERE w.yes > w.no

Add pgTAP to assert a published round with zero votes becomes final. Do it. Yesterday.


163-169: Your tally view also leaks NULLs. Normalize it.

Expose zeros, not NULL, or clients will branch on nonsense.

-  SELECT r.room_id, r.id as round_id,
-    SUM(CASE WHEN v.kind='continue' AND (v.ballot->>'choice')='continue' THEN 1 ELSE 0 END) AS yes,
-    SUM(CASE WHEN v.kind='continue' AND (v.ballot->>'choice')='end' THEN 1 ELSE 0 END) AS no
+  SELECT r.room_id, r.id as round_id,
+    COALESCE(SUM(CASE WHEN v.kind='continue' AND (v.ballot->>'choice')='continue' THEN 1 ELSE 0 END), 0) AS yes,
+    COALESCE(SUM(CASE WHEN v.kind='continue' AND (v.ballot->>'choice')='end' THEN 1 ELSE 0 END), 0) AS no
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5d5a6e9 and c34e2b0.

⛔ Files ignored due to path filters (1)
  • AGENTS.md is excluded by !*.md
📒 Files selected for processing (2)
  • db/rpc.sql (1 hunks)
  • db/test/30_rpcs.pgtap (3 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions
📚 Learning: 2025-09-29T10:17:53.577Z
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions

Applied to files:

  • db/test/30_rpcs.pgtap
  • db/rpc.sql
🔇 Additional comments (7)
db/rpc.sql (5)

19-27: Bounds check: good. Now test the edges.

The [1..64] and [1..1440] checks are sane. Add tests for exactly 1, 64, 1440 to prevent future “off-by-one optimizations.”


31-44: Stop the SELECT-then-INSERT nonsense—this is racy under concurrency. Use an upsert.

Two clients with the same nonce can both miss the SELECT and race the INSERT. If you truly have a UNIQUE on rooms.client_nonce, this blows up with an unhandled exception; if you don’t, you mint dup rooms. Fix it with a single INSERT … ON CONFLICT … RETURNING and make seeding idempotent (it already is).

Apply this diff:

-  IF v_client_nonce IS NOT NULL THEN
-    SELECT id INTO v_room_id FROM rooms WHERE client_nonce = v_client_nonce;
-  END IF;
-
-  IF v_room_id IS NULL THEN
-    INSERT INTO rooms (title, client_nonce)
-    VALUES (NULLIF(p_topic, ''), v_client_nonce)
-    RETURNING id INTO v_room_id;
-    v_created := true;
-  ELSE
-    UPDATE rooms
-       SET title = COALESCE(title, NULLIF(p_topic, ''))
-     WHERE id = v_room_id;
-  END IF;
+  INSERT INTO rooms (title, client_nonce)
+  VALUES (NULLIF(p_topic, ''), v_client_nonce)
+  ON CONFLICT (client_nonce)
+  DO UPDATE SET title = COALESCE(rooms.title, EXCLUDED.title)
+  RETURNING id INTO v_room_id;
+  -- Seeding below is safe to run every time because of ON CONFLICT DO NOTHING.
+  v_created := true;

And drop the conditional around seeding (see next hunk).


12-16: Casting JSON with ::int will explode on garbage. Pick a policy (strict vs graceful) and implement it.

Right now “3min” blows up with a generic error, not your range checks. Either validate and raise a clear 22023, or guard-parse and default. Do it consistently for both fields.

Guarded-parse option:

-  v_participants integer := COALESCE((p_cfg->>'participant_count')::int, 4);
-  v_submit_minutes integer := COALESCE((p_cfg->>'submit_minutes')::int, 5);
+  v_participants integer := COALESCE(
+    CASE WHEN (p_cfg->>'participant_count') ~ '^\d+$' THEN (p_cfg->>'participant_count')::int END, 4);
+  v_submit_minutes integer := COALESCE(
+    CASE WHEN (p_cfg->>'submit_minutes') ~ '^\d+$' THEN (p_cfg->>'submit_minutes')::int END, 5);

Strict-with-message option:

IF p_cfg ? 'participant_count' AND NOT (p_cfg->>'participant_count') ~ '^\d+$' THEN
  RAISE EXCEPTION 'participant_count must be an integer string' USING ERRCODE='22023';
END IF;

Which behavior do you want? Add pgTAP to lock it down.


3-7: Document the damn contract inline.

State defaults and bounds for participant_count [1..64], submit_minutes [1..1440], and the idempotency nonce semantics. Keep callers and tests honest.


31-38: WHAT THE HELL – client_nonce is already UNIQUE in the schema
The client_nonce column in db/schema.sql is declared text UNIQUE (line 11), so idempotency is already enforced.

Likely an incorrect or invalid review comment.

db/test/30_rpcs.pgtap (2)

52-56: Stop relying on search_path roulette. Qualify your functions.

Qualify all to_regprocedure() checks with public. You fixed one and forgot the rest.

-SELECT ok(
-  to_regprocedure('submission_upsert(uuid,uuid,text,jsonb,jsonb,text,text)') IS NOT NULL,
+SELECT ok(
+  to_regprocedure('public.submission_upsert(uuid,uuid,text,jsonb,jsonb,text,text)') IS NOT NULL,
@@
-SELECT ok(
-  to_regprocedure('vote_submit(uuid,uuid,uuid,text,jsonb,text)') IS NOT NULL,
+SELECT ok(
+  to_regprocedure('public.vote_submit(uuid,uuid,uuid,text,jsonb,text)') IS NOT NULL,
@@
-SELECT ok(to_regprocedure('round_publish_due()') IS NOT NULL, 'round_publish_due exists');
-SELECT ok(to_regprocedure('round_open_next()') IS NOT NULL, 'round_open_next exists');
+SELECT ok(to_regprocedure('public.round_publish_due()') IS NOT NULL, 'round_publish_due exists');
+SELECT ok(to_regprocedure('public.round_open_next()') IS NOT NULL, 'round_open_next exists');

Also applies to: 86-90, 121-122


124-129: Running test coverage search… results incoming.

Comment thread db/rpc.sql Outdated
Comment thread db/test/30_rpcs.pgtap Outdated
Comment thread db/test/30_rpcs.pgtap
Comment thread db/test/30_rpcs.pgtap
Comment thread db/test/30_rpcs.pgtap

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
db/test/30_rpcs.pgtap (1)

63-66: Stop playing search_path roulette. Use pgTAP has_function and qualify schema.

Qualify all RPC existence checks (room_create already is; the rest aren’t).

-SELECT ok(
-  to_regprocedure('submission_upsert(uuid,uuid,text,jsonb,jsonb,text,text)') IS NOT NULL,
-  'submission_upsert exists'
-);
+SELECT has_function('public','submission_upsert', ARRAY['uuid','uuid','text','jsonb','jsonb','text','text']);

-SELECT ok(
-  to_regprocedure('vote_submit(uuid,uuid,uuid,text,jsonb,text)') IS NOT NULL,
-  'vote_submit exists'
-);
+SELECT has_function('public','vote_submit', ARRAY['uuid','uuid','uuid','text','jsonb','text']);

-SELECT ok(to_regprocedure('round_publish_due()') IS NOT NULL, 'round_publish_due exists');
-SELECT ok(to_regprocedure('round_open_next()') IS NOT NULL, 'round_open_next exists');
+SELECT has_function('public','round_publish_due', ARRAY[]::text[]);
+SELECT has_function('public','round_open_next', ARRAY[]::text[]);

Based on learnings.

Also applies to: 97-100, 131-132

db/schema.sql (4)

66-76: Enforce (room_id, round_id) coherence in votes or drop room_id.

Right now nothing stops votes.room_id disagreeing with rounds.room_id. Add a composite FK.

 CREATE TABLE IF NOT EXISTS votes (
   id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-  room_id       uuid        NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
+  room_id       uuid        NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
   round_id      uuid        NOT NULL REFERENCES rounds(id) ON DELETE CASCADE,
   voter_id      uuid        NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
@@
   UNIQUE (round_id, voter_id, kind, client_nonce)
 );
 
+-- Ensure room/round coherence for votes
+CREATE UNIQUE INDEX IF NOT EXISTS uniq_rounds_room_id_id ON rounds (room_id, id);
+ALTER TABLE votes
+  ADD CONSTRAINT fk_votes_room_round
+  FOREIGN KEY (room_id, round_id) REFERENCES rounds(room_id, id)
+  ON DELETE CASCADE;

If you don’t want the redundancy, drop votes.room_id instead. Pick one and be consistent.

Also applies to: 78-78


19-23: 2038 time-bomb: stop storing epoch seconds in int4.

Use bigint or, better, timestamptz. Don’t be that team.

-  submit_deadline_unix      integer     NOT NULL DEFAULT 0,
-  published_at_unix         integer,
-  continue_vote_close_unix  integer,
+  submit_deadline_unix      bigint      NOT NULL DEFAULT 0,
+  published_at_unix         bigint,
+  continue_vote_close_unix  bigint,

Or replace these with proper timestamptz columns and drop the “_unix” misfeature.


50-58: canonical_sha256 should be exactly 64 hex chars, not “some text”.

Tighten the type/constraint.

-  canonical_sha256    text        NOT NULL,
+  canonical_sha256    char(64)    NOT NULL CHECK (canonical_sha256 ~ '^[0-9a-f]{64}$'),

71-76: Lock down vote kinds. Free‑form text invites garbage.

Add a CHECK or an enum.

-  kind          text        NOT NULL, -- e.g., 'continue'
+  kind          text        NOT NULL CHECK (kind IN ('continue')),

Expand the set as you add flows.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c34e2b0 and 01c8ec4.

📒 Files selected for processing (2)
  • db/schema.sql (3 hunks)
  • db/test/30_rpcs.pgtap (3 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions
📚 Learning: 2025-09-29T10:17:53.577Z
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions

Applied to files:

  • db/schema.sql
  • db/test/30_rpcs.pgtap
🔇 Additional comments (6)
db/schema.sql (2)

61-62: Drop the duplicate nonce index. PostgreSQL already built it for the UNIQUE.

You are doubling write amplification for nothing. Kill it.

-CREATE INDEX IF NOT EXISTS idx_submissions_nonce ON submissions (round_id, author_id, client_nonce);

11-11: Nullable UNIQUE nonce = idempotency hole. Decide and fix it.

Either require a nonce (NOT NULL) or make uniqueness “when present” via partial index. Pick one and stop the footgun.

--- Option A: strict idempotency
-  client_nonce  text UNIQUE,
+  client_nonce  text UNIQUE NOT NULL,

--- Option B: unique-when-present
-  client_nonce  text UNIQUE,
+  client_nonce  text,
+);
+CREATE UNIQUE INDEX IF NOT EXISTS uniq_rooms_client_nonce
+  ON rooms (client_nonce) WHERE client_nonce IS NOT NULL;
db/test/30_rpcs.pgtap (4)

40-50: Test the edges, not just the failures.

Add acceptance tests for min/max bounds.

 SELECT throws_ok(
@@
   'room_create rejects submit_minutes above range'
 );
+
+-- Accepts min/max bounds
+SELECT ok(room_create('Edge Low',  '{"participant_count":1,"submit_minutes":1}'::jsonb,   'nonce-room-edge-1') IS NOT NULL, 'accepts min bounds');
+SELECT ok(room_create('Edge High', '{"participant_count":64,"submit_minutes":1440}'::jsonb,'nonce-room-edge-2') IS NOT NULL, 'accepts max bounds');

Based on learnings.


102-128: vote_submit: Count both nonces. Prove two rows exist.

You stop at “different id”. Count to 2.

   SELECT vote_submit(v_room, v_round, voter, 'continue', '{}'::jsonb, n2) INTO id3;
   PERFORM ok(id3 <> id1, 'vote_submit creates new row for different nonce');
+  PERFORM ok(
+    (SELECT count(*) FROM votes WHERE round_id = v_round AND voter_id = voter AND kind='continue') = 2,
+    'vote_submit stores two rows for two different nonces'
+  );

Based on learnings.


69-94: submission_upsert: Assert the ON CONFLICT actually updates.

Idempotency is not enough; prove the DO UPDATE sets canonical.

   SELECT submission_upsert(round, author, 'hello', '[]'::jsonb, '[]'::jsonb, 'deadbeef', n) INTO id1;
   SELECT submission_upsert(round, author, 'hello', '[]'::jsonb, '[]'::jsonb, 'deadbeef', n) INTO id2;
   PERFORM ok(id1 = id2, 'submission_upsert idempotent for same nonce');
+  -- Change canonical and ensure the row updates on same nonce
+  PERFORM submission_upsert(round, author, 'hello', '[]'::jsonb, '[]'::jsonb, 'cafebabe', n);
+  PERFORM ok((SELECT canonical_sha256 FROM submissions WHERE id = id1) = 'cafebabe',
+             'submission_upsert updates canonical on conflict');

Based on learnings.


22-38: Idempotency isn’t “same id”. Prove “no duplicate seeds” for participants and rounds.

Right now you could silently double-seed. Count before/after with same nonce.

 DECLARE
   new_room uuid;
   participant_count integer;
+  participant_count_after integer;
   round_deadline integer;
   round_phase text;
   round_idx integer;
   role_count integer;
   same_room uuid;
   new_room2 uuid;
+  round_count integer;
+  round_count_after integer;
 BEGIN
   SELECT room_create('Test Room', '{"participant_count":3,"submit_minutes":1}'::jsonb, 'nonce-room-1') INTO new_room;
   SELECT count(*) INTO participant_count FROM participants WHERE room_id = new_room;
+  SELECT count(*) INTO round_count FROM rounds WHERE room_id = new_room;
@@
   SELECT room_create('Updated Title', '{"participant_count":5}'::jsonb, 'nonce-room-1') INTO same_room;
   PERFORM ok(same_room = new_room, 'room_create reuses room id for same nonce');
+  SELECT count(*) INTO participant_count_after FROM participants WHERE room_id = new_room;
+  PERFORM ok(participant_count_after = participant_count, 'room_create does not duplicate participants on retry');
+  SELECT count(*) INTO round_count_after FROM rounds WHERE room_id = new_room;
+  PERFORM ok(round_count_after = round_count, 'room_create does not create extra rounds on retry');

Based on learnings.

Comment thread db/schema.sql
@@ -24,11 +25,25 @@ CREATE TABLE IF NOT EXISTS rounds (

CREATE INDEX IF NOT EXISTS idx_rounds_room_idx ON rounds (room_id, idx DESC);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Redundant rounds index. Drop it.

UNIQUE(room_id, idx) already gives you a btree usable in descending scans; the extra DESC index is waste.

-CREATE INDEX IF NOT EXISTS idx_rounds_room_idx ON rounds (room_id, idx DESC);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
CREATE INDEX IF NOT EXISTS idx_rounds_room_idx ON rounds (room_id, idx DESC);
🤖 Prompt for AI Agents
In db/schema.sql around line 26, remove the redundant index creation "CREATE
INDEX IF NOT EXISTS idx_rounds_room_idx ON rounds (room_id, idx DESC);" because
the existing UNIQUE(room_id, idx) already provides a btree that supports
descending scans; simply delete this CREATE INDEX statement to avoid wasted
storage and maintenance overhead.

Comment thread db/schema.sql Outdated
Comment thread db/test/30_rpcs.pgtap Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
db/schema.sql (2)

20-23: *Use bigint for _unix columns or enjoy overflow later.

Epoch seconds won’t fit in int4 long‑term. Change rounds.*_unix to bigint.

-  submit_deadline_unix      integer     NOT NULL DEFAULT 0,
-  published_at_unix         integer,
-  continue_vote_close_unix  integer,
+  submit_deadline_unix      bigint      NOT NULL DEFAULT 0,
+  published_at_unix         bigint,
+  continue_vote_close_unix  bigint,

66-76: Normalize votes: remove denormalized room_id or enforce a composite FK.

Right now the schema allows mismatched (room_id, round_id). Either:

  • Remove room_id from votes, or
  • Add UNIQUE on rounds(room_id, id) and a FK votes(room_id, round_id) → rounds(room_id, id).

Prefer removal to avoid duplication and bugs.

db/rpc.sql (1)

89-113: Prevent mismatched room_id and round_id in votes

votes currently allows inserting a vote with a room_id that doesn’t match the referenced round’s room. Choices:

  • Drop votes.room_id and derive it via JOIN on rounds.
  • Add a composite FK (room_id, round_id) → rounds(room_id, id) (with a UNIQUE constraint on rounds(room_id, id)).
    I recommend dropping votes.room_id and simplifying vote_submit accordingly.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 01c8ec4 and 8ab3b00.

📒 Files selected for processing (4)
  • db/rpc.sql (1 hunks)
  • db/schema.sql (3 hunks)
  • db/test/10_rooms_rounds.pgtap (2 hunks)
  • docs/LocalDB.md (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions
📚 Learning: 2025-09-29T10:17:53.577Z
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions

Applied to files:

  • db/rpc.sql
  • docs/LocalDB.md
  • db/test/10_rooms_rounds.pgtap
  • db/schema.sql
🔇 Additional comments (4)
db/schema.sql (3)

61-61: Drop this duplicate submissions index; the UNIQUE covers the prefix.

Btree on (round_id, author_id, client_nonce) serves (round_id, author_id) lookups.

-CREATE INDEX IF NOT EXISTS idx_submissions_round_author ON submissions (round_id, author_id);

26-26: Drop the redundant rounds index. UNIQUE(room_id, idx) already covers it.

It wastes write I/O and storage.

-CREATE INDEX IF NOT EXISTS idx_rounds_room_idx ON rounds (room_id, idx DESC);

11-11: Optional: make nonce semantics explicit and shrink the index.

If NULL nonces are allowed (docs say optional), use a partial UNIQUE to exclude NULLs and reduce bloat.

-  client_nonce  text UNIQUE,
+  client_nonce  text,

Then:

CREATE UNIQUE INDEX IF NOT EXISTS uniq_rooms_client_nonce
  ON rooms (client_nonce)
  WHERE client_nonce IS NOT NULL;
db/rpc.sql (1)

16-17: Casting untrusted JSON with ::int will throw opaque 22P02. Decide strict vs forgiving and document it.

Either guard with a numeric regex and default, or raise 22023 with a clear message to map to a 4xx. Don’t leave clients guessing.

Comment thread db/rpc.sql Outdated
Comment thread db/rpc.sql Outdated
Comment thread db/test/10_rooms_rounds.pgtap Outdated
Comment thread docs/LocalDB.md Outdated
@flyingrobots
flyingrobots merged commit db566b8 into main Sep 29, 2025
2 of 3 checks passed
@flyingrobots
flyingrobots deleted the feat/room-create branch September 29, 2025 16:09

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
docs/Features.md (1)

13-20: Pick a name and stick to it. You document anon_1..anon_5, but the code seeds agent_1..N.

Align docs to the implementation or, better, fix the implementation to anon_* and keep the docs. Right now you’ll confuse every client and test.

docs/Architecture.md (1)

709-745: Your sample server code doesn’t enforce Zod at the edges for votes and still accesses RPCs loosely.

Per coding guidelines, validate inputs with Zod and pass client_nonce. Show the vote.continue route calling vote_submit with the 5‑arg signature. Fix the example or it will mislead implementers.

Also applies to: 747-753

server/test/rpc.db.integration.test.js (1)

27-34: Stop smuggling room_id — enforce the 5‑arg vote_submit contract.

Remove room_id from all /rpc/vote.continue request bodies in tests and assert the DB call uses exactly 5 params (round_id, voter_id, kind, ballot, client_nonce).

Files to update: server/test/rpc.db.integration.test.js, server/test/rpc.db.postgres.test.js, server/test/rpc.vote_continue.test.js, server/test/watcher.transitions.test.js, server/test/state.enrichment.test.js

Add assertion in FakePool checks:
expect(voteCalls.at(-1).params.length).toBe(5); // (round_id, voter_id, kind, ballot, client_nonce)

Add a negative test that missing client_nonce is rejected once the DB enforces it.

db/schema.sql (1)

45-58: Hard integrity hole: submissions can cross-contaminate rooms. Fix it with a constraint trigger.

You FK to rounds and participants independently. Nothing stops author_id from room A submitting into a round in room B. That’s garbage data.

Add a DEFERRABLE constraint trigger to enforce (participants.room_id == rounds.room_id):

CREATE OR REPLACE FUNCTION check_submission_room_coherence()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
  p_room uuid;
  r_room uuid;
BEGIN
  SELECT room_id INTO p_room FROM participants WHERE id = NEW.author_id;
  SELECT room_id INTO r_room FROM rounds WHERE id = NEW.round_id;
  IF p_room IS NULL OR r_room IS NULL OR p_room <> r_room THEN
    RAISE EXCEPTION 'submissions(author_id %, round_id %) violate room coherence (% vs %)', NEW.author_id, NEW.round_id, p_room, r_room
      USING ERRCODE = '23514';
  END IF;
  RETURN NEW;
END $$;

DROP TRIGGER IF EXISTS trg_submissions_room_coherence ON submissions;
CREATE CONSTRAINT TRIGGER trg_submissions_room_coherence
AFTER INSERT OR UPDATE OF round_id, author_id ON submissions
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_submission_room_coherence();

While you’re here: stop using char(64) for hashes. It’s slow and pads spaces.

-  canonical_sha256    char(64)    NOT NULL CHECK (canonical_sha256 ~ '^[0-9a-f]{64}$'),
+  canonical_sha256    text        NOT NULL CHECK (char_length(canonical_sha256) = 64 AND canonical_sha256 ~ '^[0-9a-f]{64}$'),

And couple signature fields so half‑filled signatures don’t creep in:

ALTER TABLE submissions
  ADD CONSTRAINT chk_signature_fields
  CHECK (
    (signature_kind IS NULL AND signature_b64 IS NULL AND signer_fingerprint IS NULL)
    OR (signature_kind IS NOT NULL AND signature_b64 IS NOT NULL)
  );

Finally, define allowed status values or you’ll get a zoo. Add a CHECK once you finalize the enum.

I can add pgTAP tests that prove the cross-room insert/update fails and signature coupling works. Say the word.

Also applies to: 52-53

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8ab3b00 and 2911169.

⛔ Files ignored due to path filters (1)
  • AGENTS.md is excluded by !*.md
📒 Files selected for processing (11)
  • db/rpc.sql (4 hunks)
  • db/schema.sql (3 hunks)
  • db/test/10_rooms_rounds.pgtap (3 hunks)
  • db/test/20_submissions_votes.pgtap (1 hunks)
  • db/test/30_rpcs.pgtap (1 hunks)
  • docs/Architecture.md (24 hunks)
  • docs/Features.md (6 hunks)
  • docs/LocalDB.md (1 hunks)
  • server/rpc.js (1 hunks)
  • server/test/rpc.db.integration.test.js (1 hunks)
  • server/test/rpc.db.postgres.test.js (2 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
@(server|web|cli)/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

Validate external inputs with Zod at the edges (server/controllers, web handlers/forms, CLI parsing)

Files:

  • server/test/rpc.db.postgres.test.js
  • server/test/rpc.db.integration.test.js
  • server/rpc.js
**/*.{test,spec}.js

📄 CodeRabbit inference engine (AGENTS.md)

Write minimal Vitest tests for canonical JSON and RPC schema validation

Files:

  • server/test/rpc.db.postgres.test.js
  • server/test/rpc.db.integration.test.js
server/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

Do not access process.env directly in server code; use SecretSource and ConfigBuilder

Files:

  • server/test/rpc.db.postgres.test.js
  • server/test/rpc.db.integration.test.js
  • server/rpc.js
🧠 Learnings (3)
📓 Common learnings
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions
📚 Learning: 2025-09-29T10:17:53.577Z
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Applies to db/test/**/*.sql : Write/adjust pgTAP invariants for rooms, rounds, and submissions

Applied to files:

  • server/test/rpc.db.postgres.test.js
  • db/test/20_submissions_votes.pgtap
  • db/test/30_rpcs.pgtap
  • db/test/10_rooms_rounds.pgtap
  • db/rpc.sql
  • db/schema.sql
  • docs/LocalDB.md
  • docs/Architecture.md
📚 Learning: 2025-09-29T10:17:53.577Z
Learnt from: CR
PR: flyingrobots/db8#0
File: AGENTS.md:0-0
Timestamp: 2025-09-29T10:17:53.577Z
Learning: Keep server and watcher thin; push heavy work to Supabase (SQL/RPC/RLS) or the worker

Applied to files:

  • docs/Architecture.md
🔇 Additional comments (4)
docs/LocalDB.md (1)

27-27: DOCUMENT THE ACTUAL FUNCTION SIGNATURE.
Line 27: The SQL defines room_create(topic text, cfg jsonb DEFAULT '{}'::jsonb, client_nonce text DEFAULT NULL), but this doc pretends the third arg is a mandatory nonce text. Stop misleading users and write down the real contract.

-The new `room_create(topic text, cfg jsonb DEFAULT '{}'::jsonb, nonce text)` RPC seeds a room, round 0, and a roster of anonymous participants.
+The new `room_create(topic text, cfg jsonb DEFAULT '{}'::jsonb, client_nonce text DEFAULT NULL)` RPC seeds a room, round 0, and a roster of anonymous participants.
db/rpc.sql (1)

16-20: Stop throwing 22P02 on garbage JSON. Validate and raise 22023 consistently.

Direct casts (p_cfg->>'…')::int will explode with 22P02 before your bounds checks. Guard the parse and raise a clean 22023.

Apply:

-  v_participants integer := COALESCE(NULLIF(p_cfg->>'participant_count', '')::int, 4);
-  v_submit_minutes integer := COALESCE(NULLIF(p_cfg->>'submit_minutes', '')::int, 5);
+  v_participants integer := COALESCE(
+    CASE WHEN (p_cfg->>'participant_count') ~ '^\d+$' THEN (p_cfg->>'participant_count')::int END,
+    4
+  );
+  v_submit_minutes integer := COALESCE(
+    CASE WHEN (p_cfg->>'submit_minutes') ~ '^\d+$' THEN (p_cfg->>'submit_minutes')::int END,
+    5
+  );

And keep the existing range checks to raise SQLSTATE 22023. Based on learnings.

db/schema.sql (2)

11-12: Nonce semantics are wrong. Decide and fix, or you’ll create duplicate rooms.

UNIQUE with NULL allows unlimited NULLs. If callers can omit client_nonce, your idempotency is a joke.

Pick one:

  • Strict idempotency: make it NOT NULL and keep UNIQUE.
  • “Unique when present”: drop the inline UNIQUE and add a partial UNIQUE index.

Patch (unique-when-present):

 CREATE TABLE IF NOT EXISTS rooms (
   id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
   title       text,
-  client_nonce  text UNIQUE,
+  client_nonce  text,
   created_at  timestamptz NOT NULL DEFAULT now()
 );
+
+-- Ensure uniqueness only when provided
+CREATE UNIQUE INDEX IF NOT EXISTS uniq_rooms_client_nonce
+  ON rooms (client_nonce)
+  WHERE client_nonce IS NOT NULL;

State explicitly in docs whether client_nonce is required. If required, flip to NOT NULL instead of the partial index.


26-26: Drop the redundant rounds index. It buys you nothing and costs writes.

UNIQUE(room_id, idx) already provides a btree that supports DESC scans. Delete this duplicate.

-CREATE INDEX IF NOT EXISTS idx_rounds_room_idx ON rounds (room_id, idx DESC);

Comment thread db/rpc.sql
Comment on lines +40 to +43
INSERT INTO rounds (room_id, idx, phase, submit_deadline_unix)
VALUES (v_room_id, 0, 'submit', v_submit_deadline)
ON CONFLICT (room_id, idx) DO NOTHING;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Round engine logic is bare‑bones; document “planned” or ship the missing invariants.

  • No soft quorum.
  • No per‑room advisory lock on open_next.
  • Views tally only 'continue' ballots; fine, but make the semantics explicit.

Add TODOs or implement. Don’t pretend it’s complete.

Also applies to: 134-143, 155-156

🤖 Prompt for AI Agents
In db/rpc.sql around lines 40-43 (and similarly at 134-143 and 155-156), the
round-engine logic is incomplete and lacks explicit invariants: there is no
soft-quorum handling, no per-room advisory lock around open_next, and the
views/tally semantics (only counting 'continue' ballots) are not documented. Add
concise TODO comments at these locations (or implement the behavior) stating:
(1) soft-quorum policy needed and where to enforce it; (2) acquire/release a
per-room advisory lock around open_next to prevent concurrent round opens; and
(3) document that view-tally only considers 'continue' ballots and define the
expected outcome for other ballot types. Ensure each TODO references the
invariant it addresses and points to where a full implementation should be added
or why it is intentionally omitted.

Comment thread db/rpc.sql
Comment on lines +44 to +47
INSERT INTO participants (room_id, anon_name, role)
SELECT v_room_id, format('agent_%s', gs), 'debater'
FROM generate_series(1, v_participants) AS gs
ON CONFLICT (room_id, anon_name) DO NOTHING;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Why are you seeding agent_%s when the rest of the stack says anon_%s? Pick one.

This discrepancy will break RLS mappings, docs, and clients. Use anon_%s.

-  SELECT v_room_id, format('agent_%s', gs), 'debater'
+  SELECT v_room_id, format('anon_%s', gs), 'debater'

🏁 Script executed:

#!/bin/bash
rg -n "anon_" -n -C2
rg -n "agent_" -n -C2

Length of output: 4402


🏁 Script executed:

#!/bin/bash
# Display lines around the participants insert in db/rpc.sql
sed -n '1,100p' db/rpc.sql

Length of output: 3153


Replace agent_%s with anon_%s when seeding participants
Seeding names with agent_%s conflicts with the anon_%s prefix used in UI, tests and RLS policies—change to anon_%s to align the stack.

-  SELECT v_room_id, format('agent_%s', gs), 'debater'
+  SELECT v_room_id, format('anon_%s', gs), 'debater'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
INSERT INTO participants (room_id, anon_name, role)
SELECT v_room_id, format('agent_%s', gs), 'debater'
FROM generate_series(1, v_participants) AS gs
ON CONFLICT (room_id, anon_name) DO NOTHING;
INSERT INTO participants (room_id, anon_name, role)
SELECT v_room_id, format('anon_%s', gs), 'debater'
FROM generate_series(1, v_participants) AS gs
ON CONFLICT (room_id, anon_name) DO NOTHING;
🤖 Prompt for AI Agents
In db/rpc.sql around lines 44 to 47, the INSERT uses format('agent_%s', gs) to
seed participant anon names but the rest of the stack expects the anon_%s
prefix; change the format string to format('anon_%s', gs) so seeded anon_name
values use the anon_ prefix (keep the rest of the INSERT, including ON CONFLICT
DO NOTHING, unchanged).

Comment thread db/rpc.sql
Comment on lines +88 to 96
IF p_kind <> 'continue' THEN
RAISE EXCEPTION 'unsupported vote kind: %', p_kind USING ERRCODE = '22023';
END IF;

INSERT INTO votes (round_id, voter_id, kind, ballot, client_nonce)
VALUES (p_round_id, p_voter_id, p_kind, p_ballot, p_client_nonce)
ON CONFLICT (round_id, voter_id, kind, client_nonce)
DO NOTHING
DO UPDATE SET ballot = votes.ballot
RETURNING id INTO v_id;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Your vote idempotency is broken. Different nonces = unlimited votes per voter.

UNIQUE on (round_id, voter_id, kind, client_nonce) permits a user to cast N votes by changing the nonce. That’s amateur hour.

  • Enforce one vote per (round_id, voter_id, kind).
  • Keep client_nonce only for retry dedupe.

If the schema has UNIQUE(round_id, voter_id, kind), use it:

-  INSERT INTO votes (round_id, voter_id, kind, ballot, client_nonce)
+  INSERT INTO votes (round_id, voter_id, kind, ballot, client_nonce)
   VALUES (p_round_id, p_voter_id, p_kind, p_ballot, p_client_nonce)
-  ON CONFLICT (round_id, voter_id, kind, client_nonce)
-  DO UPDATE SET ballot = votes.ballot
+  ON CONFLICT (round_id, voter_id, kind)
+  DO UPDATE SET ballot = votes.ballot
   RETURNING id INTO v_id;

If the triple UNIQUE doesn’t exist yet, add it in schema and keep a separate covering index for (round_id, voter_id, kind, client_nonce) if you insist. Also, VALIDATE the ballot:

   IF p_kind <> 'continue' THEN
     RAISE EXCEPTION 'unsupported vote kind: %', p_kind USING ERRCODE = '22023';
   END IF;
+  IF (p_ballot->>'choice') NOT IN ('continue','end') THEN
+    RAISE EXCEPTION 'invalid continue ballot.choice: %', p_ballot->>'choice' USING ERRCODE = '22023';
+  END IF;

🏁 Script executed:

#!/bin/bash
# Show vote uniques in schema
rg -nP "(create\s+table\s+votes|unique\s*\()" db -n -C3

Length of output: 2965


Enforce a true one-vote policy per (round_id, voter_id, kind)
You’re letting users flood votes by swapping nonces—unacceptable. Fix it NOW:

  • Add a UNIQUE(round_id, voter_id, kind) constraint to the votes table to ban duplicates outright.
  • Change the ON CONFLICT clause in db/rpc.sql to use (round_id, voter_id, kind) only—drop client_nonce there.
  • Preserve a separate covering index on (round_id, voter_id, kind, client_nonce) solely for retry dedupe, if you insist.
  • Validate p_ballot->>'choice' against the allowed set ('continue','end') before inserting.

Diff example:

-  ON CONFLICT (round_id, voter_id, kind, client_nonce)
+  ON CONFLICT (round_id, voter_id, kind)
   DO UPDATE SET ballot = votes.ballot

Update your schema migrations and pgtap tests to verify the new UNIQUE and remove the old four-column constraint. No excuses.

Comment thread db/schema.sql
Comment on lines +20 to 23
submit_deadline_unix bigint NOT NULL DEFAULT 0,
published_at_unix bigint,
continue_vote_close_unix bigint,
UNIQUE (room_id, idx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Stop mixing time types. Use timestamptz or at least fence the epoch ints.

bigint “*_unix” beside timestamptz elsewhere is a footgun. Either store timestamptz or add checks + generated views to avoid off‑by‑timezone bugs.

Minimal hardening:

   phase                     text        NOT NULL DEFAULT 'submit' CHECK (phase IN ('submit','published','final')),
-  submit_deadline_unix      bigint      NOT NULL DEFAULT 0,
-  published_at_unix         bigint,
-  continue_vote_close_unix  bigint,
+  submit_deadline_unix      bigint      NOT NULL DEFAULT 0 CHECK (submit_deadline_unix >= 0),
+  published_at_unix         bigint CHECK (published_at_unix IS NULL OR published_at_unix >= 0),
+  continue_vote_close_unix  bigint CHECK (continue_vote_close_unix IS NULL OR continue_vote_close_unix >= 0),

Optionally expose sane columns (clients can keep using epoch):

ALTER TABLE rounds
  ADD COLUMN IF NOT EXISTS submit_deadline_ts timestamptz
    GENERATED ALWAYS AS (to_timestamp(submit_deadline_unix)) STORED,
  ADD COLUMN IF NOT EXISTS published_at_ts timestamptz
    GENERATED ALWAYS AS (CASE WHEN published_at_unix IS NULL THEN NULL ELSE to_timestamp(published_at_unix) END) STORED,
  ADD COLUMN IF NOT EXISTS continue_vote_close_ts timestamptz
    GENERATED ALWAYS AS (CASE WHEN continue_vote_close_unix IS NULL THEN NULL ELSE to_timestamp(continue_vote_close_unix) END) STORED;

Do your queries need range scans over these timestamps? If yes, add btree indexes on the generated timestamptz columns instead of raw bigint.

🤖 Prompt for AI Agents
In db/schema.sql around lines 20 to 23, the schema mixes bigint epoch columns
with timestamptz elsewhere which is error-prone; add generated timestamptz
columns (or convert the bigint columns to timestamptz) and/or add sanity checks
so timestamps aren’t stored as raw ints without timezone context. Implement
generated STORED timestamptz columns (e.g., to_timestamp(...) or NULL-safe
variants) for submit_deadline_unix, published_at_unix and
continue_vote_close_unix so clients can continue using epoch ints but server
queries use proper timestamptz; if your queries do range scans on these
timestamps, create btree indexes on the generated timestamptz columns instead of
the raw bigint; also consider adding CHECK constraints or migrations to validate
epoch ranges if you keep the bigint columns.

Comment thread db/schema.sql
Comment on lines +28 to +41
-- Participants: seeded roster for each room / agent configuration
CREATE TABLE IF NOT EXISTS participants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
anon_name text NOT NULL,
role text NOT NULL DEFAULT 'debater' CHECK (role IN ('debater','host','judge')),
jwt_sub text,
ssh_fingerprint text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (room_id, anon_name)
);

CREATE INDEX IF NOT EXISTS idx_participants_room ON participants (room_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Participants looks better, but tighten name semantics.

If anon_name should be case‑insensitive unique per room, enforce it in the database.

Patch:

 CREATE TABLE IF NOT EXISTS participants (
@@
-  UNIQUE (room_id, anon_name)
+  UNIQUE (room_id, anon_name) -- keep if case-sensitive names are desired
 );
 
-CREATE INDEX IF NOT EXISTS idx_participants_room ON participants (room_id);
+-- If you actually want case-insensitive uniqueness, replace the constraint with:
+-- DROP INDEX/CONSTRAINT as needed and then:
+-- CREATE UNIQUE INDEX IF NOT EXISTS uniq_participants_name_ci
+--   ON participants (room_id, lower(anon_name));
+-- This also serves room_id lookups; measure before keeping a separate room_id index.

Also: decide whether jwt_sub/ssh_fingerprint need uniqueness (per room or global). If yes, add partial unique indexes with WHERE jwt_sub IS NOT NULL, etc.

List expected collation/uniqueness rules for anon_name. If it’s case-sensitive by design, say so in a COMMENT.

Also applies to: 40-41

🤖 Prompt for AI Agents
In db/schema.sql around lines 28 to 41 (and note 40-41), anon_name currently has
a UNIQUE(room_id, anon_name) but no case-insensitive enforcement; if anon_name
must be case‑insensitive unique per room, replace or supplement that constraint
with a unique index on (room_id, lower(anon_name)) to enforce CI uniqueness; if
instead anon_name is intentionally case‑sensitive, add a COMMENT on the column
stating that design decision and expected collation; additionally, if jwt_sub
and/or ssh_fingerprint must be unique (globally or per room), add appropriate
partial unique indexes (e.g., WHERE jwt_sub IS NOT NULL or per room via
(room_id, jwt_sub) with WHERE jwt_sub IS NOT NULL) to enforce those rules.

Comment on lines +88 to +101
-- participants role column has CHECK constraint enforcing allowed roles
SELECT ok(
EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'participants'
AND c.contype = 'c'
AND pg_get_constraintdef(c.oid) ILIKE '%CHECK%role%'
),
'participants role check constraint exists'
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

“role CHECK exists” is meaningless unless you prove allowed values. Test the set.

Assert that the CHECK def enumerates your allowed roles.

Add:

-- participants.role CHECK enumerates allowed roles
SELECT ok(
  EXISTS (
    SELECT 1
    FROM pg_constraint c
    JOIN pg_class t ON t.oid = c.conrelid
    JOIN pg_namespace n ON n.oid = t.relnamespace
    WHERE n.nspname = 'public'
      AND t.relname = 'participants'
      AND c.contype = 'c'
      AND pg_get_constraintdef(c.oid) ILIKE '%CHECK%' 
      AND pg_get_constraintdef(c.oid) ILIKE '%role in (%debater%moderator%fact_checker%judge%viewer%)%'
  ),
  'participants.role CHECK enumerates allowed roles'
);
🤖 Prompt for AI Agents
In db/test/10_rooms_rounds.pgtap around lines 88–101 the test only asserts a
CHECK constraint exists on participants.role but does not verify which values
are allowed; add a new test that queries pg_constraint/pg_get_constraintdef and
asserts the CHECK definition enumerates the expected roles (debater, moderator,
fact_checker, judge, viewer) — e.g. add a SELECT ok(EXISTS(...)) that includes
c.contype='c' and pg_get_constraintdef(c.oid) ILIKE '%CHECK%' AND
pg_get_constraintdef(c.oid) ILIKE pattern(s) that confirm the role IN(...) list
contains each allowed role so the test fails if any role is missing or the list
differs.

Comment thread db/test/30_rpcs.pgtap
@@ -1,48 +1,176 @@
BEGIN;
SELECT plan(8);
SELECT plan(27);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

PLAN IS WRONG. FIX IT.
Line 2: You bolted on nine extra ok() calls plus the existing has_function(...) checks and that pass() at the end, yet you left the plan at 27. The suite now fires 29 assertions, so pgTAP will scream bloody murder. Bump the plan to 29 before CI slaps you again.

-SELECT plan(27);
+SELECT plan(29);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
SELECT plan(27);
-- db/test/30_rpcs.pgtap
SELECT plan(29);
🤖 Prompt for AI Agents
In db/test/30_rpcs.pgtap around line 2, the test plan is incorrect: you added
nine extra ok() assertions (in addition to the existing has_function(...) checks
and the final pass()), so the suite now runs 29 assertions but the plan is still
27; update the SELECT plan(...) call to SELECT plan(29); to match the actual
number of assertions so pgTAP will not fail the CI.

Comment thread docs/Architecture.md
Comment on lines 684 to 688
export const ContinueVote = z.object({
room_id: z.string().uuid(),
round_id: z.string().uuid(),
choice: z.enum(["continue","end"])
choice: z.enum(['continue', 'end'])
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Schema mismatch: ContinueVote still requires room_id. You removed it from the RPC.

Update the wire schema to drop room_id and include client_nonce for idempotency.

-export const ContinueVote = z.object({
-  room_id: z.string().uuid(),
-  round_id: z.string().uuid(),
-  choice: z.enum(['continue', 'end'])
-});
+export const ContinueVote = z.object({
+  round_id: z.string().uuid(),
+  choice: z.enum(['continue', 'end']),
+  client_nonce: z.string().min(1)
+});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const ContinueVote = z.object({
room_id: z.string().uuid(),
round_id: z.string().uuid(),
choice: z.enum(["continue","end"])
choice: z.enum(['continue', 'end'])
});
export const ContinueVote = z.object({
round_id: z.string().uuid(),
choice: z.enum(['continue', 'end']),
client_nonce: z.string().min(1)
});
🤖 Prompt for AI Agents
In docs/Architecture.md around lines 684 to 688, the ContinueVote Zod schema
still requires room_id but the RPC no longer sends it; update the wire schema by
removing room_id and adding a client_nonce field for idempotency (e.g.,
client_nonce: z.string().uuid() or z.string() if non-UUID nonces are used), and
ensure the exported ContinueVote object reflects this change so the schema
matches the RPC payload.

Comment thread docs/Features.md
Comment on lines 148 to 152
- Votes table + RLS.
- RPC `vote_submit(room, round, voter, kind, ballot)`.
- RPC `vote_submit(round, voter, kind, ballot)`.
- Aggregates for continue pass/fail and final placements.
- Simple majority of cast ballots; soft quorum (≥3 votes) to avoid zombie rounds.
- Simple majority of cast ballots; soft quorum (≥3 votes) to avoid zombie rounds.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

You removed room_id from vote_submit, but you also “forgot” the idempotency token.

Document the actual RPC: vote_submit(round_id, voter_id, kind, ballot, client_nonce). If you don’t require client_nonce, retries will double‑vote. Fix the docs.

🤖 Prompt for AI Agents
In docs/Features.md around lines 148 to 152, the RPC signature for vote_submit
is incorrect: it removed room_id and omitted the idempotency token. Update the
documented RPC to the accurate signature vote_submit(round_id, voter_id, kind,
ballot, client_nonce) (or include room_id if your protocol requires it) and add
a short note explaining client_nonce is required for idempotency to prevent
retry double-votes; ensure parameter names match server implementation and
adjust any surrounding text that references the old signature.

Comment thread docs/Features.md
Comment on lines +151 to 152
- Simple majority of cast ballots; soft quorum (≥3 votes) to avoid zombie rounds.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Stop lying about soft quorum. You don’t implement it anywhere.

Either mark it “planned” or implement the ≥3 votes rule in SQL before claiming it here. Right now round_open_next only checks yes > no.

Also applies to: 239-245

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant