Files
nocal/docs/ARCHITECTURE.md
Bernardo Magri 787daf00dd feat: add durable provider cache
Add a private provider-neutral SQLite WAL cache with transactional migrations, retained raw payloads and tombstones, per-window opaque checkpoints, and reserved outbox/conflict tables.

Verify migrations, permissions, identity invariants, concurrent access, atomic pull-page rollback, and future-schema rejection with deterministic tests. No OAuth, networking, secrets, or Graph synchronization is included yet.
2026-07-18 10:53:41 +01:00

173 lines
9.7 KiB
Markdown

# Architecture
Nocal is split into four layers. Dependencies point inward, keeping remote sync
and terminal rendering replaceable.
```text
src/main.cpp
├── tui/ ANSI rendering, input decoding, terminal lifecycle
├── storage/ iCalendar file adapter and provider-neutral cache
└── domain/ Event, Calendar, civil dates, month layout and queries
future sync workers
├── caldav/ Nextcloud and generic CalDAV
├── google/ Google Calendar REST/OAuth
└── graph/ Microsoft Graph/OAuth
└──────────> domain/ through a SyncProvider interface
```
## Domain
The domain layer uses C++20 `<chrono>` civil dates and time points. It knows
nothing about escape sequences, files, OAuth, or HTTP. Month layout always
produces 42 cells, starting on an application-selected weekday (Monday by
default), which keeps redraw geometry stable. The selected week start rotates
the header, grid spill cells, and the finite range queried for that grid. It is
presentation input only: agenda windows, recurrence rules, and storage remain
independent of it.
Events retain a stable UID, title, half-open start/end interval, all-day flag,
optional descriptive fields, and calendar identity. Queries define overlap as
`event.start < range.end && event.end > range.start`; this matters for events
crossing midnight.
Recurring events remain one domain object. Occurrence queries expand only the
requested time window and return values that point back to their source series,
preserving series-level storage and mutation semantics. The supported rule
surface is deliberately bounded to daily/weekly/monthly/yearly frequency,
interval, count or until, weekly `BYDAY`, monthly `BYMONTHDAY`, yearly
`BYMONTH`, compatible DATE or DATE-TIME `RDATE`, and `EXDATE`. An occurrence set
merges `DTSTART`, rule expansion, and additional `RDATE` starts, deduplicates by
start, and then subtracts exclusions. Mutations continue to address the source
series as a whole. Invalid metadata retains at most the base event rather than
crashing a render.
Search matching is a pure domain predicate over normalized event text; the TUI
owns result presentation and occurrence identity. Recurring searches expand a
finite window of five years on either side of the selected date so an unbounded
rule cannot turn an interactive query into unbounded work.
Calendar visibility is session presentation state. The controller derives its
calendar list from event calendar identifiers and applies the same domain
predicate to month rendering, agenda queries, focus traversal, and search.
Hidden events remain in the canonical collection passed to persistence, and
focus identities are always derived from that complete collection so filtering
cannot renumber events with duplicate or missing UIDs.
The agenda is a TUI projection over domain occurrence queries, not a second
calendar model. It requests exactly one 42-day window at a time so recurrence
expansion remains finite, preserves occurrence identity when returning to the
month grid, and keeps its window and selection as non-persistent controller
state. Calendar and search overlays may be opened above it, but mutations stay
in the month workflow and continue to operate on the canonical event
collection.
Timed events retain their source basis: UTC, floating process-local time, or an
IANA `TZID`. C++20 time-zone conversion keeps zoned recurrences at their civil
wall time across daylight-saving transitions. The month grid displays instants
in the user's local zone; the reader also names an explicit source zone.
## Storage
Version 0.1 uses a user-owned iCalendar file, defaulting to
`$XDG_DATA_HOME/nocal/calendar.ics` or `~/.local/share/nocal/calendar.ics`.
The adapter unfolds content lines before parsing and escapes text on output.
On POSIX systems, writes are serialized with an advisory lock, staged in a
same-directory private temporary file, flushed, and atomically renamed over the
destination. A load retains an immutable snapshot of the exact source bytes.
Before saving, the writer compares that snapshot with the destination while it
holds the same lock used for replacement, then checks again after staging and
immediately before commit. This catches same-size and same-timestamp changes
and fully serializes cooperating Nocal writers. The lock is advisory: an
unrelated program can ignore it, so no portable filesystem API can provide a
true compare-and-swap against every possible external writer.
Before an existing destination is replaced, its exact bytes are atomically
written to the adjacent `.bak` file. Explicit backup restoration uses the same
lock, source-revision check, private staging, and atomic replacement path; it
does not consume the backup. The TUI mutates a copyable in-memory model and
rolls it back if persistence fails. Its bounded undo/redo history contains only
mutations that crossed the persistence boundary successfully.
Explicit CLI import and export reuse the storage parser, safety classification,
validation, and atomic persistence boundary; these workflows do not depend on
the TUI and perform no network access. Import loads and validates both source
and target before staging a target replacement. UIDs in both files must be
non-empty and unique, every interval must be valid, and every component in both
files must be safe to round-trip. Matching UID plus identical fields is an
idempotent duplicate; matching UID with different fields is a collision that
aborts the whole operation. A committed merge writes the exact old target to
`.bak` before atomic replacement. If every source event is already present
exactly, import performs no write and does not alter the backup.
Export accepts only a valid, safely round-trippable source, serializes a
canonical calendar through private staging, and atomically creates a destination
that must not already exist. Destination refusal is part of the
commit boundary, so export never replaces an existing path. Import, export,
demo, non-interactive frame printing, and backup restoration are distinct
top-level application modes; import and export cannot be selected together or
combined with any of the other modes.
The current writer deliberately refuses to mutate an existing file when the
loader encounters information it cannot round-trip, including recurrence
overrides, `RDATE` periods or values incompatible with `DTSTART`'s time basis or
`TZID`, custom `VTIMEZONE` definitions, alarms, attendees, unknown properties,
or malformed components. Supported recurrence rules, compatible additional
starts, exclusions, and system IANA time-zone identifiers round-trip
semantically. Browsing remains available for unsupported files. This
conservative boundary is more important than partial editing because a
successful-looking edit must not erase unrelated calendar data.
Provider synchronization does not write directly into this UI file. Its
prerequisite durable boundary is a provider-neutral SQLite database in WAL mode.
The database is a private local file, and schema changes run as versioned,
all-or-nothing migrations so a failed upgrade cannot leave a partially migrated
cache.
The cache separates provider records from normalized projections. It retains
raw provider payloads alongside normalized fields so an older Nocal cannot
destroy fields it does not understand. Inactive calendars and tombstoned events
remain recorded rather than disappearing from history. Normalized event
instances are materialized for immutable UI snapshots; provider payload details
do not cross into rendering or the domain model.
Incremental progress is scoped by provider account, calendar, and synchronization
window. A provider's opaque cursor is committed in the same transaction as each
pulled page, preventing a crash from advancing the cursor beyond durable data.
Transactional outbox and conflict records are reserved in the boundary for
future local writes and deterministic conflict handling. This cache does not
itself implement networking, authentication, or Microsoft Graph synchronization.
OAuth tokens and other secrets are explicitly excluded from SQLite and belong
in the Freedesktop Secret Service.
## Terminal backend
The initial backend depends only on POSIX `termios`, `poll`, and ANSI/ECMA-48.
It uses the alternate screen, hides the cursor while rendering, restores all
terminal state through RAII, and repaints from a complete frame buffer. A resize
signal only sets a flag; terminal size and rendering are handled safely in the
main loop.
Colors are semantic ANSI slots and default background, intentionally delegating
actual RGB values to the terminal theme. This is both simpler and more native
than shipping an application theme that fights the desktop palette.
## Sync roadmap
All providers implement the same conceptual operations: authenticate, list
calendars, perform an incremental pull, push a local mutation, and resolve a
conflict. CalDAV uses sync tokens/ETags, Google uses page/sync tokens, and
Microsoft uses Graph delta links. Secrets belong in the Freedesktop Secret
Service, never in the calendar database or command line.
Conflict resolution is deterministic: unchanged side wins; otherwise retain
both versions and mark a conflict for the user. Network work happens outside the
render loop and publishes immutable snapshots to it.
The first provider-facing step after the cache is verified is an injectable HTTP
boundary, browser-based authorization-code flow with PKCE, and fake transports
for deterministic authentication and failure tests. Read-only Microsoft Graph
calendar discovery and delta synchronization follows. Graph delta links remain
opaque and are persisted only through the calendar/window transaction described
above; remote writes and conflict resolution come later.