Andrey Epifancev 38990dcaf6 docs(openspec): aurora-1848 — решения R-1..R-5 без обсуждения
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:40:58 +04:00

245 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Design: тяжёлые GET — готовый JSON без pydantic-модели на строку и без null-полей (AURORA-1848, #233)
Мотивация и цифры — в proposal.md («Why»). Требования — в `specs/sheet-read-api/spec.md` и `specs/realtime-sheet-updates/spec.md`. Здесь описано, как их выполнить.
## Context
Ветка `test` @ 2a28748.
**Путь ответа сейчас** (FastAPI 0.111, pydantic 2.11, SQLAlchemy 2 + asyncpg):
1. Роутер замеряет `t0` и вызывает сервис, сервис вызывает репозиторий, репозиторий выполняет `SELECT row_type, depth, sort_order, data FROM v3.<функция>(...)`. Для jsonb SQLAlchemy регистрирует в asyncpg кодек с `json.loads`, поэтому каждая строка `data` разбирается в dict ещё до возврата из `execute`. Этот разбор попадает в окно `X-DB-Time-Ms`.
2. Роутер ставит `response.headers["X-DB-Time-Ms"]` и пишет лог `db_time`. Затем строит `SheetResponse` на каждую строку: `_rows_to_sheet_response` в `forms.py:27-35` и `summary.py:29-37`, `_rows_to_payload` в `projects.py:60-64` (dict, но всё равно через `response_model`).
3. Аннотация возврата `-> BaseListResponse[SheetResponse]` становится `response_model`. FastAPI валидирует результат, сериализует его, `JSONResponse` делает `json.dumps`, `GZipMiddleware(minimum_size=1000, compresslevel=1)` сжимает (`main.py:248`).
**Затронутые обработчики:**
| эндпоинт | роутер | сервис → репозиторий → SQL |
|---|---|---|
| GET `/form/{id}/sheet/{sheet}` | `forms.py:150-202` `get_sheet` | `SheetService.get` → `SheetRepository.get` (`sheet_repository.py:33-68`) → `v3.v_form_view(form, sheet, sections[, direction])` |
| GET `/svod/1/{sheet}/{year}` | `summary.py:50-90` | `SummaryService.get_svod1_rows` (проверка орг. `_allowed_org_ids`) → `SummaryRepository.get_svod1_rows` → `v3.v_summary_view` |
| GET `/svod/2/{sheet}/{year}` | `summary.py:93-146` | `get_svod2_rows` → `v3.v_svod2_go` / `v3.v_svod2_rf` (row_type и depth — константы в SELECT) |
| GET `/projects/{id}/report/{year}/{type}` | `projects.py:242-277` | `ProjectService.get_report_rows` (`resolve_report_id`) → `v3.v_form3_report_jsonb` |
| GET `/rf-rollup/{branch}/{year}` | `projects.py:280-312` | `get_rf_rollup_rows` → `v3.v_form3_rf_rollup_jsonb` |
| GET `/projects/{id}/summary` | `projects.py:315-338` | `get_project_summary_rows` → `v3.v_project_summary_jsonb` |
| GET `/projects/{plan,fact,detail}-summary` | `projects.py:142-226` + `_get_projects_summary_response` `:117-139` | `get_*_summary_rows` → `v3.v_project_{plan,fact,detail}_summary_jsonb` |
У всех функций столбец `data` имеет тип `jsonb`.
**Кто ещё читает те же данные** и не должен измениться:
- выгрузка Excel: `export_service.py:219, 236` — `SummaryService.get_svod1_rows/get_svod2_rows`; `:438` — `SheetService.get`; `:484, :561` — `ProjectService.get_report_rows`;
- WebSocket (`websocket.py`) целые листы через эти методы не читает. Он вызывает пути записи (`update_cell`, `add_line`, …), и они здесь не меняются;
- инструменты `perf/` читают GET листа: `perfkit/coauthor.py:52, :73`, `perfkit/gen/grow.py:45-67`, `perfkit/gen/run.py:248, 304, 467, 504-506`.
**Фронт.** Начальная загрузка листа и отчёта проекта — `useRealtimeData.js:36-58`, в обоих случаях потом используется `buildRowHierarchy`. Входящие правки идут через `updateCells` (`cellUtils.js:46-100`). На `test` правка вливается в блок `data[key]`, только если `row.data[key] !== undefined` (`:79`), иначе она молча теряется. PR #223 (AURORA-1838, открыт, не в `test`) переписывает `updateCells`: отсутствующий ключ вставляется (`mergeIntoRow`, ветка `else if (hasData)`), а глубже `mergeImmutable` и раньше создавал недостающие узлы.
## Goals / Non-Goals
**Goals:**
- Тело тяжёлых GET собирается из текста, который вернул Postgres, без разбора в dict и без модели на строку (PR A).
- `data` листа и сводов без null-полей, фронт не теряет правки соавторов (PR B).
- Доказательства: сравнение ответов до и после на стенде, тесты эквивалентности, замер `api_split.py`.
**Non-Goals:**
- Менять SQL-функции, данные в `data` (это делает AURORA-1846) и SQL листа FORM_2/AHR (это AURORA-1847).
- Постраничная загрузка свода 2 и листов (М10), смена уровня gzip, переход на orjson или другой сериализатор для остальных эндпоинтов.
- Менять ответы путей записи и сообщения WebSocket, выгрузку Excel.
- Убирать null из отчётов проектов (только PR A).
## Decisions
### Решения человека (согласовано 28.10)
1. GET листов и сводов потребляет только наш фронт. Выгрузка Excel и WebSocket идут своими путями (через репозитории и сервисы) и не меняются. Старые методы репозиториев для них остаются.
2. Два PR. **A** — готовый JSON из Postgres (`jsonb::text`) без pydantic-модели на строку. Ответ по смыслу тот же, это доказывается сравнением ответов всех листов и сводов на стенде до и после. **B** — без null-полей в `data` (`jsonb_strip_nulls`) и правка фронта, чтобы входящие правки соавторов по отсутствующим ключам не терялись. В B проверить, что `updateCells` из AURORA-1838 (PR #223) для этого достаточно, и покрыть тестом. B стеком на A.
3. Ответ на правку ячейки (PATCH `/cell(s)` и WebSocket `cell_updated`) продолжает отдавать `null` явно: очистка ячейки должна дойти до соавторов.
4. Отчёты проектов РФ (`projects.py`, `SheetResponse`) — тем же хелпером, отдельным коммитом в A.
5. Критерий успеха: полное время GET свода 2 GO и листа FORM_2/AHR на стенде (масштаб 1) падает не менее чем на 30 % (для листа уточнено в п. 7). «Доля Python ≤ 10 %» из Jira — справочная метрика. Заголовок `X-DB-Time-Ms` должен сохраниться: на нём держится методика `perf/analysis/api_split.py`.
6. `response_model` в декораторах остаётся ради OpenAPI.
7. Порог времени уточнён (заменяет порог для листа из п. 5). Свод 2 GO — полное время после B ниже минимум на 30 %. Лист FORM_2/AHR — минимум на 10 %, и распакованный размер меньше прежнего. Остальное ускорение листа — в AURORA-1847.
8. Пороги производительности не условие мержа. PR вливается по зелёным тестам и доказанной корректности (сравнение ответов exact / strip-nulls), цифры — в описании PR, недобор — в follow-up. Корректность — жёсткое условие.
### D1. Строка ответа собирается в SQL функцией `json_build_object`
Новый метод репозитория выполняет:
```sql
SELECT json_build_object(
'row_type', row_type, 'depth', depth,
'data', data, -- PR B: jsonb_strip_nulls(data)
'sort_order', sort_order)::text
FROM v3.<функция>(<те же аргументы, что в старом методе>)
```
Для свода 2 `row_type` и `depth` — те же константы, что в старом SELECT (`'INPUT'`, `3`).
- `json_build_object` (тип `json`) сохраняет порядок ключей обёртки строки. Аргумент jsonb он выводит своим текстовым представлением, то есть `data` сериализуется один раз.
- `::text` даёт тип `text`, и кодек `json.loads` у asyncpg не срабатывает.
- Порядок строк — порядок вывода функции, без `ORDER BY`, как сейчас.
Отвергнуто:
- `jsonb_build_object(...)::text`: пересобирает `data` во внутреннее представление jsonb и сортирует ключи обёртки (`data, depth, row_type, sort_order`). Это лишняя работа на 30–50 МБ.
- `format('{"row_type":%s,...}', ...)`: хрупко (экранирование строк, `NULL` → пустая подстановка), а выигрыш только в пробелах.
- `string_agg` всего ответа в одну строку в SQL: выигрыш на тысячах строк ничтожен, а `count` и порядок придётся выражать отдельно.
- Сериализация в Python через orjson без моделей: остаётся `json.loads` в драйвере, и нужна новая зависимость.
### D2. Новые методы рядом со старыми
- Репозитории: `SheetRepository.get_json`, `SummaryRepository.get_svod1_json` / `get_svod2_json`, `ProjectRepository.get_report_json`, `get_rf_rollup_json`, `get_project_summary_json`, `get_{plan,fact,detail}_summary_json`. Параметр `strip_nulls: bool = False` добавляется в PR B только к методам листа и сводов. Возврат — `list[str]`.
- Сервисы: методы `*_json` с теми же проверками, что у старых (`_allowed_org_ids`, `_allowed_summary_branch_ids`, `resolve_report_id`, `get_instance`, `AccessDeniedException`, ранний `[]` при пустом списке организаций).
- Старые методы (`get`, `get_svod1_rows`, `get_svod2_rows`, `get_report_rows`, …) **не меняются ни в одной строке**: их используют выгрузка и сравнительные тесты.
- Дублирование FROM-части устраняет исполнитель: вынести построение FROM-фрагмента и параметров в приватную функцию, которую вызывают и старый, и новый метод. Текст SQL старого метода после выноса должен остаться эквивалентным. Валидация `SheetRepository.get` (`:40-49`, `SheetValidationError`) обязательна и в `get_json`.
### D3. Хелпер ответа и роутеры
Новый модуль `api/src/api/v1/json_response.py`:
```python
def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -> Response:
body = b'{"success":true,"message":null,"result":[' + ",".join(rows).encode() + b'],"count":' + str(len(rows)).encode() + b"}"
return Response(content=body, media_type="application/json", headers=headers)
```
Точная форма кода — на усмотрение исполнителя. Инварианты: обёртка ровно как у `BaseListResponse` при успехе (`success`, `message`, `result`, `count`), UTF-8, `media_type="application/json"` (без `charset`, как у `JSONResponse`).
Роутеры (`get_sheet`, `get_summary`, `get_summary2`, шесть GET в `projects.py`):
- аннотация возврата меняется на `Response`, а `response_model=BaseListResponse[SheetResponse]` переносится в декоратор `@router.get(...)`. Схема OpenAPI остаётся прежней (требование «Схема OpenAPI не меняется»);
- **`X-DB-Time-Ms` ставится на возвращаемый `Response`** (`headers=`). Заголовки, записанные в инъецированный параметр `response: Response`, FastAPI переносит только при возврате не-Response значения. Параметр `response` из сигнатуры убрать;
- окно замера то же: от `t0` до возврата строк из сервиса. Лог `db_time` с прежними полями;
- проверки до запроса (404 формы и листа, `_validate_sheet_query_params`, недопустимые `sections`, список листов свода) не меняются;
- `_rows_to_sheet_response` в `forms.py` остаётся: её используют PATCH, POST и DELETE. В `summary.py` она становится неиспользуемой и удаляется. `_rows_to_payload` в `projects.py` остаётся для правок FORM_3.
`StreamingResponse` не используется (см. «Decisions made without discussion»).
### D4. PR B: `jsonb_strip_nulls` для листа и сводов
- В `get_json`, `get_svod1_json`, `get_svod2_json` выражение `data` заменяется на `jsonb_strip_nulls(data)`. Роутеры листа и сводов вызывают методы со `strip_nulls=True`. Отчёты проектов — нет.
- Семантика PG 15: функция рекурсивно удаляет поля объектов со значением `null`, в том числе в объектах внутри массивов. Элементы массивов, равные `null`, остаются, пустые объекты `{}` остаются. Строка META (`editable[].closes_at = null`) тоже теряет null-поля, фронт читает из неё только `editable[].column` (`useRealtimeData.js:49-50`).
- Обёртка строки (`row_type`, `depth`, `sort_order`) не трогается: `sort_order` может остаться `null`.
### D5. PR B: фронт
1. **`updateCells`.** Предусловие B — PR #223 смержен в `test`, и ветка B включает его (rebase на `test` после merge A и #223). B проверяет, что ветка `else if (hasData)` в `mergeIntoRow` и `mergeImmutable(undefined, …)` вставляют отсутствующий блок и отсутствующий лист, и добавляет тесты в `web/src/components/RealtimeTable/utils/cellUtils.test.js`:
- строка без блока `q1`, приходит `{q1: {m1: 5}}` → `row.data.q1.m1 === 5`;
- блок `q1` есть, но без `m1`, приходит `{q1: {m1: 5}}` → значение вставлено, остальные поля `q1` сохранены;
- строка с `q1.m1 = 5`, приходит `{q1: {m1: null}}` → `row.data.q1.m1 === null`;
- отсутствующий скалярный ключ верхнего уровня `data` (например, `line_id`) вставляется.
Если какой-то тест падает на версии #223, правка вносится в `cellUtils.js` в B.
2. **Отправка правки с отсутствующим значением (найдено при сверке).** `EditCell.handleSave` (`EditCell.jsx:117-148`) при выходе из редактирования всегда вызывает `onChange(normalizeNumericInput(currentValue))`, а `RealtimeContext.updateCell` (`RealtimeContext.js:304-330`) кладёт значение в `data.value`. Для отсутствующего ключа `currentValue === undefined`, `JSON.stringify` опускает `value`, и сервер падает на `event_data["value"]` (`websocket.py:472`, для FORM_3 — `:703`): пользователь получит ошибку там, где раньше уходил `null`. Исправление на фронте: в `updateCell` нормализовать `undefined` в `null` (`value: value === undefined ? null : value`), чистую функцию вынести в `.js` и покрыть тестом. Сервер не трогаем (решение 1: WebSocket не меняется).
3. **Аудит отличий `null` от `undefined`** на страницах листа, отчёта проекта, свода 1 и свода 2. При сверке проверено: `validation.js:94-209`, `Cell.jsx:149-156`, `tableColumns.jsx:88`, `SummaryTable.jsx:25, 71-72`, `Svod2Page.jsx:38-39, 354-365`, `SvodPage.jsx:53-54`, `RealtimeTable.jsx:48-57`, `rowUtils.js` — все обрабатывают и `undefined`. Сортировка на клиенте в сводах выключена (`enableSorting: false`). Проверок `in` / `hasOwnProperty` по `data` нет. Исполнитель повторяет поиск (`=== null`, `!== null`, `typeof … === 'object'`, `in `, `hasOwnProperty`, `Object.keys/entries` по `data`) по `web/src` на ветке B, потому что за время работы в `test` могли влиться другие PR, и фиксирует результат в описании PR.
4. **Потребители в `perf/`.** `perfkit/gen/grow.py:67` (`r["data"]["header"]["project_id"]` для ITEM) и аналогичные обращения через `[...]` к полям, которые могут быть `null`, заменить на `.get(...)`, чтобы генератор стенда не падал на ответах B.
### D6. Тесты
**Unit (`api/tests/unit/test_json_list_response.py`, PR A):**
- тело хелпера разбирается `json.loads` в `{"success": True, "message": None, "result": [...], "count": n}`;
- пустой список, одна строка, кириллица и экранированные символы в строке;
- `media_type`, переданные заголовки.
**Интеграционные (`api/tests/integration/test_heavy_get_json.py`, PR A, дополняются в B).** Эталон — старый путь в той же сессии: `client.portal.call(...)` поверх `SheetRepository(db_session).get(...)`, `SummaryRepository.get_svod1_rows/get_svod2_rows`, `ProjectRepository.get_*_rows`, превращённый в `[{"row_type", "depth", "sort_order", "data"}]`. Так же устроен тест AURORA-1846, см. ниже про `db_session`.
- лист: FORM_1 AHR Support (форма 1 фикстуры), лист без `direction`, вариант с `sections`. `response.json()["result"] == эталон` (A) или `== strip_nulls(эталон)` (B). `count == len(result)`;
- свод 1 по всем `VALID_SUMMARY_SHEETS`, свод 2 GO/RF без параметров, с `org_ids`, с `offset/limit`, с `sort_by/sort_direction`;
- отчёты проектов (A): проект создаётся в тесте, как в `test_projects_api_smoke.py`, сравниваются report, project summary и plan/fact/detail-summary. В B — проверка, что `null` в отчётах остались;
- заголовок `X-DB-Time-Ms` есть и разбирается как число на всех девяти эндпоинтах;
- роль: `isp_tokens` получает свод только по своим организациям, как и через старый путь;
- OpenAPI: у девяти путей схема ответа 200 ссылается на ту же компоненту, что в эталонном снимке. Снимок снимается на базовом коммите и кладётся в тест как литерал (имя компоненты);
- B: PATCH ячейки со значением `null` → в строке ответа ключ присутствует со значением `null` (требование «Ответы на правку отдают null явно»);
- рекурсивный эталон `strip_nulls` на Python пишется в тесте так же, как работает PG: удаляются поля объектов, `null` в массивах остаются, `{}` остаётся.
Сравнение чисел: эталон приходит через `json.loads` драйвера (float), новый ответ — через `response.json()` (float). Равенство точное.
Фикстура `db_session`. AURORA-1846 (PR #232) добавляет её в `api/tests/integration/conftest.py` (`client` начинает зависеть от `db_session`). Если к началу работы #232 не в `test`, внести в A **тот же самый хунк**: при одинаковых правках git сливает без конфликта.
Существующие тесты: `test_summary_api_smoke.py`, `test_forms_api_smoke.py`, `test_db_functions_integration.py:79-93` должны проходить в A без изменений. В B `test_summary2_sheets_smoke` проверяет наличие ключей `header.smeta_direction` и блоков `data`. Если у первой строки эти значения `null`, тест меняется так: проверять ключи только у строк, где значение не `null`, или проверять набор ключей по эталону со `strip_nulls`. Это ожидаемое изменение контракта, в PR его нужно оговорить.
**Окружение тестов бэка.** Полный `pytest` гоняется на PG16: контейнер `perf-pg`, порт 55432, своя БД `dfip_1848`.
- Создать: `docker exec perf-pg createdb -U perf dfip_1848`. Пересоздать: `dropdb` + `createdb`, затем `--init_db`.
- БД `perf` не трогать.
- Пароли `OPENBAO__TEST_*` по умолчанию `admin123`.
- Команда: `cd api && DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1848 <venv>/bin/pytest --init_db -q -p no:cacheprovider -W ignore`.
- Совместимость с PG 15.12 (`dfip-pg15:55434`) проверяет контроллер. Новый SQL этого change — `json_build_object`, `jsonb_strip_nulls`, `::text` — есть в PG 9.5+.
**Фронт (B):** `cd web && node --test src/components/RealtimeTable/utils/*.test.js`, `npx biome check <изменённые файлы>`, `npm run build`.
**Ручная проверка (B, чек-лист PR):**
- на FORM_2/AHR второй пользователь заполняет пустую ячейку, первый видит значение без перезагрузки;
- то же с очисткой;
- вход в пустую ячейку и выход без ввода — без ошибки;
- свод 2: пустые ячейки показывают «—», поиск работает;
- отчёт проекта не изменился.
Сценарий соавтора на стенде — после ночного прогона: `PERF_TARGETS='FORM_2/AHR/*' PERF_COAUTHOR_RATES=1 PERF_REPEATS=1`, `page_ws_updates > 0`.
### D7. Замер и сравнение на стенде
Замеры на стенде выполняет контроллер после основных ночных замеров. В плане это точка ожидания. PR A и B открываются без цифр, цифры дописываются в описание PR после замера. Пороги не блокируют мерж (решение человека 8), а сравнение ответов (п. 1 ниже) — обязательное доказательство корректности до мержа. Замеры не идут параллельно с другими замерами и тестами на `perf-pg`: общий `pg_stat_statements`.
Данные: масштаб 1, одна и та же БД `perf` для «до» и «после» (восстановить из дампа s1 перед серией). База сравнения для порогов — `test` на момент замера «до» A. Миграции «до» и «после» одинаковые: у A и B миграций нет. Если до замера «после» в `test` влилась миграция или правка SQL листа (AURORA-1847, 1846, 1844), серия «до» переснимается на новой базе.
1. **Сравнение ответов** — новый скрипт `perf/analysis/compare_responses.py` (PR A):
- `dump --scale 1 --out <dir>` обходит по `results/manifest-s1.json` все листы (все `owner`, с `direction` где есть, плюс один вариант с `sections` для FORM_2/AHR), своды 1 (`FORM_1..4`, `MAIN`) и 2 (`GO`, `RF`) за год манифеста, отчёты и сводные проектов. Запросы идут под ролью из манифеста (`rf` для своих листов, `dfip` для сводов), ответы сохраняются как `.json.gz` по имени запроса;
- `compare <before> <after> --mode exact|strip-nulls` сравнивает `json.loads` ответов. В режиме `strip-nulls` сравниваются `after` и рекурсивно очищенный `before`, но только для листов и сводов, отчёты проектов сравниваются точно. Вывод — число совпавших и список расхождений с путём до первого отличия, код возврата ≠ 0 при расхождении.
Порядок: `dump` на базовом коммите → `dump` на A → `compare --mode exact` → `dump` на B → `compare --mode strip-nulls` (с дампом «до»).
2. **Время.** `cd perf && .venv/bin/python analysis/api_split.py --scale 1 --repeats 10 --out report-data/<tag>-1848-<before|A|B>/analysis` три раза: на базовом коммите, на A, на B. API перезапускается на каждом коммите. Метрики:
- `total_ms` свода 2 GO: порог «B ≤ 0,7 × до»;
- `total_ms` листа FORM_2/AHR: порог «B ≤ 0,9 × до»;
- `total_ms` всех запросов набора: «A ≤ 1,05 × до»;
- `bytes` FORM_2/AHR: «B < до»;
- справочно `python_share` и новое поле `wire_bytes`.
3. **`api_split.py`, PR A:** добавить поле `wire_bytes` (`r.num_bytes_downloaded`, размер на проводе) и колонку в `.md`. Существующие поля и их смысл не меняются.
4. Ссылка на `X-DB-Time-Ms` в docstring `api_split.py` (номера строк `forms.py:184` и др.) обновляется на новые места.
Оценка (не замер): разбор jsonb драйвером синтетически ≈ 8 мс/МБ. Это ≈ 60 мс из 726 мс `X-DB-Time-Ms` листа FORM_2/AHR и ≈ 240 мс из 718 мс свода 2 GO.
## Risks / Trade-offs
- **[Порог для FORM_2/AHR]** По замеру 26.10: полное время 813 мс, `X-DB-Time-Ms` 726 мс, остаток 87 мс. Задача убирает остаток (кроме gzip, передачи и распаковки) и разбор jsonb (≈ 60 мс по оценке выше), ожидаемое снижение — порядка 12–20 %. Поэтому человек 28.10 снизил порог для листа до −10 % плюс уменьшение размера, а остальное отдал AURORA-1847. Для свода 2 GO порог −30 % реален: ≈ 240 мс разбора и большая часть 339 мс остатка. → Пороги не блокируют мерж. Недобор — follow-up с цифрами разложения.
- **[`jsonb::text` пишет `": "` и `", "`]** В A распакованный размер может вырасти примерно на 10–14 %, gzip уровня 1 обрабатывает больше байт. → Порог A — «не медленнее 5 %». Уменьшение размера даёт только B (критерий Jira).
- **[Ответ в обход `response_model` не валидируется]** Ошибка формы ответа не будет поймана FastAPI. → Тесты эквивалентности со старым путём на всех девяти эндпоинтах, проверка OpenAPI, сравнение на стенде.
- **[Заголовок теряется при возврате `Response`]** → Заголовок ставится на возвращаемый объект, тест на всех эндпоинтах, `api_split.py` падает на `nan` (сценарий в спеке).
- **[Потеря правок соавторов в B]** → Предусловие #223, тесты `cellUtils.test.js`, ручной сценарий. B не мержится раньше #223.
- **[`value` пропадает из сообщения WebSocket в B]** → Нормализация `undefined → null` в `updateCell` (D5.2) и тест.
- **[Скрытые проверки `=== null` на фронте]** → Аудит (D5.3) с перечнем проверенного, ручной чек-лист.
- **[В dev-сборке TanStack Table пишет `console.warn` для `undefined` во вложенном `accessorKey`]** В prod-сборке этого нет. Для `null`-блоков предупреждение появлялось и раньше. → Не исправляем, отмечаем в PR B.
- **[Выгрузка или WebSocket случайно переходят на новые методы]** → Старые методы не меняются. В ревью — дифф `export_service/` и `websocket.py` должен быть пустым (кроме D5.2, это фронт). Интеграционные тесты выгрузки остаются зелёными.
- **[Пиковая память]** Тело ответа 30–50 МБ (масштаб 2 — до ~200 МБ) собирается одним `bytes`, плюс список строк того же объёма. Это меньше, чем дерево dict, pydantic-модели и строка `json.dumps` сейчас. → Приемлемо, стриминг не нужен.
- **[Конфликты с параллельными PR]** `conftest.py` с #232 (одинаковый хунк), `cellUtils.js` с #223 (B после #223). Миграций нет, конфликта номеров 0050 у этого change нет.
## Migration Plan
- Один план superpowers в две фазы, A и B, под issue #233.
- Ветка A — `feat/233-aurora-1848-sheet-json-no-pydantic`. Ветка B — та же с суффиксом `-b`, стеком на ветке A.
- Порядок: PR A → merge в `test` → PR B (rebase на `test` после merge A и #223) → merge. B вливается только после A и PR #223.
- `openspec archive aurora-1848-sheet-json` — только после merge B. Иначе в `openspec/specs/` попадут требования PR B (без null-полей, фронт) без кода.
- Миграций БД нет, деплой — обычный выкат API и фронта. B выкатывается одновременно с фронтом B, потому что это одна сборка (фронт отдаёт API).
- Откат: revert PR B возвращает null-поля, фронт B совместим и с ними. Revert PR A возвращает модели. Данные не затрагиваются.
## Decisions made without discussion
| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 2026-10-28 | Строка собирается `json_build_object(...)::text`, а не `jsonb_build_object` и не `format()` (D1) | Порядок ключей обёртки сохраняется, `data` сериализуется один раз, нет ручного экранирования | Низкая: замена выражения в одном SQL-фрагменте |
| 2026-10-28 | Ответ собирается из списка строк (по строке на ряд) в Python, а не одной `string_agg` в SQL | `count` и порядок как сейчас, проще эквивалентность; разница в стоимости ничтожна при тысячах строк | Низкая |
| 2026-10-28 | Обычный `Response`, без `StreamingResponse` | Сессия БД не держится открытой на время отправки; GZipMiddleware и Content-Length работают как раньше; память ниже текущей | Средняя только на масштабе 2+ (пиковая память); переделка локальна в хелпере |
| 2026-10-28 | Имена новых методов `*_json` и параметр `strip_nulls` в репозиториях листа и сводов | Явная пара «старый метод для выгрузки / новый для GET»; одно место включения B | Низкая: переименование |
| 2026-10-28 | «Отчёты проектов РФ (projects.py, SheetResponse)» понято как все шесть GET в `projects.py` с `BaseListResponse[SheetResponse]`: report, rf-rollup, project summary, plan/fact/detail-summary | Все объявляют `SheetResponse` и строятся одинаково | Низкая: лишние 2–3 эндпоинта в отдельном коммите, их легко исключить |
| 2026-10-28 | В PR B null убираются только у листа и сводов 1/2; отчёты проектов отдают null как раньше | Человек отнёс отчёты проектов к A; их ответ мал (1,5 МБ), а проверять фронт проектов отдельно — лишний объём | Недополученный выигрыш на отчётах проектов; можно сделать follow-up |
| 2026-10-28 | `jsonb_strip_nulls` применяется ко всем строкам ответа, включая META | Единое правило; фронт читает из META только `editable[].column` | Низкая: если что-то читает null-поля META, исключить её одним условием |
| 2026-10-28 | Пороги времени (−30 % свод 2 GO, −10 % FORM_2/AHR) проверяются на итоговом состоянии (после B); для A — «не медленнее 5 % ни на одном запросе `api_split.py`» | Человек задал критерий задачи целиком; A ради эквивалентности может увеличить размер ответа | Низкая: пороги не блокируют мерж (решение человека 8) |
| 2026-10-28 | Критерий Jira «размер ответа FORM_2/AHR уменьшился» включён в спеку как требование к B | Он есть в приёмке Jira, человек его не отменял | Низкая |
| 2026-10-28 | В `api_split.py` добавляется поле `wire_bytes` (размер на проводе), прежние поля не меняются | Эффект B на сжатый размер иначе не виден | Низкая |
| 2026-10-28 | Новый скрипт `perf/analysis/compare_responses.py` (dump/compare) в PR A | Доказательство эквивалентности на всех листах и сводах стенда требует повторяемой команды | Низкая |
| 2026-10-28 | Фикстура `db_session` вносится тем же хунком, что в AURORA-1846, если #232 ещё не в `test` | Тесту эквивалентности нужен старый путь в той же сессии; одинаковый хунк сливается без конфликта | Низкая: ручное слияние conftest |
| 2026-10-28 | PR B не мержится раньше PR #223 (AURORA-1838); при задержке #223 B ждёт, а не дублирует `updateCells` | Две реализации `updateCells` дадут конфликт и двойное ревью | Задержка B до #223 |
| 2026-10-28 | Пустое значение правки нормализуется в `null` на фронте (`updateCell`), сервер WebSocket не меняется (D5.2) | Человек зафиксировал «WebSocket не меняется»; дефект проявляется только из-за B | Если есть другой клиент WebSocket, он сохранит старое поведение (его нет) |
| 2026-10-28 | B правит потребителей GET в `perf/perfkit` (`[...]` → `.get`) для полей, которые могут исчезнуть | Генератор и бот стенда — наши инструменты замера; падение сорвёт повторный прогон | Низкая |
| 2026-10-28 | В B тест `test_summary2_sheets_smoke` ослабляется для ключей с `null`, если он на них падает | Это прямое следствие нового контракта | Низкая |
| 2026-10-28 | `_rows_to_sheet_response` в `summary.py` удаляется, в `forms.py` остаётся (её используют пути записи) | Мёртвый код | Нет |
| 2026-10-28 | Один план superpowers в две фазы (A, B); ветка B — стеком на ветке A, с суффиксом `-b`, под issue #233; B вливается после A и PR #223 (решение контроллера) | Одна задача Jira и одна issue; B зависит от кода A | Средняя: при долгом ревью A ветка B требует повторных rebase |
| 2026-10-28 | `openspec archive` — только после merge B (решение контроллера) | Иначе в `openspec/specs/` попадут требования PR B без кода | Низкая: архив откладывается до B |
| 2026-10-28 | База сравнения для порогов — `test` на момент замера «до» A; если AURORA-1847 (или другая правка SQL/миграция) вольётся раньше замера «после», «до» переснимается на новой базе (решение контроллера) | Иначе в разницу попадёт чужое ускорение или замедление | Лишняя серия замеров (~10 мин) |
| 2026-10-28 | Замеры на стенде выполняет контроллер после основных ночных замеров; в плане — точка ожидания; PR открываются без цифр, цифры дописываются (решение контроллера) | Стенд занят ночным прогоном, замеры нельзя вести параллельно (`pg_stat_statements`) | Цифры появляются в PR позже ревью кода |
| 2026-10-28 | Полный pytest — на PG16 (`perf-pg:55432`, БД `dfip_1848`, пароли `OPENBAO__TEST_*` = `admin123`); совместимость с PG 15.12 (`dfip-pg15:55434`) проверяет контроллер (решение контроллера) | `test` пока не накатывается на PG15 (AURORA-1922); новый SQL change совместим с PG 9.5+ | Низкая: несовместимость с PG15 ловит контроллер до мержа |
| 2026-10-28 | (план) Ветка B вливает ветку PR #223 (`feat/212-aurora-1838-ws-immutable-update`) merge-коммитом в начале фазы B, а не ждёт merge #223 в `test`; после merge #223 и A ветка B перебазируется на `test` | Человек задал «между фазами без остановки», а тесты фронта B без `updateCells` из #223 красные; merge-коммит не дублирует код #223 и уходит из диффа при rebase | Средняя: PR B до rebase показывает дифф #223; если #223 изменится на ревью — повторный merge или rebase B |
| 2026-10-28 | (план) Фаза B — в отдельном worktree `.claude/worktrees/aurora-1848-b`, а не переключением ветки в worktree A | Правки по ревью PR A (H-n) идут в A параллельно с работой над B | Низкая: лишний worktree, удаляется при закрытии |
| 2026-10-28 | (план) Красный шаг TDD для GET — тест «GET не вызывает старый метод» (monkeypatch старого метода репозитория на исключение); тесты эквивалентности со старым путём зелёные и до правки, они — страховка | Эквивалентность по определению не отличает старый код от нового; без такого теста шаг «падает» невозможен | Низкая |
| 2026-10-28 | (план) Общий модуль `api/src/repository/row_json.py` (`ROW_COLUMNS`, `row_json_select`, `fetch_row_texts`); `strip_nulls` добавляется в `row_json_select` в фазе B | Одно место для выражения строки (D1) и для включения B (D4) во всех репозиториях | Низкая: перенос функции |
| 2026-10-28 | (план) Нормализация `undefined → null` вынесена в `web/src/components/RealtimeTable/utils/messageUtils.js` (`buildCellUpdatedMessage`) | `RealtimeContext.js` импортирует React и не тестируется `node --test`; D5.2 требует чистую функцию в `.js` | Низкая |
| 2026-10-28 | (план) `compare_responses.py` не снимает `rf-rollup`: в манифесте стенда нет `branch_id`; эндпоинт покрыт интеграционным тестом эквивалентности | Требование «все листы, своды, отчёты проектов по манифесту» выполнимо только для того, что есть в манифесте | Низкая: регрессия rf-rollup на данных стенда не видна сравнению |
| 2026-10-28 | (план) В режиме `strip-nulls` `compare_responses.py` чистит только `data` строк (обёртка, `sort_order`, `message` не трогаются); `dump` пишет статусы в `index.json`, `compare` сравнивает и статусы | Спека: «из `data` которого рекурсивно удалены поля со значением null»; ошибки тоже должны совпадать | Низкая |
| 2026-10-28 | (план) Финальное ревью opus — по одному на каждую ветку фазы: A до PR A, B (без коммитов #223) до PR B | Два PR ревьюются и мержатся раздельно | Низкая: два прогона opus вместо одного |
| 2026-10-28 | (план) Проверка PG15 главным контроллером не блокирует открытие PR, блокирует merge; в PR A пишется `Part of #233`, `Closes #233` — в PR B | Замеры и PG15 у главного контроллера, PR открываются без них; issue закрывается только всем объёмом (A+B) | Низкая |
| 2026-10-28 | R-1 (design #1): тест экранирования листа вставляет `budget_line` (form 4, `name` = строка с кавычкой, обратным слэшем, переводом строки, табом и кириллицей) вместо `UPDATE justification` из плана; сравнение по декодированному значению `data.header.name` | В фикстуре у FORM_2 нет строк `budget_line`, `UPDATE` ничего не меняет; в `data` листа `name` попадает в `header.name` | Низкая: слабее покрытие экранирования на других полях `data` |
| 2026-10-28 | R-2 (design #2): фикстура `svod2_plan_line` (в откатываемой сессии теста) вместо запасного `POST .../line` из плана | `POST` на FORM_2 даёт 400 или строку с нулевым планом, которую свод 2 пропускает; без данных сравнения свода 2 пусты | Низкая: привязка к идентификаторам фикстуры `fixture.sql` |
| 2026-10-28 | R-3 (design #3): для `rf-rollup` вместо сравнения с старым путём — тест фиксации ответа об ошибке; `_add_line` в тестах проектов не реализован | `v3.v_form3_rf_rollup_jsonb` на `test` обращается к несуществующей колонке `r.year` и всегда отдаёт 400 (O-3, вне объёма, миграции запрещены); POST строки проекта даёт 400/409 и рвёт транзакцию теста. Исправляет прежнее утверждение выше о покрытии `rf-rollup` интеграционным тестом эквивалентности: успешный путь `rf-rollup` не покрыт ничем до исправления O-3 | Средняя: регрессия успешного пути `rf-rollup` невидна; follow-up на исправление `r.year` и перевод теста в эквивалентность |
| 2026-10-28 | R-4 (design #4): мок в `test_api_split.py` использует `stream=httpx.ByteStream(gz)` вместо `content=gz` | С `content=` httpx не считает `num_bytes_downloaded`, `wire_bytes` был бы 0 | Нет: только тест |
| 2026-10-28 | R-5 (design #5): после ревью задачи 7 в `compare_responses.py` `dump` принимает готовый `httpx.Client`, пишет `index.json` в `finally` и записывает сетевые ошибки как `error: <Exc>`; `compare` сравнивает не-JSON тела побайтно | Ночной `dump` на стенде идёт без присмотра; падение на одном запросе не должно терять остальные | Низкая |