Phase 0+1: native post writes, Ghost no longer write-primary
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m50s

- Final sync script with HTML verification + author→user migration
- Make ghost_id nullable on posts/authors/tags, add UUID/timestamp defaults
- Add user profile fields (bio, slug, profile_image, etc.) to User model
- New PostUser M2M table (replaces post_authors for new posts)
- PostWriter service: direct DB CRUD with Lexical rendering, optimistic
  locking, AP federation, tag upsert
- Rewrite create/edit/settings routes to use PostWriter (no Ghost API calls)
- Neuter Ghost webhooks (post/page/author/tag → 204 no-op)
- Disable Ghost startup sync

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 12:33:37 +00:00
parent e8bc228c7f
commit 0f9af31ffe
10 changed files with 1203 additions and 211 deletions

View File

@@ -14,7 +14,6 @@ from quart import (
url_for,
)
from .ghost_db import DBClient # adjust import path
from shared.db.session import get_session
from .filters.qs import makeqs_factory, decode
from .services.posts_data import posts_data
from .services.pages_data import pages_data
@@ -47,33 +46,9 @@ def register(url_prefix, title):
@blogs_bp.before_app_serving
async def init():
from .ghost.ghost_sync import sync_all_content_from_ghost
from sqlalchemy import text
import logging
logger = logging.getLogger(__name__)
# Advisory lock prevents multiple Hypercorn workers from
# running the sync concurrently (which causes PK conflicts).
async with get_session() as s:
got_lock = await s.scalar(text("SELECT pg_try_advisory_lock(900001)"))
if not got_lock:
await s.rollback() # clean up before returning connection to pool
return
try:
await sync_all_content_from_ghost(s)
await s.commit()
except Exception:
logger.exception("Ghost sync failed — will retry on next deploy")
try:
await s.rollback()
except Exception:
pass
finally:
try:
await s.execute(text("SELECT pg_advisory_unlock(900001)"))
await s.commit()
except Exception:
pass # lock auto-releases when session closes
# Ghost startup sync disabled (Phase 1) — blog service owns content
# directly. The final_ghost_sync.py script was run before cutover.
pass
@blogs_bp.before_request
def route():
@@ -258,9 +233,8 @@ def register(url_prefix, title):
@blogs_bp.post("/new/")
@require_admin
async def new_post_save():
from .ghost.ghost_posts import create_post
from .ghost.lexical_validator import validate_lexical
from .ghost.ghost_sync import sync_single_post
from services.post_writer import create_post as writer_create
form = await request.form
title = form.get("title", "").strip() or "Untitled"
@@ -290,35 +264,24 @@ def register(url_prefix, title):
html = await render_new_post_page(tctx)
return await make_response(html, 400)
# Create in Ghost
ghost_post = await create_post(
# Create directly in db_blog
post = await writer_create(
g.s,
title=title,
lexical_json=lexical_raw,
status=status,
user_id=g.user.id,
feature_image=feature_image or None,
custom_excerpt=custom_excerpt or None,
feature_image_caption=feature_image_caption or None,
)
# Sync to local DB
await sync_single_post(g.s, ghost_post["id"])
await g.s.flush()
# Set user_id on the newly created post
from models.ghost_content import Post
from sqlalchemy import select
local_post = (await g.s.execute(
select(Post).where(Post.ghost_id == ghost_post["id"])
)).scalar_one_or_none()
if local_post and local_post.user_id is None:
local_post.user_id = g.user.id
await g.s.flush()
# Clear blog listing cache
await invalidate_tag_cache("blog")
# Redirect to the edit page (post is likely a draft, so public detail would 404)
return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_post["slug"])))
# Redirect to the edit page
return redirect(host_url(url_for("blog.post.admin.edit", slug=post.slug)))
@blogs_bp.get("/new-page/")
@@ -340,9 +303,8 @@ def register(url_prefix, title):
@blogs_bp.post("/new-page/")
@require_admin
async def new_page_save():
from .ghost.ghost_posts import create_page
from .ghost.lexical_validator import validate_lexical
from .ghost.ghost_sync import sync_single_page
from services.post_writer import create_page as writer_create_page
form = await request.form
title = form.get("title", "").strip() or "Untitled"
@@ -374,35 +336,24 @@ def register(url_prefix, title):
html = await render_new_post_page(tctx)
return await make_response(html, 400)
# Create in Ghost (as page)
ghost_page = await create_page(
# Create directly in db_blog
page = await writer_create_page(
g.s,
title=title,
lexical_json=lexical_raw,
status=status,
user_id=g.user.id,
feature_image=feature_image or None,
custom_excerpt=custom_excerpt or None,
feature_image_caption=feature_image_caption or None,
)
# Sync to local DB (uses pages endpoint)
await sync_single_page(g.s, ghost_page["id"])
await g.s.flush()
# Set user_id on the newly created page
from models.ghost_content import Post
from sqlalchemy import select
local_post = (await g.s.execute(
select(Post).where(Post.ghost_id == ghost_page["id"])
)).scalar_one_or_none()
if local_post and local_post.user_id is None:
local_post.user_id = g.user.id
await g.s.flush()
# Clear blog listing cache
await invalidate_tag_cache("blog")
# Redirect to the page admin
return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_page["slug"])))
return redirect(host_url(url_for("blog.post.admin.edit", slug=page.slug)))
@blogs_bp.get("/drafts/")