← Назад Harupa
2026-06-05RU EN UA BY PL

My Architect, частина 2: архітектура без бази даних

Це друга стаття серії про My Architect. У частині 1 я розповів, навіщо агенту пам'ять між сесіями і як один проєкт обслуговує два інтерфейси: canvas для людини і MCP для агента. Тепер про те, на чому все це стоїть. Спойлер: бази даних там немає, і це свідоме рішення, за яке я одного разу заплатив втраченими даними.

Сховище: просто файли

Весь проєкт на диску виглядає так:

workspace.yaml
projects/<id>/
  project.yaml              # метадані, релізи
  hierarchy/hierarchy.yaml  # дерево вузлів
  requirements/             # FR, NFR, SAR, CON
  diagrams/<id>.yaml + .dsl # метадані + текстовий DSL
  docs/<id>.md              # документи вузлів

Ієрархія і вимоги лежать у YAML, документація в Markdown, діаграми зберігаються як YAML-метадані плюс текстовий DSL. Усе читається очима і редагується будь-яким редактором. Можна відкрити hierarchy.yaml, перейменувати вузол руками, і система це підхопить. Можна покласти workspace у git і отримати історію всіх рішень безкоштовно: кожну зміну плану видно в дифі. І жодного lock-in: якщо My Architect завтра зникне, ваші дані залишаться звичайними текстовими файлами.

Стек навколо файлів скромний. Бекенд на Fastify і TypeScript слухає localhost:3100, фронтенд на React 19, MCP-сервер спілкується з агентом через stdio. Важлива деталь: MCP-інструменти не лізуть у файли напряму, вони ходять у той самий HTTP API, що й браузер. Одна доменна логіка, одні правила валідації, два клієнти.

Баг, який змусив написати м'ютекс

Файли замість бази означають, що транзакції тепер моя проблема. Я це знав теоретично. Практично мене наздогнало на інструменті plan_release.

Сценарій: агент планує реліз і призначає на нього пів сотні вузлів. Перша версія plan_release чесно випускала по одному PUT-запиту на вузол, усі паралельно. Кожен запит робив класичний read-modify-write: прочитав hierarchy.yaml цілком, змінив один вузол, записав файл назад. П'ятдесят запитів прочитали той самий знімок файлу майже одночасно, і кожен записав свою версію поверх чужої. Last-writer-wins у чистому вигляді: вижили призначення лише тих вузлів, чий запис прийшов останнім. Близько 85% призначень просто випарувалися. Агент відзвітував про успіх, людина відкрила User Story Map і побачила напівпорожній реліз.

Лікування вийшло двошаровим. По-перше, з'явився bulk-ендпоінт PUT /hierarchy/nodes/bulk: plan_release тепер надсилає всі призначення одним запитом, і це один read-modify-write замість п'ятдесяти. По-друге, і це головне, з'явився project-mutex: асинхронний лок на ключ проєкту, через який проходять усі мутації.

const chains = new Map<string, Promise<unknown>>();
const owners = new AsyncLocalStorage<Set<string>>();

export async function withProjectLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
  const held = owners.getStore();
  if (held?.has(key)) return fn(); // re-entrant: лок уже наш

  const prev = chains.get(key) ?? Promise.resolve();
  // ... стаємо у FIFO-чергу за prev
}

Весь файл займає 33 рядки. Операції над одним проєктом шикуються у FIFO-чергу через ланцюжок промісів, різні проєкти працюють паралельно і одне одного не чекають. Лок реентерабельний: власник відстежується через AsyncLocalStorage, тому доменна функція під локом може викликати іншу доменну функцію того самого проєкту і не зависне в дедлоці. Без цього довелося б плодити «внутрішні» версії кожної функції без лока, а це певний шлях до того, що хтось одного разу викличе не ту.

Файлове сховище не звільняє від конкурентності. Воно лише робить її наслідки наочними: ось файл, ось два писці, ось зниклі рядки в дифі.

Атомарний запис і file watcher

Друга пастка файлового сховища: читач може побачити наполовину записаний файл. Рішення старе як POSIX: писати в тимчасовий файл і перейменовувати.

const tmpPath = `${filePath}.tmp.${Date.now()}.${random}`;
await fs.writeFile(tmpPath, content, 'utf-8');
await fs.rename(tmpPath, filePath);

rename на POSIX-системах атомарний: файл за цільовим шляхом або старий цілком, або новий цілком, проміжного стану не існує. Через цю функцію atomicWrite ідуть усі записи: ієрархія, вимоги, документи, діаграми. А watcher налаштований ігнорувати маску */.tmp.*, тож тимчасові файли не породжують хибних подій.

Жива синхронізація

Watcher побудований на chokidar і стежить за всім workspace. Коли файл змінюється, подія класифікується за шляхом: hierarchy/ означає «перечитати дерево», requirements/ означає «перечитати вимоги», і так далі. Далі дебаунс 100 мс, читання свіжих даних і broadcast у браузер через WebSocket. Canvas оновлюється без перезавантаження сторінки.

Ця схема і дає ефект із першої частини: агент закрив задачу через MCP, файл змінився, людина бачить посірілий вузол прямо під час сесії. Працює і у зворотний бік: правка файлу руками або git checkout іншої гілки теж прилітають у браузер.

Одна тонкість: сервер сам пише файли і сам же за ними стежить, тому без захисту кожен власний запис повертався б луною. Хук markOwnWrite позначає файл перед записом, і watcher ігнорує події по ньому протягом 200 мс. Зовнішні правки проходять, власна луна гаситься.

Файли — це агентно-нативний дизайн

Рефлекс старої школи: інструмент зберігає проєкти, задачі й вимоги — отже, під ним база даних. My Architect проєктувався з іншої точки відліку: його головний користувач — агент, а рідне середовище агента — текстові файли. Claude Code і будь-який MCP-агент уміють читати, грепати і правити текст напряму; YAML з ієрархією і Markdown з доками агент розуміє без жодного посередника. Звичайний git diff плану проєкту — готовий матеріал для рев'ю, який агент читає так само вільно, як людина.

База даних у цій картині нічого не додає, зате забирає. Дані їдуть за SQL і схему, і навколо виростає шар обслуговування: міграції, конекти, docker-compose, бекапи, адмінка. Для агента все це непрозоро — замість «відкрив файл і прочитав» з'являється «сходи через API, який хтось має підтримувати». Перевантаження системи без профіту: немає жодної задачі, яку тут база робила б краще за файли.

Ціна рішення — дисципліна: кожна мутація зобов'язана йти через withProjectLock і atomicWrite. Цю дисципліну охороняють тести, зараз їх 746, і серед них є саме той сценарій, який мене вкусив: 50 паралельних updateNode призначають реліз, тест перевіряє, що вижили всі 50. Раніше виживали сім-вісім.

Якщо завтра знадобиться багатокористувацький сервер із тисячами проєктів, візьму Postgres без жалю — на тому масштабі в бази з'являється робота. Але для інструмента, який живе поруч із кодом і чий основний користувач — агент, база розв'язувала б проблеми, яких немає, і відгороджувала б дані від того, хто з ними працює.

Далі

У частині 3 розберу модель планування: ієрархію вузлів, вимоги зі спадкуванням вниз по дереву і User Story Map з релізами. Тобто те, заради чого вся ця файлова машинерія взагалі існує. Спробувати My Architect можна на my-architect.app.