-- Ported from the SQLite migration of the same number. Same version, same -- meaning, different spelling: SQLite's storage classes and PostgreSQL's types -- do not line up, and one shared file would be a promise neither could keep. -- -- Times are milliseconds since the epoch in BIGINT, exactly as in SQLite, -- rather than TIMESTAMPTZ. The Go code reads and writes int64 milliseconds -- everywhere; converting here would mean two time representations in one -- codebase, and the conversion would be the thing that is wrong at 2am. -- 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 BIGINT; 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 BIGINT 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 BIGINT 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 BIGINT 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) );