Files
llmgw/internal/store/migrations/postgres/0007_memory.sql
T
Ilker ManapandClaude Opus 5 a76ebdb79a
CI / test (push) Successful in 3m11s
Run the store on PostgreSQL, chosen by what the address looks like
A path is still SQLite and a postgres:// URL is PostgreSQL, so every existing
configuration keeps working untouched and moving is one line changed rather
than a new setting to find. An address of the form @/path/to/file reads the
real one out of that file, which is how a DSN carrying a password stays out of
a configuration that gets copied, diffed and pasted into issues.

The driver is pgx, which is pure Go. That is not a preference: the whole binary
is built with CGO_ENABLED=0 so it runs on a node whose libc is older than the
builder's, and a cgo driver would take that away from every deployment,
including the ones still on SQLite.

Tested against a real PostgreSQL, which found two things nothing else could.
The schema_migrations table is created in Go rather than in a migration file,
so it escaped the port and still said INTEGER — 64 bits in SQLite, 32 in
PostgreSQL, and a millisecond timestamp does not fit, so the very first
migration failed to record that it had run. And the memory migration uses an
FTS5 virtual table with three triggers keeping it in step, which has no
counterpart: PostgreSQL derives a tsvector from the row, so there is nothing
to keep in step and no trigger to forget.

That leaves exactly one query that is not the same sentence in both dialects —
the note search, MATCH against @@ — and it is written out twice rather than
hidden behind a shared spelling that would mean something subtly different in
each. Everything else is one set of queries.

The text search uses the 'simple' configuration rather than English. These are
an agent's own notes, in whatever language it chose, and stemming English
rules over Turkish would quietly stop finding things.

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

42 lines
2.0 KiB
SQL

-- Ported from the SQLite migration of the same number, and the only one that
-- is not a mechanical translation.
--
-- SQLite gets full-text search from an FTS5 virtual table with an external
-- content table and three triggers keeping it in step. PostgreSQL derives a
-- tsvector from the row itself, so there is nothing to keep in step and no
-- trigger to forget: the column cannot disagree with the row it is computed
-- from.
--
-- The search query differs too, and that difference lives in the Go code
-- rather than being hidden here — a query that means "match these words" is
-- spelled MATCH in one and @@ in the other, and pretending otherwise would
-- cost more than admitting it.
ALTER TABLE projects ADD COLUMN memory_retention_hours BIGINT;
CREATE TABLE memory_notes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-- Scoped to a project and checked in SQL on every read, like threads.
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
-- A namespace inside the project, so two agents sharing a key do not read
-- each other's notes.
agent_id TEXT NOT NULL,
key TEXT NOT NULL,
text TEXT NOT NULL,
-- Normalised to ",a,b," so a tag matches whole, never as part of another.
tags TEXT NOT NULL DEFAULT '',
created_at BIGINT NOT NULL,
-- Touched on every write, so retention measures how long a note has gone
-- unrefreshed rather than how old it is.
updated_at BIGINT NOT NULL,
-- 'simple' rather than a language configuration: these are an agent's own
-- notes, in whatever language it chose, and stemming English rules over
-- Turkish text would quietly stop finding things.
search tsvector GENERATED ALWAYS AS (
to_tsvector('simple', coalesce(key, '') || ' ' || coalesce(text, '') || ' ' || coalesce(tags, ''))
) STORED,
UNIQUE (project_id, agent_id, key)
);
CREATE INDEX memory_notes_by_updated ON memory_notes(updated_at);
CREATE INDEX memory_notes_search ON memory_notes USING GIN (search);