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>
8.9 KiB
8.9 KiB
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 ✓)
collab_card_statetable (local) — maps a localcards.idto a collabbucket_uid+relay_uidpluslast_push_atandlast_remote_atfor LWW tracking. Poll phase 1 finds dirty cards viac.updated_at > cs.last_push_at.- 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. - Relay
collab_cardstable + routes —POST /api/collab-cardsdecrypts-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). - 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. - 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.
- 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. - 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 aconflictscount; the frontend pops a toast pointing the user at the Conflicts bucket when it's non-zero. - 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_uidno 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 newbucket_uidandlast_push_at = NULLso the main push loop re-uploads under the new bucket. If the card left collab entirely, the state row is removed.
Phase 4 — polish
- 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/pollresponse 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 onbeforeunloadviafetch(..., keepalive). Advisory only — users can still force-edit; LWW + conflict stashing remain the final arbiter. Coreeditor.jsdispatcheshyprcrd:editor-open/hyprcrd:editor-closeCustomEvents so the plugin hooks in without monkey-patching. - Ownership transfer —
PATCH /api/collab-buckets/{uid}accepts{owner: <username>}. The relay validates that the new owner is currently a member, swapsowner_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
positionvia 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
- 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 newuid, pushes it to the relay, and writescollab_uidonto the same localbucketsrow — no card shuffling, no id rewrite. The first poll after the upgrade sees every card in the bucket as dirty (nocollab_card_state) and pushes them. - Undo for destructive actions — local bucket delete/leave now
hooks into the existing
pushUndosystem: the undo entry recreates the bucket viaPOST /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. - Resilient create via outbox — a new
collab_bucket_outbox(id, bucket_id, members_json, queued_at)table. The create/upgrade flow always creates the localbucketsrow first and then triescollabAttemptUpgrade(). On relay failure the intent is queued in the outbox and the next poll retries. The response includesqueued: trueso 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. - 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 clean429 rate limitedresponse. - Inline member picker in the bucket context menu — currently
needs the manage-modal for any change. A quick
+user/-userflyout 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
Conflictsbucket 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_atfilter 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.