feat: v6.5.0 Synced blocks production — pages contenu par lignes de DB, résolution serveur à chaque lecture, propagation écrite réelle
FlowDeck CI / lint (push) Successful in 1m27s
FlowDeck CI / test (push) Successful in 10m51s
FlowDeck CI / docker (push) Successful in 1m21s

This commit is contained in:
2026-09-24 08:28:25 -04:00
parent f2f2f3209e
commit 5951c707eb
16 changed files with 990 additions and 50 deletions
+27
View File
@@ -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 `<p>`) + é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é).
+33 -4
View File
@@ -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 `<p>`) + é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<write<admin`, RFC 7807, idempotence, audit, webhooks CRUD, OpenAPI 402 chemins). Reste: SSO/SAML, synced blocks prod (databases/vues).*
*Dernière mise à jour: 2026-09-24 — **v6.5.0 Synced blocks production (databases & vues) COMPLETED** (page contenu par ligne de DB + ouverture correcte des lignes dans les vues, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public des synced blocks ; 17 tests dédiés, suite 742) + **v6.4.0** realtime + **v6.3.0** API v2. Reste: SSO/SAML, migration de `sync.py` vers Bearer.*
+1 -1
View File
@@ -1 +1 @@
6.4.0
6.5.0
+1 -1
View File
@@ -122,7 +122,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="6.4.0",
version="6.5.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+24
View File
@@ -959,3 +959,27 @@ def _migration_sync_version_columns(conn: sqlite3.Connection) -> 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"
)
+11
View File
@@ -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}")
+37 -6
View File
@@ -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("<h2>Page not found</h2>", 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 ["", ""]
+29
View File
@@ -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", {
+43 -6
View File
@@ -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}</style></head><body><h1>404</h1></body></html>""",
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;"><iframe src="{src}" loading="lazy" frameborder="0" '
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
)
elif t == "synced":
# v6.5.0: render synced block instances (resolved server-side).
if b.get("_synced_deleted"):
html_parts.append(
'<div style="margin:8px 0;padding:8px 12px;border-left:3px solid #e05e5e;'
'background:rgba(224,94,94,.08);border-radius:4px;font-size:13px;opacity:.8;">'
'Deleted synced block</div>'
)
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(
'<div style="margin:8px 0;padding-left:12px;'
'border-left:3px solid var(--accent,#4c9aff);">'
+ _render_blocks_public(inner, titles) + '</div>'
)
else:
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
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}
+16 -14
View File
@@ -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()
+123
View File
@@ -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),
)
+125 -10
View File
@@ -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"])
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
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)
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"] = sb.get("title", "")
b["_synced_title"] = title
b.pop("_synced_deleted", None)
changed = True
break
elif 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:
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:
+15 -1
View File
@@ -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; });
}
})();
</script>
+7
View File
@@ -570,6 +570,13 @@ window.__fdEditorScriptsLoaded = true;
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setEmbedUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔗</div><div style="font-size:14px;color:var(--text-dim);">Click to add embed URL</div></div></div>`;
}
if(b.type==='synced'){
if(b._synced_deleted){
// v6.5.0: source deleted → clear state instead of stale cache.
return `<div class="block-wrapper synced-block-wrapper" data-id="${b.id}" style="border-left:3px solid #e05e5e;background:var(--bg-secondary);opacity:.8">
<div class="synced-badge" style="font-size:11px;color:#e05e5e;padding:4px 8px;font-weight:600;">🔗 Deleted synced block</div>
<div style="color:var(--text-dim);padding:0 8px 8px;font-size:12px;">The original synced block was deleted — this copy no longer updates.</div>
</div>`;
}
var syncedTitle=b._synced_title||'Synced block';
var syncedContent=b._synced_content||[];
var sid=b.synced_id||'';
+1 -1
View File
@@ -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 |
+491
View File
@@ -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": "[email protected]", "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