mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
deleteProject cascade-deletes a project's tasks, notes, sections, repeat config, and archive data in one reducer pass. When that op lost an LWW conflict to a concurrent project edit, only the PROJECT entity was reversed: every client resurrected an empty project and the winning client's status-blind hydration replay cascaded its tasks away after a restart (live state != post-restart replay). Rather than recreate every cascaded entity (payload scales with project size and cannot restore every side effect safely), give schema-v4 deleteProject operations explicit delete-wins precedence: - new deleteProject actions carry a shared PROJECT_DELETE_WINS_MARKER; the shared LWW planner accepts a host-supplied delete-wins classifier. A marked remote delete is applied regardless of timestamps; a marked local delete is replaced with one op whose vector clock dominates both sides. - historical unmarked (schema-v3) deletions keep timestamp-based LWW; the absence of the marker (never added by the no-op v3->v4 migration) is the real discriminator, and a schema v3->v4 barrier (mirroring v2->v3) makes older clients block on the newer-schema gate instead of mis-resolving. Delete-wins plans reuse the archive-win resolution pipeline, so they inherit its atomic persistence and losing-op rejection, and disjoint merge leaves them untouched (the delete must win the whole entity). Hardening from multi-agent review: - union allTaskIds/noteIds across multiple concurrent marked deletes for the same project, so a single replacement cannot leave orphan tasks on clients that only receive it (the task reducer removes by allTaskIds). - gate the classifier on the AUTHENTICATED payload projectId matching the plaintext entityId, so a tampered/replayed delete retargeted onto a live entity cannot silently drop a concurrent edit. - guard a null/undefined delete payload in the classifier instead of throwing and wedging the conflict pass. - pin the server's legacy-misc conflict alias to the fixed v1->v2 split boundary, not CURRENT_SCHEMA_VERSION, so this bump does not fabricate false GLOBAL_CONFIG:misc/tasks conflicts during rollout. - bind the marker with a shared const (compiler-checked on producer and consumer) and rename _isArchivePlan -> _isWholeEntityWinPlan. Documents the policy as ARCHITECTURE-DECISIONS.md #7. Addresses #8997.
389 lines
19 KiB
Markdown
389 lines
19 KiB
Markdown
# 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**:
|
|
|
|
- [`task.model.ts`](src/app/features/tasks/task.model.ts) - Field definitions with JSDoc
|
|
- [`task-shared-scheduling.reducer.ts`](src/app/root-store/meta/task-shared-meta-reducers/task-shared-scheduling.reducer.ts) - Write implementation
|
|
- [`work-context.selectors.ts`](src/app/features/work-context/store/work-context.selectors.ts) - Read pattern
|
|
- [`planner.selectors.ts`](src/app/features/planner/store/planner.selectors.ts) - Read pattern
|
|
- [`task.selectors.ts`](src/app/features/tasks/store/task.selectors.ts) - Read pattern
|
|
|
|
**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**:
|
|
|
|
- [`tag.const.ts`](src/app/features/tag/tag.const.ts) - TODAY_TAG definition
|
|
- [`work-context.selectors.ts`](src/app/features/work-context/store/work-context.selectors.ts) - Membership computation
|
|
- [`task-shared-helpers.ts`](src/app/root-store/meta/task-shared-meta-reducers/task-shared-helpers.ts) - Invariant enforcement
|
|
|
|
**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`](docs/sync-and-op-log/package-boundaries.md)
|
|
|
|
**Key Files**:
|
|
|
|
- [`packages/sync-core/src/index.ts`](packages/sync-core/src/index.ts) - Core public API
|
|
- [`packages/sync-providers/src/index.ts`](packages/sync-providers/src/index.ts) - Provider public API
|
|
- [`eslint.config.js`](eslint.config.js) - Package boundary enforcement
|
|
- [`src/app/op-log/sync-providers/sync-providers.factory.ts`](src/app/op-log/sync-providers/sync-providers.factory.ts) - App-side provider composition
|
|
|
|
**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
|
|
- A causal `REPAIR` snapshot must prove that its state includes the current
|
|
server prefix; the same row serializes that base-cursor check with later writes
|
|
- 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
|
|
- `REPAIR` uploads persist `repairBaseServerSeq` on the operation row. The HTTP
|
|
handler rejects an obviously stale base before quota cleanup, and the upload
|
|
transaction repeats the check under `SELECT ... FOR UPDATE` before insertion
|
|
- Markerless legacy repairs are compatibility records, not causal boundaries:
|
|
they cannot drive download fast-forward, snapshot trust, history pruning, or
|
|
server-generated restore points; snapshot replay across one fails closed
|
|
- 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`](docs/sync-and-op-log/diagrams/02-server-sync.md)
|
|
|
|
**Key Files**:
|
|
|
|
- [`packages/super-sync-server/src/sync/sync.service.ts`](packages/super-sync-server/src/sync/sync.service.ts) - Upload transaction and batch primitive
|
|
- [`packages/super-sync-server/prisma/schema.prisma`](packages/super-sync-server/prisma/schema.prisma) - `user_sync_state.last_seq`
|
|
- [`packages/super-sync-server/tests/integration/repair-causality.integration.spec.ts`](packages/super-sync-server/tests/integration/repair-causality.integration.spec.ts) - Real-PostgreSQL race coverage
|
|
|
|
**When to Update This Pattern**:
|
|
|
|
- Changing upload conflict detection
|
|
- Changing server sequence assignment
|
|
- Changing transaction isolation for upload operations
|
|
- Changing repair base-cursor validation or full-state history pruning
|
|
- 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**:
|
|
|
|
- [`project.actions.ts`](src/app/features/project/store/project.actions.ts), [`project.reducer.ts`](src/app/features/project/store/project.reducer.ts)
|
|
- [`project.service.ts`](src/app/features/project/project.service.ts) — `complete` / `moveTasksToInbox` / `markTasksDone`
|
|
- [`work-context-menu.component.ts`](src/app/core-ui/work-context-menu/work-context-menu.component.ts) — `completeProject()` flow
|
|
|
|
**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
|
|
|
|
---
|
|
|
|
### 6. Passkeys Stay Pending Until Email Verification
|
|
|
|
**Status**: ✅ Active (since July 2026)
|
|
|
|
**Decision**: A passkey submitted during account registration is stored as a
|
|
`PendingPasskeyRegistration` tied to its exact email-verification token. It is
|
|
promoted to the user's active `Passkey` set only when that token is consumed.
|
|
|
|
**Rationale**:
|
|
|
|
- A WebAuthn registration ceremony proves possession of a credential, not
|
|
ownership of the email address entered alongside it.
|
|
- Storing a submitted credential directly on an unverified user lets an attacker
|
|
pre-register a victim's address, then have the victim's later magic-link
|
|
verification activate the attacker's passkey.
|
|
- Keeping separate pending attempts prevents concurrent registrations from
|
|
replacing or activating one another. The email owner chooses the credential
|
|
by consuming the link produced by that same registration attempt.
|
|
- Failed email delivery leaves the bounded, expiring pending attempt in place.
|
|
Deleting the shared unverified user can race a concurrent registration and
|
|
invalidate a link that was successfully delivered.
|
|
|
|
**Implementation**:
|
|
|
|
- Passkey registration stores no active credential and creates one pending row
|
|
per verification token.
|
|
- Email verification atomically claims the unverified user, replaces active
|
|
passkeys with the credential bound to that token, and deletes the user's
|
|
remaining pending attempts.
|
|
- Passkey verification tokens live only on pending registrations; user-row
|
|
verification tokens belong to magic-link registrations. Consuming a user-row
|
|
token verifies the email but removes untrusted active and pending passkeys.
|
|
- The migration moves the latest legacy credential for each unverified user to
|
|
the pending table and removes all active credentials from unverified users.
|
|
- The resend cap bounds pending rows per unverified account; rows also expire
|
|
with their verification tokens.
|
|
|
|
**Key Files**:
|
|
|
|
- [`auth.ts`](packages/super-sync-server/src/auth.ts)
|
|
- [`passkey.ts`](packages/super-sync-server/src/passkey.ts)
|
|
- [`schema.prisma`](packages/super-sync-server/prisma/schema.prisma)
|
|
|
|
**When to Update This Pattern**:
|
|
|
|
- Changing passkey enrollment or email-verification flows
|
|
- Adding another credential type to registration
|
|
- Changing verification-token persistence or cleanup
|
|
|
|
---
|
|
|
|
### 7. Versioned Delete-Wins Semantics for Project Deletion
|
|
|
|
**Status**: ✅ Active (since July 2026)
|
|
|
|
**Decision**: Project deletions created with schema v4 or newer carry an explicit
|
|
`projectDeleteWins` marker and beat concurrent project updates. Historical,
|
|
unmarked deletions keep timestamp-based LWW semantics.
|
|
|
|
This is a deliberate semantic trade-off: a concurrent project rename or field
|
|
edit that is vector-clock CONCURRENT with a marked delete **loses**, regardless
|
|
of which has the newer wall-clock timestamp. Deleting an entity another device is
|
|
editing wins over the edit — the alternative (timestamp LWW) resurrects an empty
|
|
project shell and silently loses its task subtree. The lost edit is only
|
|
recoverable via local undo, not via sync.
|
|
|
|
**Rationale**:
|
|
|
|
- `deleteProject` is one user intent whose reducer cascade removes the project,
|
|
active tasks, notes, sections, repeat configuration, and related archive data.
|
|
Reversing only the project entity after that operation loses data and violates
|
|
replay determinism.
|
|
- Capturing every cascaded entity in the delete payload or emitting restoration
|
|
sidecars makes payload size scale with project size and still cannot restore
|
|
every side effect safely.
|
|
- Deletion is the only complete, deterministic result already represented by the
|
|
operation. A concurrent rename or project-field edit must not partially undo it.
|
|
- The schema-v4 barrier makes clients that do not understand this conflict policy
|
|
stop before applying the operation (they block on the newer-schema gate rather
|
|
than mis-resolving). The **absence** of the payload marker on historical
|
|
deletions — never added by the no-op v3→v4 migration — is what preserves their
|
|
timestamp-LWW semantics; the marker, not the version number, is the real
|
|
discriminator. The classifier additionally requires the marked delete's
|
|
plaintext `entityId` to match its authenticated payload `projectId`, so a
|
|
tampered/replayed delete retargeted onto a live entity cannot win.
|
|
|
|
**Implementation**:
|
|
|
|
- New `deleteProject` actions include `projectDeleteWins: true`; replacement
|
|
delete operations preserve that payload.
|
|
- The shared LWW planner accepts a host-supplied delete-wins classifier. A remote
|
|
marked delete is applied regardless of timestamps. A local marked delete is
|
|
replaced with one operation whose vector clock dominates both conflict sides.
|
|
- SuperSync keeps its generic conflict protocol: if the first delete upload is
|
|
rejected, the existing retry path uploads the causally dominant replacement.
|
|
File-based providers use the same client planner and marker.
|
|
- Do not add per-task/note restoration operations or project-sized snapshots to
|
|
compensate a losing marked project delete.
|
|
|
|
**Key Files**:
|
|
|
|
- [`task-shared.actions.ts`](src/app/root-store/meta/task-shared.actions.ts) — the `PROJECT_DELETE_WINS_MARKER` producer
|
|
- [`conflict-resolution.ts`](packages/sync-core/src/conflict-resolution.ts)
|
|
- [`conflict-resolution.service.ts`](src/app/op-log/sync/conflict-resolution.service.ts) — the delete-wins classifier
|
|
- [`schema-version.ts`](packages/shared-schema/src/schema-version.ts)
|
|
- [`project-delete-wins-barrier-v3-to-v4.ts`](packages/shared-schema/src/migrations/project-delete-wins-barrier-v3-to-v4.ts) (registered in [`migrations/index.ts`](packages/shared-schema/src/migrations/index.ts))
|
|
|
|
**When to Update This Pattern**:
|
|
|
|
- Changing the cascade performed by `deleteProject`
|
|
- Adding another operation with delete-wins conflict semantics
|
|
- Changing schema compatibility or LWW replacement behavior
|
|
|
|
---
|
|
|
|
## 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]
|
|
```
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
- [`docs/sync-and-op-log/`](docs/sync-and-op-log/) - Operation log architecture
|
|
- [`docs/long-term-plans/`](docs/long-term-plans/) - Future architectural plans
|
|
|
|
---
|
|
|
|
## 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)
|
|
```
|