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, які хтосьці мусіць падтрымліваць». Перагрузка сістэмы без профіту: няма ніводнай задачы, якую тут база рабіла б лепш за файлы.
- Гарантыі канкурэнтнасці, дзеля якіх звычайна бяруць базу, дае 33-радковы м'ютэкс: сервер адзін, працэс адзін.
- Даныя лакальныя і маленькія: нават вялікі праект — гэта сотні вузлоў, мегабайты YAML. Поўнае чытанне іерархіі таннейшае за паход у базу па сетцы.
- Гісторыя, бэкап і рэв'ю змен плана — гэта проста git, бясплатна. З базай тое самае давялося б будаваць рукамі.
- Усталяванне трывіяльнае: ні дэмана, ні міграцый. Скланіраваў, запусціў, працуе.
Цана рашэння — дысцыпліна: кожная мутацыя абавязаная ісці праз withProjectLock і atomicWrite. Гэтую дысцыпліну ахоўваюць тэсты, цяпер іх 746, і сярод іх ёсць менавіта той сцэнарый, які мяне ўкусіў: 50 паралельных updateNode прызначаюць рэліз, тэст правярае, што выжылі ўсе 50. Раней выжывалі сем-восем.
Калі заўтра спатрэбіцца шматкарыстальніцкі сервер з тысячамі праектаў, вазьму Postgres без шкадавання — на тым маштабе ў базы з'яўляецца праца. Але для інструмента, які жыве побач з кодам і чый асноўны карыстальнік — агент, база вырашала б праблемы, якіх няма, і адгароджвала б даныя ад таго, хто з імі працуе.
Далей
У частцы 3 разбяру мадэль планавання: іерархію вузлоў, патрабаванні са спадкаваннем уніз па дрэве і User Story Map з рэлізамі. Гэта значыць тое, дзеля чаго ўся гэтая файлавая машынерыя ўвогуле існуе. Паспрабаваць My Architect можна на my-architect.app.