diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 3653b0b..b7d0553 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -92,6 +92,7 @@ export default async function StructuredEditorPage({ extras={extraSections.map(({ id, slug, title }) => ({ id, slug, title }))} /> - +
{sections.map(({ slug, title, description }) => ( diff --git a/app/admin/page.tsx b/app/admin/page.tsx index b642e6e..c927e1a 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -3,6 +3,7 @@ import { connection } from "next/server"; import { StructuredDashboard } from "@/components/admin/structured-dashboard"; import { AdminHeader } from "@/components/admin/admin-header"; +import { LiveRefresh } from "@/components/live-refresh"; import { getAdminSession } from "@/lib/auth/server"; import { getCurrentCatalogVersion } from "@/lib/catalog/sync"; import { listGuideCards } from "@/lib/guides/queries"; @@ -19,6 +20,7 @@ export default async function AdminPage() { ]); return (
+
diff --git a/components/admin/autosave-status.tsx b/components/admin/autosave-status.tsx index bbd2944..16c2c26 100644 --- a/components/admin/autosave-status.tsx +++ b/components/admin/autosave-status.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import type { AutosaveState } from "@/hooks/use-autosave"; -export function AutosaveStatus({ state, issues = [], onSave }: { state: AutosaveState; issues?: readonly string[]; onSave?: () => void }) { +export function AutosaveStatus({ state, issues = [], onSave, conflictVersion, canOverwrite = false, onReloadLatest, onOverwrite }: { state: AutosaveState; issues?: readonly string[]; onSave?: () => void; conflictVersion?: number | null; canOverwrite?: boolean; onReloadLatest?: () => void; onOverwrite?: () => void }) { const [showIssues, setShowIssues] = useState(false); if (state === "saving") return Saving…; @@ -27,6 +27,19 @@ export function AutosaveStatus({ state, issues = [], onSave }: { state: Autosave : null}
; + if (state === "conflict") return + ; if (state === "error") return
Autosave failed{onSave ? : null}
; return Saved; } diff --git a/components/admin/guide-overview-form.tsx b/components/admin/guide-overview-form.tsx index ebddd8c..3f854dd 100644 --- a/components/admin/guide-overview-form.tsx +++ b/components/admin/guide-overview-form.tsx @@ -203,6 +203,8 @@ export function GuideOverviewForm({ const invalidOverview = !overview.trim(); const localDraft = { overview, youtubeLinks, cover, posterArt, posterArtOffset, posterArtScale, posterTopColors, posterBottomColors, bleeding }; const autosave = useAutosave({ + guideId: guide.id, + scope: "overview", value, valid: Boolean(cover.id && overview.trim() && !invalidYoutubeLinks), initialVersion: guide.version, @@ -512,6 +514,10 @@ export function GuideOverviewForm({ diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index f0c0c04..a2add9e 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -1375,6 +1375,8 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) { !validation.success && hasIssuePrefix(validation.error.issues, ...path); const localDraft = { note, groups, activeGroupIndex }; const autosave = useAutosave({ + guideId: guide.id, + scope: "weapon", value, valid: validation.success, initialVersion: guide.version, @@ -1728,6 +1730,10 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) { ({ localDraft, parseLocalDraft, restoreLocalDraft, + guideId, + scope, }: { value: Value; valid: boolean; @@ -35,7 +38,10 @@ export function useAutosave({ localDraft?: Draft; parseLocalDraft?: (value: unknown) => Draft | null; restoreLocalDraft?: (draft: Draft) => void; + guideId: string; + scope: AdminEditorScope; }) { + const router = useRouter(); const fingerprint = JSON.stringify(value); const draftFingerprint = JSON.stringify(localDraft ?? value); const [savedFingerprint, setSavedFingerprint] = useState(fingerprint); @@ -45,6 +51,8 @@ export function useAutosave({ const [version, setVersion] = useState(initialVersion); const [hydrated, setHydrated] = useState(!storageKey); const [manualRequest, setManualRequest] = useState(0); + const [conflictVersion, setConflictVersion] = useState(null); + const [overwriteRequest, setOverwriteRequest] = useState(null); const handledManualRequest = useRef(0); const storageWarningShown = useRef(false); @@ -117,15 +125,17 @@ export function useAutosave({ } }, [draftFingerprint, hydrated, localDraft, savedDraftFingerprint, storageKey, version]); - const save = useEffectEvent(async (snapshot: Value, snapshotFingerprint: string, snapshotDraftFingerprint: string) => { + const save = useEffectEvent(async (snapshot: Value, snapshotFingerprint: string, snapshotDraftFingerprint: string, expectedVersion = version) => { setSaving(true); setErrorFingerprint(null); try { - const attempt = await saveWithConflictRetry(persist, snapshot, version); - const result = attempt.response; - if (attempt.retried) setVersion(attempt.version); - if (result.status === "error" || result.status === "conflict") { + const result = await persist(snapshot, expectedVersion); + if (result.status === "conflict") { if (result.currentVersion) setVersion(result.currentVersion); + setConflictVersion(result.currentVersion ?? expectedVersion); + return; + } + if (result.status === "error") { setErrorFingerprint(snapshotFingerprint); toast.error(result.message ?? "บันทึกอัตโนมัติไม่สำเร็จ"); return; @@ -133,6 +143,7 @@ export function useAutosave({ if (result.version) setVersion(result.version); setSavedFingerprint(snapshotFingerprint); setSavedDraftFingerprint(snapshotDraftFingerprint); + setConflictVersion(null); } catch (cause) { setErrorFingerprint(snapshotFingerprint); toast.error(cause instanceof Error ? cause.message : "บันทึกอัตโนมัติไม่สำเร็จ"); @@ -141,8 +152,35 @@ export function useAutosave({ } }); + const receiveInvalidation = useEffectEvent((message: Event) => { + if (!(message instanceof MessageEvent) || typeof message.data !== "string") return; + const event = parseInvalidationEvent(message.data); + if (!event || event.type !== "admin.updated" || event.id !== guideId || event.version <= version) return; + if (event.scope && event.scope !== scope) { + setVersion((current) => Math.max(current, event.version)); + setConflictVersion((current) => current === null ? null : Math.max(current, event.version)); + return; + } + if (hydrated && valid && !saving && fingerprint === savedFingerprint) { + setVersion((current) => Math.max(current, event.version)); + router.refresh(); + return; + } + setVersion((current) => Math.max(current, event.version)); + setConflictVersion((current) => Math.max(current ?? 0, event.version)); + }); + useEffect(() => { - if (!hydrated || !valid || saving) return; + const source = new EventSource("/api/events/admin"); + source.addEventListener("invalidation", receiveInvalidation); + return () => { + source.removeEventListener("invalidation", receiveInvalidation); + source.close(); + }; + }, []); + + useEffect(() => { + if (!hydrated || !valid || saving || conflictVersion !== null) return; const manual = manualRequest !== handledManualRequest.current; if (!manual && (fingerprint === savedFingerprint || fingerprint === errorFingerprint)) return; if (manual) handledManualRequest.current = manualRequest; @@ -150,10 +188,24 @@ export function useAutosave({ void save(JSON.parse(fingerprint) as Value, fingerprint, draftFingerprint); }, manual ? 0 : delay); return () => window.clearTimeout(timer); - }, [delay, draftFingerprint, errorFingerprint, fingerprint, hydrated, manualRequest, savedFingerprint, saving, valid]); + }, [conflictVersion, delay, draftFingerprint, errorFingerprint, fingerprint, hydrated, manualRequest, savedFingerprint, saving, valid]); + + useEffect(() => { + if (overwriteRequest === null || !valid || saving) return; + const timer = window.setTimeout(() => { + void save( + JSON.parse(fingerprint) as Value, + fingerprint, + draftFingerprint, + overwriteRequest, + ).finally(() => setOverwriteRequest(null)); + }, 0); + return () => window.clearTimeout(timer); + }, [draftFingerprint, fingerprint, overwriteRequest, saving, valid]); let state: AutosaveState; - if (!valid) state = "incomplete"; + if (conflictVersion !== null) state = "conflict"; + else if (!valid) state = "incomplete"; else if (saving) state = "saving"; else if (fingerprint === errorFingerprint) state = "error"; else if (fingerprint !== savedFingerprint) state = "unsaved"; @@ -179,9 +231,22 @@ export function useAutosave({ return { state, saveNow: () => { - if (!valid || saving) return; + if (!valid || saving || conflictVersion !== null) return; setErrorFingerprint(null); setManualRequest((current) => current + 1); }, + conflictVersion, + canOverwrite: valid && !saving && overwriteRequest === null, + reloadLatest: () => { + try { + if (storageKey) window.localStorage.removeItem(storageKey); + } finally { + window.location.reload(); + } + }, + overwrite: () => { + if (!valid || saving || conflictVersion === null) return; + setOverwriteRequest(conflictVersion); + }, }; } diff --git a/lib/editor/autosave.test.ts b/lib/editor/autosave.test.ts index de3f47a..32c9792 100644 --- a/lib/editor/autosave.test.ts +++ b/lib/editor/autosave.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { AutosaveCoordinator, - saveWithConflictRetry, type AutosaveResponse, type AutosaveStatus, } from "./autosave"; @@ -115,24 +114,3 @@ describe("AutosaveCoordinator", () => { expect(invalid.statuses.at(-1)).toMatchObject({ state: "error", issues: ["title"] }); }); }); - -describe("saveWithConflictRetry", () => { - it("retries one version conflict against the current server version", async () => { - const calls: number[] = []; - const save = vi.fn(async (_value: string, expectedVersion: number) => { - calls.push(expectedVersion); - if (calls.length === 1) return { status: "conflict" as const, currentVersion: 8 }; - return { status: "saved" as const, version: 9 }; - }); - const result = await saveWithConflictRetry(save, "constellation draft", 7); - expect(calls).toEqual([7, 8]); - expect(result).toEqual({ response: { status: "saved", version: 9 }, version: 8, retried: true }); - }); - - it("stops after one retry when another concurrent save wins", async () => { - const save = vi.fn(async (_value: string, expectedVersion: number) => ({ status: "conflict" as const, currentVersion: expectedVersion + 1 })); - const result = await saveWithConflictRetry(save, "constellation draft", 3); - expect(save).toHaveBeenCalledTimes(2); - expect(result.response).toEqual({ status: "conflict", currentVersion: 5 }); - }); -}); diff --git a/lib/editor/autosave.ts b/lib/editor/autosave.ts index 3739f9e..ab6f792 100644 --- a/lib/editor/autosave.ts +++ b/lib/editor/autosave.ts @@ -12,22 +12,6 @@ export type AutosaveStatus = | { state: "conflict"; currentVersion: number } | { state: "error"; message: string; issues?: string[] }; -export async function saveWithConflictRetry( - save: (value: Value, expectedVersion: number) => Promise, - value: Value, - expectedVersion: number, -): Promise<{ response: Response; version: number; retried: boolean }> { - const first = await save(value, expectedVersion); - if (first.status !== "conflict" || !first.currentVersion || first.currentVersion === expectedVersion) { - return { response: first, version: expectedVersion, retried: false }; - } - return { - response: await save(value, first.currentVersion), - version: first.currentVersion, - retried: true, - }; -} - interface AutosaveCoordinatorOptions { initialVersion: number; createPayload: (draft: Draft, expectedVersion: number) => Payload; diff --git a/lib/events/invalidation.test.ts b/lib/events/invalidation.test.ts index da05cea..d347570 100644 --- a/lib/events/invalidation.test.ts +++ b/lib/events/invalidation.test.ts @@ -41,4 +41,24 @@ describe("invalidation event envelopes", () => { }), ).toBeNull(); }); + + it("keeps valid scopes only on authenticated admin events", () => { + const id = "4afeea7b-6f24-43f3-b747-2db98335e01e"; + expect(toInvalidationEvent({ + topic: "admin", + aggregateId: id, + eventType: "admin.updated", + payload: { id, version: 4, scope: "team" }, + })).toEqual({ type: "admin.updated", id, version: 4, scope: "team" }); + expect(parseInvalidationEvent(JSON.stringify({ type: "admin.updated", id, version: 5, scope: `extra:${id}` }))) + .toEqual({ type: "admin.updated", id, version: 5, scope: `extra:${id}` }); + expect(parseInvalidationEvent(JSON.stringify({ type: "page.updated", id, version: 5, scope: "team" }))) + .toBeNull(); + expect(toInvalidationEvent({ + topic: "admin", + aggregateId: id, + eventType: "admin.updated", + payload: { id, version: 6, scope: "unknown" }, + })).toBeNull(); + }); }); diff --git a/lib/events/invalidation.ts b/lib/events/invalidation.ts index b9035e4..771b4de 100644 --- a/lib/events/invalidation.ts +++ b/lib/events/invalidation.ts @@ -6,10 +6,20 @@ export const INVALIDATION_TYPES = [ export type InvalidationType = (typeof INVALIDATION_TYPES)[number]; +export type AdminEditorScope = + | "overview" + | "weapon" + | "artifact" + | "constellations" + | "team" + | "extras:list" + | `extra:${string}`; + export interface InvalidationEvent { type: InvalidationType; id: string; version: number; + scope?: AdminEditorScope; } export interface OutboxEventLike { @@ -23,6 +33,13 @@ function isInvalidationType(value: string): value is InvalidationType { return INVALIDATION_TYPES.some((type) => type === value); } +function isAdminEditorScope(value: unknown): value is AdminEditorScope { + return typeof value === "string" && ( + ["overview", "weapon", "artifact", "constellations", "team", "extras:list"].includes(value) + || /^extra:[0-9a-f-]{36}$/iu.test(value) + ); +} + export function expectedTopic(event: InvalidationEvent): string { switch (event.type) { case "directory.updated": @@ -38,6 +55,11 @@ export function toInvalidationEvent( record: OutboxEventLike, ): InvalidationEvent | null { if (!isInvalidationType(record.eventType)) return null; + if ( + record.eventType === "admin.updated" + && record.payload.scope !== undefined + && !isAdminEditorScope(record.payload.scope) + ) return null; const id = record.payload.id; const version = record.payload.version; if ( @@ -53,6 +75,9 @@ export function toInvalidationEvent( type: record.eventType, id, version, + ...(record.eventType === "admin.updated" && isAdminEditorScope(record.payload.scope) + ? { scope: record.payload.scope } + : {}), }; return expectedTopic(event) === record.topic ? event : null; } @@ -63,6 +88,7 @@ export function parseInvalidationEvent(value: string): InvalidationEvent | null const type = parsed.type; const id = parsed.id; const version = parsed.version; + const scope = parsed.scope; if ( typeof type !== "string" || !isInvalidationType(type) || @@ -73,7 +99,10 @@ export function parseInvalidationEvent(value: string): InvalidationEvent | null ) { return null; } - return { type, id, version }; + if (scope !== undefined && (type !== "admin.updated" || !isAdminEditorScope(scope))) { + return null; + } + return { type, id, version, ...(scope === undefined ? {} : { scope }) }; } catch { return null; } diff --git a/lib/guides/mutations.ts b/lib/guides/mutations.ts index 2b6372b..45d9594 100644 --- a/lib/guides/mutations.ts +++ b/lib/guides/mutations.ts @@ -40,6 +40,7 @@ import { } from "@/lib/guides/schemas"; import { processCatalogSync } from "@/lib/catalog/sync"; import { normalizeExtraRichText } from "@/lib/rich-text"; +import type { AdminEditorScope } from "@/lib/events/invalidation"; const DEFAULT_SECTIONS = [ { kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 }, @@ -55,13 +56,13 @@ export class GuideVersionConflictError extends Error { } } -async function emitGuideEvents(tx: Parameters["transaction"]>[0]>[0], guideId: string, version: number, directoryChanged = false) { +async function emitGuideEvents(tx: Parameters["transaction"]>[0]>[0], guideId: string, version: number, directoryChanged = false, scope?: AdminEditorScope) { await tx.insert(outboxEvents).values([ ...(directoryChanged ? [{ topic: "directory", aggregateId: guideId, eventType: "directory.updated", payload: { id: guideId, version } }] : []), { topic: `page:${guideId}`, aggregateId: guideId, eventType: "page.updated", payload: { id: guideId, version } }, - { topic: "admin", aggregateId: guideId, eventType: "admin.updated", payload: { id: guideId, version } }, + { topic: "admin", aggregateId: guideId, eventType: "admin.updated", payload: { id: guideId, version, ...(scope ? { scope } : {}) } }, ]); } @@ -101,14 +102,14 @@ export async function createGuide(input: unknown) { }); } -async function advanceGuideVersion(tx: Parameters["transaction"]>[0]>[0], guideId: string, expectedVersion: number, directoryChanged = false) { +async function advanceGuideVersion(tx: Parameters["transaction"]>[0]>[0], guideId: string, expectedVersion: number, directoryChanged = false, scope?: AdminEditorScope) { const [guide] = await tx.update(guides).set({ version: expectedVersion + 1 }).where(and(eq(guides.id, guideId), eq(guides.version, expectedVersion), sql`${guides.trashedAt} is null`)).returning(); if (!guide) { const [current] = await tx.select({ version: guides.version }).from(guides).where(eq(guides.id, guideId)).limit(1); if (current) throw new GuideVersionConflictError(current.version); throw new Error("ไม่พบ Guide"); } - await emitGuideEvents(tx, guide.id, guide.version, directoryChanged); + await emitGuideEvents(tx, guide.id, guide.version, directoryChanged, scope); return guide; } @@ -117,7 +118,7 @@ export async function saveWeapons(guideId: string, input: unknown) { const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1); if (!guide) throw new Error("ไม่พบ Guide"); return getDb().transaction(async (tx) => { - const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion); + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "weapon"); await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "weapon"))); await tx.delete(weaponConditions).where(eq(weaponConditions.guideId, guideId)); for (const [sortOrder, group] of data.groups.entries()) { @@ -153,7 +154,7 @@ export async function updateGuideBasics(guideId: string, input: unknown) { : []; if (bleedingMedia.length !== bleedingMediaIds.length) throw new Error("ไม่พบภาพ Bleeding ที่อัปโหลดแล้ว"); } - const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, true); + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, true, "overview"); await tx.update(guides).set({ overview: data.overview, coverMediaId: data.coverMediaId, @@ -206,7 +207,7 @@ export async function saveArtifacts(guideId: string, input: unknown) { const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1); if (!guide) throw new Error("ไม่พบ Guide"); return getDb().transaction(async (tx) => { - const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion); + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "artifact"); await tx.delete(artifactGroups).where(eq(artifactGroups.guideId, guideId)); for (const [sortOrder, group] of data.groups.entries()) { const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder }).returning(); @@ -220,7 +221,7 @@ export async function saveArtifacts(guideId: string, input: unknown) { export async function saveConstellations(guideId: string, input: unknown) { const data = constellationSectionSchema.parse(input); return getDb().transaction(async (tx) => { - const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion); + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "constellations"); await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "constellations"))); await tx.delete(constellationConditions).where(eq(constellationConditions.guideId, guideId)); for (const [sortOrder, group] of data.groups.entries()) { @@ -255,7 +256,7 @@ export async function saveTeams(guideId: string, input: unknown) { } } return getDb().transaction(async (tx) => { - const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion); + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "team"); await tx.delete(teamConditions).where(eq(teamConditions.guideId, guideId)); for (const [groupOrder, group] of data.groups.entries()) { const [condition] = await tx.insert(teamConditions).values({ guideId, name: group.name, note: group.note, sortOrder: groupOrder }).returning(); @@ -271,7 +272,7 @@ export async function saveTeams(guideId: string, input: unknown) { export async function saveExtraSections(guideId: string, input: unknown) { const data = extraSectionSchema.parse(input); return getDb().transaction(async (tx) => { - const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion); + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "extras:list"); await tx.delete(guideSections).where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra"))); for (const [sortOrder, section] of data.sections.entries()) { const [createdSection] = await tx.insert(guideSections).values({ guideId, kind: "extra", slug: `extra-${sortOrder + 1}`, title: section.title, enabled: true, sortOrder: 4 + sortOrder }).returning(); @@ -323,7 +324,7 @@ export async function createExtraSection(guideId: string, input: unknown) { }) .returning(); await tx.insert(extraSections).values({ sectionId: section.id }); - await emitGuideEvents(tx, guideId, updated.version); + await emitGuideEvents(tx, guideId, updated.version, false, "extras:list"); return { ...section, version: updated.version }; }); } @@ -346,7 +347,7 @@ export async function saveExtraSection( .limit(1); if (!target) throw new Error("ไม่พบ Extra section"); - const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion); + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, `extra:${sectionId}`); await tx .update(guideSections) .set({ title: data.section.title }) @@ -406,7 +407,7 @@ export async function reorderExtraSections(guideId: string, input: unknown) { throw new Error("รายการ Extra sections ไม่ตรงกับข้อมูลล่าสุด"); } - const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion); + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "extras:list"); for (const [index, sectionId] of data.sectionIds.entries()) { await tx .update(guideSections)