diff --git a/docs/reviews/2026-06-28-NEO-151.md b/docs/reviews/2026-06-28-NEO-151.md index 5b256bf..7e4895e 100644 --- a/docs/reviews/2026-06-28-NEO-151.md +++ b/docs/reviews/2026-06-28-NEO-151.md @@ -53,6 +53,7 @@ None. |----------|----------|---------|--------| | Low | `scripts/reset-contract-instances-helper.js` | Reset helper only cleared one capped GET page (active + 10 completed); older rows could survive pre-request cleanup | **Done.** — loop GET → reset until list empty (max 100 passes) | | Low | `contracts/scripts/` helper path | Bruno resolves `./scripts/` from collection root, not per-folder — CI module not found | **Done.** — moved helper to `neon-sprawl-server/scripts/`; `.js` suffix on requires | +| Medium | `PostgresContractInstanceStore.ListForPlayer` | Active + completed read in two queries without snapshot — duplicate row if status flips mid-read | **Done.** — single CTE query for atomic list snapshot | ## Verification diff --git a/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs index 56ce4ae..87b7890 100644 --- a/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs +++ b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs @@ -330,56 +330,41 @@ public sealed class PostgresContractInstanceStore(Npgsql.NpgsqlDataSource dataSo return []; } - ContractInstanceState? active = null; - using (var activeCmd = new Npgsql.NpgsqlCommand( - """ - SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at - FROM contract_instance - WHERE player_id = @pid AND status = 'active' - LIMIT 1; - """, - conn)) - { - activeCmd.Parameters.AddWithValue("pid", player); - using var reader = activeCmd.ExecuteReader(); - if (reader.Read()) - { - var snapshot = ReadSnapshot(reader); - if (snapshot.Status == ContractInstanceStatus.Active) - { - active = snapshot; - } - } - } - - var completed = new List(maxCompletedRows); - if (maxCompletedRows > 0) - { - using var cmd = new Npgsql.NpgsqlCommand( - """ - SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at + // Single statement snapshot — avoids active+completed duplicate rows if status flips mid-read. + using var cmd = new Npgsql.NpgsqlCommand( + """ + WITH active_row AS ( + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at, 0 AS section + FROM contract_instance + WHERE player_id = @pid AND status = 'active' + LIMIT 1 + ), + completed_rows AS ( + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at, 1 AS section FROM contract_instance WHERE player_id = @pid AND status = 'completed' ORDER BY issued_at DESC, contract_instance_id ASC - LIMIT @limit; - """, - conn); - cmd.Parameters.AddWithValue("pid", player); - cmd.Parameters.AddWithValue("limit", maxCompletedRows); - using var reader = cmd.ExecuteReader(); - while (reader.Read()) - { - completed.Add(ReadSnapshot(reader)); - } - } + LIMIT @limit + ) + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at + FROM ( + SELECT * FROM active_row + UNION ALL + SELECT * FROM completed_rows + ) AS listed + ORDER BY section, issued_at DESC, contract_instance_id ASC; + """, + conn); + cmd.Parameters.AddWithValue("pid", player); + cmd.Parameters.AddWithValue("limit", maxCompletedRows); - if (active is null) + var result = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) { - return completed; + result.Add(ReadSnapshot(reader)); } - var result = new List(1 + completed.Count) { active }; - result.AddRange(completed); return result; }