|
| 1 | +-- Bind every deterministic 1:1 DM id to the workspace and sorted participant |
| 2 | +-- pair that derived it. The primary key is the atomic reservation seam: a |
| 3 | +-- conflicting digest can never overwrite or alias another pair's conversation. |
| 4 | +CREATE TABLE dm_conversation_reservations ( |
| 5 | + conversation_id TEXT PRIMARY KEY, |
| 6 | + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, |
| 7 | + participant_one_id TEXT NOT NULL, |
| 8 | + participant_two_id TEXT NOT NULL, |
| 9 | + created_at INTEGER NOT NULL DEFAULT (unixepoch()), |
| 10 | + CONSTRAINT dm_conversation_reservations_sorted_pair_check |
| 11 | + CHECK (participant_one_id <= participant_two_id) |
| 12 | +); |
| 13 | + |
| 14 | +CREATE UNIQUE INDEX dm_conversation_reservations_pair_unique |
| 15 | + ON dm_conversation_reservations (workspace_id, participant_one_id, participant_two_id); |
| 16 | + |
| 17 | +-- PRE-FLIGHT AUDIT (run BOTH read-only queries BEFORE applying this migration). |
| 18 | +-- The backfill can abort for two independent reasons, and each needs its own |
| 19 | +-- check. An earlier version of this comment shipped only query (a) and claimed an |
| 20 | +-- empty result meant the migration would apply cleanly. That was wrong: a |
| 21 | +-- duplicate pair passes (a) and still aborts the backfill on the pair-uniqueness |
| 22 | +-- index. Raised in review of PR #303. |
| 23 | +-- |
| 24 | +-- (a) Roster shape. The backfill aborts if any single legacy 1:1 has a roster |
| 25 | +-- that is not 1 or 2 distinct participants. That is deliberate - inventing a |
| 26 | +-- tuple would bind a conversation to the wrong pair - but one corrupt row |
| 27 | +-- blocks the whole deployment. |
| 28 | +-- |
| 29 | +-- SELECT dc.id, dc.workspace_id, COUNT(DISTINCT dp.agent_id) AS participants |
| 30 | +-- FROM dm_conversations dc |
| 31 | +-- LEFT JOIN dm_participants dp ON dp.conversation_id = dc.id |
| 32 | +-- WHERE dc.dm_type = '1:1' |
| 33 | +-- GROUP BY dc.id, dc.workspace_id |
| 34 | +-- HAVING participants NOT BETWEEN 1 AND 2; |
| 35 | +-- |
| 36 | +-- (b) Duplicate pairs. Two DISTINCT legacy 1:1 conversations in one workspace |
| 37 | +-- that resolve to the same sorted pair both satisfy (a), but only one can be |
| 38 | +-- reserved - the second violates dm_conversation_reservations_pair_unique and |
| 39 | +-- aborts the migration. This is exactly what a pre-deterministic id scheme |
| 40 | +-- leaves behind, so it is the likelier of the two in practice. |
| 41 | +-- |
| 42 | +-- SELECT workspace_id, participant_one_id, participant_two_id, |
| 43 | +-- COUNT(*) AS conversations |
| 44 | +-- FROM ( |
| 45 | +-- SELECT dc.workspace_id, |
| 46 | +-- MIN(dp.agent_id) AS participant_one_id, |
| 47 | +-- MAX(dp.agent_id) AS participant_two_id |
| 48 | +-- FROM dm_conversations dc |
| 49 | +-- JOIN dm_participants dp ON dp.conversation_id = dc.id |
| 50 | +-- WHERE dc.dm_type = '1:1' |
| 51 | +-- GROUP BY dc.id, dc.workspace_id |
| 52 | +-- HAVING COUNT(DISTINCT dp.agent_id) BETWEEN 1 AND 2 |
| 53 | +-- ) |
| 54 | +-- GROUP BY workspace_id, participant_one_id, participant_two_id |
| 55 | +-- HAVING conversations > 1; |
| 56 | +-- |
| 57 | +-- (c) Ids that do not match the CURRENT derivation. This one cannot be written |
| 58 | +-- in SQL - it needs SHA-256, which SQLite does not have - and it is the check |
| 59 | +-- that matters most operationally, because it is the only failure that is |
| 60 | +-- INVISIBLE AT MIGRATION TIME. (a) and (b) abort the migration loudly. (c) |
| 61 | +-- lets it succeed, and then every subsequent DM between that pair returns 409 |
| 62 | +-- forever, because the backfill reserved the pair under an id the send path |
| 63 | +-- will never re-derive. |
| 64 | +-- |
| 65 | +-- Run: node scripts/audit-dm-reservations.mjs --sqlite <path> |
| 66 | +-- D1: wrangler d1 execute <DB> --json --command "<see script header>" \ |
| 67 | +-- | node scripts/audit-dm-reservations.mjs --stdin |
| 68 | +-- |
| 69 | +-- That script also re-runs (a) and (b), so it is the single command to trust. |
| 70 | +-- |
| 71 | +-- All three clean means this migration will apply AND no existing pair will start |
| 72 | +-- failing afterwards. Remediate anything any of them returns - for (b) that means |
| 73 | +-- deciding which conversation survives, since the reservation can only bind one; |
| 74 | +-- for (c) it means re-keying the conversation to the derived id, or seeding its |
| 75 | +-- reservation under the derived id, before deploying the code that reserves. |
| 76 | + |
| 77 | +-- Backfill ONLY conversations with exactly two distinct participants. |
| 78 | +-- |
| 79 | +-- A one-row roster is ambiguous and MUST NOT be reserved. It looks like a |
| 80 | +-- self-DM, but `dm_participants.agent_id` cascades on agent deletion, so a |
| 81 | +-- perfectly ordinary two-party 1:1 collapses to a single row the moment one |
| 82 | +-- participant's agent is deleted - while its id still encodes the ORIGINAL pair. |
| 83 | +-- |
| 84 | +-- Reading those as (X, X) is wrong twice over. Several orphans belonging to the |
| 85 | +-- same surviving agent all collapse to the same (workspace, X, X) tuple and |
| 86 | +-- collide on the pair-uniqueness index, aborting the migration; and any that |
| 87 | +-- survived would reserve a self-DM tuple against an id no derivation produces, |
| 88 | +-- so that agent's next self-DM would 409 forever. |
| 89 | +-- |
| 90 | +-- This is not hypothetical. An earlier version of this backfill used |
| 91 | +-- MIN/MAX over 1-2 participants; audited against production it produced 4 |
| 92 | +-- colliding pair groups and 30 mismatched ids, all of them orphaned two-party |
| 93 | +-- conversations, and would have failed the deployment. Restricted to exactly two |
| 94 | +-- participants the same data yields zero findings across 3425 conversations. |
| 95 | +-- |
| 96 | +-- Skipping is safe rather than merely convenient. An unreserved conversation is |
| 97 | +-- in exactly the state every conversation was in before this migration: the |
| 98 | +-- first send through the reservation path claims it, and because a genuine |
| 99 | +-- self-DM's id already equals its derivation, that claim adopts the existing |
| 100 | +-- conversation instead of creating a second one. Orphaned two-party rows are |
| 101 | +-- simply never re-derived, so they stay readable and inert. |
| 102 | +-- |
| 103 | +-- Malformed rosters (zero, or more than two) are skipped for the same reason. |
| 104 | +-- The earlier version aborted the whole migration on them; skipping avoids |
| 105 | +-- inventing a tuple just as effectively without blocking a deployment, and any |
| 106 | +-- future send still goes through the reservation path. |
| 107 | +INSERT INTO dm_conversation_reservations ( |
| 108 | + conversation_id, |
| 109 | + workspace_id, |
| 110 | + participant_one_id, |
| 111 | + participant_two_id, |
| 112 | + created_at |
| 113 | +) |
| 114 | +SELECT |
| 115 | + dc.id, |
| 116 | + dc.workspace_id, |
| 117 | + MIN(dp.agent_id), |
| 118 | + MAX(dp.agent_id), |
| 119 | + dc.created_at |
| 120 | +FROM dm_conversations dc |
| 121 | +JOIN dm_participants dp ON dp.conversation_id = dc.id |
| 122 | +WHERE dc.dm_type = '1:1' |
| 123 | +GROUP BY dc.id, dc.workspace_id, dc.created_at |
| 124 | +HAVING COUNT(DISTINCT dp.agent_id) = 2; |
0 commit comments