cca40affe4
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Failing after 29s
CI & Build / Build & push image (push) Has been skipped
Milestone #232 step 1 (task #2081). Takes the enabler first rather than the write-path trigger: reverse lookup, drift checks and the duplicate finder all need to QUERY structured fields, and building them on body-regex first means writing them twice. #227 deferred this bag "unless body-convention ergonomics prove insufficient" — answering "which snippets live in this file?" by scanning every snippet and regexing its body is that condition being met. Migration 0070 adds `notes.data` (nullable JSONB) + a GIN index. The body is UNCHANGED and still what gets embedded and read by humans; `data` mirrors the same facts in a shape Postgres can index. Code is deliberately not copied into it — the body holds it, and duplicating a blob into the column we index around would be waste. - compose_data() builds the mirror, omitting empties so the column stays sparse - snippet_fields() prefers `data`, falling back to parsing the body. Rows written before 0070 have no `data` and are never backfilled, so a hand-edited body stays authoritative for them with no conversion deadline - create / update / merge all write body and mirror from the same merged field set, so the two can't drift; merge in particular has to grow the mirror with the survivor's location set or a merged snippet would be unfindable at the very call sites the merge just recorded Named `data`, not `metadata`, because that collides with SQLAlchemy's declarative Base.metadata — which is why the pre-0069 model had to map an awkward `entity_metadata` attribute. Not a revival of the column 0069 dropped: different name, different purpose, nothing reads the old shape. Two test fakes needed an explicit `data = None`: snippet_fields prefers `data` when truthy and an auto-MagicMock attribute is truthy, so every parsed field would have come back a MagicMock. Checked every fake reaching snippet code this time rather than waiting for CI (note 2109). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
129 lines
5.7 KiB
Python
129 lines
5.7 KiB
Python
import enum
|
|
from datetime import date, datetime
|
|
|
|
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
|
|
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from scribe.models import Base
|
|
from scribe.models.base import TimestampMixin, SoftDeleteMixin
|
|
|
|
|
|
class TaskStatus(str, enum.Enum):
|
|
todo = "todo"
|
|
in_progress = "in_progress"
|
|
done = "done"
|
|
cancelled = "cancelled"
|
|
|
|
|
|
class TaskPriority(str, enum.Enum):
|
|
none = "none"
|
|
low = "low"
|
|
medium = "medium"
|
|
high = "high"
|
|
|
|
|
|
class Note(Base, TimestampMixin, SoftDeleteMixin):
|
|
__tablename__ = "notes"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
|
)
|
|
title: Mapped[str] = mapped_column(Text, default="")
|
|
body: Mapped[str] = mapped_column(Text, default="")
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
consolidated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
|
|
parent_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
# Provenance: the task/feature an issue arose from. Distinct from parent_id
|
|
# (sub-task hierarchy) — this is "what spawned this". Only meaningful for
|
|
# issues; nullable for every record.
|
|
arose_from_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
project_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
milestone_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("milestones.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
status: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
priority: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
recurrence_rule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
# Note type — 'note' (default) or 'process' (a stored process). Task-ness is
|
|
# tracked by `status`, not here. (person/place/list entity types removed 2026-07.)
|
|
note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note")
|
|
# Task sub-kind — 'work' (default), 'plan', or 'issue' (corrective work).
|
|
# Only meaningful when the note is a task (status is not None); ordinary
|
|
# notes keep the 'work' default and ignore it. Orthogonal to note_type
|
|
# (which is the note/entity axis).
|
|
task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work")
|
|
# Queryable structured fields for typed records — currently snippets, whose
|
|
# name/language/signature/locations live here so they can be INDEXED. The
|
|
# body keeps the same facts in readable markdown and remains what gets
|
|
# embedded; this is a mirror for querying, not the source of truth for
|
|
# display. NULL on every row written before migration 0070, so readers fall
|
|
# back to parsing the body (see services/snippets.snippet_fields).
|
|
data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
|
|
__table_args__ = (
|
|
Index("ix_notes_tags", "tags", postgresql_using="gin"),
|
|
Index("ix_notes_status", "status"),
|
|
Index("ix_notes_title", "title"),
|
|
Index("ix_notes_user_id", "user_id"),
|
|
Index("ix_notes_project_id", "project_id"),
|
|
Index("ix_notes_milestone_id", "milestone_id"),
|
|
Index("ix_notes_note_type", "note_type"),
|
|
Index("ix_notes_arose_from_id", "arose_from_id"),
|
|
# Containment queries into `data` — e.g. which snippets name a given
|
|
# repo/path in their locations. See migration 0070.
|
|
Index("ix_notes_data_gin", "data", postgresql_using="gin"),
|
|
)
|
|
|
|
@property
|
|
def is_task(self) -> bool:
|
|
return self.status is not None
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"title": self.title,
|
|
"body": self.body,
|
|
"description": self.description,
|
|
"consolidated_at": (
|
|
self.consolidated_at.isoformat() if self.consolidated_at else None
|
|
),
|
|
"tags": self.tags or [],
|
|
"parent_id": self.parent_id,
|
|
"arose_from_id": self.arose_from_id,
|
|
"project_id": self.project_id,
|
|
"milestone_id": self.milestone_id,
|
|
"status": self.status,
|
|
"priority": self.priority,
|
|
"due_date": self.due_date.isoformat() if self.due_date else None,
|
|
"started_at": self.started_at.isoformat() if self.started_at else None,
|
|
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
|
"recurrence_rule": self.recurrence_rule,
|
|
"recurrence_next_spawn_at": (
|
|
self.recurrence_next_spawn_at.isoformat()
|
|
if self.recurrence_next_spawn_at
|
|
else None
|
|
),
|
|
"is_task": self.is_task,
|
|
"note_type": self.note_type or "note",
|
|
"task_kind": self.task_kind,
|
|
"created_at": self.created_at.isoformat(),
|
|
"updated_at": self.updated_at.isoformat(),
|
|
}
|