Files
llmgw/internal/store/migrations/sqlite/0006_threads.sql
T
Ilker ManapandClaude Opus 5 b0592670a4
CI / test (push) Successful in 1m39s
Give projects somewhere to keep a conversation, off unless they ask
The storage half of continuing a thread on a different model than it started
on. Nothing uses it yet.

Until now llmgw stored no message content anywhere: usage rows carry token
counts, costs and latencies, never text, and even its own chat page keeps the
conversation in a JavaScript variable that a refresh discards. That property
is worth more than the feature, so it is the default and stays the default.
A project stores nothing until someone turns it on for that project.

Retention is a number of hours rather than a boolean, because a thread store
with no expiry is a transcript of everything a team ever typed, kept forever
because nobody chose a number. Turning it on means saying for how long. The
window measures idleness rather than age — expiring a conversation someone is
still having would be a strange way to keep that promise.

Threads are scoped to a project and the scope is part of every lookup, not a
check afterwards. Reading a thread from another project and reading one that
does not exist return the same error: two errors would let a caller learn
which ids are real in projects they cannot read. Writing across projects is
refused for the same reason in the other direction — append, then read your
own reply back.

Deleting a thread takes its messages with it, which the test checks by
counting rows rather than by trusting the cascade. Someone has to be able to
say "forget that", and a retention window is no answer to a message sent by
mistake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:05:34 +02:00

49 lines
2.2 KiB
SQL

-- Server-side conversations, so a thread can continue on a different model
-- than it started on.
--
-- Off unless a project turns it on. Until this migration ran, llmgw stored no
-- message content anywhere — usage rows carry token counts and costs, never
-- text — and that stays true for every project that does not ask. An on-prem
-- gateway keeping nothing anyone typed is much easier to put in front of a
-- team than one keeping everything, so storing is a choice a project makes
-- rather than a default it discovers.
-- retention_hours is the switch and the limit at once. NULL means this project
-- stores nothing, which is every project that existed before this and every
-- one created since without asking.
--
-- One column rather than a boolean and a duration: a thread store with no
-- expiry is a transcript of everything a team ever typed, kept forever by
-- omission. Turning it on therefore requires saying for how long.
ALTER TABLE projects ADD COLUMN thread_retention_hours INTEGER;
CREATE TABLE threads (
id TEXT PRIMARY KEY,
-- Scoped to a project, and checked on every read. Thread ids are opaque, but
-- an unguessable id is a secret and secrets leak into logs, shell history
-- and bug reports; the scope is what makes a leaked id useless to someone
-- outside the project.
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
-- Touched on every append, so retention measures idleness rather than age:
-- expiring a conversation someone is still having would be a strange way to
-- keep a promise about storage.
updated_at INTEGER NOT NULL
);
CREATE INDEX threads_by_project ON threads(project_id);
CREATE INDEX threads_by_updated ON threads(updated_at);
CREATE TABLE thread_messages (
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
-- Position rather than a timestamp, because order is the point and two
-- messages can share a millisecond.
seq INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
-- Which model produced an assistant turn. A thread that moved between models
-- otherwise reads as one voice, and when the answers disagree that is the
-- first thing anyone wants to know.
model TEXT,
PRIMARY KEY (thread_id, seq)
);