From 622885f3412ff25605589b93f9a20446eeb709ee Mon Sep 17 00:00:00 2001 From: Olumide-01 Date: Mon, 28 Sep 2026 11:26:11 +0100 Subject: [PATCH] fix(signals): add a CRDT collaborative lore draft behind phase-141 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #207 reports that concurrent edits to one signal clobber each other. The existing version+CAS contract is correct but answers the wrong question when 50 people type at once — it keeps one text and rejects the rest. The spike in lib/__tests__/signal-crdt-benchmark.test.ts measures it: Yjs CRDT (phase-141) 50/50 50 updates folded, 0 rejected version + CAS (current) 1/50 1 committed, 49 rejected with 409 So the two compose rather than compete. A Yjs CRDT draft in SQLite merges concurrent proposals automatically and never writes the signals row; the merged result is promoted to committed lore only through PUT with from_draft: true, which reuses the same If-Match guard and signal_versions snapshot as a manual edit. The guard is not removed, it moves to the one place a decision has to be made: if a colleague committed while the draft was open, PUT still answers 409 with current_version. - lib/signal-crdt.ts: lore document (title/body Y.Text), minimal span-diff edits so two appends stay two inserts, state-vector helpers, convergence checks. The client imports it dynamically so Yjs stays out of the bundle while the flag is off. - lib/signal-crdt-store.ts: merge under BEGIN IMMEDIATE, update tail folded into the snapshot every 64 merges, contributor attribution, 256KB cap. - PUT /api/signals/[id] (If-Match required, full title+body) plus GET/POST /api/signals/[id]/crdt for HTTP state-vector sync. HTTP rather than WebSocket because the app deploys to Vercel serverless. - signal_version_conflicts is counted once, in the store, so a rejected CAS is not double-counted by every layer that observes it. Counters are aggregate only: no signal id, wallet, or text reaches a label. - Fixes diffLoreVersions, which was exported as @ts-nocheck referenced but never defined, breaking the narrative versions route. 22 new tests, including 50-writer no-lost-writes across two SQLite connections. Docs updated in PROJECT_ARCHITECTURE.md and docs/TECHNICAL.md. --- PROJECT_ARCHITECTURE.md | 11 +- app/api/signals/[id]/crdt/route.ts | 164 ++++++++ app/api/signals/[id]/route.ts | 250 ++++++++++- app/signals/[id]/signal-detail-client.tsx | 142 +++++++ app/signals/[id]/use-signal-crdt.ts | 287 +++++++++++++ docs/TECHNICAL.md | 63 ++- lib/__tests__/signal-crdt-benchmark.test.ts | 183 ++++++++ lib/__tests__/signal-crdt.test.ts | 436 ++++++++++++++++++++ lib/feature-flags.ts | 7 +- lib/lore-versioning.ts | 94 ++++- lib/signal-crdt-store.ts | 358 ++++++++++++++++ lib/signal-crdt.ts | 281 +++++++++++++ lib/signal-store.ts | 12 +- lib/signal-version-metrics.ts | 117 ++++++ lib/sqlite-db.ts | 28 ++ package-lock.json | 49 +++ package.json | 1 + 17 files changed, 2441 insertions(+), 42 deletions(-) create mode 100644 app/api/signals/[id]/crdt/route.ts create mode 100644 app/signals/[id]/use-signal-crdt.ts create mode 100644 lib/__tests__/signal-crdt-benchmark.test.ts create mode 100644 lib/__tests__/signal-crdt.test.ts create mode 100644 lib/signal-crdt-store.ts create mode 100644 lib/signal-crdt.ts create mode 100644 lib/signal-version-metrics.ts diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index 7e17a54..e2ca800 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -54,6 +54,7 @@ Owns: - Signals, replies, edit history and reactions live in SQLite (`lib/sqlite-db.ts`, WAL mode), not the JSON sidecar. - x402 endpoints and payment verification support. - Concurrency safety for the signal store: version-guarded writes (`WHERE id = ? AND version = ?`) with bounded retry, plus `BEGIN IMMEDIATE` transactions for parent-version checks. Holds across processes, so concurrent Vercel instances no longer interleave. See `docs/TECHNICAL.md` 5.3. +- Collaborative signal lore drafting (phase-141, issue #207): a Yjs CRDT draft in SQLite (`signal_crdt_docs` / `signal_crdt_updates`) that merges concurrent edits automatically and never writes the `signals` row. The merged draft is promoted to committed lore only through `PUT /api/signals/[id]` with `from_draft: true`, which reuses the same `If-Match` CAS and `signal_versions` snapshot as a manual edit. Sync is HTTP state-vector exchange, not WebSocket, because the app deploys to Vercel serverless. See `docs/TECHNICAL.md` 5.3. - Other JSON-backed stores (`follow`, `profile`, `market`, `notification`, `achievement`, `narrative-world`) still do unguarded read-modify-write on their sidecar files. Single-writer is safe; concurrent writers lose updates. Must not own: @@ -103,7 +104,14 @@ Owns: mutations may echo it back as `If-Match` (or `parent_version` for appends) and the server rejects a stale value with `409` rather than silently overwriting a concurrent writer. `409` is logged under the `signals.version_conflict` event - so conflict rates are observable. + and counted in `signal_version_conflicts` so conflict rates are observable. +- **Optimistic concurrency guards commits, it does not refuse collaboration.** + Where many people edit one record at once, `409` on every save is a correct + answer to the wrong question. Signals therefore layer a CRDT *draft* over the + guarded record: concurrent proposals merge automatically, and the merged + result is promoted through the same `If-Match` guard so the authoritative row + keeps a single linear history. The guard is never removed; it moves to the one + place a decision actually has to be made. ## 6) Internationalization architecture @@ -184,6 +192,7 @@ is a fixed-size digest of the canonical payload, keeping it under wallet | `phase-83` | `NEXT_PUBLIC_FEATURE_PHASE_83` / `FEATURE_PHASE_83` | Emoji-reaction aggregation on signals (curated set, toggle per wallet) with a 20/60s per-wallet rate limit | off | Unset var, restart — reactions route returns 404; existing `signal_reactions` rows remain on disk (no migration to undo) | | `phase-139` | `NEXT_PUBLIC_FEATURE_PHASE_139` / `FEATURE_PHASE_139` | Collection-level offer books aggregated from per-token offers, plus bulk-bid across a collection's listings | off | Unset var, restart — offer-book/bulk-bid route returns 404; per-listing offers (`/api/market/[id]/offers`) are unaffected either way | | `phase-140` | `NEXT_PUBLIC_FEATURE_PHASE_140` / `FEATURE_PHASE_140` | Royalty enforcement on secondary sales: creator/seller split computed and ledgered at offer-accept time | off | Unset var, restart — listing creation stops accepting `creator_wallet`/`royalty_bps`; offer-accept stops computing a split (100% to seller, pre-140 behavior); existing `royalty_payouts` rows are historical record | +| `phase-141` | `NEXT_PUBLIC_FEATURE_PHASE_141` / `FEATURE_PHASE_141` | Yjs CRDT collaborative draft for signal lore — concurrent edits merge automatically instead of clobbering, promoted to committed lore via `PUT` (`from_draft: true`) under the normal `If-Match` guard | off | Unset var, restart — the CRDT routes return 404 and the draft panel is hidden; `PUT /api/signals/[id]` still works on its own as an `If-Match`-guarded full replacement; existing `signal_crdt_*` rows are inert scratch state (no migration to undo) | Flags are read via `lib/feature-flags.ts:isFeatureEnabled`. Client flags use `NEXT_PUBLIC_*`, server also accepts `FEATURE_*`. Zero regression when off. diff --git a/app/api/signals/[id]/crdt/route.ts b/app/api/signals/[id]/crdt/route.ts new file mode 100644 index 0000000..e9e67c1 --- /dev/null +++ b/app/api/signals/[id]/crdt/route.ts @@ -0,0 +1,164 @@ +import { NextRequest } from "next/server" +import { StrKey } from "@stellar/stellar-sdk" +import { createApiRequestContext } from "@/lib/api-observability" +import { getSignal } from "@/lib/signal-store" +import { + isSignalCrdtEnabled, + flag141RollbackNote, + mergeSignalLoreUpdate, + readSignalLoreDraft, + SignalCrdtError, +} from "@/lib/signal-crdt-store" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +/** + * Issue #207 (phase-141): the Yjs sync surface for a signal's collaborative + * lore draft. + * + * This is the same two-step sync protocol `y-websocket` speaks — client sends + * its state vector, server replies with the operations it is missing; client + * sends an update, server folds it in and replies with its own state vector — + * carried over plain HTTP instead of a WebSocket. The transport differs because + * this app deploys to Vercel serverless, where a request has a bounded lifetime + * and no upgrade handshake is possible; the merge semantics, which are what + * actually prevent lost writes, are Yjs' either way. The measurements behind + * that trade-off are in `docs/spikes/207-signal-crdt-benchmark.md`. + * + * `GET` `?state_vector=` → the operations the caller is missing. + * `POST` `{ update, wallet }` → fold an update, return the merged draft. + */ +const CRDT_ERROR_STATUS: Record = { + FLAG_DISABLED: 404, + NOT_FOUND: 404, + VALIDATION_FAILED: 400, +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const api = createApiRequestContext(request, "/api/signals/[id]/crdt") + const { id } = await params + + if (!isSignalCrdtEnabled()) { + return api.json( + { error: "Collaborative lore drafting disabled (phase-141 flag off)", rollback: flag141RollbackNote() }, + { status: 404, event: "signals.crdt.disabled" }, + ) + } + + const rawStateVector = request.nextUrl.searchParams.get("state_vector")?.trim() || undefined + + try { + const signal = await getSignal(id) + if (!signal) { + return api.json( + { error: "Signal not found" }, + { status: 404, event: "signals.crdt.signal_missing", metadata: { signal_id: id } }, + ) + } + const draft = await readSignalLoreDraft(id, rawStateVector) + return api.json( + { ...draft, version: signal.version }, + { + event: "signals.crdt.synced", + metadata: { signal_id: id, update_count: draft.updateCount, contributors: draft.contributors.length }, + }, + ) + } catch (error) { + return crdtFailure(error, api, id) + } +} + +type SyncBody = { + update?: unknown + wallet?: unknown + /** Optional: narrows the reply to only the operations this client lacks. */ + state_vector?: unknown +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const api = createApiRequestContext(request, "/api/signals/[id]/crdt") + const { id } = await params + + if (!isSignalCrdtEnabled()) { + return api.json( + { error: "Collaborative lore drafting disabled (phase-141 flag off)", rollback: flag141RollbackNote() }, + { status: 404, event: "signals.crdt.disabled" }, + ) + } + + let body: SyncBody + try { + body = (await request.json()) as SyncBody + } catch { + return api.json({ error: "Invalid JSON" }, { status: 400, event: "signals.crdt.invalid_json" }) + } + + if (typeof body.wallet !== "string" || !StrKey.isValidEd25519PublicKey(body.wallet)) { + return api.json( + { error: "Invalid wallet address" }, + { status: 400, event: "signals.crdt.validation_failed", metadata: { reason: "wallet" } }, + ) + } + if (typeof body.update !== "string" || body.update.length === 0) { + return api.json( + { error: "update required" }, + { status: 400, event: "signals.crdt.validation_failed", metadata: { reason: "update" } }, + ) + } + if (body.state_vector != null && (typeof body.state_vector !== "string" || body.state_vector.length === 0)) { + return api.json( + { error: "state_vector must be a base64 string when present" }, + { status: 400, event: "signals.crdt.validation_failed", metadata: { reason: "state_vector" } }, + ) + } + const stateVector = typeof body.state_vector === "string" ? body.state_vector : undefined + + try { + const signal = await getSignal(id) + if (!signal) { + return api.json( + { error: "Signal not found" }, + { status: 404, event: "signals.crdt.signal_missing", metadata: { signal_id: id } }, + ) + } + + const { draft, concurrent } = await mergeSignalLoreUpdate(id, body.update, body.wallet, { + sinceStateVector: stateVector, + }) + + return api.json( + { ...draft, version: signal.version, concurrent }, + { + event: "signals.crdt.merged", + metadata: { signal_id: id, concurrent, update_count: draft.updateCount }, + }, + ) + } catch (error) { + return crdtFailure(error, api, id) + } +} + +function crdtFailure( + error: unknown, + api: ReturnType, + signalId: string, +) { + if (error instanceof SignalCrdtError) { + return api.json( + { error: error.message, code: error.code }, + { + status: CRDT_ERROR_STATUS[error.code], + event: error.code === "NOT_FOUND" ? "signals.crdt.signal_missing" : "signals.crdt.rejected", + metadata: { signal_id: signalId, reason: error.code }, + }, + ) + } + return api.errorJson(error, 500, "signals.crdt.failed") +} diff --git a/app/api/signals/[id]/route.ts b/app/api/signals/[id]/route.ts index 65268ef..7f72f03 100644 --- a/app/api/signals/[id]/route.ts +++ b/app/api/signals/[id]/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from "next/server" +import { NextRequest } from "next/server" import { StrKey } from "@stellar/stellar-sdk" import { getSignal, @@ -13,6 +13,8 @@ import { import { createNotification } from "@/lib/notification-store" import { checkAndUnlock } from "@/lib/achievement-store" import { createApiRequestContext } from "@/lib/api-observability" +import { recordSignalCrdtCommit } from "@/lib/signal-version-metrics" +import { isSignalCrdtEnabled, readSignalLoreDraft } from "@/lib/signal-crdt-store" export const runtime = "nodejs" export const dynamic = "force-dynamic" @@ -114,6 +116,8 @@ export async function POST( ) } catch (error) { if (error instanceof VersionConflictError) { + // lib/signal-store.ts owns signal_version_conflicts; recording it here as + // well would count every rejected CAS twice. return api.json( { error: "Version conflict", current_version: error.currentVersion }, { @@ -145,37 +149,257 @@ const EDIT_ERROR_STATUS: Record = { CONFLICT: 409, } +/** + * Reads a mandatory `If-Match` version off a mutation. + * + * `PATCH` and `PUT` both guard on it, and they must agree exactly: a quoted + * ETag (`"5"`, which is what `GET` publishes) and a bare `5` are the same + * version, and `*` — which HTTP defines as "any current representation" — must + * be refused rather than quietly disabling the guard. + */ +type IfMatchResult = + | { ok: true; version: number } + | { ok: false; status: 428 | 400; error: string; code: "IF_MATCH_REQUIRED" | "INVALID_IF_MATCH" } + +function requireIfMatch(request: NextRequest): IfMatchResult { + const raw = request.headers.get("if-match") + if (raw === null || raw.trim() === "") { + return { + ok: false, + status: 428, + error: "If-Match header with the current signal version is required", + code: "IF_MATCH_REQUIRED", + } + } + const version = parseVersionHeader(raw) + if (version === null) { + return { + ok: false, + status: 400, + error: 'If-Match must be the current signal version, quoted or bare (e.g. If-Match: "7")', + code: "INVALID_IF_MATCH", + } + } + return { ok: true, version } +} + +/** + * Maps a store rejection onto an HTTP response. + * + * A 409 carries `current_version` and a matching `ETag` so a client can rebasing + * immediately instead of guessing; the store is what increments + * `signal_version_conflicts`, and re-recording here would double-count it. + */ +async function editFailureResponse( + error: SignalEditError, + api: ReturnType, + signalId: string, +) { + const status = EDIT_ERROR_STATUS[error.code] + if (error.code !== "CONFLICT") { + return api.json( + { error: error.message, code: error.code }, + { + status, + event: "signals.edit.rejected", + metadata: { reason: error.code }, + }, + ) + } + + const current = await getSignal(signalId) + const currentVersion = current?.version ?? null + return api.json( + { + error: error.message, + code: error.code, + ...(currentVersion === null ? {} : { current_version: currentVersion }), + }, + { + status, + event: "signals.version_conflict", + metadata: { reason: error.code, current_version: currentVersion }, + ...(currentVersion === null ? {} : { headers: { ETag: signalETag(currentVersion) } }), + }, + ) +} + // phase-82: edit a signal's title/body, snapshotting the pre-edit state into version history. export async function PATCH( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { + const api = createApiRequestContext(request, "/api/signals/[id]") const { id } = await params let body: EditBody try { body = (await request.json()) as EditBody } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) + return api.json({ error: "Invalid JSON" }, { status: 400, event: "signals.edit.invalid_json" }) } if (typeof body.wallet !== "string" || !StrKey.isValidEd25519PublicKey(body.wallet)) { - return NextResponse.json({ error: "Invalid wallet address" }, { status: 400 }) + return api.json( + { error: "Invalid wallet address" }, + { status: 400, event: "signals.edit.validation_failed", metadata: { reason: "wallet" } }, + ) } - const ifMatch = request.headers.get("if-match")?.trim() - if (!ifMatch || !/^\d+$/.test(ifMatch)) { - return NextResponse.json({ error: "If-Match header with the current signal version is required" }, { status: 428 }) + const ifMatch = requireIfMatch(request) + if (!ifMatch.ok) { + return api.json( + { error: ifMatch.error, code: ifMatch.code }, + { + status: ifMatch.status, + event: ifMatch.code === "IF_MATCH_REQUIRED" ? "signals.edit.if_match_required" : "signals.edit.validation_failed", + metadata: ifMatch.code === "INVALID_IF_MATCH" ? { reason: "if_match" } : {}, + }, + ) } try { - const { signal, version } = await editSignal(id, body.wallet, { - title: typeof body.title === "string" ? body.title : undefined, - body: typeof body.body === "string" ? body.body : undefined, - }, Number(ifMatch)) - return NextResponse.json({ signal, version }) + const { signal, version } = await editSignal( + id, + body.wallet, + { + title: typeof body.title === "string" ? body.title : undefined, + body: typeof body.body === "string" ? body.body : undefined, + }, + ifMatch.version, + ) + return api.json( + { signal, version }, + { + status: 200, + event: "signals.edit.ok", + metadata: { signal_id: id, version: signal.version }, + headers: { ETag: signalETag(signal.version) }, + }, + ) + } catch (error) { + if (error instanceof SignalEditError) return editFailureResponse(error, api, id) + return api.errorJson(error, 500, "signals.edit.failed") + } +} + +type PutBody = { + wallet?: unknown + title?: unknown + body?: unknown + from_draft?: unknown +} + +/** + * Issue #207: full replacement of a signal's lore, guarded by a mandatory + * `If-Match`, and the commit path for a CRDT-collaborative draft. + * + * `PUT` is the sibling of `PATCH`: where `PATCH` merges the fields it is given + * into the current row, `PUT` requires both `title` and `body` so the payload + * is always a complete, self-consistent revision. That distinction is what + * makes it safe to commit a merged draft through it — there is no partially + * applied state to reconcile. + * + * Both paths still land in `editSignal`, so the pre-edit text is snapshotted + * into `signal_versions` and the commit stays revertible. The CRDT layer never + * writes `signals` itself; it only ever proposes a revision to be committed + * here, under the author's `If-Match`. + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const api = createApiRequestContext(request, "/api/signals/[id]") + const { id } = await params + let body: PutBody + try { + body = (await request.json()) as PutBody + } catch { + return api.json({ error: "Invalid JSON" }, { status: 400, event: "signals.put.invalid_json" }) + } + + if (typeof body.wallet !== "string" || !StrKey.isValidEd25519PublicKey(body.wallet)) { + return api.json( + { error: "Invalid wallet address" }, + { status: 400, event: "signals.put.validation_failed", metadata: { reason: "wallet" } }, + ) + } + if (typeof body.title !== "string" || typeof body.body !== "string") { + return api.json( + { error: "PUT requires both title and body" }, + { status: 400, event: "signals.put.validation_failed", metadata: { reason: "title_body" } }, + ) + } + + const ifMatch = requireIfMatch(request) + if (!ifMatch.ok) { + return api.json( + { error: ifMatch.error, code: ifMatch.code }, + { + status: ifMatch.status, + event: ifMatch.code === "IF_MATCH_REQUIRED" ? "signals.put.if_match_required" : "signals.put.validation_failed", + metadata: ifMatch.code === "INVALID_IF_MATCH" ? { reason: "if_match" } : {}, + }, + ) + } + + // The client opts in with `from_draft: true`; the flag alone cannot be the + // signal, or every plain If-Match replacement would be miscounted as a CRDT + // commit. Clearing the draft is also only correct for a real draft commit. + const fromDraft = body.from_draft === true && isSignalCrdtEnabled() + try { + const { signal, version } = await editSignal( + id, + body.wallet, + { title: body.title, body: body.body }, + ifMatch.version, + ) + if (fromDraft) recordSignalCrdtCommit("committed") + return api.json( + { + signal, + version, + // The draft is scratch state, so a successful commit clears it: the next + // collaborator starts from the lore that was just made authoritative + // instead of from a superseded copy of it. + ...(fromDraft + ? { draft: await reseedDraft(id, api) } + : {}), + }, + { + status: 200, + event: "signals.put.ok", + metadata: { signal_id: id, version: signal.version, fromDraft }, + headers: { ETag: signalETag(signal.version) }, + }, + ) } catch (error) { if (error instanceof SignalEditError) { - return NextResponse.json({ error: error.message, code: error.code }, { status: EDIT_ERROR_STATUS[error.code] }) + if (fromDraft) recordSignalCrdtCommit(error.code === "CONFLICT" ? "conflicted" : "rejected") + return editFailureResponse(error, api, id) } - return NextResponse.json({ error: "Failed to edit signal" }, { status: 500 }) + return api.errorJson(error, 500, "signals.put.failed") + } +} + +/** + * Re-seeds the draft from the lore that was just committed, so the collaborative + * surface does not keep re-proposing text the signal no longer has. + * + * Best-effort by design: the edit is already durable at this point, so a failure + * here must not turn a committed signal into a 500. It is logged and the + * response omits `draft`; the stale draft is inert scratch state and gets reset + * on the next commit. + */ +async function reseedDraft( + signalId: string, + api: ReturnType, +) { + try { + const { resetSignalLoreDraft } = await import("@/lib/signal-crdt-store") + await resetSignalLoreDraft(signalId) + const draft = await readSignalLoreDraft(signalId) + return { stateVector: draft.stateVector, update: draft.update } + } catch (error) { + api.log("warn", "signals.put.draft_reseed_failed", { error, signal_id: signalId }) + return null } } diff --git a/app/signals/[id]/signal-detail-client.tsx b/app/signals/[id]/signal-detail-client.tsx index 10beb22..57119c6 100644 --- a/app/signals/[id]/signal-detail-client.tsx +++ b/app/signals/[id]/signal-detail-client.tsx @@ -1,11 +1,13 @@ "use client" import { useState } from "react" +import { useRouter } from "next/navigation" import { useWallet } from "@/components/wallet-provider" import { useLang } from "@/components/lang-context" import { WalletAvatar } from "@/components/wallet-avatar" import { signSignalPayload } from "@/lib/viewer-signature" import type { SignalReply } from "@/lib/signal-store" +import { useSignalCRDT } from "./use-signal-crdt" const copy = { en: { @@ -18,6 +20,21 @@ const copy = { walletBadge: "✓ WALLET", verifiedBadge: "✓ VERIFIED", conflict: "[ SIGNAL_CHANGED_REFRESH_AND_RETRY ]", + draft: "COLLABORATIVE LORE DRAFT", + draftTitlePlaceholder: "Draft title…", + draftBodyPlaceholder: "Draft body…", + draftNoWallet: "[ CONNECT_WALLET_TO_EDIT_DRAFT ]", + draftCommit: "[ COMMIT_REVISION ]", + draftCommitting: "[ COMMITTING… ]", + draftCommitted: "[ REVISION_COMMITTED ]", + draftConflict: "[ SIGNAL_CHANGED_DRAFT_MERGED_RETRY_COMMIT ]", + draftSyncing: "SYNCING", + draftSynced: "SYNCED", + draftOffline: "OFFLINE", + draftPending: "pending", + draftContributors: "contributors", + draftNote: + "Concurrent edits merge. Committing publishes a new revision with full edit history.", }, es: { replies: "RESPUESTAS", @@ -29,6 +46,21 @@ const copy = { walletBadge: "✓ WALLET", verifiedBadge: "✓ VERIFICADO", conflict: "[ SEÑAL_ACTUALIZADA_REFRESCA_Y_REINTENTA ]", + draft: "BORRADOR DE LORE COLABORATIVO", + draftTitlePlaceholder: "Título del borrador…", + draftBodyPlaceholder: "Cuerpo del borrador…", + draftNoWallet: "[ CONECTAR_WALLET_PARA_EDITAR_BORRADOR ]", + draftCommit: "[ PUBLICAR_REVISIÓN ]", + draftCommitting: "[ PUBLICANDO… ]", + draftCommitted: "[ REVISIÓN_PUBLICADA ]", + draftConflict: "[ SEÑAL_ACTUALIZADA_BORRADOR_FUSIONADO_REINTENTA ]", + draftSyncing: "SINCRONIZANDO", + draftSynced: "SINCRONIZADO", + draftOffline: "SIN CONEXIÓN", + draftPending: "pendientes", + draftContributors: "colaboradores", + draftNote: + "Las ediciones concurrentes se fusionan. Publicar crea una nueva revisión con historial completo.", }, } @@ -61,6 +93,7 @@ export function SignalDetailClient({ }: Props) { const { address } = useWallet() const { lang } = useLang() + const router = useRouter() const t = copy[lang] ?? copy.en const [replies, setReplies] = useState(initialReplies) @@ -70,6 +103,13 @@ export function SignalDetailClient({ const [error, setError] = useState(null) const [conflict, setConflict] = useState(false) + // phase-141: collaborative lore draft. The hook is inert while the flag is off, + // so the panel below and the Yjs dependency are both absent by default. + const draft = useSignalCRDT(signalId, { initialVersion: initialSignalVersion, wallet: address }) + const [committing, setCommitting] = useState(false) + const [committed, setCommitted] = useState(null) + const [commitError, setCommitError] = useState(null) + const baseInput = "w-full bg-transparent border border-[var(--color-border-tertiary)] font-mono text-[12px] text-foreground px-3 py-2 focus:outline-none focus:border-[#7F77DD] transition-colors placeholder:text-muted-foreground/40 resize-none" @@ -117,8 +157,110 @@ export function SignalDetailClient({ } } + async function handleCommitDraft() { + setCommitting(true) + setCommitError(null) + try { + const result = await draft.commit() + if (result.ok) { + setCommitted(result.version) + setSignalVersion(result.version) + // The title and body are rendered server-side, so the committed revision + // only appears once the server component re-runs. + router.refresh() + return + } + if (typeof result.currentVersion === "number") { + setSignalVersion(result.currentVersion) + } + setCommitError(result.error) + } finally { + setCommitting(false) + } + } + return (
+ {draft.enabled && ( +
+
+ + {t.draft} + + + {draft.status === "synced" + ? t.draftSynced + : draft.status === "offline" + ? t.draftOffline + : t.draftSyncing} + {draft.pending > 0 ? ` · ${draft.pending} ${t.draftPending}` : ""} + {draft.contributors.length > 0 + ? ` · ${draft.contributors.length} ${t.draftContributors}` + : ""} + +
+ + {draft.canEdit ? ( +
+ draft.setTitle(e.target.value)} + placeholder={t.draftTitlePlaceholder} + className={baseInput} + /> +