Files
thandClaude Opus 4.6 26ffa0e71f plugin(collab-buckets): collaborative buckets via relay
End-to-end shared buckets between relay tenants. Bucket metadata + members
sync through new relay routes (collab-buckets, collab-cards, collab-locks);
card content rides the existing inter-tenant encryption hop. Edit locks,
ownership transfer, LWW conflict stashing, undo for local-bucket destructive
actions, "Make collaborative…" upgrade path, and a resilient outbox for
relay-down create attempts. See plugins/collab-buckets/README.md and
TODO.md for the full picture.

Core hooks added so the plugin can integrate without monkey-patching:
- buckets.js context menu: collab-aware rename/color/delete + Members &
  owner / Make collaborative entries
- editor.js dispatches hyprcrd:editor-open / hyprcrd:editor-close for the
  per-card edit-lock heartbeat
- app.js exposes window.state for the plugin's MutationObserver
- index.php POST/PATCH/DELETE on /api/buckets refuse to mutate collab
  buckets directly (must go through the relay)
- Database.php adds buckets.collab_uid column + unique partial index
- import handler now restores color and collab_uid (was silently dropping
  color, which caused the test runner's auto-restore to wipe custom
  bucket colors)
- relay bootstrap creates collab_buckets, collab_bucket_members,
  collab_cards, collab_card_locks tables and prunes tombstones / expired
  locks on every request

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:49:53 +02:00

152 lines
8.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# collab-buckets plugin — roadmap
> Phase 1, 2 and 3 are shipped: metadata + membership sync, bucket-bar
> integration, and full card sync with encrypted round-trip and LWW
> conflict resolution. Phase 4 is polish — edit locks, ownership
> transfer, member presence, etc. Phase 5 is hardening.
## Phase 3 — card sync (DONE ✓)
- [x] **`collab_card_state` table (local)** — maps a local `cards.id` to
a collab `bucket_uid` + `relay_uid` plus `last_push_at` and
`last_remote_at` for LWW tracking. Poll phase 1 finds dirty cards
via `c.updated_at > cs.last_push_at`.
- [x] **Save propagation without editor coupling** — the poll's push
phase is query-based (no trigger, no hook). Whenever the user
saves a card and the core bumps `cards.updated_at`, the next
poll within 30s finds it dirty and pushes.
- [x] **Relay `collab_cards` table + routes** — `POST /api/collab-cards`
decrypts-reencrypts via inter-tenant key; `GET ?since=<ts>`
re-encrypts per recipient; `DELETE /{bucket}/{card}` marks a
tombstone with a 7-day TTL (cleaned up by bootstrap.php).
- [x] **Poll extension** — `collabSyncCards()` runs three phases
(tombstones → push → pull) with a temp-table suppress flag so the
delete trigger doesn't re-enqueue during apply.
- [x] **Card visibility** — collab buckets already take part in the core
bucket-bar's click-to-filter flow, so cards synced into them
automatically show up on the board.
- [x] **Card deletion propagation** — BEFORE DELETE trigger writes to
`collab_tombstones`. Drained in the next poll as a DELETE.
Suppressed during the apply phase so remote deletes don't loop
back as local tombstones.
- [x] **Conflict losers as "(conflict from X)" duplicates** — when the
apply phase is about to LWW-clobber a local card that had edits
since our last push (`local.updated_at > collab_card_state.last_push_at`),
we now INSERT a copy of the local version into a local-only
"Conflicts" bucket with a `(conflict from <author>) ...` title
prefix before the overwrite. The poll response carries a
`conflicts` count; the frontend pops a toast pointing the user at
the Conflicts bucket when it's non-zero.
- [x] **Moving a card between collab buckets (or out to a local one)**
— the push phase now has a pre-pass that finds cards where
`collab_card_state.bucket_uid` no longer matches the card's
current bucket collab_uid. For each such card it sends a
tombstone to the old bucket. If the card is still in a (different)
collab bucket, state is rewritten with the new `bucket_uid` and
`last_push_at = NULL` so the main push loop re-uploads under the
new bucket. If the card left collab entirely, the state row is
removed.
## Phase 4 — polish
- [x] **Per-card edit lock** — when the editor opens on a collab card the
plugin heartbeats `POST /api/collab-locks {bucket_uid, card_uid}`
every 30 s with a 60 s TTL on the relay. The lock list is folded
into the regular `/api/collab-buckets/poll` response so no extra
round-trips are needed. Foreign locks show a 🔒 badge on the
affected board card + a subtle inset outline. Releases on editor
close and best-effort on `beforeunload` via `fetch(..., keepalive)`.
Advisory only — users can still force-edit; LWW + conflict stashing
remain the final arbiter. Core `editor.js` dispatches
`hyprcrd:editor-open` / `hyprcrd:editor-close` `CustomEvent`s so
the plugin hooks in without monkey-patching.
- [x] **Ownership transfer** — `PATCH /api/collab-buckets/{uid}`
accepts `{owner: <username>}`. The relay validates that the new
owner is currently a member, swaps `owner_id` + `owner_username`,
and forces both the caller and the new owner to stay in the member
list on the same request. Surfaced in the UI via a new "Members &
owner…" entry in the bucket context menu that opens a plugin
dialog with a member picker + owner dropdown (only shown when the
caller is the current owner; other members see a read-only view).
- [ ] **Position propagation (optional)** — reorder is purely local right
now. If multiple members expect the same order, we'd need to sync
`position` via the relay, but it's probably fine to leave this as a
per-user preference.
- [ ] **Member presence indicator** — show a small dot next to usernames in
the members panel when they were seen by the relay within the last
minute (reuse chat's heartbeat or add a collab-specific ping).
- [ ] **"Recently active members"** — show avatars (first letter) of
members who touched a card in the last hour, on the collab bucket tab.
- [ ] **Read receipts** — "3 / 5 seen" for the latest update on each
collab bucket. Requires the relay to store per-member `last_seen_at`
— bigger schema change.
## Phase 5 — hardening & UX
- [x] **Migration of existing local buckets** — the bucket context menu
on a non-collab bucket shows a "Make collaborative…" entry when
the plugin is loaded. It opens a plugin dialog where the owner
picks members, then `POST /api/collab-buckets/upgrade
{bucket_id, members}` generates a new `uid`, pushes it to the
relay, and writes `collab_uid` onto the same local `buckets` row
— no card shuffling, no id rewrite. The first poll after the
upgrade sees every card in the bucket as dirty (no
`collab_card_state`) and pushes them.
- [x] **Undo for destructive actions** — local bucket delete/leave now
hooks into the existing `pushUndo` system: the undo entry
recreates the bucket via `POST /api/buckets`, restores the
original color+position with PATCH, and re-attaches every card
that pointed at the old row. Collab bucket delete/leave is
intentionally *not* wired — restoring would need to recreate on
the relay under a new uid and re-attach cards there too, which
is messy and less valuable since collab deletes are usually
deliberate.
- [x] **Resilient create via outbox** — a new
`collab_bucket_outbox(id, bucket_id, members_json, queued_at)`
table. The create/upgrade flow always creates the local `buckets`
row first and then tries `collabAttemptUpgrade()`. On relay
failure the intent is queued in the outbox and the next poll
retries. The response includes `queued: true` so the frontend can
show a "saved locally, will sync later" info toast; a follow-up
poll that successfully drains the outbox fires a second toast.
Stale outbox entries for deleted buckets are dropped automatically.
- [x] **Rate limiting on collab endpoints** — every collab route now
funnels through the existing relay `isRateLimited()` helper with
generous caps (120/min for GET, 240/min for POST). Over-limit
requests get a clean `429 rate limited` response.
- [ ] **Inline member picker in the bucket context menu** — currently
needs the manage-modal for any change. A quick `+user` / `-user`
flyout would help for tiny edits.
- [ ] **Owner-side member approval flow** — owners can still add any
relay user unilaterally. A pending-invite state with accept/reject
on the invitee side would be nicer for consent but requires a new
relay table + plumbing.
## Known limitations (current)
- **LWW conflicts still clobber on the remote side.** When two users save
the same card within a few seconds, the later writer wins. The earlier
writer gets their local version stashed into a `Conflicts` bucket and
a toast notification — nothing is lost — but their version is no
longer the canonical one anyone else sees. Phase 4 edit-locks prevent
the race in the first place.
- **Up to ~30 s sync latency.** Changes propagate on the next poll, not
instantly. Acceptable for MVP; a "push now" hook after save would cut
latency to ~1 s.
- **Full resync on every poll.** Cheap for small member counts but grows
O(N_buckets × N_members) on the relay side. A `since=updated_at` filter
would help — adding it is safe because the poll also has to handle full
removals (kicked from bucket), and we can fold the deletion list into the
response.
- **Bucket metadata is plaintext on the relay.** Name + color + member list
are visible to a relay admin with DB access. Card content (Phase 3) will
be encrypted end-to-end via the existing decrypt-reencrypt hop; bucket
metadata could follow the same pattern but adds complexity for minimal
gain and makes member picking harder.
- **No offline support.** Creating a bucket while the relay is unreachable
fails hard. Phase 5 outbox pattern would fix this.
- **Race on UID generation.** The client generates `bin2hex(random_bytes(8))`
= 64 bits of entropy. Collisions between independent clients are
astronomically unlikely, but the relay rejects them with 409 if they ever
happen. No retry on the client side yet.