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
+131 -16
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"])
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: