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

8.9 KiB
Raw Permalink Blame History

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_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.
  • 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_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).
  • 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 a conflicts count; 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_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

  • 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 CustomEvents 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, 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

  • 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.
  • 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.
  • 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.
  • 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.