adding claude file and some plans
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# 03 — Domain Events, Outbox & Jobs
|
||||
|
||||
Every mutation in SoloPM emits a domain event. One mechanism feeds five consumers: **audit log, notifications, SSE, Meilisearch sync, outgoing webhooks**. The design is a **hand-rolled transactional outbox in Postgres with a single in-process dispatcher** — no external queue, no River. Rationale in [17-decisions.md](17-decisions.md); the explicit assumption is **one app instance** (`FOR UPDATE SKIP LOCKED` makes even accidental multi-instance mostly safe, but the SSE hub is per-process).
|
||||
|
||||
## Emit contract
|
||||
|
||||
- [ ] Implement `events.Emit(ctx, tx, Event{Type, ActorID, ProjectID, ResourceType, ResourceID, Payload, ViaWebhook})` which, **inside the caller's transaction**:
|
||||
- [ ] Inserts the `audit_log` row (exactly-once, atomic with the mutation — an audit entry can never be lost or orphaned)
|
||||
- [ ] Inserts the `events` outbox row
|
||||
- [ ] `Payload` includes: changed-field map `{field: {prev, new}}`, denormalized display data (issue title, project title, `#N`), and server-parsed `@mention` usernames where applicable ([08-collaboration.md](08-collaboration.md))
|
||||
- [ ] After commit, the service fires `pg_notify('solopm_events', '')` — best-effort wake-up; polling is the safety net
|
||||
- [ ] Every service mutation calls `Emit`; a mutation without an event is a spec violation
|
||||
|
||||
## Event taxonomy
|
||||
|
||||
Format `resource.action`, past tense. `action_type` in audit_log uses the same strings.
|
||||
|
||||
- [ ] `project.created` / `project.updated` / `project.status_changed` / `project.deleted`
|
||||
- [ ] `project.member_added` / `project.member_removed` / `project.role_changed` / `project.ownership_transferred`
|
||||
- [ ] `project.dependency_added` / `project.dependency_removed`
|
||||
- [ ] `project.exported` (audit-only; no notification/SSE/search/webhook fan-out)
|
||||
- [ ] `label.created` / `label.updated` / `label.deleted`
|
||||
- [ ] `epic.created` / `epic.updated` / `epic.status_changed` / `epic.deleted`
|
||||
- [ ] `issue.created` / `issue.updated` / `issue.status_changed` / `issue.assigned` / `issue.unassigned` / `issue.moved` (rank/epic/parent changes)
|
||||
- [ ] `issue.blocker_added` / `issue.blocker_removed`
|
||||
- [ ] `issue.label_added` / `issue.label_removed`
|
||||
- [ ] `comment.created` / `comment.updated` / `comment.resolved` / `comment.unresolved` / `comment.deleted`
|
||||
- [ ] `attachment.uploaded` / `attachment.deleted`
|
||||
- [ ] `link.added` / `link.removed`
|
||||
- [ ] `wiki.created` / `wiki.updated` / `wiki.deleted`
|
||||
- [ ] `user.created` / `user.updated` / `user.deleted`
|
||||
- [ ] `auth.login_succeeded` / `auth.login_failed` / `auth.token_created` / `auth.token_revoked`
|
||||
- [ ] `webhook.received` / `webhook.processed` / `webhook.failed` (incoming-webhook lifecycle)
|
||||
|
||||
## Dispatcher
|
||||
|
||||
- [ ] One goroutine started in `main`, stopped on shutdown: `LISTEN solopm_events` + 2-second poll fallback
|
||||
- [ ] Claims batches: `SELECT … FROM events WHERE processed_at IS NULL ORDER BY id LIMIT 50 FOR UPDATE SKIP LOCKED`
|
||||
- [ ] For each event, run the fan-out router (below), then `UPDATE events SET processed_at = now()`
|
||||
- [ ] Crash mid-fan-out → event re-processed on restart; **all consumers must be idempotent** (see guarantees table)
|
||||
- [ ] `attempts` incremented per processing try; events failing 8 times are marked processed with an error log (never wedge the queue)
|
||||
|
||||
## Consumers & guarantees
|
||||
|
||||
| Consumer | Mechanism | Guarantee |
|
||||
|---|---|---|
|
||||
| Audit log | synchronous inside `Emit` (same tx) | exactly-once |
|
||||
| Notifications | inline in dispatcher → `notifications` inserts + `send_email` jobs | at-least-once, deduped |
|
||||
| SSE | inline publish to in-memory hub | best-effort (clients refetch on reconnect) |
|
||||
| Meilisearch | `search_sync` job | at-least-once, idempotent upsert/delete by doc id |
|
||||
| Outgoing webhooks | delivery row + `webhook_delivery` job | at-least-once; UNIQUE(webhook_id, event_id) dedupes |
|
||||
|
||||
- [ ] **Notifications consumer**: compute recipients per the rules in [09-notifications-realtime.md](09-notifications-realtime.md); skip the actor themself; insert `notifications` rows (dedupe: skip if an identical `(user_id, type, resource, event)` row was already created for this event id); for each recipient with `email_mode = instant`, enqueue a `send_email` job; `daily_digest` users are handled by the nightly digest job
|
||||
- [ ] **SSE consumer**: build the envelope ([09-notifications-realtime.md](09-notifications-realtime.md)) and publish to the hub; hub delivers `data` frames only to connected users who are members of `event.project_id`
|
||||
- [ ] **Search consumer**: map event → `{index, doc_id, op: upsert|delete}` per [10-search.md](10-search.md); enqueue `search_sync` job; skip event types with no search impact
|
||||
- [ ] **Webhook consumer**: for each active `outgoing_webhooks` row of the project whose `event_types` is empty or contains the event type: insert `outgoing_webhook_deliveries` (pending) + `webhook_delivery` job
|
||||
|
||||
## Job queue
|
||||
|
||||
- [ ] Worker pool: 3 goroutines polling `jobs` every 1s: `SELECT … WHERE status = 'pending' AND run_at <= now() ORDER BY run_at LIMIT 10 FOR UPDATE SKIP LOCKED`, set `running`, execute, set `done`
|
||||
- [ ] On failure: `attempts++`, `last_error` recorded, `run_at = now() + 30s × 2^attempts` (capped at 1h), back to `pending`
|
||||
- [ ] After `max_attempts` (8): status `dead`; dead jobs visible in admin (and webhook deliveries surface as `failed` in the delivery log)
|
||||
- [ ] Job kinds: `send_email`, `webhook_delivery`, `search_sync`, `daily_digest` (scheduled nightly), `cull` (scheduled daily; see [12-retention.md](12-retention.md))
|
||||
- [ ] Scheduled jobs: on startup and every hour, ensure the next `daily_digest` and `cull` jobs exist (idempotent upsert by kind + date)
|
||||
- [ ] Email and webhook delivery never block an HTTP request; search sync typically lands < 2s after the mutation
|
||||
Reference in New Issue
Block a user