models.py
python
| 1 | """SQLAlchemy ORM models for Muse commit history. |
| 2 | |
| 3 | Tables: |
| 4 | - muse_objects: content-addressed file blobs (sha256 keyed) |
| 5 | - muse_snapshots: snapshot manifests mapping paths to object IDs |
| 6 | - muse_commits: commit history with parent linkage, branch tracking, |
| 7 | and an extensible ``extra_metadata`` JSON blob for annotations such as |
| 8 | meter (time signature), tempo, key, and other compositional metadata. |
| 9 | - muse_tags: music-semantic tags attached to commits |
| 10 | |
| 11 | These tables are owned by the Muse CLI (``muse commit``) and are |
| 12 | distinct from the Muse variation tables (``muse_variations``, ``muse_phrases``, |
| 13 | ``muse_note_changes``) which track DAW-level note editing history. |
| 14 | |
| 15 | ``muse_cli_commits.metadata`` is an extensible JSON blob for commit-level |
| 16 | annotations. Current keys: |
| 17 | |
| 18 | - ``tempo_bpm`` (``float | None``): BPM set via ``muse tempo --set``. |
| 19 | - ``key`` (``str | None``): Key string (e.g. ``"Eb major"``) auto-updated by |
| 20 | ``muse transpose`` when transposing a commit that has this annotation. |
| 21 | """ |
| 22 | from __future__ import annotations |
| 23 | |
| 24 | from datetime import datetime, timezone |
| 25 | import uuid |
| 26 | |
| 27 | from sqlalchemy import DateTime, ForeignKey, Integer, String, Text |
| 28 | from sqlalchemy.orm import Mapped, mapped_column |
| 29 | from sqlalchemy.types import JSON |
| 30 | |
| 31 | from maestro.db.database import Base |
| 32 | |
| 33 | |
| 34 | def _utc_now() -> datetime: |
| 35 | return datetime.now(timezone.utc) |
| 36 | |
| 37 | |
| 38 | class MuseCliObject(Base): |
| 39 | """A content-addressed blob: sha256(file_bytes) → bytes on disk. |
| 40 | |
| 41 | Objects are deduplicated across commits — the same file committed on |
| 42 | two different branches is stored exactly once. |
| 43 | """ |
| 44 | |
| 45 | __tablename__ = "muse_objects" |
| 46 | |
| 47 | object_id: Mapped[str] = mapped_column(String(64), primary_key=True) |
| 48 | size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) |
| 49 | created_at: Mapped[datetime] = mapped_column( |
| 50 | DateTime(timezone=True), nullable=False, default=_utc_now |
| 51 | ) |
| 52 | |
| 53 | def __repr__(self) -> str: |
| 54 | return f"<MuseCliObject {self.object_id[:8]} size={self.size_bytes}>" |
| 55 | |
| 56 | |
| 57 | class MuseCliSnapshot(Base): |
| 58 | """An immutable snapshot manifest: sha256(sorted(path:object_id pairs)). |
| 59 | |
| 60 | The manifest JSON maps relative file paths to their object IDs. |
| 61 | Content-addressed: two identical working trees produce the same snapshot_id. |
| 62 | """ |
| 63 | |
| 64 | __tablename__ = "muse_snapshots" |
| 65 | |
| 66 | snapshot_id: Mapped[str] = mapped_column(String(64), primary_key=True) |
| 67 | manifest: Mapped[dict[str, str]] = mapped_column(JSON, nullable=False) |
| 68 | created_at: Mapped[datetime] = mapped_column( |
| 69 | DateTime(timezone=True), nullable=False, default=_utc_now |
| 70 | ) |
| 71 | |
| 72 | def __repr__(self) -> str: |
| 73 | files = len(self.manifest) if self.manifest else 0 |
| 74 | return f"<MuseCliSnapshot {self.snapshot_id[:8]} files={files}>" |
| 75 | |
| 76 | |
| 77 | class MuseCliCommit(Base): |
| 78 | """A versioned commit record pointing to a snapshot and its parent. |
| 79 | |
| 80 | commit_id = sha256(sorted(parent_ids) | snapshot_id | message | committed_at_iso) |
| 81 | |
| 82 | This derivation is deterministic: given the same working tree state, |
| 83 | message, and timestamp two machines produce identical commit IDs. |
| 84 | The ``committed_at`` field is the timestamp used in the hash; ``created_at`` |
| 85 | is the wall-clock DB write time and is non-deterministic. |
| 86 | """ |
| 87 | |
| 88 | __tablename__ = "muse_commits" |
| 89 | |
| 90 | commit_id: Mapped[str] = mapped_column(String(64), primary_key=True) |
| 91 | repo_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) |
| 92 | branch: Mapped[str] = mapped_column(String(255), nullable=False) |
| 93 | parent_commit_id: Mapped[str | None] = mapped_column( |
| 94 | String(64), nullable=True, index=True |
| 95 | ) |
| 96 | parent2_commit_id: Mapped[str | None] = mapped_column( |
| 97 | String(64), nullable=True, index=True |
| 98 | ) |
| 99 | snapshot_id: Mapped[str] = mapped_column( |
| 100 | String(64), |
| 101 | ForeignKey("muse_snapshots.snapshot_id", ondelete="RESTRICT"), |
| 102 | nullable=False, |
| 103 | ) |
| 104 | message: Mapped[str] = mapped_column(Text, nullable=False) |
| 105 | author: Mapped[str] = mapped_column(String(255), nullable=False, default="") |
| 106 | committed_at: Mapped[datetime] = mapped_column( |
| 107 | DateTime(timezone=True), nullable=False |
| 108 | ) |
| 109 | created_at: Mapped[datetime] = mapped_column( |
| 110 | DateTime(timezone=True), nullable=False, default=_utc_now |
| 111 | ) |
| 112 | commit_metadata: Mapped[dict[str, object] | None] = mapped_column( |
| 113 | "metadata", JSON, nullable=True, default=None |
| 114 | ) |
| 115 | |
| 116 | def __repr__(self) -> str: |
| 117 | return ( |
| 118 | f"<MuseCliCommit {self.commit_id[:8]} branch={self.branch!r}" |
| 119 | f" msg={self.message[:30]!r}>" |
| 120 | ) |
| 121 | |
| 122 | |
| 123 | class MuseCliTag(Base): |
| 124 | """A music-semantic tag attached to a Muse CLI commit. |
| 125 | |
| 126 | Tags are free-form strings supporting namespaced conventions: |
| 127 | - ``emotion:*`` — emotional character (e.g. emotion:melancholic) |
| 128 | - ``stage:*`` — production stage (e.g. stage:rough-mix) |
| 129 | - ``ref:*`` — reference track or external source (e.g. ref:beatles) |
| 130 | - ``key:*`` — musical key (e.g. key:Am) |
| 131 | - ``tempo:*`` — tempo annotation (e.g. tempo:120bpm) |
| 132 | - free-form — any other descriptive label |
| 133 | |
| 134 | Multiple tags can be attached to the same commit. Tags are scoped to a |
| 135 | repo so that different local repos can use independent tag spaces. |
| 136 | """ |
| 137 | |
| 138 | __tablename__ = "muse_tags" |
| 139 | |
| 140 | tag_id: Mapped[str] = mapped_column( |
| 141 | String(36), primary_key=True, default=lambda: str(uuid.uuid4()) |
| 142 | ) |
| 143 | repo_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) |
| 144 | commit_id: Mapped[str] = mapped_column( |
| 145 | String(64), |
| 146 | ForeignKey("muse_commits.commit_id", ondelete="CASCADE"), |
| 147 | nullable=False, |
| 148 | index=True, |
| 149 | ) |
| 150 | tag: Mapped[str] = mapped_column(Text, nullable=False, index=True) |
| 151 | created_at: Mapped[datetime] = mapped_column( |
| 152 | DateTime(timezone=True), nullable=False, default=_utc_now |
| 153 | ) |
| 154 | |
| 155 | def __repr__(self) -> str: |
| 156 | return f"<MuseCliTag {self.tag!r} commit={self.commit_id[:8]}>" |