From 5951c707ebab8f4dcd657548dfdb10c3b770bcf3 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 24 Sep 2026 08:28:25 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20v6.5.0=20Synced=20blocks=20production?= =?UTF-8?q?=20=E2=80=94=20pages=20contenu=20par=20lignes=20de=20DB,=20r?= =?UTF-8?q?=C3=A9solution=20serveur=20=C3=A0=20chaque=20lecture,=20propaga?= =?UTF-8?q?tion=20=C3=A9crite=20r=C3=A9elle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 27 ++ ROADMAP.md | 37 +- VERSION | 2 +- app/main.py | 2 +- app/migrations.py | 24 + app/routers/api_v2.py | 11 + app/routers/board.py | 43 +- app/routers/collections.py | 29 ++ app/routers/dashboard.py | 49 +- app/services/realtime_server.py | 30 +- app/services/row_pages.py | 123 ++++++ app/services/synced_blocks.py | 147 +++++- app/templates/_database_table_scripts.html | 16 +- app/templates/_page_editor_scripts.html | 7 + docs/API_GUIDE_V6.md | 2 +- tests/test_v65_synced_db.py | 491 +++++++++++++++++++++ 16 files changed, 990 insertions(+), 50 deletions(-) create mode 100644 app/services/row_pages.py create mode 100644 tests/test_v65_synced_db.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff2b9a..10287ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog - FlowDeck +## v6.5.0 (2026-09-24) — Synced blocks production (databases & vues) + +> Les synced blocks passent en production et existent enfin dans les databases : +> chaque ligne de database gagne une page contenu (éditeur complet), la lecture +> résout la source côté serveur (zéro cache périmé) et la mise à jour d'une +> source réécrit réellement toutes les pages référentes. + +### Added + +- **Page contenu par ligne de database** — migration 22 (`pages.collection_row_id INTEGER REFERENCES collection_pages(id) ON DELETE CASCADE` + index) ; `app/services/row_pages.py` : `ensure_row_page()` (création lazy, réparation si trashée, workspace hérité de la page hôte, `collection_id` hérité pour les ACL) ; sync de titre bidirectionnelle ligne ↔ page +- **Endpoint `GET /db/pages/{row_id}/open/api`** — renvoie le `page_id` de contenu d'une ligne (garde `_require_view` collection) ; le peek de toute vue DB (table/board/gallery/list/calendar) passe par là +- **Résolution serveur à chaque lecture** — `resolve_content_json()` sur les 2 routes `/pages/{id}` (dashboard + board), `GET /api/pages/{id}/content`, `GET /api/local-workspace/page-content/{id}`, page publique `/p/{slug}`, `GET /api/v2/pages/{id}` et les rooms realtime (`load_room`) +- **Rendu public des synced blocks** — branche `synced` dans `_render_blocks_public` (avant : JSON brut en `

`) + état « Deleted synced block » +- **Éditeur** — état « Deleted synced block » quand la source a été supprimée (au lieu du cache fantôme / « Loading… » éternel) +- **API v2** — `content_page_id` sur `GET /api/v2/pages/{id}` pour une ligne (lecture sans création lazy) + +### Fixed + +- **Propagation des synced blocks** — `PUT /api/synced-blocks/{id}` réécrit le contenu stocké de chaque page référente via `sync_synced_blocks_in_page()` (fonction morte depuis v5.14.0), récursif dans les `children` (colonnes/toggles), broadcast WS seulement après réécriture DB +- **Suppression d'une source** — ids des pages référentes collectés avant la cascade FK, `_synced_deleted` marqué dans le stocké (`mark_synced_block_deleted`), broadcast dédié `manager._broadcast_synced_to()` +- **Ouverture des lignes de database** — le peek appelait `/pages/{rowId}` qui lit la table `pages` : avec des ids croisés il ouvrait une page au hasard (ligne id 5 « Ask AI » → page id 5 « Données ») ; désormais résolution via l'endpoint dédié +- **Listings** — les pages contenu (`parent_section='DbRow'`) sont exclues (`AND collection_row_id IS NULL`) de l'arbre sidebar, des racines workspace, de la liste des pages du board, des projets builtins, de l'arbre Library et du `page_count` des workspaces + +### Tests + +- `tests/test_v65_synced_db.py` — **17 tests** ; suite complète **742 verts** (`pytest -n auto`) ; `ruff check app tests` OK ; `eslint static/js` 0 problème + ## v6.4.0 (2026-09-22) — Realtime editing (production) > Le realtime passe en mode production : résolution de conflits au-delà du last-write-wins (merge à trois versions) et édition à grande échelle (broadcast non bloquant, coalescence des curseurs, corrections de fuites, observabilité). diff --git a/ROADMAP.md b/ROADMAP.md index d288847..75cb859 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -800,7 +800,7 @@ Détails livrés : - [x] `GET/POST/PATCH/DELETE /api/v2/webhooks`, `POST /api/v2/webhooks/{id}/test` (ping) - [x] `GET /api/v2/webhooks/{id}/deliveries` (journal basique) -- [ ] *(reporté v6.4)* : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 events +- [x] *(reporté v6.4, livré plus tôt)* **v5.15.0** : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 events (Webhooks v2) #### Phase 7 — Forges & ressources restantes ✅ @@ -845,7 +845,36 @@ Détails livrés : - `app/templates/_page_editor_realtime.html` — envoi de `base`, adoption du bloc fusionné, toast de conflit - **26 tests** `tests/test_realtime_v64.py` (merge purs, protocole WS, convergence 2 clients, rétro-compat LWW, fuite 4404, coalescence, stats, anti-flood) ; **14 tests** `tests/test_realtime.py` préservés - `ruff check app tests` OK · `eslint static/js` 0 problème -- **Version** — 6.4.0 +- [x] **Version** — 6.4.0 + +--- + +## v6.5.0 — Synced blocks production (databases & vues) ✅ (2026-09-24) +> **Objectif** : passer les synced blocks en « production » (résolution + +> propagation fiables partout) et les faire vivre dans les databases/vues — +> le « reste en v6 » du point **v5.14.0**. **COMPLETED**. + +#### Contenu des lignes de database (le point « databases ») +- [x] **Migration 22** — `pages.collection_row_id INTEGER REFERENCES collection_pages(id) ON DELETE CASCADE` + index partiel ; chaque ligne de database gagne une **page contenu** (page `blocks` standard) portant son éditeur complet +- [x] **Service `app/services/row_pages.py`** — `ensure_row_page()` : création lazy/réparation (page trashée restaurée, `parent_section='DbRow'`), workspace hérité de la page hôte (DB full-page → page parente inline → `collections.gitea_owner/repo`), `collection_id` hérité pour que les ACL de collection s'appliquent au contenu +- [x] **Endpoint `GET /db/pages/{row_id}/open/api`** — renvoie `{page_id}` (garde `_require_view` de la collection) ; utilisé par le peek de **toute** vue (table/board/gallery/list/calendar) +- [x] **Fix bug d'ouverture de ligne** — le peek appelait `/pages/{rowId}` qui lit la table `pages` → avec des ids croisés il ouvrait une **page au hasard** (ligne id 5 « Ask AI » → page id 5 « Données ») ; désormais résolution via l'endpoint puis iframe `/pages/{pageId}` +- [x] **Titres synchronisés bidirectionnellement** — renommer la ligne (`PUT /db/pages/{id}/api`) → page contenu ; renommer dans l'éditeur (`PUT /board/api/pages/{id}`, `POST .../blocks`, `PUT /api/pages/{id}/rename`) → ligne +- [x] **Cascade** — supprimer une ligne supprime sa page contenu (FK `ON DELETE CASCADE`, refs `page_synced_blocks` en cascade) +- [x] **Exclusions des listings** — `AND collection_row_id IS NULL` sur : arbre sidebar (`_build_page_tree`), racines workspace (`_load_workspace_pages`), liste des pages du board, projets builtins, arbre Library (`_build_tree_children` + API tree), `page_count` des workspaces ; `parent_section='DbRow'` les tient hors des sections Private/Trash + +#### Résolution serveur + propagation (le point « production ») +- [x] **Résolution à chaque lecture** — `resolve_content_json()` (`app/services/synced_blocks.py`) appliqué sur : les 2 routes de rendu `/pages/{id}` (dashboard + board, dual-route), `GET /api/pages/{id}/content`, `GET /api/local-workspace/page-content/{id}`, page publique `/p/{slug}`, `GET /api/v2/pages/{id}`, et les rooms realtime (`load_room`) → le cache `_synced_content` périmé n'est plus jamais servi +- [x] **Propagation écrite réelle** — `PUT /api/synced-blocks/{id}` réécrit le contenu stocké de **chaque** page référente (`sync_synced_blocks_in_page`, jusqu'ici jamais appelé = code mort depuis v5.14.0) **avant** le broadcast WS ; récursif dans les `children` (colonnes, toggles) +- [x] **Suppression de source** — ids des pages référentes collectés **avant** la cascade FK, `mark_synced_block_deleted()` marque `_synced_deleted` dans le stocké, broadcast dédié `manager._broadcast_synced_to()` +- [x] **État « deleted »** — `resolve_synced_block()` marque `_synced_deleted` (source absente) ; rendu dédié dans l'éditeur (« Deleted synced block » au lieu d'un cache fantôme / « Loading… » éternel) +- [x] **Rendu public des synced blocks** — branche `synced` ajoutée à `_render_blocks_public` (avant : JSON brut en `

`) + état deleted +- [x] **API v2** — `content_page_id` sur `GET /api/v2/pages/{id}` pour une ligne (sans création lazy en lecture) + +#### Tests +- [x] **`tests/test_v65_synced_db.py` — 17 tests** (migration, création/idempotence/cascade/restauration de la page contenu, sync des titres, exclusions de listings, résolution lecture éditeur/content/public, propagation écrite + récursivité + deleted, synced block dans une ligne de DB de bout en bout, `content_page_id` v2) +- [x] Suite complète **742 verts** (`pytest -n auto`) · `ruff check app tests` OK · `eslint static/js` 0 problème +- [x] **Version** — 6.5.0 (VERSION + `app/main.py`) --- @@ -857,7 +886,7 @@ Détails livrés : - [x] **API publique complète** — REST API documentée (OpenAPI) ✅ (livré v6.3.0) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 OpenAPI](/docs/openapi-v2.json) - [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md) - [x] **Realtime editing (production)** ✅ livré **v6.4.0** (merge 3-voix au-delà du LWW, broadcast non bloquant) ; voir **v5.13.0** pour le socle (curseurs + présence) -- [ ] **Synced blocks (production)** — voir **v5.14.0** (bloc de base) ; reste en v6 : syncing côté databases/vues +- [x] **Synced blocks (production)** ✅ livré **v6.5.0** (page contenu par ligne de database, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public) ; socle : **v5.14.0** (bloc de base) --- @@ -903,4 +932,4 @@ Quality DB views, Agent IA Palette → Realtime + E DB avancée, redo, drag&drop, bookmark, avancée Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅ -*Dernière mise à jour: 2026-09-22 — **v6.4.0 Realtime editing (production) COMPLETED** (merge 3-voix au-delà du LWW via `realtime_merge.py`, broadcast non bloquant avec file sortante + writer, coalescence des curseurs, fix fuite de rooms 4404, anti-flood, `GET /api/realtime/stats`, 26 tests dédiés) + **v6.3.0 API publique complète v2 COMPLETED** (~100 endpoints `/api/v2`, scopes hiérarchiques `read None: UPDATE collections SET sync_version = sync_version + 1 WHERE id = NEW.id; END""" ) + + +@register(22, "v6.5.0: database row content pages") +def _migration_row_content_pages(conn: sqlite3.Connection) -> None: + """v6.5.0 — Synced blocks production: content for database rows. + + A database row (``collection_pages``) gains a shadow ``pages`` row + (``pages.collection_row_id``) that carries the Notion-style block + content of the row: the full page editor, synced blocks, versions and + realtime all work on it unchanged. + + ``ON DELETE CASCADE``: deleting a database row deletes its content + page (and ``page_synced_blocks`` cascades from ``pages``). + """ + cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()} + if "collection_row_id" not in cols: + conn.execute( + "ALTER TABLE pages ADD COLUMN collection_row_id INTEGER " + "REFERENCES collection_pages(id) ON DELETE CASCADE" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_pages_row " + "ON pages(collection_row_id) WHERE collection_row_id IS NOT NULL" + ) diff --git a/app/routers/api_v2.py b/app/routers/api_v2.py index 35ffd27..b494d84 100644 --- a/app/routers/api_v2.py +++ b/app/routers/api_v2.py @@ -802,9 +802,20 @@ async def get_page_v2(page_id: int, request: Request, authorization: str | None if not row2: raise HTTPException(404, "Page not found") d = row_to_dict(row2) + # v6.5.0: resolve synced blocks server-side (fresh content). + if (d.get("content_format") or "blocks") == "blocks" and d.get("content"): + from app.services.synced_blocks import resolve_content_json + d["content"] = resolve_content_json(d["content"], d["content_format"]) return d d = row_to_dict(row) # property_values_json already parsed by row_to_dict + # v6.5.0: expose the row's content page when it exists (no lazy + # creation on a read-only endpoint). + content_page_id = conn.execute( + "SELECT id FROM pages WHERE collection_row_id=?", + (page_id,), + ).fetchone() + d["content_page_id"] = content_page_id["id"] if content_page_id else None return d @router.patch("/pages/{page_id}") diff --git a/app/routers/board.py b/app/routers/board.py index 9cae5c6..46b1180 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -431,7 +431,7 @@ def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, m if depth >= max_depth: return [] rows = conn.execute( - "SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL ORDER BY sort_order ASC, updated_at DESC", + "SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY sort_order ASC, updated_at DESC", (ws_key, parent_id), ).fetchall() items = [] @@ -499,7 +499,7 @@ def _load_workspace_pages(ws_cookie: str) -> list: rows = conn.execute( "SELECT id, title, parent_section, content_format, " "is_shared, share_mode, COALESCE(published,0) AS published " - "FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC", + "FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC", (ws_id,), ).fetchall() items = [] @@ -916,13 +916,13 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s if ws_key: rows = conn.execute( "SELECT id, title, workspace, updated_at FROM pages " - "WHERE workspace=? ORDER BY updated_at DESC", + "WHERE workspace=? AND collection_row_id IS NULL ORDER BY updated_at DESC", (ws_key,), ).fetchall() else: rows = conn.execute( "SELECT id, title, workspace, updated_at FROM pages " - "ORDER BY updated_at DESC", + "WHERE collection_row_id IS NULL ORDER BY updated_at DESC", ).fetchall() all_pages = [] for r in rows: @@ -1125,11 +1125,21 @@ async def update_synced_block_api(request: Request, sid: int): body = await request.json() except Exception: raise HTTPException(400, "Invalid JSON body") from None - from app.services.synced_blocks import get_synced_block, update_synced_block + from app.services.synced_blocks import ( + get_synced_block, + page_ids_for_synced, + sync_synced_blocks_in_page, + update_synced_block, + ) sb = get_synced_block(sid) if not sb: raise HTTPException(404, "Synced block not found") update_synced_block(sid, body.get("title", sb["title"]), body.get("content", [])) + # v6.5.0: rewrite every referencing page's stored content first (DB row + # content pages included), THEN push the realtime update so open rooms + # reload the fresh content from the DB. + for pid in page_ids_for_synced(sid): + sync_synced_blocks_in_page(pid) from app.services.realtime_server import manager await manager._propagate_synced(sid) return {"status": "ok"} @@ -1138,11 +1148,22 @@ async def update_synced_block_api(request: Request, sid: int): @router.delete("/api/synced-blocks/{sid}") async def delete_synced_block_api(request: Request, sid: int): """Delete a synced block.""" - from app.services.synced_blocks import delete_synced_block, get_synced_block + from app.services.synced_blocks import ( + delete_synced_block, + get_synced_block, + mark_synced_block_deleted, + page_ids_for_synced, + ) sb = get_synced_block(sid) if not sb: raise HTTPException(404, "Synced block not found") + # v6.5.0: collect referencing pages BEFORE the FK cascade wipes the + # refs, rewrite their stored content (deleted state), then broadcast. + pids = page_ids_for_synced(sid) delete_synced_block(sid) + mark_synced_block_deleted(sid, pids) + from app.services.realtime_server import manager + await manager._broadcast_synced_to(pids, sid) return {"status": "ok"} @@ -1426,6 +1447,9 @@ async def update_page(request: Request, page_id: int, title: str = Query(default _ensure_page_editable(conn, page_id, user) if title: conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id)) + # v6.5.0: renaming a database row's content page updates the row. + from app.services.row_pages import sync_page_title_to_row + sync_page_title_to_row(conn, page_id) if content: conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id)) if content_format: @@ -1473,6 +1497,9 @@ async def save_page_blocks(request: Request, page_id: int): _ensure_page_editable(conn, page_id, user) if title: conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id)) + # v6.5.0: renaming a database row's content page updates the row. + from app.services.row_pages import sync_page_title_to_row + sync_page_title_to_row(conn, page_id) conn.execute( "UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?", (blocks_json, page_id), @@ -1974,6 +2001,10 @@ async def view_page(request: Request, page_id: int): if not row: return HTMLResponse("

Page not found

", status_code=404) page = dict(row) + # v6.5.0: synced blocks resolve server-side at read time (fresh content + # even when the stored cache is stale). + from app.services.synced_blocks import resolve_content_json + page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format")) ws = page.get("workspace", "") parts = ws.split("/") if "/" in ws else ["", ""] diff --git a/app/routers/collections.py b/app/routers/collections.py index 4761a6e..c3cc453 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -499,6 +499,32 @@ async def get_page_api(request: Request, page_id: int): return dict(page) +@router.get("/pages/{page_id}/open/api") +async def open_row_page_api(request: Request, page_id: int): + """v6.5.0 — content page of a database row (lazy-created). + + Any DB view (table/board/gallery/list/calendar) opens a row through + this endpoint: it returns the shadow ``pages`` id whose full page + editor carries the row's block content (synced blocks included). + """ + with get_conn() as conn: + row = conn.execute( + "SELECT collection_id FROM collection_pages WHERE id=?", + (page_id,), + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Page not found") + coll_id = row["collection_id"] + # v6.0.0: granular collection permissions (same gate as the row itself). + _require_view(coll_id, _session_user(request)) + from app.services.row_pages import ensure_row_page + try: + content_page_id = ensure_row_page(page_id) + except KeyError: + raise HTTPException(status_code=404, detail="Page not found") from None + return {"page_id": content_page_id, "row_id": page_id} + + @router.put("/pages/{page_id}/api") async def update_page_api(request: Request, page_id: int): """API: update a page's properties.""" @@ -552,6 +578,9 @@ async def update_page_api(request: Request, page_id: int): WHERE id=?""", (title, icon, cover_url, position, parent_id, property_values, page_id), ) + # v6.5.0: keep the row's content page title in sync (row → page). + from app.services.row_pages import sync_row_title_to_page + sync_row_title_to_page(conn, page_id) conn.commit() await fire_event("page.updated", { diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 71d4bc7..323416f 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -321,6 +321,9 @@ async def view_page_root(request: Request, page_id: int): if not row: return RedirectResponse("/workspaces", status_code=302) page = dict(row) + # v6.5.0: synced blocks resolve server-side at read time. + from app.services.synced_blocks import resolve_content_json + page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format")) ws = page.get("workspace", "") parts = ws.split("/") if "/" in ws else ["", ""] @@ -885,7 +888,7 @@ async def list_workspace_projects(request: Request): builtin = [] with get_conn() as conn: rows = conn.execute( - "SELECT id, title FROM pages WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 20" + "SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20" ).fetchall() for r in rows: count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0] @@ -1081,7 +1084,9 @@ async def get_page_content(page_id: int): fmt = row["content_format"] if fmt == "file": return JSONResponse({"content": "(uploaded file)", "format": fmt}) - return {"content": row["content"] or "", "format": fmt} + # v6.5.0: resolve synced blocks server-side (fresh content on read). + from app.services.synced_blocks import resolve_content_json + return {"content": resolve_content_json(row["content"] or "", fmt), "format": fmt} def _file_page_disk_path(page: dict): @@ -1185,7 +1190,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | Non "SELECT id, title, parent_section, content_format, content, page_icon, " "(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, " "created_at, updated_at FROM pages " - "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL " + "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL " "ORDER BY created_at DESC", (ws_id,), ).fetchall() @@ -1364,7 +1369,7 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int = else: rows = conn.execute( "SELECT id, title, parent_section, content_format FROM pages " - "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL " + "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL " "ORDER BY sort_order ASC, created_at DESC", (ws_id,), ).fetchall() @@ -1726,7 +1731,7 @@ async def list_workspaces(request: Request): uid = user["id"] if user and user.get("id") else 1 with get_conn() as conn: rows = conn.execute( - "SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id) as page_count " + "SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count " "FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC", (uid,), ).fetchall() @@ -2213,6 +2218,8 @@ h1{font-size:3rem;opacity:.3}

404

""", import json as _json try: blocks = _json.loads(page["content"]) + from app.services.synced_blocks import resolve_synced_block + blocks = resolve_synced_block(blocks) from app.db import get_conn as _gc from app.services.wiki_links import token_labels with _gc() as conn: @@ -2413,6 +2420,31 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str: f'background:#0a0a0a;">' ) + elif t == "synced": + # v6.5.0: render synced block instances (resolved server-side). + if b.get("_synced_deleted"): + html_parts.append( + '
' + 'Deleted synced block
' + ) + else: + inner = b.get("_synced_content") + if not isinstance(inner, list) or not inner: + try: + import json as _sj + parsed = _sj.loads(b.get("content") or "[]") + inner = parsed if isinstance(parsed, list) else [] + except (ValueError, TypeError): + inner = [] + inner = [{"type": "paragraph", "content": str(x)} if not isinstance(x, dict) else x + for x in inner] + if inner: + html_parts.append( + '
' + + _render_blocks_public(inner, titles) + '
' + ) else: html_parts.append(f'

{c}

') return "\n".join(html_parts) @@ -2430,9 +2462,11 @@ async def api_page_content(page_id: int): ).fetchone() if not row: return JSONResponse({"error": "Not found"}, status_code=404) + # v6.5.0: resolve synced blocks server-side (fresh content on read). + from app.services.synced_blocks import resolve_content_json return { "title": row["title"], - "content": row["content"], + "content": resolve_content_json(row["content"], row["content_format"]), "format": row["content_format"] or "blocks", } @@ -2449,6 +2483,9 @@ async def api_rename_page(page_id: int, request: Request): "UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL", (title, page_id), ) + # v6.5.0: renaming a database row's content page updates the row. + from app.services.row_pages import sync_page_title_to_row + sync_page_title_to_row(conn, page_id) conn.commit() return {"status": "ok", "title": title} diff --git a/app/services/realtime_server.py b/app/services/realtime_server.py index fe4909f..c12437b 100644 --- a/app/services/realtime_server.py +++ b/app/services/realtime_server.py @@ -207,7 +207,11 @@ class RealtimeManager: room.title = row["title"] or "" if (row["content_format"] or "") == "blocks" and row["content"]: try: - room.blocks = ensure_block_ids(json.loads(row["content"])) + blocks = ensure_block_ids(json.loads(row["content"])) + # v6.5.0: rooms serve server-resolved synced blocks so a + # sync_req never returns a stale cached copy. + from app.services.synced_blocks import resolve_synced_block + room.blocks = resolve_synced_block(blocks) except Exception: room.blocks = [] return True @@ -503,19 +507,8 @@ class RealtimeManager: for r in self._rooms.values()], } - async def _propagate_synced(self, synced_id: int) -> None: - """Broadcast a synced-block update to all rooms that reference it.""" - from app.db import get_conn as _get_conn - pages: list[int] = [] - try: - with _get_conn() as conn: - rows = conn.execute( - "SELECT page_id FROM page_synced_blocks WHERE synced_block_id=?", - (synced_id,), - ).fetchall() - pages = [r["page_id"] for r in rows] - except Exception: - return + async def _broadcast_synced_to(self, pages: list[int], synced_id: int) -> None: + """Broadcast a synced-block event to the given pages' open rooms.""" for pid in pages: room = self._rooms.get(pid) if room and room.conns: @@ -524,5 +517,14 @@ class RealtimeManager: "synced_id": synced_id, "version": room.version}) + async def _propagate_synced(self, synced_id: int) -> None: + """Broadcast a synced-block update to all rooms that reference it.""" + from app.services.synced_blocks import page_ids_for_synced + try: + pages = page_ids_for_synced(synced_id) + except Exception: + return + await self._broadcast_synced_to(pages, synced_id) + manager = RealtimeManager() diff --git a/app/services/row_pages.py b/app/services/row_pages.py new file mode 100644 index 0000000..f0b3142 --- /dev/null +++ b/app/services/row_pages.py @@ -0,0 +1,123 @@ +"""FlowDeck — v6.5.0 database row content pages (synced blocks production). + +A database row (``collection_pages``) has no block content of its own: +its Notion-style content lives in a shadow ``pages`` row linked via +``pages.collection_row_id``. The full page editor (synced blocks, +versions, realtime, permissions) is reused unchanged on that page. + +``ensure_row_page()`` lazily creates (or heals) the shadow page and is +the single entry point used by the row-open endpoint. +""" +from __future__ import annotations + +import logging + +from app.db import get_conn + +logger = logging.getLogger(__name__) + + +def _host_workspace(conn, coll: dict) -> tuple[str, int | None]: + """Workspace (key, id) the shadow page should live in. + + Resolution order: full-page database host page → inline parent page → + the collection's own ``workspace_id`` (key left empty). + """ + host = conn.execute( + "SELECT workspace, workspace_id FROM pages " + "WHERE collection_id=? AND deleted_at IS NULL " + "AND collection_row_id IS NULL LIMIT 1", + (coll.get("id"),), + ).fetchone() + if not host and coll.get("parent_page_id"): + host = conn.execute( + "SELECT workspace, workspace_id FROM pages WHERE id=?", + (coll["parent_page_id"],), + ).fetchone() + if host: + return host["workspace"] or "", host["workspace_id"] + if coll.get("gitea_owner") and coll.get("gitea_repo"): + return f"{coll['gitea_owner']}/{coll['gitea_repo']}", coll.get("workspace_id") + return "", coll.get("workspace_id") + + +def ensure_row_page(row_id: int) -> int: + """Return the content page id for a database row, creating it if needed. + + - existing live page → returned as-is; + - soft-deleted page → restored (title resynced); + - missing → created with the row's title and the collection workspace. + + Raises ``KeyError`` when the row does not exist. + """ + with get_conn() as conn: + row = conn.execute( + "SELECT id, collection_id, title FROM collection_pages WHERE id=?", + (row_id,), + ).fetchone() + if not row: + raise KeyError(f"row {row_id} not found") + coll_row = conn.execute( + "SELECT id, workspace_id, parent_page_id, gitea_owner, gitea_repo " + "FROM collections WHERE id=?", + (row["collection_id"],), + ).fetchone() + coll = dict(coll_row) if coll_row else {"id": row["collection_id"]} + + existing = conn.execute( + "SELECT id, deleted_at, title FROM pages WHERE collection_row_id=?", + (row_id,), + ).fetchone() + if existing: + if existing["deleted_at"]: + conn.execute( + "UPDATE pages SET deleted_at=NULL, parent_section='DbRow', " + "title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (row["title"] or existing["title"] or "Untitled", existing["id"]), + ) + conn.commit() + return existing["id"] + + ws_key, ws_id = _host_workspace(conn, coll) + cur = conn.execute( + """INSERT INTO pages + (workspace, workspace_id, title, content, content_format, + parent_section, collection_id, collection_row_id) + VALUES (?, ?, ?, '', 'blocks', 'DbRow', ?, ?)""", + (ws_key, ws_id, row["title"] or "Untitled", + row["collection_id"], row_id), + ) + page_id = cur.lastrowid + conn.commit() + logger.debug("created content page %s for row %s", page_id, row_id) + return page_id + + +def sync_row_title_to_page(conn, row_id: int) -> None: + """Row → page: copy the row's title onto its content page (if any).""" + conn.execute( + """UPDATE pages SET title=( + SELECT title FROM collection_pages WHERE id=pages.collection_row_id + ), updated_at=CURRENT_TIMESTAMP + WHERE collection_row_id=? AND EXISTS ( + SELECT 1 FROM collection_pages cp + WHERE cp.id=pages.collection_row_id AND cp.title<>pages.title + )""", + (row_id,), + ) + + +def sync_page_title_to_row(conn, page_id: int) -> None: + """Page → row: a rename inside the editor propagates to the row.""" + conn.execute( + """UPDATE collection_pages SET title=( + SELECT title FROM pages WHERE id=? + ), updated_at=CURRENT_TIMESTAMP + WHERE id=(SELECT collection_row_id FROM pages WHERE id=?) + AND EXISTS ( + SELECT 1 FROM pages p + WHERE p.id=? AND p.collection_row_id IS NOT NULL + AND p.title<>collection_pages.title + )""", + (page_id, page_id, page_id), + ) diff --git a/app/services/synced_blocks.py b/app/services/synced_blocks.py index a4f5a09..bc2ec42 100644 --- a/app/services/synced_blocks.py +++ b/app/services/synced_blocks.py @@ -61,11 +61,17 @@ def update_synced_block(synced_id: int, title: str, conn.commit() -def delete_synced_block(synced_id: int) -> None: - """Delete a synced block (cascades via FK).""" +def delete_synced_block(synced_id: int) -> list[int]: + """Delete a synced block (cascades via FK). + + v6.5.0: returns the page ids that referenced it so the caller can + refresh/broadcast them (the FK cascade removes ``page_synced_blocks``). + """ + page_ids = page_ids_for_synced(synced_id) with get_conn() as conn: conn.execute("DELETE FROM synced_blocks WHERE id=?", (synced_id,)) conn.commit() + return page_ids def list_synced_blocks(workspace: str) -> list[dict]: @@ -136,18 +142,55 @@ def resolve_synced_block(blocks: list[dict]) -> list[dict]: b["_synced_title"] = sb.get("title", "") b["_synced_id"] = sb["id"] b["_synced_updated"] = sb.get("updated_at", "") + b.pop("_synced_deleted", None) except (json.JSONDecodeError, TypeError): pass + elif not b.get("_synced_deleted"): + # v6.5.0: source deleted → instances render a clear state + # instead of a stale cache / eternal "Loading…". + b["_synced_deleted"] = True + b.pop("_synced_content", None) + b.pop("_synced_title", None) if isinstance(b.get("children"), list): resolve_synced_block(b["children"]) return blocks +def page_ids_for_synced(synced_id: int) -> list[int]: + """Pages currently referencing a synced block (before any cascade).""" + with get_conn() as conn: + rows = conn.execute( + "SELECT page_id FROM page_synced_blocks WHERE synced_block_id=?", + (synced_id,), + ).fetchall() + return [r["page_id"] for r in rows] + + +def resolve_content_json(content: str, content_format: str | None) -> str: + """Resolve synced-block references inside a page's stored blocks JSON. + + Server-side freshness guarantee (v6.5.0): every read path passes through + this so a stale cached ``_synced_content`` is replaced by the current + source-of-truth content. Non-blocks content is returned unchanged. + """ + if (content_format or "blocks") != "blocks" or not content: + return content + try: + blocks = json.loads(content) + except (json.JSONDecodeError, TypeError): + return content + if not isinstance(blocks, list): + return content + return json.dumps(resolve_synced_block(blocks), ensure_ascii=False) + + def sync_synced_blocks_in_page(page_id: int) -> None: """Re-resolve all synced blocks in a page's content. Called after a synced block is updated — refreshes the referencing - pages' block content so they reflect the latest source. + pages' stored content so they reflect the latest source (v6.5.0: this + used to be dead code; it is now invoked by the update/delete routes, + recursively covering blocks nested in columns/toggles). """ refs = get_page_synced(page_id) if not refs: @@ -163,22 +206,48 @@ def sync_synced_blocks_in_page(page_id: int) -> None: blocks = json.loads(row["content"]) except (json.JSONDecodeError, TypeError): return + if not isinstance(blocks, list): + return + sources: dict[int, dict] = {r["synced_block_id"]: get_synced_block(r["synced_block_id"]) + for r in refs} changed = False - for ref in refs: - sb = get_synced_block(ref["synced_block_id"]) - if sb: - try: - resolved = json.loads(sb["content"]) - except (json.JSONDecodeError, TypeError): - continue - for b in blocks: - if b.get("type") == "synced" and b.get("synced_id") == ref["synced_block_id"]: - b["content"] = json.dumps(resolved, ensure_ascii=False) if isinstance(resolved, list) else json.dumps(resolved) - b["_synced_content"] = resolved - b["_synced_title"] = sb.get("title", "") + + def _walk(bl: list[dict]) -> None: + nonlocal changed + for b in bl: + if b.get("type") == "synced" and b.get("synced_id"): + sid = b["synced_id"] + if sid not in sources: + sources[sid] = get_synced_block(sid) + sb = sources.get(sid) + if sb: + try: + resolved = json.loads(sb["content"]) + except (json.JSONDecodeError, TypeError): + continue + content_str = (json.dumps(resolved, ensure_ascii=False) + if isinstance(resolved, list) + else json.dumps(resolved)) + title = sb.get("title", "") + if (b.get("content") != content_str + or b.get("_synced_content") != resolved + or b.get("_synced_title") != title + or b.get("_synced_deleted")): + b["content"] = content_str + b["_synced_content"] = resolved + b["_synced_title"] = title + b.pop("_synced_deleted", None) + changed = True + elif not b.get("_synced_deleted"): + b["_synced_deleted"] = True + b.pop("_synced_content", None) + b.pop("_synced_title", None) changed = True - break + if isinstance(b.get("children"), list): + _walk(b["children"]) + + _walk(blocks) if changed: with get_conn() as conn: @@ -189,6 +258,52 @@ def sync_synced_blocks_in_page(page_id: int) -> None: conn.commit() +def mark_synced_block_deleted(synced_id: int, page_ids: list[int]) -> None: + """Flag a deleted synced block's instances as ``_synced_deleted``. + + v6.5.0 — called after ``delete_synced_block()``: the FK cascade already + removed ``page_synced_blocks`` refs, so ``sync_synced_blocks_in_page`` + cannot be used here; this rewrites the stored blocks directly so every + surface (editor, offline sync, export) sees the deleted state. + """ + for pid in page_ids: + with get_conn() as conn: + row = conn.execute( + "SELECT content, content_format FROM pages WHERE id=?", + (pid,), + ).fetchone() + if not row or row["content_format"] != "blocks": + continue + try: + blocks = json.loads(row["content"]) + except (json.JSONDecodeError, TypeError): + continue + if not isinstance(blocks, list): + continue + changed = False + + def _walk(bl: list[dict]) -> None: + nonlocal changed + for b in bl: + if (b.get("type") == "synced" + and b.get("synced_id") == synced_id + and not b.get("_synced_deleted")): + b["_synced_deleted"] = True + b.pop("_synced_content", None) + b.pop("_synced_title", None) + changed = True + if isinstance(b.get("children"), list): + _walk(b["children"]) + + _walk(blocks) + if changed: + conn.execute( + "UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (json.dumps(blocks, ensure_ascii=False), pid), + ) + conn.commit() + + # ── Unsync: convert synced block to independent copy ────────────── def unsync_block(page_id: int, synced_block_id: int) -> list[dict] | None: diff --git a/app/templates/_database_table_scripts.html b/app/templates/_database_table_scripts.html index f8a0069..aec6b5b 100644 --- a/app/templates/_database_table_scripts.html +++ b/app/templates/_database_table_scripts.html @@ -1412,7 +1412,21 @@ }; } var iframe = document.getElementById('db-side-peek-iframe'); - if (iframe) { iframe.src = '/pages/'+pageId+'?embed=1'; panel.style.display = 'flex'; } + // v6.5.0: a database row id is NOT a pages id (raw /pages/{rowId} + // opened an unrelated page with the same id) — resolve the row's + // content page first (lazy-created), then load the real editor. + resolveRowContentPage(pageId).then(function(pid){ + var fr = document.getElementById('db-side-peek-iframe'); + if (fr) { fr.src = '/pages/'+pid+'?embed=1'; panel.style.display = 'flex'; } + }); + if (iframe) panel.style.display = 'flex'; + } + + function resolveRowContentPage(rowId){ + return fetch('/db/pages/'+rowId+'/open/api') + .then(function(r){ return r.ok ? r.json() : null; }) + .then(function(d){ return (d && d.page_id) ? d.page_id : rowId; }) + .catch(function(){ return rowId; }); } })(); diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index 89b33e3..b522065 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -570,6 +570,13 @@ window.__fdEditorScriptsLoaded = true; return `
🔗
Click to add embed URL
`; } if(b.type==='synced'){ + if(b._synced_deleted){ + // v6.5.0: source deleted → clear state instead of stale cache. + return `
+
🔗 Deleted synced block
+
The original synced block was deleted — this copy no longer updates.
+
`; + } var syncedTitle=b._synced_title||'Synced block'; var syncedContent=b._synced_content||[]; var sid=b.synced_id||''; diff --git a/docs/API_GUIDE_V6.md b/docs/API_GUIDE_V6.md index 58c8fab..af5a858 100644 --- a/docs/API_GUIDE_V6.md +++ b/docs/API_GUIDE_V6.md @@ -364,7 +364,7 @@ Endpoints publics v2 : |---------|-------|-------------| | GET | `/api/v2/collections/{id}/pages?filter[status]=…&sort=…` | Lister (pagination, filtres, tri, fields) | | POST | `/api/v2/collections/{id}/pages` | Créer (title, icon, property_values, parent_id) | -| GET | `/api/v2/pages/{id}` | Détail (propriétés parsées) | +| GET | `/api/v2/pages/{id}` | Détail (propriétés parsées) — v6.5.0 : + `content_page_id` (page contenu de la ligne, `null` si absente, sans création lazy) | | PATCH | `/api/v2/pages/{id}` | Titre, icon, position, property_values (merge) | | DELETE | `/api/v2/pages/{id}` | Corbeille (soft delete via `deleted_at`) | | POST | `/api/v2/pages/{id}/restore` | Restaurer | diff --git a/tests/test_v65_synced_db.py b/tests/test_v65_synced_db.py new file mode 100644 index 0000000..330181f --- /dev/null +++ b/tests/test_v65_synced_db.py @@ -0,0 +1,491 @@ +"""FlowDeck — v6.5.0 Synced blocks production (databases & vues). + +Covers: +- migration 22 (``pages.collection_row_id``) + index; +- database row content pages: lazy creation, idempotence, cascade delete, + title sync row ↔ page; +- sidebar/tree/workspace listings exclude content pages; +- server-side resolution of synced blocks on every read path + (content API, public page) — stale cache is replaced at read time; +- propagation: updating/deleting a source REWRITES every referencing + page's stored content (was dead code), recursively (columns/toggles), + including content pages of database rows; +- API v2 exposes ``content_page_id`` for rows. +""" +from __future__ import annotations + +import json +import secrets + +from app.db import get_conn +from app.services.synced_blocks import ( + create_synced_block, + get_page_synced, + get_synced_block, + update_synced_block, +) + +# ── Helpers (patterns from test_v514 / test_v57) ───────────────── + +def _login(client): + from app.auth.session import SessionManager + + login = f"v65_{secrets.token_hex(4)}" + with get_conn() as conn: + conn.execute( + "INSERT INTO users (login, full_name, email, is_admin) " + "VALUES (?, 'V65', ?, 0)", + (login, f"{login}@test.com"), + ) + uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"] + conn.commit() + session = SessionManager.create_session({"id": uid, "login": login}) + csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"] + return session, csrf, uid + + +def _hdr(csrf): + return {"X-CSRF-Token": csrf} + + +def _create_page(client, session, csrf, title="Page"): + r = client.post( + f"/board/api/pages?title={title}", + headers=_hdr(csrf), + cookies={"flowdeck_session": session}, + ) + assert r.status_code == 200, r.text + return r.json()["id"] + + +def _add_collection(client, name="DB"): + r = client.post("/db/api", json={"name": name}) + assert r.status_code == 200, r.text + return r.json()["id"] + + +def _add_row(client, cid, title="Row A"): + r = client.post(f"/db/{cid}/pages/api", json={"title": title}) + assert r.status_code == 200, r.text + return r.json()["id"] + + +def _raw_page_content(page_id): + with get_conn() as conn: + row = conn.execute( + "SELECT content, content_format, title, parent_section, " + "collection_row_id, deleted_at FROM pages WHERE id=?", + (page_id,), + ).fetchone() + return dict(row) if row else None + + +# ── Migration ──────────────────────────────────────────────────── + +def test_migration_v65_column_and_index(client): + with get_conn() as conn: + cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()} + idx = {r[1] for r in conn.execute("PRAGMA index_list(pages)").fetchall()} + version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0] + assert "collection_row_id" in cols + assert "idx_pages_row" in idx + assert version >= 22 + + +# ── Row content pages ──────────────────────────────────────────── + +def test_open_row_creates_content_page(client): + session, csrf, uid = _login(client) + cid = _add_collection(client) + rid = _add_row(client, cid, "My row") + + r1 = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}) + assert r1.status_code == 200, r1.text + pid1 = r1.json()["page_id"] + assert pid1 > 0 + + page = _raw_page_content(pid1) + assert page is not None + assert page["collection_row_id"] == rid + assert page["title"] == "My row" + assert page["parent_section"] == "DbRow" + assert page["content_format"] == "blocks" + + # Idempotent: second open returns the same page. + r2 = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}) + assert r2.json()["page_id"] == pid1 + + +def test_open_unknown_row_404(client): + session, _, _ = _login(client) + r = client.get("/db/pages/999999/open/api", cookies={"flowdeck_session": session}) + assert r.status_code == 404 + + +def test_row_title_sync_to_content_page(client): + session, csrf, _ = _login(client) + cid = _add_collection(client) + rid = _add_row(client, cid, "Original") + pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"] + + r = client.put( + f"/db/pages/{rid}/api", + json={"title": "Renamed row"}, + headers=_hdr(csrf), + cookies={"flowdeck_session": session}, + ) + assert r.status_code == 200, r.text + assert _raw_page_content(pid)["title"] == "Renamed row" + + +def test_content_page_title_sync_to_row(client): + session, csrf, _ = _login(client) + cid = _add_collection(client) + rid = _add_row(client, cid, "Before") + pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"] + + r = client.put( + f"/api/pages/{pid}/rename", + json={"title": "Renamed in editor"}, + headers=_hdr(csrf), + cookies={"flowdeck_session": session}, + ) + assert r.status_code == 200, r.text + with get_conn() as conn: + row = conn.execute("SELECT title FROM collection_pages WHERE id=?", (rid,)).fetchone() + assert row["title"] == "Renamed in editor" + + +def test_row_delete_cascades_content_page(client): + session, csrf, _ = _login(client) + cid = _add_collection(client) + rid = _add_row(client, cid, "Doomed") + pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"] + + r = client.delete( + f"/db/pages/{rid}/api", + headers=_hdr(csrf), + cookies={"flowdeck_session": session}, + ) + assert r.status_code == 200, r.text + assert _raw_page_content(pid) is None + + +def test_content_page_restored_from_trash(client): + session, csrf, _ = _login(client) + cid = _add_collection(client) + rid = _add_row(client, cid, "Trashed") + pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"] + + # Simulate the user trashing the content page from the editor menu. + client.post(f"/api/pages/{pid}/trash", headers=_hdr(csrf), + cookies={"flowdeck_session": session}) + assert _raw_page_content(pid)["deleted_at"] + + r = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}) + assert r.json()["page_id"] == pid + page = _raw_page_content(pid) + assert page["deleted_at"] is None + assert page["parent_section"] == "DbRow" + + +# ── Listings exclusion ─────────────────────────────────────────── + +def test_sidebar_tree_excludes_content_pages(client): + session, _, uid = _login(client) + cid = _add_collection(client) + rid = _add_row(client, cid, "Shadow row") + pid = client.get(f"/db/pages/{rid}/open/api").json()["page_id"] + + with get_conn() as conn: + conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, content, " + "content_format, parent_section) VALUES ('ws/tree', NULL, " + "'Visible page', '', 'blocks', 'Private')" + ) + conn.execute( + "UPDATE pages SET workspace='ws/tree' WHERE id=? OR id=?", + (pid, pid), + ) + conn.commit() + from app.routers.board import _build_page_tree + items = _build_page_tree(conn, None, "ws/tree") + titles = {i["name"] for i in items} + assert "Visible page" in titles + assert "Shadow row" not in titles + + +def test_workspace_tree_excludes_content_pages(client): + from app.routers.dashboard import _build_tree_children + + session, csrf, uid = _login(client) + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO workspaces (name, owner_id) VALUES ('WS65', ?)", (uid,) + ) + ws_id = cur.lastrowid + conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, content, " + "content_format, parent_section) VALUES ('', ?, 'Visible', '', " + "'blocks', 'Workspace')", + (ws_id,), + ) + conn.commit() + + # a content page living in the same workspace (link preserved → + # the IS NULL filter is what must hide it) + cid = _add_collection(client) + rid = _add_row(client, cid, "Hidden row") + with get_conn() as conn: + conn.execute( + "UPDATE pages SET workspace_id=? WHERE collection_row_id=?", + (ws_id, rid), + ) + conn.commit() + rows = conn.execute( + "SELECT title FROM pages WHERE parent_id IS NULL AND workspace_id=? " + "AND deleted_at IS NULL AND collection_row_id IS NULL", + (ws_id,), + ).fetchall() + items = _build_tree_children(conn, None, ws_id, uid) + titles = {r["title"] for r in rows} + item_titles = {i.get("title") or i.get("name") for i in items} + assert "Visible" in titles + assert "Hidden row" not in titles + assert "Hidden row" not in item_titles + + +# ── Server-side resolution on read ─────────────────────────────── + +def _page_with_stale_synced(client, session, csrf, uid): + """Page whose stored synced cache is stale (source updated offline).""" + sid = create_synced_block( + workspace="v65", title="Src", + content=[{"type": "paragraph", "content": "VERSION_ONE"}], + created_by=uid, + ) + page_id = _create_page(client, session, csrf, "Reader") + r = client.post( + f"/board/api/pages/{page_id}/blocks", + json={ + "blocks": [ + {"type": "paragraph", "content": "intro", "id": "b1"}, + {"type": "synced", "synced_id": sid, "id": "b2", + "_synced_content": [{"type": "paragraph", "content": "VERSION_ONE"}], + "_synced_title": "Src"}, + ], + "title": "Reader", + }, + headers=_hdr(csrf), + cookies={"flowdeck_session": session}, + ) + assert r.status_code == 200, r.text + # Source changes WITHOUT going through the API (worst case: stale cache). + update_synced_block(sid, "Src", [{"type": "paragraph", "content": "VERSION_TWO"}]) + return sid, page_id + + +def test_content_api_resolves_stale_synced_cache(client): + session, csrf, uid = _login(client) + sid, page_id = _page_with_stale_synced(client, session, csrf, uid) + + # stored content is still stale… + assert "VERSION_ONE" in _raw_page_content(page_id)["content"] + + r = client.get(f"/api/pages/{page_id}/content") + assert r.status_code == 200 + blocks = json.loads(r.json()["content"]) + synced = [b for b in blocks if b.get("type") == "synced"][0] + assert synced["_synced_content"] == [{"type": "paragraph", "content": "VERSION_TWO"}] + + +def test_editor_render_resolves_stale_synced_cache(client): + session, csrf, uid = _login(client) + sid, page_id = _page_with_stale_synced(client, session, csrf, uid) + + r = client.get(f"/pages/{page_id}", cookies={"flowdeck_session": session}) + assert r.status_code == 200 + # page_data JSON embedded in the editor contains the fresh content + assert "VERSION_TWO" in r.text + + +def test_public_page_resolves_and_renders_synced(client): + session, csrf, uid = _login(client) + sid, page_id = _page_with_stale_synced(client, session, csrf, uid) + with get_conn() as conn: + conn.execute( + "UPDATE pages SET is_published=1, published=1, publish_slug='v65pub' " + "WHERE id=?", + (page_id,), + ) + conn.commit() + + r = client.get("/p/v65pub") + assert r.status_code == 200 + assert "VERSION_TWO" in r.text + # no raw block JSON leaked into the public HTML + assert '{"type"' not in r.text + + +# ── Propagation rewrites stored content ────────────────────────── + +def test_put_synced_rewrites_referencing_pages(client): + session, csrf, uid = _login(client) + sid = create_synced_block( + workspace="v65", title="Src", + content=[{"type": "paragraph", "content": "V1"}], created_by=uid, + ) + page_id = _create_page(client, session, csrf, "Ref page") + r = client.post( + f"/board/api/pages/{page_id}/blocks", + json={"blocks": [{"type": "synced", "synced_id": sid, "id": "b1", + "_synced_content": [{"type": "paragraph", "content": "V1"}]}], + "title": "Ref page"}, + headers=_hdr(csrf), + cookies={"flowdeck_session": session}, + ) + assert r.status_code == 200 + + r = client.put( + f"/board/api/synced-blocks/{sid}", + json={"title": "Src", "content": [{"type": "paragraph", "content": "V3"}]}, + headers=_hdr(csrf), + cookies={"flowdeck_session": session}, + ) + assert r.status_code == 200, r.text + + # The page's STORED content was rewritten (not just resolved at read). + raw = _raw_page_content(page_id)["content"] + assert "V3" in raw + assert "V1" not in raw + + +def test_propagation_recurses_into_children(client): + session, csrf, uid = _login(client) + sid = create_synced_block( + workspace="v65", title="Nested", + content=[{"type": "paragraph", "content": "OLD"}], created_by=uid, + ) + page_id = _create_page(client, session, csrf, "Columns page") + r = client.post( + f"/board/api/pages/{page_id}/blocks", + json={"blocks": [ + {"type": "columns", "id": "cols", "children": [ + {"type": "column", "id": "c1", "children": [ + {"type": "synced", "synced_id": sid, "id": "n1", + "_synced_content": [{"type": "paragraph", "content": "OLD"}]}, + ]}, + ]}, + ], "title": "Columns page"}, + headers=_hdr(csrf), + cookies={"flowdeck_session": session}, + ) + assert r.status_code == 200 + assert get_page_synced(page_id), "nested synced block must be tracked" + + r = client.put( + f"/board/api/synced-blocks/{sid}", + json={"title": "Nested", "content": [{"type": "paragraph", "content": "NEW"}]}, + headers=_hdr(csrf), + cookies={"flowdeck_session": session}, + ) + assert r.status_code == 200 + raw = _raw_page_content(page_id)["content"] + assert "NEW" in raw + assert "OLD" not in raw + + +def test_delete_source_marks_instances_deleted(client): + session, csrf, uid = _login(client) + sid = create_synced_block( + workspace="v65", title="Doomed", + content=[{"type": "paragraph", "content": "X"}], created_by=uid, + ) + page_id = _create_page(client, session, csrf, "Victim") + r = client.post( + f"/board/api/pages/{page_id}/blocks", + json={"blocks": [{"type": "synced", "synced_id": sid, "id": "b1", + "_synced_content": [{"type": "paragraph", "content": "X"}]}], + "title": "Victim"}, + headers=_hdr(csrf), + cookies={"flowdeck_session": session}, + ) + assert r.status_code == 200 + + r = client.delete( + f"/board/api/synced-blocks/{sid}", + headers=_hdr(csrf), + cookies={"flowdeck_session": session}, + ) + assert r.status_code == 200, r.text + assert get_synced_block(sid) is None + assert get_page_synced(page_id) == [] # refs cascaded away + + raw = json.loads(_raw_page_content(page_id)["content"]) + synced = [b for b in raw if b.get("type") == "synced"][0] + assert synced.get("_synced_deleted") is True + assert "_synced_content" not in synced + + # read path agrees + r = client.get(f"/api/pages/{page_id}/content") + blocks = json.loads(r.json()["content"]) + assert [b for b in blocks if b.get("type") == "synced"][0].get("_synced_deleted") is True + + +# ── Synced blocks inside database rows (the v6 point) ──────────── + +def test_synced_block_lives_and_syncs_inside_a_db_row(client): + session, csrf, uid = _login(client) + cid = _add_collection(client) + rid = _add_row(client, cid, "Row with content") + pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"] + + sid = create_synced_block( + workspace="v65", title="Row synced", + content=[{"type": "paragraph", "content": "ROW_V1"}], created_by=uid, + ) + r = client.post( + f"/board/api/pages/{pid}/blocks", + json={"blocks": [{"type": "synced", "synced_id": sid, "id": "b1", + "_synced_content": [{"type": "paragraph", "content": "ROW_V1"}]}], + "title": "Row with content"}, + headers=_hdr(csrf), + cookies={"flowdeck_session": session}, + ) + assert r.status_code == 200, r.text + assert any(x["synced_block_id"] == sid for x in get_page_synced(pid)) + # saving blocks with a title synced the row title too + with get_conn() as conn: + assert conn.execute("SELECT title FROM collection_pages WHERE id=?", (rid,)).fetchone()["title"] == "Row with content" + + # update the source → the ROW's content page is rewritten + r = client.put( + f"/board/api/synced-blocks/{sid}", + json={"title": "Row synced", "content": [{"type": "paragraph", "content": "ROW_V2"}]}, + headers=_hdr(csrf), + cookies={"flowdeck_session": session}, + ) + assert r.status_code == 200 + assert "ROW_V2" in _raw_page_content(pid)["content"] + + +def test_api_v2_row_exposes_content_page_id(client): + r = client.post("/auth/register", json={ + "email": "v65api@test.dev", "password": "secret123", "name": "v65api", + }) + assert r.status_code == 200, r.text + tok = client.post("/api/v1/token").json()["token"] + headers = {"Authorization": f"Bearer {tok}"} + + cid = _add_collection(client) + rid = _add_row(client, cid, "Api row") + pid = client.get(f"/db/pages/{rid}/open/api").json()["page_id"] + + r = client.get(f"/api/v2/pages/{rid}", headers=headers) + assert r.status_code == 200, r.text + assert r.json().get("content_page_id") == pid + + # row without content page → null (no lazy creation on read) + rid2 = _add_row(client, cid, "No content yet") + r = client.get(f"/api/v2/pages/{rid2}", headers=headers) + assert r.json().get("content_page_id") is None