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 /// All tasks carrying `C`, with the component. Presence is the capability.
56 async fn list<C: Component>(&self) -> Vec<(Id, C)>;
57}
58
59/// The minimal `task` entity (spec §7): identity + timestamps only. Everything
60/// else is a [`Component`]. `delete` cascades every component of the id.
61#[async_trait(?Send)]
62pub trait TaskEntityStore {
63 async fn create(&self, id: &Id, created: Timestamp, updated: Timestamp);
64 /// Bump `updated_at` (after a mutation produced events).
65 async fn touch(&self, id: &Id, updated: Timestamp);
66 /// `(created_at, updated_at)`, or `None` if the id has no entity.
67 async fn meta(&self, id: &Id) -> Option<(Timestamp, Timestamp)>;
68 async fn delete(&self, id: &Id);
69 async fn all(&self) -> Vec<Id>;
70}
71
72#[async_trait(?Send)]
73pub trait LinkRepository {
74 /// Insert or replace the edge keyed by `(from, to, kind)`.
75 async fn put(&self, link: Link);
76 async fn remove(&self, from: &Id, to: &Id, kind: LinkKind);
77 /// Edges out of `from` of this kind, ordered by `position` ascending.
78 async fn outgoing(&self, from: &Id, kind: LinkKind) -> Vec<Link>;
79 /// Edges into `to` of this kind.
80 async fn incoming(&self, to: &Id, kind: LinkKind) -> Vec<Link>;
81}
82
83/// Blob storage for `Attachment` file/image content (spec §3): separate from
84/// `ComponentStore` since blobs aren't a per-task-capability row — a task's
85/// `Attachments` component only carries a `blob` id reference.
86#[async_trait(?Send)]
87pub trait BlobStore {
88 /// Store `bytes`, returning an id to fetch them back by. Content-addressed
89 /// (same bytes ⇒ same id) — a cheap, incidental dedup, not a guarantee.
90 async fn put(&self, bytes: Vec<u8>) -> Id;
91 async fn get(&self, id: &Id) -> Option<Vec<u8>>;
92 async fn remove(&self, id: &Id);
93}
94
95#[async_trait(?Send)]
96pub trait CollectionRepository {
97 async fn save(&self, collection: Collection);
98 async fn get(&self, id: &Id) -> Option<Collection>;
99 async fn by_name(&self, name: &str) -> Option<Collection>;
100 async fn all(&self) -> Vec<Collection>;
101}
102
103/// Evaluate a query's *filter* — the ids of tasks that match (unsorted). `today`
104/// is the reference date for `due:today`/`overdue`. Object-safe (no generic
105/// methods), so it rides in `Services` as `&dyn`. Sorting + breadcrumbs are the
106/// caller's job (`todoapp-app`), identical across stores.
107#[async_trait(?Send)]
108pub trait QueryEngine {
109 async fn select(&self, filter: &Filter, today: Date) -> Vec<Id>;
110}