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

@@ -13,6 +13,7 @@ MODELS = [
TABLES = frozenset({
"posts", "authors", "post_authors", "tags", "post_tags",
"post_users",
"snippets", "tag_groups", "tag_group_tags",
"menu_items", "menu_nodes", "kv",
"page_configs",

View File

@@ -0,0 +1,67 @@
"""Make ghost_id nullable, add defaults, create post_users M2M table.
Revision ID: 0004
Revises: 0003_add_page_configs
"""
from alembic import op
import sqlalchemy as sa
revision = "0004"
down_revision = "0003_add_page_configs"
branch_labels = None
depends_on = None
def upgrade():
# Make ghost_id nullable
op.alter_column("posts", "ghost_id", existing_type=sa.String(64), nullable=True)
op.alter_column("authors", "ghost_id", existing_type=sa.String(64), nullable=True)
op.alter_column("tags", "ghost_id", existing_type=sa.String(64), nullable=True)
# Add server defaults for Post
op.alter_column(
"posts", "uuid",
existing_type=sa.String(64),
server_default=sa.text("gen_random_uuid()"),
)
op.alter_column(
"posts", "updated_at",
existing_type=sa.DateTime(timezone=True),
server_default=sa.text("now()"),
)
op.alter_column(
"posts", "created_at",
existing_type=sa.DateTime(timezone=True),
server_default=sa.text("now()"),
)
# Create post_users M2M table (replaces post_authors for new posts)
op.create_table(
"post_users",
sa.Column("post_id", sa.Integer, sa.ForeignKey("posts.id", ondelete="CASCADE"), primary_key=True),
sa.Column("user_id", sa.Integer, primary_key=True),
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
)
op.create_index("ix_post_users_user_id", "post_users", ["user_id"])
# Backfill post_users from post_authors for posts that already have user_id.
# This maps each post's authors to the post's user_id (primary author).
# Multi-author mapping requires the full sync script.
op.execute("""
INSERT INTO post_users (post_id, user_id, sort_order)
SELECT p.id, p.user_id, 0
FROM posts p
WHERE p.user_id IS NOT NULL
AND p.deleted_at IS NULL
ON CONFLICT DO NOTHING
""")
def downgrade():
op.drop_table("post_users")
op.alter_column("posts", "created_at", existing_type=sa.DateTime(timezone=True), server_default=None)
op.alter_column("posts", "updated_at", existing_type=sa.DateTime(timezone=True), server_default=None)
op.alter_column("posts", "uuid", existing_type=sa.String(64), server_default=None)
op.alter_column("tags", "ghost_id", existing_type=sa.String(64), nullable=False)
op.alter_column("authors", "ghost_id", existing_type=sa.String(64), nullable=False)
op.alter_column("posts", "ghost_id", existing_type=sa.String(64), nullable=False)