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>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-11 18:49:53 +02:00
1 parent a29708df85
commit 26ffa0e71f
16 files changed
+3017 -13

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
# 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.