ai-relational-memory
v0.2.0
Published
Механика реляционной памяти ИИ-сотрудника: доступ к PostgreSQL драйвером JavaScript, миграции с учётом применённого и принятием живой базы — CLI и программный API; внешний клиент psql не нужен
Maintainers
Readme
ai-relational-memory
Механика реляционной памяти ИИ-сотрудника: доступ к PostgreSQL и миграции с учётом применённого. Пакет намеренно ничего не знает о предметной области — ни про людей, ни про проекты, ни про журнал. Схема данных, файлы миграций, сид и команды в терминах домена живут у потребителя; здесь — то, на чём они стоят: доступ к базе, две оси потолка, классификация отказов, порядок миграций, контрольные суммы, транзакции, принятие уже живой базы.
Node.js ≥ 18. Единственная зависимость — драйвер pg; он
приезжает вместе с пакетом, и внешний клиент psql не нужен. До версии 0.2.0 было наоборот:
пакет не тянул за собой ничего, но требовал установленного postgresql-client. Требование
оказалось непроходимым там, где нет системных прав, — а это ровно те машины, на которых работают
ИИ-сотрудники в песочнице.
npm install -g ai-relational-memory # или npx ai-relational-memory --help
export RELMEM_DATABASE_URL=postgres://… # строка подключения — из окружения; файлов с секретами пакет не читает
export RELMEM_MIGRATIONS_DIR=./migrations # каталог ВАШИХ файлов миграций
relmem ping
relmem status
relmem apply --dry-runНастройка
Приоритет: явные параметры программного вызова → переменные окружения → умолчания. Значение строки подключения пакет не печатает никогда — ни в выводе, ни в текстах отказа: если оно попало в текст ошибки сервера, оно оттуда вырезается. В аргументы дочернего процесса строка подключения тоже не попадает (уходит стандартным вводом) — значит, не видна и в списке процессов машины.
| Параметр | Переменная | Умолчание | Что это |
|---|---|---|---|
| url | RELMEM_DATABASE_URL | — | строка подключения |
| timeoutSec | RELMEM_TIMEOUT | 15 | потолок всего вызова: соединение плюс запрос |
| connectTimeoutSec | RELMEM_CONNECT_TIMEOUT | 5 | отдельный потолок установления соединения |
| maxBufferMb | RELMEM_MAX_BUFFER_MB | 64 | потолок РАЗМЕРА ответа |
| timeZone | RELMEM_TZ | пояс машины | пояс сеанса (PGTZ): от него зависит, как рендерится timestamptz |
| dir | RELMEM_MIGRATIONS_DIR | — | каталог файлов миграций |
| table | RELMEM_TABLE | schema_migration | таблица учёта применённого |
| appliedBy | RELMEM_APPLIED_BY | — | подпись автора применения; умолчания у механизма нет |
Потолок времени и потолок размера — разные оси, и путать их нельзя. Node гасит обращение одним и тем же сигналом в обоих случаях, поэтому «ответ не влез» разбирается по коду ошибки, а не по сигналу: иначе переполнение буфера выдаётся за недоступность базы, и чинят не то.
Поверхность пакета синхронна: query возвращает результат, а не обещание. Драйвер асинхронен,
поэтому обращение уходит в короткий дочерний процесс, которого вызывающий ждёт синхронно. Это
осознанный выбор, а не недосмотр: синхронного драйвера PostgreSQL на чистом JavaScript не бывает
(сокеты в Node асинхронны по устройству), а асинхронная поверхность потребовала бы переписать всех
потребителей. Процесс на запрос здесь был и раньше — им был сам psql.
Команды
relmem ping # связь с базой: печатает версию сервера
relmem status [--json] # применено, к применению, принято, bootstrap, дрейф
relmem apply [--dry-run] # применить недостающие по порядку
relmem adopt [--through 0014_x.sql] # принять применённое в учёт, НИ ОДНОГО файла не выполняя
relmem verify # дрейф, пропавшие файлы, нарушенный порядок
relmem query --sql 'SELECT 1' [--json|--tuples] [--var имя=значение]Флаги, снимающие защиту, — каждый по своему поводу и только явным вызовом:
--allow-nonempty (непустая схема без учёта), --allow-drift (применённый файл изменился),
--allow-out-of-order (файл с номером ниже максимального применённого).
Коды возврата: 0 выполнено · 1 отказ механизма · 2 база недоступна · 3 ошибка вызова.
Разделены они не для красоты: «база не отвечает» и «миграции разъехались» лечатся разными людьми.
Механизм миграций
Файлы берутся из вашего каталога по маске NNNN_*.sql и сортируются по числовому префиксу, затем
по имени. Всё остальное в каталоге (README, сид, что угодно) механизм не замечает.
Применяется то, чего нет в таблице учёта, — повторный запуск не выполняет ничего. Это
идемпотентность механизма; идемпотентность содержимого файла (IF NOT EXISTS) остаётся вашей
заботой и от механизма не требуется: применённое второй раз не запускается.
Каждый файл — в своей транзакции вместе с записью в таблицу учёта, поэтому применённое и
отметка о применении не могут разъехаться. Разбор останавливается на первом же отказе: операторы
уходят на сервер по одному, и недосланный COMMIT означает откат — иначе прогон продолжался бы
после ошибки и отмечал успех на половине файла.
Файл с номером ниже максимального применённого (появился в ветке, пока в основной уже ушёл более поздний) — отказ, а не тихое применение вне порядка. Порядок и есть то, ради чего механизм существует; снимается флагом.
Таблица учёта
| Колонка | Зачем |
|---|---|
| name (PK) | имя файла целиком — сортировка и идентичность в одном значении |
| checksum | sha256 содержимого: правка применённого файла обязана быть видна |
| applied_at | когда |
| applied_by | кто (значение даёте вы) |
| duration_ms | сколько шло — на живой базе это единственный след стоимости |
| adopted | принято, а не выполнено: строка появилась из adopt, файл не запускался |
| bootstrap | файл помечен как выполняемый вне механизма |
adopted — не украшение: без него журнал утверждает, что механизм выполнил файлы, которых он не
выполнял, и через полгода по такому журналу нельзя понять, проверялась ли миграция вообще.
Директивы файла
Читаются из шапки — строк-комментариев до первой строки с кодом; форма -- relmem: <директива>.
Директива живёт рядом с файлом, а не в отдельном манифесте: разъехаться нечему. Неизвестная
директива — отказ, а не тихое игнорирование (опечатка в защите хуже отсутствия защиты).
bootstrap— механизм не выполняет файл никогда. Это про создание роли и базы: другая база, суперпользователь, разовый ручной запуск. Учитывается отдельно, чтобыstatusне показывал такой файл вечно недостающим.no-transaction— файл выполняется вне транзакции (CREATE DATABASE,CREATE INDEX CONCURRENTLY,ALTER TYPE … ADD VALUEв транзакции жить не могут). Отметка о применении тогда ставится отдельным запросом после успеха, и это более слабая гарантия: обрыв ровно между выполнением и отметкой оставит файл выполненным, но неучтённым.
Непустая база без учёта не мигрируется
Инвариант, ради которого механизм и написан:
Таблицы учёта нет, а в схеме уже есть отношения — применять нельзя.
apply в этом состоянии отказывается с ненулевым кодом и называет команду принятия. Переустановить
схему поверх живых данных перестаёт быть возможным по построению, а не по внимательности того, кто
набирает команду. Если база и правда должна заполняться с нуля — это говорится явным флагом
--allow-nonempty.
Принятие живой базы
relmem adopt --through 0014_reviewer_condition.sql
relmem statusadopt записывает файлы в таблицу учёта как применённые, не выполняя ни одного, и помечает
строки признаком adopted. Границу задаёт --through <файл> (включительно); без него принимаются
все найденные. Кроме создания самой таблицы учёта никаких изменений схемы и данных не происходит,
уже учтённые строки не переписываются, а повторный запуск — пустая операция.
Это штатный способ перевести под управление механизмом базу, к которой миграции применялись руками.
Программный API
CLI и API — одна и та же механика; API нужен обёртке, у которой поверх механики стоит предметная область.
import { createDb, createMigrator } from 'ai-relational-memory';
const db = createDb({
url: process.env.MY_DATABASE_URL,
timeoutSec: 15,
// Имена, которыми настройки называются в текстах отказа: человек должен увидеть СВОЮ переменную.
labels: { url: 'MY_DATABASE_URL' },
toolName: 'my-memory',
});
const rows = db.queryJson('SELECT name FROM person WHERE name ILIKE :\'q\'', { q: '%иван%' }, { soft: true });
const migrator = createMigrator({ db, dir: './migrations', appliedBy: 'my-memory' });
console.log(migrator.status());Библиотека не завершает процесс. Отказ — это исключение с полем класса (DbError.kind:
no-url, no-driver, timeout, too-big, sql, bad-json; MigrateError.kind: not-empty,
drift, out-of-order, no-dir, bad-table, bad-directive, verify), а решение «упасть или
деградировать» принимает вызывающий: у него для этого больше сведений. Для второго варианта есть
мягкий режим { soft: true } — вместо исключения null и названная причина в onSoftError.
Самопроверка
node selftest.mjs # герметичная половина: базы не нужно
RELMEM_SELFTEST_DATABASE_URL=postgres://… node selftest.mjs # плюс живая половинаЖивая половина работает на своей временной схеме, создаёт и удаляет её сама и чужой схемы не касается. Без переменной она пропускается с названной причиной, а не молча.
Лицензия
MIT.
