feat(book): add the Book domain — notes, tasks, decisions, questions, rejected (T-1)
The Office (workspace D-64..D-72) needs a store for Notes, Tasks,
Decisions, Questions and Rejected options. D-68 puts it here, in
core-api's own database, behind its own auth.
Migration 005 lands the whole schema now, by hand (down_revision="004";
the chain has a hash-named revision between 003 and 004, confirmed
before trusting the numbering): book_records (kind, status validated
per kind in the service, scope work/personal, a generated tsvector
search column + GIN index), book_tags, book_record_links, book_attachments
(table only, no file handling yet), and book_events — append-only via a
database trigger that raises on UPDATE and DELETE for every role,
including the owning application.
Routes this round (D-70's core verbs), every one behind
require_caller("office", "user"), writes with identity_required=True,
every change writing a book_events row: add, show, list (the nine
views), find (full-text), change, close (task done/dropped; question
resolved via a `resolves` link into `settled`; decision superseded via
a `supersedes` link into `superseded`), link/unlink, tag/untag, history.
Append, remind, reschedule, undo, purge, redact, pin, check, snooze,
archive/trash/restore, pause/resume, due, next, batch calls and
idempotency keys are explicitly not this round — the schema already
carries what they need.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
"""Create Book tables (T-1, D-68/D-69/D-70)
|
||||
|
||||
Revision ID: 005
|
||||
Revises: 004
|
||||
Create Date: 2026-10-19
|
||||
|
||||
The whole Book schema lands in this one migration — book_records,
|
||||
book_tags, book_record_links, book_attachments, book_events — so later
|
||||
rounds (append, remind, reschedule, pause/resume, redact, ...) need no
|
||||
migration of their own for these tables.
|
||||
|
||||
book_events is append-only: a trigger raises on UPDATE and DELETE, for
|
||||
every role including the owning application. `redact` (D-70 rule 4) is the
|
||||
one sanctioned exception and is a later round; this migration does not
|
||||
soften the trigger.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR, UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "005"
|
||||
down_revision: Union[str, None] = "004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the Book tables, its search column/index, and the append-only trigger."""
|
||||
|
||||
op.create_table(
|
||||
"book_records",
|
||||
sa.Column("id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("title", sa.String(length=500), nullable=True),
|
||||
sa.Column("body", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("checklist", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("scope", sa.String(length=10), nullable=False, server_default="personal"),
|
||||
sa.Column("priority", sa.Integer(), nullable=True),
|
||||
sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("energy", sa.String(length=10), nullable=True),
|
||||
sa.Column("estimate_minutes", sa.Integer(), nullable=True),
|
||||
sa.Column("next_step", sa.Text(), nullable=True),
|
||||
sa.Column("start_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("all_day", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("remind_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("recurrence", sa.String(length=255), nullable=True),
|
||||
sa.Column("recurrence_mode", sa.String(length=20), nullable=True),
|
||||
sa.Column("remind_to", sa.String(length=63), nullable=True),
|
||||
sa.Column("last_touched_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("trashed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("paused_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("paused_statuses", JSONB(), nullable=True),
|
||||
sa.Column("source", sa.String(length=50), nullable=True),
|
||||
sa.Column("external_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("colour", sa.String(length=20), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=63), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("source", "external_id", name="uq_book_records_source_external_id"),
|
||||
)
|
||||
op.create_index("ix_book_records_kind", "book_records", ["kind"])
|
||||
op.create_index("ix_book_records_status", "book_records", ["status"])
|
||||
op.create_index("ix_book_records_due_at", "book_records", ["due_at"])
|
||||
|
||||
# Generated column — 'simple' because the household writes mixed Dutch
|
||||
# and English, and a language-specific config would stem one of the two
|
||||
# badly. Added via raw SQL: Alembic's op.add_column has no first-class
|
||||
# GENERATED ALWAYS AS support.
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE book_records
|
||||
ADD COLUMN search tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('simple', coalesce(title, '') || ' ' || body)) STORED
|
||||
"""
|
||||
)
|
||||
op.create_index(
|
||||
"ix_book_records_search", "book_records", ["search"], postgresql_using="gin"
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"book_tags",
|
||||
sa.Column("record_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("tag", sa.String(length=63), nullable=False),
|
||||
sa.PrimaryKeyConstraint("record_id", "tag"),
|
||||
)
|
||||
op.create_index("ix_book_tags_tag", "book_tags", ["tag"])
|
||||
|
||||
op.create_table(
|
||||
"book_record_links",
|
||||
sa.Column("from_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("relation", sa.String(length=20), nullable=False),
|
||||
sa.Column("to_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=63), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("from_id", "relation", "to_id"),
|
||||
)
|
||||
op.create_index("ix_book_record_links_to_id", "book_record_links", ["to_id"])
|
||||
|
||||
op.create_table(
|
||||
"book_attachments",
|
||||
sa.Column("id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("record_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("path", sa.String(length=1000), nullable=False),
|
||||
sa.Column("mime", sa.String(length=255), nullable=True),
|
||||
sa.Column("size", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_book_attachments_record_id", "book_attachments", ["record_id"])
|
||||
|
||||
op.create_table(
|
||||
"book_events",
|
||||
sa.Column("id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("record_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("identity", sa.String(length=63), nullable=False),
|
||||
sa.Column("client", sa.String(length=63), nullable=True),
|
||||
sa.Column("action", sa.String(length=20), nullable=False),
|
||||
sa.Column("diff", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_book_events_record_id", "book_events", ["record_id"])
|
||||
|
||||
# Append-only, for every role including the owning application.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE FUNCTION book_events_append_only() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'book_events is append-only: % not allowed', TG_OP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER book_events_no_update_delete
|
||||
BEFORE UPDATE OR DELETE ON book_events
|
||||
FOR EACH ROW EXECUTE FUNCTION book_events_append_only()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the Book tables, in dependency order."""
|
||||
op.execute("DROP TRIGGER IF EXISTS book_events_no_update_delete ON book_events")
|
||||
op.execute("DROP FUNCTION IF EXISTS book_events_append_only()")
|
||||
|
||||
op.drop_index("ix_book_events_record_id", table_name="book_events")
|
||||
op.drop_table("book_events")
|
||||
|
||||
op.drop_index("ix_book_attachments_record_id", table_name="book_attachments")
|
||||
op.drop_table("book_attachments")
|
||||
|
||||
op.drop_index("ix_book_record_links_to_id", table_name="book_record_links")
|
||||
op.drop_table("book_record_links")
|
||||
|
||||
op.drop_index("ix_book_tags_tag", table_name="book_tags")
|
||||
op.drop_table("book_tags")
|
||||
|
||||
op.drop_index("ix_book_records_search", table_name="book_records")
|
||||
op.drop_index("ix_book_records_due_at", table_name="book_records")
|
||||
op.drop_index("ix_book_records_status", table_name="book_records")
|
||||
op.drop_index("ix_book_records_kind", table_name="book_records")
|
||||
op.drop_table("book_records")
|
||||
Reference in New Issue
Block a user