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:
1 parent
a29708df85
commit
26ffa0e71f
16 files changed
+3017
-13
No files matched your search
@@ -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.
|
||||
Reference in new issue
Block a user