Back to Super Productivity

Architecture Decision Records

ARCHITECTURE-DECISIONS.md

18.10.012.6 KB
Original Source

Architecture Decision Records

This document tracks significant architectural decisions and patterns in the Super Productivity codebase. When making changes that affect these patterns, reference this document and update it if needed.

Active Patterns & Decisions

1. dueDay/dueWithTime Mutual Exclusivity Pattern

Status: ✅ Active (since commit 400ca8c1, 2026-01-29)

Decision: The task.dueDay and task.dueWithTime fields are mutually exclusive in new data. When setting dueWithTime, dueDay must be cleared (set to undefined). When reading, dueWithTime takes priority over dueDay.

Rationale:

  • Prevents state inconsistency bugs where both fields had conflicting values
  • Single source of truth for task scheduling
  • Simpler state management

Implementation:

  • Writing: Clear dueDay when setting dueWithTime (in meta-reducers)
  • Reading: Check dueWithTime first; only check dueDay if dueWithTime is not set (in selectors)
  • Legacy Data: Old data with both fields works via priority pattern (no migration needed)

Key Files:

When to Update This Pattern:

  • Adding new date/time scheduling fields
  • Modifying task scheduling logic
  • Working with task selectors that check due dates

2. TODAY_TAG Virtual Tag Pattern

Status: ✅ Active (established pattern)

Decision: TODAY_TAG (ID: 'TODAY') is a virtual tag whose membership is determined by task.dueWithTime or task.dueDay, not by task.tagIds. The tag's taskIds field stores only the ordering of tasks, not membership.

Key Invariant: TODAY_TAG.id must NEVER be added to task.tagIds

Rationale:

  • Uniform move operations across all tags (virtual and regular)
  • Single source of truth for "today" membership (date fields, not tagIds)
  • Self-healing ordering (stale entries automatically filtered)
  • Natural integration with planner (which uses date fields)

Related: Uses the dueDay/dueWithTime mutual exclusivity pattern (Decision #1)

Key Files:

When to Update This Pattern:

  • Adding new virtual tags
  • Modifying tag membership logic
  • Working with today's task list

3. Sync Package Boundary Direction

Status: ✅ Active (since May 2026)

Decision: Operation-log sync code is split by dependency direction: src/app composes host-specific wiring, @sp/sync-providers owns bundled provider implementations, and @sp/sync-core owns framework-agnostic reusable sync primitives.

Rationale:

  • Keeps reusable sync algorithms independent of Angular, NgRx, app models, and provider implementations
  • Prevents provider IDs, app action/entity enums, validation schemas, UI, OAuth, and platform bridges from leaking into the core engine package
  • Gives boundary lint a clear rule: packages never import app code, and providers consume only public sync-core exports

Implementation:

  • ESLint rejects Angular, NgRx, app, shared-schema, sync-core deep imports, and dynamic imports inside package sources
  • @sp/sync-core has no runtime dependencies and owns vector-clock algorithms used by client/server compatibility paths
  • packages/shared-schema compatibility-re-exports generic vector-clock algorithms from @sp/sync-core; @sp/sync-core must not import @sp/shared-schema
  • @sp/sync-providers depends on public @sp/sync-core plus provider runtime helpers, while app factories inject credentials, platform bridges, validators, OAuth routing, and config

Documentation: docs/sync-and-op-log/package-boundaries.md

Key Files:

When to Update This Pattern:

  • Moving sync code between app and packages
  • Adding a package export or dependency
  • Adding a provider implementation or plugin-facing provider contract
  • Changing vector-clock ownership or shared-schema compatibility

4. Batch Uploads Under RepeatableRead

Status: ✅ Active (since May 2026)

Decision: SuperSync batch uploads derive conflict-safety from the shared user_sync_state.lastSeq row write that reserves server sequence numbers, not from PostgreSQL RepeatableRead snapshot isolation alone.

Rationale:

  • PostgreSQL RepeatableRead does not provide full serializable snapshot isolation
  • Two concurrent upload transactions can both pass conflict prefetch checks when they read the same pre-insert snapshot
  • Reserving sequence numbers through one user_sync_state.lastSeq row forces accepted writers for the same user to serialize on that row lock
  • If two batches race, the later writer blocks on the row and the transaction retry path handles the serialization failure rather than silently accepting conflicting operations

Implementation:

  • Batch upload conflict detection runs in memory against prefetched latest entity rows and updates that map as operations are accepted
  • Accepted operations reserve one contiguous sequence range with INSERT ... ON CONFLICT ... DO UPDATE SET last_seq = last_seq + delta
  • The batch insert does not use skipDuplicates; an unexpected unique conflict aborts the transaction and lets the request retry
  • Removing or sharding the lastSeq write requires replacing this safety mechanism with an equivalent per-user serialization primitive

Documentation: docs/sync-and-op-log/diagrams/02-server-sync.md

Key Files:

When to Update This Pattern:

  • Changing upload conflict detection
  • Changing server sequence assignment
  • Changing transaction isolation for upload operations
  • Introducing multi-writer or multi-region upload processing

5. Project Completion: Decoupled Resolution over Atomic Multi-Entity Op

Status: ✅ Active (since 2026-06-06, branch feat/completing-projects-48eeb4)

Decision: "Complete project" is a plain single-entity PROJECT flag flip (completeProject, OpType.Update, mirroring archiveProject → sets isDone/doneOn/isArchived). The accompanying resolution of unfinished tasks ("move to Inbox" / "mark done") runs first, as the normal per-task actions (moveToOtherProject / updateTask isDone) dispatched in a loop with the Rule #6 bulk-dispatch flush — not bundled into a single atomic multi-entity op.

Rationale: An earlier iteration made completion one atomic Batch op (completeProject) that marked/moved tasks inside the project-shared meta-reducer. Because that op deliberately routed around the normal per-task actions, every system that observes those actions had to be re-taught about completeProject separately:

  • Conflict detection needed a whole new affectedEntities multi-entity-ref feature threaded through sync-core, the sync server (+ a Prisma migration), shared-schema and the op-log — ~1,565 LOC, of which completeProject was the only producer.
  • Native-reminder cancellation, issue two-way-sync, time-block sync and repeat-cfg effects each needed a dedicated completeProject listener to re-derive the task changes the atomic op skipped.

The atomic op's headline benefit — reversing the whole thing as one unit — was never realized: reopenProject only clears the project flags; it does not un-move or un-complete the resolved tasks. So the bundle paid a large cross-cutting cost for an undo guarantee it didn't provide. Decoupling makes the existing effects and per-entity conflict detection fire naturally and deletes ~1,750 LOC total (revert + decouple). Trade-off accepted: completion now emits N+1 ops (one per resolved task + the flag flip) instead of one, and there is a brief intermediate state — both fine for a rare, user-initiated action whose resolution is not atomically reversible anyway. One behavioral nuance vs. the old atomic op: when unfinished work is moved to Inbox, a task that was being actively tracked stays the current task (it was carried forward, not finished — consistent with Inbox's carry-forward intent); the mark-done path stops tracking the current task via the existing autoSetNextTask$ effect. The atomic op cleared the current task in both cases; the decoupled design intentionally keeps it for the carry-forward case.

Implementation:

  • Action/reducer: completeProject({ id, doneOn }) in project.actions.ts; on(completeProject) flag flip in project.reducer.ts (guards INBOX_PROJECT). reopenProject clears the flags only.
  • Service: ProjectService.complete(id, doneOn) dispatches the flag flip; moveTasksToInbox() / markTasksDone() loop the normal per-task actions + setTimeout(0) flush.
  • Flow: work-context-menu resolves unfinished work before calling complete().
  • Do NOT reintroduce a multi-entity completeProject op or affectedEntities for it without re-justifying the full downstream cost above. Prior atomic implementation is preserved in history at commit 0893a86162.

Key Files:

When to Update This Decision:

  • Adding a true bulk meta-reducer action for general use (revisit whether completion should adopt it)
  • Reworking how completion resolves unfinished tasks
  • Any proposal to make completion a single synced op again

How to Use This Document

When Making Architectural Changes

  1. Before implementing: Check if your change affects any active pattern
  2. During implementation: Follow the documented patterns
  3. After implementation: Update this document if you've:
    • Changed an existing pattern
    • Added a new architectural pattern
    • Made a decision that affects future development

When to Add a New Decision

Add a new decision record when:

  • The decision affects multiple files/modules
  • Future developers need to understand "why" not just "what"
  • The pattern needs to be followed consistently across the codebase
  • The decision prevents a specific class of bugs

Decision Record Template

markdown
### N. [Pattern/Decision Name]

**Status**: ✅ Active | 🚧 Draft | ⚠️ Deprecated | ❌ Superseded

**Decision**: [One-sentence summary of the decision]

**Rationale**:

- [Why was this decision made?]
- [What problems does it solve?]

**Implementation**:

- [How is it implemented?]
- [Key techniques or patterns used]

**Documentation**: [Link to detailed docs]

**Key Files**: [List of primary files implementing this pattern]

**When to Update This Pattern**: [Scenarios when someone should review/update this]


Commit Reference

When committing changes related to these patterns, reference this document and the specific decision:

feat(tasks): implement feature X

Uses dueDay/dueWithTime mutual exclusivity pattern (ARCHITECTURE-DECISIONS.md #1)