CI / test (push) Successful in 1m39s
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>
49 lines
2.2 KiB
SQL
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)
|
|
);
|