Skip to content

feat(store): conversation/thread persistence (HT-14) - #11

Merged
zaridan merged 2 commits into
mainfrom
feat/ht-14-conversation-store
Jul 10, 2026
Merged

feat(store): conversation/thread persistence (HT-14)#11
zaridan merged 2 commits into
mainfrom
feat/ht-14-conversation-store

Conversation

@zaridan

@zaridan zaridan commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Closes HT-14. The fourth mail-engine increment: the store the threading decision lands on, plus the portable DB seam the rest of the engine builds on.

What's here

  • src/db/client.ts — a thin raw-SQL Db/Queryable seam with a PGlite (in-process real Postgres, Apache-2.0) adapter. The same parameterized $1 SQL runs unmodified against Supabase later — no ORM, no query builder, deliberately.
  • src/db/migrate.ts — a forward-only migration runner. Migration 001 (conversations + threads) is embedded as SQL string constants so a serverless bundle stays self-contained (no runtime filesystem reads).
  • src/store/conversations.tsConversationStore: createConversation (conversation + first thread, atomic), appendThread, getConversation.
  • specs/store/conversations.md — the store contract.

Resolves the threading-spec §5 open questions

A verified reply token can point at a conversation that's since changed. This layer decides what happens (and specs/mail/threading.md §5 is updated to record it):

  • closed → append the reply and reopen (the Help Scout behavior).
  • deleted{ ok: false, reason: 'deleted' }, nothing inserted — caller starts a fresh conversation rather than resurrecting a deleted one.
  • missing{ ok: false, reason: 'not-found' }.

appendThread takes a SELECT ... FOR UPDATE row lock so concurrent replies to the same conversation resolve deterministically.

Testing

Against real in-memory PGlite, not mocks — atomicity via a genuine CHECK-constraint rollback (asserts zero orphan rows), injection-safety via a real DROP TABLE attempt (asserts the table survives), plus closed→reopen, deleted→rejected, missing→not-found, and thread ordering. 133 tests pass; typecheck and Biome clean.

Note for review

migrate.ts splits migration SQL on ; because the thin query seam runs one statement per call (Postgres extended-query protocol rejects multi-statement strings). Safe here — migration bodies are first-party constants with no semicolons in literals — and documented inline, but flagged in case we'd rather grow the Db seam an exec method than keep a text splitter. Standard persistence (not crypto/threading-authority), so CodeRabbit + review; Codex not required.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added persistent conversation and thread storage with create, ordered retrieval, and transactional thread appends.
    • Closed conversations now reopen automatically when new replies arrive.
    • Deleted or missing conversations are safely handled without inserting threads.
    • Introduced a portable SQL/transaction database layer with support for in-memory or persisted DBs, plus automatic forward-only migrations.
  • Documentation
    • Documented conversation/thread persistence behavior and threading edge cases.
  • Tests
    • Added database, migration, and store tests covering transactions, status transitions, and migration idempotency.
  • Chores
    • Increased test timeout for the Vitest suite.

The threading decision (src/mail/thread.ts) produces {conversationId,
threadId} but nothing persisted them. This adds the store layer they land
on, plus the portable DB seam the whole engine will build on.

- src/db/client.ts — thin raw-SQL `Db`/`Queryable` seam + a PGlite
  (in-process real Postgres, Apache-2.0) adapter. Same parameterized SQL
  runs unmodified against Supabase later; no ORM, no query builder.
- src/db/migrate.ts — forward-only migration runner; migration 001
  (conversations + threads) embedded as SQL constants so serverless
  bundles stay self-contained (no runtime fs reads).
- src/store/conversations.ts — ConversationStore: createConversation
  (conversation + first thread, atomic), appendThread, getConversation.
  Resolves specs/mail/threading.md §5's open questions at the storage
  layer: a valid token to a CLOSED conversation reopens it; to a DELETED
  one returns {ok:false,'deleted'} so the caller starts fresh; to a
  MISSING id returns 'not-found'. appendThread locks the conversation row
  (FOR UPDATE) so concurrent replies resolve deterministically.
- specs/store/conversations.md — the store contract.
- Tests against real in-memory PGlite (not mocks): atomicity via a real
  CHECK-violation rollback, injection-safety via a real DROP TABLE
  attempt, closed→reopen, deleted→rejected, missing→not-found, ordering.

133 tests pass; typecheck + Biome clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a PGlite database abstraction, embedded migration runner, and transactional conversation store. The store persists ordered threads, handles open/closed/deleted/missing statuses, exposes public barrels, and adds specifications and Vitest coverage.

Changes

Conversation persistence

Layer / File(s) Summary
PGlite database seam
package.json, src/db/client.ts, src/db/client.test.ts, src/db/index.ts, vitest.config.ts
Adds the PGlite dependency, parameterized SQL interfaces, in-memory and file-backed database creation, transaction and lifecycle handling, public exports, and database behavior tests.
Conversation schema migrations
src/db/migrate.ts, src/db/migrate.test.ts
Adds the conversations and threads schema, migration bookkeeping, advisory locking, transactional statement execution, and idempotency tests.
Transactional conversation store
specs/store/conversations.md, specs/mail/threading.md, src/store/conversations.ts, src/store/conversations.test.ts, src/store/index.ts
Adds typed conversation and thread persistence, atomic creation, locked status-aware appends, ordered reads, typed failure results, public exports, specifications, and comprehensive store tests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant IngestionPipeline
  participant ConversationStore
  participant DbTransaction
  participant ConversationsTable
  participant ThreadsTable
  IngestionPipeline->>ConversationStore: appendThread(conversationId, thread)
  ConversationStore->>DbTransaction: begin transaction
  DbTransaction->>ConversationsTable: lock conversation row
  ConversationsTable-->>ConversationStore: return conversation status
  ConversationStore->>ThreadsTable: insert accepted thread
  ConversationStore->>ConversationsTable: reopen closed status or update timestamp
  ConversationStore-->>IngestionPipeline: return append result
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding conversation/thread persistence in the store layer.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-14-conversation-store

Comment @coderabbitai help to get the list of available commands.

@zaridan

zaridan commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
src/store/conversations.ts (1)

4-9: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

"agent/assistant mail" conflates two distinct actor types the guidelines require keeping separate.

The module doc (and specs/store/conversations.md line 9) describes outbound threads generically as "agent/assistant mail," but NewThread/StoredThread only carry direction: 'inbound' | 'outbound' — there's no field recording whether an outbound message came from a human Agent or an AI Assistant. Precise vocabulary matters here per the guideline; either distinguish the two in prose (e.g. "outbound mail from a support Agent or AI Assistant") or, if this store layer intentionally doesn't track authorship yet, say so explicitly rather than using the slash-compound as if the distinction doesn't matter.

As per coding guidelines, "human support staff are Agents, and AI actors are Assistants; never conflate them in schema, code, documentation, or prose."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/store/conversations.ts` around lines 4 - 9, The conversation store
documentation conflates human Agents and AI Assistants. Update the module
documentation near createConversation/appendThread and the corresponding
conversations specification to explicitly describe outbound mail as coming from
a support Agent or AI Assistant, and state that this store currently tracks only
direction and does not record authorship.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/db/client.test.ts`:
- Around line 21-38: The test’s finally block removes dataDir while the second
database instance remains open. Update the cleanup around the createPgliteDb
persistence test to clear the first closed db handle and close the active db
instance before calling rm, ensuring teardown is safe across platforms.

In `@src/db/client.ts`:
- Line 20: Update the PGlite license note in the documentation comment to state
that it is dual-licensed under Apache-2.0 and the PostgreSQL License, or remove
the license parenthetical entirely.
- Around line 143-145: Update createPgliteDb to distinguish an explicitly
provided empty dataDir from an omitted option: branch on dataDir !== undefined
and either pass it to PGlite.create or reject blank values with a clear error,
rather than silently creating an in-memory database.

In `@src/db/migrate.ts`:
- Around line 122-138: Move migration discovery and pending selection from the
initial read in migrate() into a transaction protected by the same lock used for
applying migrations, ensuring concurrent calls serialize before reading
_migrations and executing DDL. Keep each migration’s recording atomic, and add a
test that invokes migrate() concurrently to verify migrations run once without
races or duplicate entries; retain the existing sequential repeat-call coverage.

In `@src/store/conversations.ts`:
- Around line 244-247: The thread query in the conversation-loading logic orders
same-timestamp rows by random UUID, violating deterministic oldest-first
ordering. Update the query in the `threadRows` retrieval to use a time-ordered
creation field as the tiebreaker, such as `created_at` with sufficient precision
or the thread’s monotonic sequence column, and preserve deterministic ascending
ordering.

---

Nitpick comments:
In `@src/store/conversations.ts`:
- Around line 4-9: The conversation store documentation conflates human Agents
and AI Assistants. Update the module documentation near
createConversation/appendThread and the corresponding conversations
specification to explicitly describe outbound mail as coming from a support
Agent or AI Assistant, and state that this store currently tracks only direction
and does not record authorship.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9ebdb531-77bc-48b9-90ae-7c454ddb41eb

📥 Commits

Reviewing files that changed from the base of the PR and between 619b868 and 32c058a.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (12)
  • package.json
  • specs/mail/threading.md
  • specs/store/conversations.md
  • src/db/client.test.ts
  • src/db/client.ts
  • src/db/index.ts
  • src/db/migrate.test.ts
  • src/db/migrate.ts
  • src/store/conversations.test.ts
  • src/store/conversations.ts
  • src/store/index.ts
  • vitest.config.ts

Comment thread src/db/client.test.ts
Comment thread src/db/client.ts Outdated
Comment thread src/db/client.ts
Comment thread src/db/migrate.ts Outdated
Comment on lines +244 to +247
const threadRows = await db.query<ThreadRow>(
`SELECT ${THREAD_COLUMNS} FROM threads WHERE conversation_id = $1 ORDER BY created_at, id`,
[conversationId],
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the migration for how thread/conversation ids are generated
fd migrate -e ts
rg -n -B2 -A2 'id\s+uuid|gen_random_uuid|uuid_generate|DEFAULT' src/db/migrate.ts 2>/dev/null

Repository: Helpthread/helpthread

Length of output: 1704


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== src/store/conversations.ts (relevant slice) =="
sed -n '220,270p' src/store/conversations.ts

echo
echo "== search for oldest-first / stable ordering docs =="
rg -n "oldest-first|stable|ORDER BY created_at, id|created_at, id|threads" src/store/conversations.ts src/db/migrate.ts README.md docs 2>/dev/null || true

echo
echo "== migration test references =="
sed -n '1,220p' src/db/migrate.test.ts

Repository: Helpthread/helpthread

Length of output: 5679


Use a time-ordered tiebreak here. gen_random_uuid() is random, so ORDER BY created_at, id can still return same-tick threads in arbitrary order and break the oldest-first contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/store/conversations.ts` around lines 244 - 247, The thread query in the
conversation-loading logic orders same-timestamp rows by random UUID, violating
deterministic oldest-first ordering. Update the query in the `threadRows`
retrieval to use a time-ordered creation field as the tiebreaker, such as
`created_at` with sufficient precision or the thread’s monotonic sequence
column, and preserve deterministic ascending ordering.

- migrate.ts (Major): take a transaction-scoped Postgres advisory lock and
  run the whole migrate — lock, ensure _migrations, read applied, apply
  pending — in ONE transaction, so two concurrent migrate() callers on real
  multi-connection Postgres serialize instead of racing on the same DDL.
  (No-op under single-connection PGlite; documented as such.)
- client.ts: branch createPgliteDb on `dataDir !== undefined`, not
  truthiness, and reject an explicitly-empty dataDir loudly rather than
  silently degrading to in-memory.
- client.ts: correct the PGlite license note — it's dual-licensed
  Apache-2.0 or the PostgreSQL License.
- client.test.ts: close the open Db handle before rm-ing its dataDir, so
  teardown is safe on platforms that refuse to unlink open files.

133 tests pass; typecheck + Biome clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/db/migrate.ts (1)

164-166: 🎯 Functional Correctness | 🔵 Trivial | 🏗️ Heavy lift

Avoid splitting migration SQL on every semicolon.

splitStatements() breaks valid SQL containing semicolons inside string literals, comments, or dollar-quoted function/trigger bodies. Store migrations as statement arrays or use a PostgreSQL-aware splitter, and add a fixture covering this case.

Suggested direction
-      for (const statement of splitStatements(migration.sql)) {
+      for (const statement of migration.statements) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db/migrate.ts` around lines 164 - 166, Replace the naive
splitStatements-based iteration in the migration runner with statement arrays
stored in migrations or a PostgreSQL-aware SQL splitter that preserves
semicolons inside literals, comments, and dollar-quoted bodies. Update the
migration loading/parsing flow and add a fixture plus test covering these cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/db/migrate.ts`:
- Around line 164-166: Replace the naive splitStatements-based iteration in the
migration runner with statement arrays stored in migrations or a
PostgreSQL-aware SQL splitter that preserves semicolons inside literals,
comments, and dollar-quoted bodies. Update the migration loading/parsing flow
and add a fixture plus test covering these cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4c3ce9ef-da1c-4a72-8483-f98584b84c5a

📥 Commits

Reviewing files that changed from the base of the PR and between 32c058a and c4e35dd.

📒 Files selected for processing (3)
  • src/db/client.test.ts
  • src/db/client.ts
  • src/db/migrate.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/db/client.test.ts
  • src/db/client.ts

@zaridan
zaridan merged commit 44a6280 into main Jul 10, 2026
5 checks passed
@zaridan
zaridan deleted the feat/ht-14-conversation-store branch July 10, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant