feat(database): inbox messages carry an ask and its answers
Step 3 of 7 of the inbox threads rollout. This is an expand-only migration so the store (step 4) can keep threads of messages that carry asks. It stacks on #3967 (wire v5).
What changes
inbox_message.ask: jsonb, nullable, typedInboxReadAsk. It holds the ask an agent's message ends with, plus itsstatusand, if withdrawn, itswithdrawnReason.inbox_message.answers: jsonb, nullable, typedInboxAnswers. It holds the answer a member's message carries.inbox_item.open_ask_message_id: uuid, nullable. It points at the message whose ask is open. It's a foreign key toinbox_message(id)withON DELETE SET NULL, addedNOT VALIDlike the precedent in20261017134832_inbox_message_thread. No existing row names a message, so validating it would only scan the table while holding a lock.- Migration
20261019231740_inbox_message_askwas generated withpnpm db:generate, then made idempotent by hand:ADD COLUMN IF NOT EXISTS, plusDROP CONSTRAINT IF EXISTSbefore theADD CONSTRAINT. I also moved theinbox_messagestatements ahead of theinbox_itemones. Code that writes or deletes a thread message locks the two tables in that order, so the migration waits behind such a write instead of deadlocking with it. - Nothing is dropped, renamed or backfilled.
blocks,responseandneeds_replystayNOT NULL. @luke/features/inbox-wirenow exportsInboxReadAskandInboxAnswers.- Fixed the stale comment in
schemas/inbox.ts. It pointed attests/drizzle-schema.test.ts'sPENDING_DROP, which no longer exists. It now says the old columns (kind,details,options,recommended) are still in the database, kept in sync by the20261007104640_inbox_item_blockstrigger, until a contract migration drops them.
Decision: store the whole read shape, not inboxAskSchema plus a status column
ask stores the ask exactly as inboxReadAskSchema reads it, with status and withdrawnReason included. Why:
- Older deployments can still read newer asks. For up to 25 hours, a deployment from before a newer ask type keeps reading rows the newer one writes. The read union's unknown-type member is there for exactly this case. If the stored value decodes through
inboxReadAskSchema, the older code gets a placeholder that still says whether the ask is open. It never fails on the row. - A status column adds nothing. The store finds an item's open ask through
inbox_item.open_ask_message_id, not by searching on status. Status is only ever read together with its ask, so it doesn't need its own column or index. A separate column would also be a second copy of the truth that could drift away from the ask it describes. - Changing status is a small update. Replacing or withdrawing an ask is a
jsonb_set(or||) onstatus/withdrawnReasonfor one or two rows. Step 4 should update those keys in place rather than rewrite the whole ask, so that an older deployment updating status never strips the keys of an ask type it doesn't know.
So InboxAsk stays unexported: no column uses it, and knip refuses an unused export. Step 4 can export it when it needs it.
Index
open_ask_message_id has no index, because no read in step 4 needs one: the open ask is found from the item through the message's primary key. The cost is that deleting an inbox_message row makes Postgres look up the referencing inbox_item rows without an index. Messages are only deleted one at a time by client key, or by cascade when their item goes. inbox_item.answered_by (SET NULL to user) makes the same trade.
Tests
packages/database/src/schemas/__tests__/inbox-message-ask.test.ts, on PGlite (getTestDB()):
- Database from before the migration: an item and a member's message written the old way read back unchanged after the migration runs, with no ask, no answers and no open ask. A deployment from before it can still insert a message and update the item without naming the new columns. Running the migration a second time changes nothing.
- Round trip: a
decideask (answered), a member'sanswersto it, a withdrawndoask with its reason, and an openanswerask all read back as written. The item points at the open ask, and the pointer becomes null when that message is deleted.
apps/web/tests/inbox-item-read-migration.test.ts and storage-schema.test.ts still pass unchanged.
Checks
Before pushing, pnpm check:affected passed all of its parts, but not in one combined run. The combined run ran out of memory in the 8 GB sandbox (@luke/panel typecheck killed with 137, @luke/web hit the heap limit), so I ran each part on its own:
- Repository checks,
check:migrationsand the unmigrated-schema check passed (they run before the parallel part). - lint, knip and discarded-effect passed.
turbo run typecheck --concurrency=1passed, with a 6 GB heap.turbo run test:vitest test:vitest:1 test:vitest:2 test:vitest:3 --concurrency=1passed. The desktop suite needed Electron's binary fetched first.turbo run buildpassed.
Made with Luke