Files
Learn_System/plans/sim-builder/phase-3-persistence-api.md

7.9 KiB

Phase 3: БД + API (custom_sims)

Status: Implemented (в рабочем дереве, не закоммичено — коммит за оркестратором) Parent plan: PLAN.md Domain: backend

Objective

Сохранение custom-симуляций: таблица БД, CRUD API под авторизацией с проверкой владения, серверная валидация спеки. После фазы спека сохраняется/грузится/удаляется через API.

Tasks

  • Миграция backend/src/db/migrations/071_custom_sims.sql: таблица custom_sims (id PK AUTOINCREMENT, owner_id FK users ON DELETE CASCADE, title, description, subject, grade, cat, spec_json TEXT NOT NULL, status TEXT 'draft|published' DEFAULT 'draft', version INT DEFAULT 1, created_at, updated_at) + индексы idx_custom_sims_owner / _status. Идемпотентна (CREATE TABLE/INDEX IF NOT EXISTS).
  • Контроллер backend/src/controllers/customSimController.js: list (own + чужой published), get (own ИЛИ published), create (через роут teacher/admin), update, remove. Владение проверяется на каждой мутации (owner ИЛИ admin → иначе 403; нет строки → 404). version++ на update.
  • Серверная валидация спеки validateSpec(spec): размер JSON (≤200KB), specVersion=1, лимиты (params≤50, objects≤200, walls≤20, springs≤50, expr≤500 симв., глубина≤8, points≤1000), типы объектов из whitelist (point|segment|vector|circle|rect|polyline|path|label|plot|readout), physics (restitution 0..1, dt 1/2000..1/30, body.mass>0), строки-подписи (text/label/unit/meta/ drag.param/param.label) санитизируются как текст (escape &<>, обрезка). Возврат {ok,error?,clean?}. Спека НЕ исполняется на сервере (нет eval/Function/SimExpr).
  • Роуты backend/src/routes/customSims.js: router-level authMiddleware (read auth-only); GET / + GET /:id (видимость в хендлере); POST /, PUT /:id, DELETE /:id — inline requireRole('teacher','admin'). Смонтировано в server.js: app.use('/api/custom-sims', ...).
  • Клиент js/api.js: customSimsList/Get/Create/Update/Deletereq(...) + добавлены в window.LS.
  • Тесты backend/tests/custom-sims.test.js (24/24 pass): CRUD happy-path, ownership (чужой PUT/DELETE → 403), get чужой draft → 403, get чужой published → 200, admin override, 404, validateSpec (12 кейсов отказа 400), санитизация. Монтирует роут на shared test-app (как lab-links).

Files to Modify/Create

  • backend/src/db/migrations/0NN_custom_sims.sql (new)
  • backend/src/controllers/customSimController.js (new)
  • backend/src/routes/customSims.js (new)
  • backend/src/server.js — монтирование роутера (modify)
  • js/api.js — клиентские методы (modify)
  • backend/tests/custom-sims.test.js (new)

Acceptance Criteria

  • POST сохраняет спеку, GET возвращает свои+published, PUT/DELETE только владельцу/админу (403 иначе).
  • Кривая/огромная спека → 400. Тесты зелёные (в пределах baseline).
  • npm run migrate применяет таблицу; роут не отдаёт SPA-fallback после рестарта.

Notes

  • node:sqlite (DatabaseSync), НЕ better-sqlite3.
  • Спека хранится как TEXT(JSON); парс/валидация — на входе.
  • Не делать blanket router.use(requireRole('admin')) — read-роуты auth-only, мутации — inline requireRole.

Review Checklist

  • Все задачи выполнены
  • Ownership и валидация спеки покрыты тестами (24/24)
  • Миграция идемпотентна (CREATE TABLE/INDEX IF NOT EXISTS)
  • npm run lint:routes чисто (0 unprotected, baseline 0); тесты в пределах baseline

Handoff to Next Phase

Что реализовано (Phase 3)

  • Миграция 071 custom_sims (применена к живой БД npm run migrate без ошибок).
  • API /api/custom-sims (смонтировано в backend/src/server.js после /api/materials):
    Метод Путь Доступ Ответ
    GET /api/custom-sims auth { sims:[{id,owner_id,title,description,subject,grade,cat,status,version,created_at,updated_at}] } (свои любого статуса + чужие published; БЕЗ spec)
    GET /api/custom-sims/:id auth (own ИЛИ published) { sim:{...meta, spec} } (spec уже распарсен из JSON); чужой draft → 403, нет → 404
    POST /api/custom-sims teacher/admin 201 { id }; принимает { title?, description?, subject?, grade?, cat?, status?, spec }; кривая спека → 400
    PUT /api/custom-sims/:id owner/admin 200 { ok:true }; любое поле опц.; spec валидируется заново и version++; 403/404
    DELETE /api/custom-sims/:id owner/admin 200 { ok:true }; 403/404
  • Клиентские методы (js/api.jswindow.LS): customSimsList(), customSimGet(id), customSimCreate(data), customSimUpdate(id, data), customSimDelete(id).

Форма записи custom_sims

id, owner_id(FK users CASCADE), title, description, subject, grade(INT), cat (math|phys|chem|bio|game|NULL), spec_json(TEXT — валидированный JSON), status(draft|published, деф. draft), version(INT, ++ на каждом update со spec), created_at, updated_at.

validateSpec — контракт для Ф4 (билдер)

Билдер должен слать в spec объект формата v1 (specVersion:1, meta, viewport, params[], objects[], physics?). Сервер вернёт 400 при: spec не объект; specVersion≠1; >200KB JSON; глубина >8; params>50; objects>200; walls>20; springs>50; строка-выражение >500 симв.; недопустимый type объекта (вне whitelist point|segment|vector|circle|rect|polyline|path|label|plot|readout); restitution вне 0..1; dt вне 1/2000..1/30; body.mass≤0. Текстовые поля (text,label,unit, meta.title/desc, param.label/unit/name, drag.param/paramY, id) обрезаются и экранируются (& < > → entities) — билдер получит обратно очищенную спеку в GET /:id. ⚠️ Не использовать имя param e (зарезервировано в SimExpr — см. Ф2).

На Ф4 (билдер: какие поля шлёт/получает)

  • Создание: LS.customSimCreate({ title, description?, subject?, grade?, cat?, status?, spec }){ id }. После — LS.customSimUpdate(id, { spec, status:'published' }) для публикации.
  • Загрузка для редактирования: LS.customSimGet(id){ sim }, где sim.spec — готовый объект для монтирования в SimEngine.mount(host, sim.spec).
  • Список «мои/опубликованные»: LS.customSimsList(){ sims } (метаданные без spec; spec тянуть лениво по customSimGet).
  • Билдер должен учитывать, что сервер вернёт спеку санитизированной (escaped-текст) — для KaTeX/canvas это безопасно, но при сравнении «до/после» учитывать экранирование.