feat(database): inbox messages carry an ask and its answers

Merged
DA
6d ago
+14338-6

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, typed InboxReadAsk. It holds the ask an agent's message ends with, plus its status and, if withdrawn, its withdrawnReason.
  • inbox_message.answers: jsonb, nullable, typed InboxAnswers. 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 to inbox_message(id) with ON DELETE SET NULL, added NOT VALID like the precedent in 20261017134832_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_ask was generated with pnpm db:generate, then made idempotent by hand: ADD COLUMN IF NOT EXISTS, plus DROP CONSTRAINT IF EXISTS before the ADD CONSTRAINT. I also moved the inbox_message statements ahead of the inbox_item ones. 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, response and needs_reply stay NOT NULL.
  • @luke/features/inbox-wire now exports InboxReadAsk and InboxAnswers.
  • Fixed the stale comment in schemas/inbox.ts. It pointed at tests/drizzle-schema.test.ts's PENDING_DROP, which no longer exists. It now says the old columns (kind, details, options, recommended) are still in the database, kept in sync by the 20261007104640_inbox_item_blocks trigger, 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 ||) on status/withdrawnReason for 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 decide ask (answered), a member's answers to it, a withdrawn do ask with its reason, and an open answer ask 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:migrations and the unmigrated-schema check passed (they run before the parallel part).
  • lint, knip and discarded-effect passed.
  • turbo run typecheck --concurrency=1 passed, with a 6 GB heap.
  • turbo run test:vitest test:vitest:1 test:vitest:2 test:vitest:3 --concurrency=1 passed. The desktop suite needed Electron's binary fetched first.
  • turbo run build passed.

Made with Luke


Devin Review