todoapp_core/ports.rs
1//! Ports: traits the core defines and adapters implement (spec §5).
2//!
3//! All take `&self` and mutate through interior mutability in the adapter, so a
4//! single store can back several repos at once without borrow fights.
5//!
6//! **Async boundary (spec §5).** The durable store (Turso, M2) is async, so the
7//! repository ports are `async` traits; `Clock`/`IdGenerator` stay sync. The
8//! `decide`/`apply` core and query evaluation remain pure & sync.
9//!
10//! `#[async_trait(?Send)]` keeps the ports dyn-compatible (used behind `&dyn`)
11//! without forcing `Send` futures — the in-memory adapter uses `RefCell`.
12//! ponytail: revisit to `Send` (Mutex-backed store) only if a multi-threaded
13//! server (M5/axum) needs it.
14//!
15//! `QueryEngine` is a port (spec §5): the *filter* runs at the store so a
16//! durable adapter pushes it into SQL (efficient `WHERE`) instead of an O(n)
17//! scan; sort + breadcrumb assembly stays in `todoapp-app`, shared by every store.
18//! [`crate::select_matching`] is the reference scan adapters reuse when they
19//! have no faster path (the in-memory store does).
20
21use async_trait::async_trait;
22
23use crate::model::{Collection, Component, Filter, Id, Link, LinkKind};
24use crate::temporal::{Date, Timestamp};
25
26/// Injected time source — deterministic in tests.
27pub trait Clock {
28 fn now(&self) -> Timestamp;
29 /// Today's date, for `due:today` / `overdue`.
30 fn today(&self) -> Date;
31}
32
33/// Injected id source — deterministic in tests.
34pub trait IdGenerator {
35 fn next_id(&self) -> Id;
36}
37
38/// Capability-keyed component access (spec §3/§7), ECS/column-store style: read,
39/// write, or detach **one capability at a time**, keyed by task `Id`. There is no
40/// whole-task load/save — a caller (or a guard) touches only the capabilities it
41/// needs. Generic methods make this *not* object-safe, so callers hold a concrete
42/// store (`Services<St>`), not `&dyn`.
43///
44/// ponytail: per-capability reads/writes inside a command mean a command is not
45/// one snapshot-in / one-save-out. Single-user/embedded is fine; wrap a command's
46/// reads+writes in a transaction at M2 (Turso) if concurrency demands it.
47#[async_trait(?Send)]
48pub trait ComponentStore {
49 /// The task's `C` component, or `None` if it doesn't carry that capability.
50 async fn get<C: Component>(&self, id: &Id) -> Option<C>;
51 /// Attach/overwrite the task's `C` component.
52 async fn set<C: Component>(&self, id: &Id, value: C);
53 /// Detach the task's `C` component (absent ⇒ no-op).
54 async fn remove<C: Component>(&self, id: &Id);
55}
56
57/// The minimal `task` entity (spec §7): identity + timestamps only. Everything
58/// else is a [`Component`]. `delete` cascades every component of the id.
59#[async_trait(?Send)]
60pub trait TaskEntityStore {
61 async fn create(&self, id: &Id, created: Timestamp, updated: Timestamp);
62 /// Bump `updated_at` (after a mutation produced events).
63 async fn touch(&self, id: &Id, updated: Timestamp);
64 /// `(created_at, updated_at)`, or `None` if the id has no entity.
65 async fn meta(&self, id: &Id) -> Option<(Timestamp, Timestamp)>;
66 async fn delete(&self, id: &Id);
67 async fn all(&self) -> Vec<Id>;
68}
69
70#[async_trait(?Send)]
71pub trait LinkRepository {
72 /// Insert or replace the edge keyed by `(from, to, kind)`.
73 async fn put(&self, link: Link);
74 async fn remove(&self, from: &Id, to: &Id, kind: LinkKind);
75 /// Edges out of `from` of this kind, ordered by `position` ascending.
76 async fn outgoing(&self, from: &Id, kind: LinkKind) -> Vec<Link>;
77 /// Edges into `to` of this kind.
78 async fn incoming(&self, to: &Id, kind: LinkKind) -> Vec<Link>;
79}
80
81/// Blob storage for `Attachment` file/image content (spec §3): separate from
82/// `ComponentStore` since blobs aren't a per-task-capability row — a task's
83/// `Attachments` component only carries a `blob` id reference.
84#[async_trait(?Send)]
85pub trait BlobStore {
86 /// Store `bytes`, returning an id to fetch them back by. Content-addressed
87 /// (same bytes ⇒ same id) — a cheap, incidental dedup, not a guarantee.
88 async fn put(&self, bytes: Vec<u8>) -> Id;
89 async fn get(&self, id: &Id) -> Option<Vec<u8>>;
90 async fn remove(&self, id: &Id);
91}
92
93#[async_trait(?Send)]
94pub trait CollectionRepository {
95 async fn save(&self, collection: Collection);
96 async fn get(&self, id: &Id) -> Option<Collection>;
97 async fn by_name(&self, name: &str) -> Option<Collection>;
98 async fn all(&self) -> Vec<Collection>;
99}
100
101/// Evaluate a query's *filter* — the ids of tasks that match (unsorted). `today`
102/// is the reference date for `due:today`/`overdue`. Object-safe (no generic
103/// methods), so it rides in `Services` as `&dyn`. Sorting + breadcrumbs are the
104/// caller's job (`todoapp-app`), identical across stores.
105#[async_trait(?Send)]
106pub trait QueryEngine {
107 async fn select(&self, filter: &Filter, today: Date) -> Vec<Id>;
108}