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>
152 lines
8.9 KiB
Markdown
152 lines
8.9 KiB
Markdown
# 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.
|