Skip to content

Migrate models to Ferro relationships + cascade definitions (retire manual service-layer cascades) #13

Description

@0x054

Summary

All ttd models use plain *_id: UUID columns and the service layer hand-rolls every join, cascade, and referential action. Ferro supports first-class relationships (ForeignKey(..., on_delete=...), BackRef(), Relation[...]) that would push this behavior into the schema, where the database enforces it. We should migrate the models to proper relationships + cascade definitions to simplify the service layer and remove a class of "forgot to clean up" bugs.

This is tech debt / refactor, not a feature. It should land as its own change, independent of feature work. Captured while designing the billable-expenses feature, where we deliberately stuck with the existing plain-id convention to avoid mixing two paradigms (see note at the bottom).

Current state: cascades and integrity are manual

Deletes are hand-rolled multi-level cascades:

  • services/clients.py:123-125 — deleting a client manually loops its projects, deletes each project's entries, deletes the projects, then the client (a two-level cascade by hand).
  • services/projects.py:134-135 — deleting a project manually deletes its entries first, then the project.

Invoice locking / referential nulling is manual:

  • services/invoicing.py:267persist_draft sets entry.invoice_id = invoice.id row by row to lock entries.
  • services/invoicing.py:320mark_invoice (void) loops linked entries and sets entry.invoice_id = None to release them (a hand-rolled SET NULL).

Joins are manual dict-building throughout the services (e.g. {pk(p): p for p in await Project.where(...)}), repeated across invoicing.py, clients.py, projects.py, reporting, etc.

What relationships would give us

  • DB-enforced cascadeClientProjectEntry with on_delete="CASCADE" replaces the manual loops in clients.py / projects.py.
  • DB-enforced SET NULLEntry.invoice / future expense lines with on_delete="SET NULL" replaces the manual unlinking in the void path.
  • Relation navigationBackRef() / Relation[list[...]] replaces repeated manual dict joins (invoice.lines, client.projects, project.entries).
  • Integrity — orphan rows become impossible at the DB level rather than by service discipline.

Caveat: invoice locking semantics need care

Voiding an invoice today is a deliberate SET NULL on entry.invoice_id (release the lock; the invoice row and its lines persist — numbers are never reused). Any migration must preserve that exact semantics: voiding releases entries without deleting the invoice. Naively making invoice lines CASCADE from the invoice while entries SET NULL needs to be modeled explicitly, and the void flow re-verified against the existing tests.

Suggested approach

  1. Map every current *_id column to a ForeignKey/BackRef pair with the correct on_delete (CASCADE for owned children, SET NULL for invoice locks).
  2. Migrate one aggregate at a time (Client/Project/Entry first; Invoice/lines second given the locking nuance), keeping tests green at each step.
  3. Replace the manual dict joins with relation navigation as each model is converted.
  4. Delete the now-redundant manual cascade/unlink code in the services.

Note

This came up during the billable-expenses design. We chose to keep the new expense models (Expense, ExpenseReceipt, InvoiceExpenseLine) on the plain-id convention so they match existing code rather than introducing relationships piecemeal. When this migration happens, the expense models should be converted alongside the rest.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions