feat: v6.5.0 Synced blocks production — pages contenu par lignes de DB, résolution serveur à chaque lecture, propagation écrite réelle
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
+131
-16
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user