feat(database): voice_avatar_sessions records the Keyframe avatar sessions a call showed, closed once with the seconds they ran

Merged
CH
5d ago
+9144-0

Summary

Plan item 3. db: voice_avatar_sessions, based on feature/avatar-video-keyframe, for video calls with Luke (Keyframe avatar). It lands dark: nothing calls it yet. Consumer: plan PR #7 (web: avatar frames and records, plus its orphan sweep).

  • voice_avatar_sessions in packages/database/src/schemas/voice.ts: id, voice_session_id (FK, cascade), user_id (FK, cascade), persona_id, started_at, ended_at, seconds, end_reason, plus a partial index over open rows by voice session. Keyframe returns no session id, so a row is addressed by its own id, and seconds is an elapsed estimate (rounded up) rather than Keyframe's own count.
  • AVATAR_END_REASON in voice-vocabulary.ts: user, server_closed, error, call_ended, orphaned.
  • @sidecar/database/models/voice-avatar-sessions:
    • openAvatarSession({userId, voiceSessionId, personaId, startedAt}) returns Option<string>, the row id. It returns none when the account doesn't own the voice session or the call has already closed. The insert selects from that account's own open voice session row with FOR SHARE, so a concurrent close of the call runs either before the open (no row) or after it (the close's sweep sees the row).
    • closeAvatarSession({id, endedAt, reason}) returns Option<number>, the seconds. Only the first close writes; a later close returns the same seconds. It returns none when no row has that id.
    • closeOpenAvatarSessionsOf({voiceSessionId, endedAt, reason}) returns how many rows it closed. A row already closed keeps its first close.
    • openAvatarSessionsOf({userId, voiceSessionId}): the account's open rows on that call. The cross-account test reads through it.

Migration history

main already has 20261013203349_voice_avatar_sessions (the reverted LiveAvatar #2440) and 20261013210943_voice_avatar_sessions_drop (revert #2455). The new folder 20261014153106_voice_avatar_sessions_keyframe, made with pnpm db:generate --name, sorts after both and creates the table fresh. Its statements are idempotent: CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, and each foreign key dropped IF EXISTS before it is added.

Drizzle's migrator applies every folder it has no record of, in name order, so production gets the same result whichever state it is in:

  • Neither old migration ran: create, drop, then create.
  • Only the create ran: drop, then create.
  • Both ran: create.

The table always ends up with the new shape. Every getTestDB() replays this full history, so the tests below run on a database that went through the old create and the drop. drizzle-kit check passes.

Evidence

  • Platform-independent checks: ./scripts/check.sh exit 0
  • macOS Electron verification (./scripts/verify.sh): not applicable (database only, no Mac or UI change)

Tests

packages/database/src/models/__tests__/voice-avatar-sessions.test.ts (5 new) on getTestDB():

  • a session closed twice records its seconds once;
  • a close of an unknown id returns nothing;
  • a call that has closed opens no avatar session;
  • a call's open sessions close together, a closed one keeps its first close, and another call is untouched;
  • one account cannot open or read avatar sessions on another's call.

apps/web/tests/storage-schema.test.ts now lists the table.

Invariants

  • ./scripts/check.sh green.
  • JSON Schema goldens unchanged.
  • Every behavior change has its test in this PR; no change-detector test added.
  • Gateway envelope goldens unchanged.
  • No new as assertion.
  • No Effect.run* outside the edges.
  • No raw async primitives.
  • No discarded Effect.
  • Test count: +5 (voice-avatar-sessions.test.ts).
  • No hand-rolled file replaced.
  • No CLAUDE.md/AGENTS.md sentence names a changed part.
  • PRIVACY.md unchanged. (Plan PR #12 updates it.)
  • Package deps unchanged; new export ./models/voice-avatar-sessions.

šŸ¤– Generated with Claude Code