diff --git a/lib/content/lifecycle.test.ts b/lib/content/lifecycle.test.ts new file mode 100644 index 0000000..c3e0cdd --- /dev/null +++ b/lib/content/lifecycle.test.ts @@ -0,0 +1,379 @@ +import { describe, expect, it } from "vitest"; + +import { ContentLifecycle } from "./lifecycle"; + +const characterId = "17d23f26-df04-47e5-b8da-907d62b15260"; +const pageId = "432cf444-dd8a-4ee5-8b21-6dc6d5d325e7"; +const secondPageId = "9e52ac3a-b3fe-431b-adb6-d9cae2798705"; +const sourceId = "33a89cab-d063-4551-9b13-5b3eb6ab6e61"; +const mediaId = "cb661013-c489-4da0-8b1f-f7afcb327dd6"; +const authorId = "admin"; + +function setup(now = new Date("2026-08-28T10:15:00.000Z")) { + let sequence = 0; + const store = new ContentLifecycle({ + now: () => new Date(now), + idFactory: () => + `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`, + }); + store.addCharacter({ + id: characterId, + slug: "mizuki", + nameTh: "มิซึกิ", + nameEn: "Mizuki", + description: null, + portraitMediaId: null, + element: "ลม", + role: "สนับสนุน", + rarity: 5, + sortOrder: 0, + }); + store.addDataSource({ + id: sourceId, + name: "อาวุธ", + version: 1, + fields: [ + { key: "name", label: "ชื่อ", type: "text" }, + { key: "base", label: "พื้นฐาน", type: "number" }, + { key: "bonus", label: "โบนัส", type: "number" }, + { + key: "score", + label: "คะแนน", + type: "number", + formula: "base + bonus", + }, + ], + rows: [ + { + id: "06200523-1a7b-478c-8587-c54483458dac", + values: { name: "ตัวเลือก A", base: "10", bonus: "2", score: null }, + }, + ], + }); + store.addPage({ + id: pageId, + characterId, + slug: "info", + title: "ข้อมูล", + navOrder: 1, + publicNote: null, + }); + store.addPage({ + id: secondPageId, + characterId, + slug: "combo", + title: "คอมโบ", + navOrder: 0, + publicNote: null, + }); + return store; +} + +function rankingBlock() { + return { + id: "1e0b3229-549c-4897-a765-50c56d26b414", + type: "ranking", + schemaVersion: 1, + sortOrder: 0, + config: { + title: "อันดับอาวุธ", + dataSourceId: sourceId, + labelField: "name", + valueField: "score", + higherIsBetter: true, + }, + }; +} + +describe("content lifecycle", () => { + it("starts new content hidden and requires both visibility flags", () => { + const store = setup(); + expect(store.getDirectory()).toEqual([]); + + store.setCharacterVisibility(characterId, true); + expect(store.getDirectory()).toEqual([]); + + const saved = store.savePage({ + pageId, + expectedVersion: 1, + authorId, + title: "ข้อมูล", + slug: "info", + visible: true, + publicNote: "อัปเดตล่าสุดสำหรับเวอร์ชัน 1.0", + blocks: [rankingBlock()], + }); + expect(saved.status).toBe("accepted"); + expect(store.getDirectory()).toMatchObject([ + { slug: "mizuki", firstPageSlug: "info" }, + ]); + expect(store.resolvePublicRoute("mizuki", "info")).toMatchObject({ + type: "page", + snapshot: { page: { publicNote: "อัปเดตล่าสุดสำหรับเวอร์ชัน 1.0" } }, + }); + }); + + it("orders pages and redirects a character route to the first visible page", () => { + const store = setup(); + store.setCharacterVisibility(characterId, true); + store.savePage({ + pageId, + expectedVersion: 1, + authorId, + title: "ข้อมูล", + slug: "info", + visible: true, + blocks: [], + }); + store.savePage({ + pageId: secondPageId, + expectedVersion: 1, + authorId, + title: "คอมโบ", + slug: "combo", + visible: true, + blocks: [], + }); + + expect(store.resolvePublicRoute("mizuki")).toEqual({ + type: "redirect", + location: "/mizuki/combo", + status: 308, + }); + }); + + it("creates permanent aliases after custom character and page slug changes", () => { + const store = setup(); + store.setCharacterVisibility(characterId, true); + store.savePage({ + pageId, + expectedVersion: 1, + authorId, + title: "ข้อมูล", + slug: "build-info", + visible: true, + blocks: [], + }); + + expect(store.resolvePublicRoute("mizuki", "info")).toEqual({ + type: "redirect", + location: "/mizuki/build-info", + status: 308, + }); + store.renameCharacter(characterId, "mizuki-guide"); + expect(store.resolvePublicRoute("mizuki", "build-info")).toEqual({ + type: "redirect", + location: "/mizuki-guide/build-info", + status: 308, + }); + }); + + it("rejects stale concurrent changes and invalid blocks", () => { + const store = setup(); + const accepted = store.savePage({ + pageId, + expectedVersion: 1, + authorId, + title: "ข้อมูลใหม่", + slug: "info", + visible: false, + blocks: [], + }); + expect(accepted.status).toBe("accepted"); + expect( + store.savePage({ + pageId, + expectedVersion: 1, + authorId, + title: "เขียนทับ", + slug: "info", + visible: false, + blocks: [], + }), + ).toEqual({ status: "conflict", currentVersion: 2 }); + expect( + store.savePage({ + pageId, + expectedVersion: 2, + authorId, + title: "ข้อมูลใหม่", + slug: "info", + visible: false, + blocks: [ + { + id: "c9c1fc08-8180-439b-8962-26fe1c8977b1", + type: "callout", + schemaVersion: 1, + sortOrder: 0, + config: { body: "", tone: "unknown" }, + }, + ], + }).status, + ).toBe("validation-error"); + }); + + it("versions global sources independently and refreshes only latest consumers", () => { + const store = setup(); + const saved = store.savePage({ + pageId, + expectedVersion: 1, + authorId, + title: "ข้อมูล", + slug: "info", + visible: false, + blocks: [rankingBlock()], + }); + expect(saved.status).toBe("accepted"); + const beforeEvents = store.getOutbox().length; + + const source = store.saveDataSource({ + id: sourceId, + expectedVersion: 1, + name: "อาวุธ", + fields: [ + { key: "value", label: "ค่า", type: "number" }, + { + key: "broken", + label: "สูตรที่ผิด", + type: "number", + formula: "value / 0", + }, + ], + rows: [ + { + id: "560bb5b4-f4c7-46ba-82d5-e3dca8217b0a", + values: { value: "4", broken: null }, + }, + ], + }); + expect(source.status).toBe("accepted"); + expect(store.getOutbox().slice(beforeEvents)).toMatchObject([ + { eventType: "data-source.updated", payload: { version: 2 } }, + { eventType: "page.updated", aggregateId: pageId }, + ]); + }); + + it("coalesces hourly checkpoints, keeps named revisions, and expires only checkpoints", () => { + const store = setup(); + store.savePage({ + pageId, + expectedVersion: 1, + authorId, + title: "ครั้งที่ 1", + slug: "info", + visible: false, + blocks: [], + now: new Date("2026-08-28T10:05:00.000Z"), + }); + store.createNamedRevision( + pageId, + "เวอร์ชันเปิดตัว", + authorId, + new Date("2026-08-28T10:10:00.000Z"), + ); + store.savePage({ + pageId, + expectedVersion: 2, + authorId, + title: "ครั้งที่ 2", + slug: "info", + visible: false, + blocks: [], + now: new Date("2026-08-28T10:55:00.000Z"), + }); + + expect(store.listRevisions(pageId)).toHaveLength(2); + expect( + store.listRevisions(pageId).filter((revision) => revision.kind === "checkpoint"), + ).toHaveLength(1); + const expired = store.expireCheckpoints( + new Date("2026-09-28T11:00:00.000Z"), + ); + expect(expired).toHaveLength(1); + expect(store.listRevisions(pageId)).toMatchObject([ + { kind: "named", label: "เวอร์ชันเปิดตัว", expiresAt: null }, + ]); + }); + + it("restores a new page version pinned to historical data and can use latest again", () => { + const store = setup(); + store.savePage({ + pageId, + expectedVersion: 1, + authorId, + title: "เวอร์ชันเดิม", + slug: "info", + visible: false, + blocks: [rankingBlock()], + }); + const revision = store.createNamedRevision(pageId, "ก่อนปรับสูตร", authorId)!; + store.saveDataSource({ + id: sourceId, + expectedVersion: 1, + name: "อาวุธรุ่นใหม่", + fields: [{ key: "value", label: "ค่า", type: "number" }], + rows: [], + }); + store.savePage({ + pageId, + expectedVersion: 2, + authorId, + title: "เวอร์ชันใหม่", + slug: "info", + visible: false, + blocks: [rankingBlock()], + }); + + const restored = store.restoreRevision(revision.id, 3, authorId); + expect(restored.status).toBe("accepted"); + if (restored.status === "accepted") { + expect(restored.value.page.version).toBe(4); + expect(restored.value.page.title).toBe("เวอร์ชันเดิม"); + expect(restored.value.versionVector[sourceId]).toBe(1); + } + + const latest = store.useLatestDataSource(pageId, sourceId, 4, authorId); + expect(latest.status).toBe("accepted"); + if (latest.status === "accepted") { + expect(latest.value.page.version).toBe(5); + expect(latest.value.versionVector[sourceId]).toBe(2); + } + }); + + it("retains media for current content or revisions and exposes it only from visible snapshots", () => { + const store = setup(); + store.setCharacterVisibility(characterId, true); + const imageBlock = { + id: "949499c7-3162-4ff5-87f0-f90711a97e6f", + type: "image", + schemaVersion: 1, + sortOrder: 0, + config: { mediaId, alt: "ภาพตัวอย่าง", fit: "cover" }, + }; + store.savePage({ + pageId, + expectedVersion: 1, + authorId, + title: "ข้อมูล", + slug: "info", + visible: true, + blocks: [imageBlock], + }); + store.createNamedRevision(pageId, "มีภาพ", authorId); + + expect(store.isMediaPublic(mediaId)).toBe(true); + expect(store.isMediaRetained(mediaId)).toBe(true); + + store.savePage({ + pageId, + expectedVersion: 2, + authorId, + title: "ข้อมูล", + slug: "info", + visible: true, + blocks: [], + }); + expect(store.isMediaPublic(mediaId)).toBe(false); + expect(store.isMediaRetained(mediaId)).toBe(true); + }); +}); diff --git a/lib/content/lifecycle.ts b/lib/content/lifecycle.ts new file mode 100644 index 0000000..aba0136 --- /dev/null +++ b/lib/content/lifecycle.ts @@ -0,0 +1,764 @@ +import { z } from "zod"; + +import { validatePageBlocks, type SerializedBlock } from "@/lib/blocks"; + +import { dataSourceSnapshotSchema, evaluateDataSource } from "./data-source"; +import type { + CharacterIdentity, + DataSourceSnapshot, + PageIdentity, + PageSnapshot, + PublicCharacterCard, +} from "./types"; + +const checkpointRetentionMs = 30 * 24 * 60 * 60 * 1_000; + +export const slugSchema = z + .string() + .trim() + .min(1) + .max(120) + .regex( + /^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*$/u, + "Slugs may contain letters, numbers, and single hyphens between words.", + ); + +interface StoredPage extends PageIdentity { + blocks: SerializedBlock[]; + dependencies: Record; +} + +interface StoredDataSource { + id: string; + name: string; + fields: DataSourceSnapshot["fields"]; + currentVersion: number; + versions: Map; +} + +export interface ContentRevision { + id: string; + pageId: string; + pageVersion: number; + kind: "checkpoint" | "named"; + label: string | null; + authorId: string; + sourceRevisionId: string | null; + checkpointHour: string | null; + snapshot: { + page: PageIdentity; + blocks: SerializedBlock[]; + dependencies: Record; + }; + dataSourceVersions: Record; + mediaIds: string[]; + createdAt: Date; + expiresAt: Date | null; +} + +export interface DomainOutboxEvent { + id: number; + topic: string; + aggregateId: string; + eventType: + | "page.updated" + | "directory.updated" + | "data-source.updated" + | "admin.updated"; + payload: { id: string; version: number }; + status: "pending" | "processed"; + attempts: number; + availableAt: Date; + createdAt: Date; +} + +interface CharacterAlias { + oldSlug: string; + characterId: string; +} + +interface PageAlias { + oldCharacterSlug: string; + oldPageSlug: string; + pageId: string; +} + +export interface SavePageInput { + pageId: string; + expectedVersion: number; + authorId: string; + title: string; + slug: string; + visible: boolean; + publicNote?: string | null; + blocks: SerializedBlock[]; + now?: Date; +} + +export type MutationResult = + | { status: "accepted"; value: T } + | { status: "conflict"; currentVersion: number } + | { status: "validation-error"; issues: string[] } + | { status: "not-found" }; + +export type PublicRouteResult = + | { type: "page"; snapshot: PageSnapshot } + | { type: "redirect"; location: string; status: 308 } + | { type: "not-found" }; + +export interface LifecycleOptions { + idFactory?: () => string; + now?: () => Date; +} + +function copy(value: T): T { + return structuredClone(value); +} + +function hourStart(date: Date): Date { + const result = new Date(date); + result.setUTCMinutes(0, 0, 0); + return result; +} + +function collectMediaIds(value: unknown, result = new Set()): Set { + if (Array.isArray(value)) { + for (const item of value) collectMediaIds(item, result); + return result; + } + if (!value || typeof value !== "object") return result; + + for (const [key, item] of Object.entries(value)) { + if (key === "mediaId" && typeof item === "string") result.add(item); + collectMediaIds(item, result); + } + return result; +} + +function collectDataSourceIds( + value: unknown, + result = new Set(), +): Set { + if (Array.isArray(value)) { + for (const item of value) collectDataSourceIds(item, result); + return result; + } + if (!value || typeof value !== "object") return result; + + for (const [key, item] of Object.entries(value)) { + if (key === "dataSourceId" && typeof item === "string") result.add(item); + collectDataSourceIds(item, result); + } + return result; +} + +export class ContentLifecycle { + private readonly characters = new Map(); + private readonly pages = new Map(); + private readonly dataSources = new Map(); + private readonly revisions = new Map(); + private readonly characterAliases: CharacterAlias[] = []; + private readonly pageAliases: PageAlias[] = []; + private readonly outbox: DomainOutboxEvent[] = []; + private nextOutboxId = 1; + private readonly idFactory: () => string; + private readonly clock: () => Date; + + constructor(options: LifecycleOptions = {}) { + this.idFactory = options.idFactory ?? (() => crypto.randomUUID()); + this.clock = options.now ?? (() => new Date()); + } + + addCharacter( + input: Omit & { visible?: boolean }, + ): CharacterIdentity { + const slug = slugSchema.parse(input.slug); + if ([...this.characters.values()].some((item) => item.slug === slug)) { + throw new Error(`Character slug already exists: ${slug}`); + } + const character: CharacterIdentity = { + ...copy(input), + slug, + visible: input.visible ?? false, + }; + this.characters.set(character.id, character); + return copy(character); + } + + setCharacterVisibility(id: string, visible: boolean): CharacterIdentity | null { + const character = this.characters.get(id); + if (!character) return null; + character.visible = visible; + this.enqueue("directory", id, "directory.updated", 1, this.clock()); + return copy(character); + } + + renameCharacter(id: string, nextSlug: string): CharacterIdentity | null { + const character = this.characters.get(id); + if (!character) return null; + const slug = slugSchema.parse(nextSlug); + if ( + [...this.characters.values()].some( + (item) => item.id !== id && item.slug === slug, + ) + ) { + throw new Error(`Character slug already exists: ${slug}`); + } + if (slug !== character.slug) { + this.characterAliases.push({ oldSlug: character.slug, characterId: id }); + character.slug = slug; + this.enqueue("directory", id, "directory.updated", 1, this.clock()); + } + return copy(character); + } + + addPage( + input: Omit & { + visible?: boolean; + version?: number; + blocks?: SerializedBlock[]; + }, + ): PageIdentity { + if (!this.characters.has(input.characterId)) { + throw new Error(`Character does not exist: ${input.characterId}`); + } + const slug = slugSchema.parse(input.slug); + if ( + [...this.pages.values()].some( + (page) => + page.characterId === input.characterId && page.slug === slug, + ) + ) { + throw new Error(`Page slug already exists for this character: ${slug}`); + } + const blocks = copy(input.blocks ?? []); + const invalid = validatePageBlocks(blocks).filter( + (resolution) => resolution.kind === "invalid", + ); + if (invalid.length > 0) throw new Error("Initial page blocks are invalid."); + + const page: StoredPage = { + id: input.id, + characterId: input.characterId, + slug, + title: input.title, + navOrder: input.navOrder, + visible: input.visible ?? false, + publicNote: input.publicNote, + version: input.version ?? 1, + blocks, + dependencies: {}, + }; + this.pages.set(page.id, page); + return copy(this.pageIdentity(page)); + } + + addDataSource(input: DataSourceSnapshot): DataSourceSnapshot { + const snapshot = dataSourceSnapshotSchema.parse(copy(input)); + evaluateDataSource(snapshot); + this.dataSources.set(snapshot.id, { + id: snapshot.id, + name: snapshot.name, + fields: snapshot.fields, + currentVersion: snapshot.version, + versions: new Map([[snapshot.version, snapshot]]), + }); + return copy(snapshot); + } + + savePage(input: SavePageInput): MutationResult { + const page = this.pages.get(input.pageId); + if (!page) return { status: "not-found" }; + if (page.version !== input.expectedVersion) { + return { status: "conflict", currentVersion: page.version }; + } + + const issues: string[] = []; + const slugResult = slugSchema.safeParse(input.slug); + if (!slugResult.success) { + issues.push(...slugResult.error.issues.map((issue) => issue.message)); + } + if (!input.title.trim()) issues.push("Page title cannot be blank."); + + const resolutions = validatePageBlocks(input.blocks); + for (const resolution of resolutions) { + if (resolution.kind === "invalid") { + issues.push( + ...resolution.issues.map( + (issue) => `${resolution.block.type}: ${issue.message}`, + ), + ); + } + } + + const dependencyIds = collectDataSourceIds( + input.blocks.map((block) => block.config), + ); + for (const dependencyId of dependencyIds) { + if (!this.dataSources.has(dependencyId)) { + issues.push(`Referenced data source does not exist: ${dependencyId}`); + } + } + + if (issues.length > 0 || !slugResult.success) { + return { status: "validation-error", issues }; + } + + const duplicateSlug = [...this.pages.values()].some( + (candidate) => + candidate.id !== page.id && + candidate.characterId === page.characterId && + candidate.slug === slugResult.data, + ); + if (duplicateSlug) { + return { + status: "validation-error", + issues: [`Page slug already exists: ${slugResult.data}`], + }; + } + + const character = this.characters.get(page.characterId)!; + if (slugResult.data !== page.slug) { + this.pageAliases.push({ + oldCharacterSlug: character.slug, + oldPageSlug: page.slug, + pageId: page.id, + }); + } + + page.slug = slugResult.data; + page.title = input.title.trim(); + page.visible = input.visible; + page.publicNote = input.publicNote?.trim() || null; + page.blocks = resolutions.map((resolution) => + resolution.kind === "ready" ? resolution.block : resolution.block, + ); + page.version += 1; + page.dependencies = Object.fromEntries( + [...dependencyIds].map((id) => [id, page.dependencies[id] ?? null]), + ); + + const now = input.now ?? this.clock(); + this.upsertCheckpoint(page, input.authorId, now); + this.enqueue(`page:${page.id}`, page.id, "page.updated", page.version, now); + this.enqueue("directory", page.id, "directory.updated", page.version, now); + this.enqueue("admin", page.id, "admin.updated", page.version, now); + + return { status: "accepted", value: this.snapshotFor(page) }; + } + + saveDataSource( + input: Omit & { + expectedVersion: number; + now?: Date; + }, + ): MutationResult { + const source = this.dataSources.get(input.id); + if (!source) return { status: "not-found" }; + if (source.currentVersion !== input.expectedVersion) { + return { status: "conflict", currentVersion: source.currentVersion }; + } + + const next: DataSourceSnapshot = { + id: input.id, + name: input.name, + version: source.currentVersion + 1, + fields: copy(input.fields), + rows: copy(input.rows), + }; + const parsed = dataSourceSnapshotSchema.safeParse(next); + if (!parsed.success) { + return { + status: "validation-error", + issues: parsed.error.issues.map((issue) => issue.message), + }; + } + + // Formula failures are retained as typed cell errors and do not reject + // unrelated valid changes. Structural validation failures do reject. + evaluateDataSource(parsed.data); + source.currentVersion = next.version; + source.name = next.name; + source.fields = next.fields; + source.versions.set(next.version, copy(next)); + + const now = input.now ?? this.clock(); + this.enqueue( + `data-source:${source.id}`, + source.id, + "data-source.updated", + next.version, + now, + ); + for (const page of this.pages.values()) { + if ( + Object.hasOwn(page.dependencies, source.id) && + page.dependencies[source.id] === null + ) { + this.enqueue(`page:${page.id}`, page.id, "page.updated", page.version, now); + } + } + + return { status: "accepted", value: copy(next) }; + } + + createNamedRevision( + pageId: string, + label: string, + authorId: string, + now = this.clock(), + ): ContentRevision | null { + const page = this.pages.get(pageId); + if (!page) return null; + const revision = this.captureRevision( + page, + "named", + label.trim() || "รุ่นที่บันทึก", + authorId, + now, + null, + ); + this.revisions.set(revision.id, revision); + return copy(revision); + } + + restoreRevision( + revisionId: string, + expectedVersion: number, + authorId: string, + now = this.clock(), + ): MutationResult { + const revision = this.revisions.get(revisionId); + if (!revision) return { status: "not-found" }; + const page = this.pages.get(revision.pageId); + if (!page) return { status: "not-found" }; + if (page.version !== expectedVersion) { + return { status: "conflict", currentVersion: page.version }; + } + + const restored = revision.snapshot; + page.slug = restored.page.slug; + page.title = restored.page.title; + page.navOrder = restored.page.navOrder; + page.visible = restored.page.visible; + page.publicNote = restored.page.publicNote; + page.blocks = copy(restored.blocks); + page.dependencies = Object.fromEntries( + Object.keys(restored.dependencies).map((sourceId) => [ + sourceId, + revision.dataSourceVersions[sourceId], + ]), + ); + page.version += 1; + + this.upsertCheckpoint(page, authorId, now, revision.id); + this.enqueue(`page:${page.id}`, page.id, "page.updated", page.version, now); + this.enqueue("directory", page.id, "directory.updated", page.version, now); + this.enqueue("admin", page.id, "admin.updated", page.version, now); + return { status: "accepted", value: this.snapshotFor(page) }; + } + + useLatestDataSource( + pageId: string, + dataSourceId: string, + expectedVersion: number, + authorId: string, + now = this.clock(), + ): MutationResult { + const page = this.pages.get(pageId); + if (!page) return { status: "not-found" }; + if (page.version !== expectedVersion) { + return { status: "conflict", currentVersion: page.version }; + } + if (!Object.hasOwn(page.dependencies, dataSourceId)) { + return { + status: "validation-error", + issues: ["The page does not reference this data source."], + }; + } + + page.dependencies[dataSourceId] = null; + page.version += 1; + this.upsertCheckpoint(page, authorId, now); + this.enqueue(`page:${page.id}`, page.id, "page.updated", page.version, now); + this.enqueue("admin", page.id, "admin.updated", page.version, now); + return { status: "accepted", value: this.snapshotFor(page) }; + } + + expireCheckpoints(now = this.clock()): string[] { + const expired: string[] = []; + for (const [id, revision] of this.revisions) { + if ( + revision.kind === "checkpoint" && + revision.expiresAt && + revision.expiresAt <= now + ) { + this.revisions.delete(id); + expired.push(id); + } + } + return expired; + } + + getRevision(id: string): ContentRevision | null { + const revision = this.revisions.get(id); + return revision ? copy(revision) : null; + } + + listRevisions(pageId: string): ContentRevision[] { + return [...this.revisions.values()] + .filter((revision) => revision.pageId === pageId) + .sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()) + .map(copy); + } + + getOutbox(): DomainOutboxEvent[] { + return this.outbox.map(copy); + } + + markOutboxProcessed(id: number): void { + const event = this.outbox.find((item) => item.id === id); + if (event) event.status = "processed"; + } + + getDirectory(): PublicCharacterCard[] { + return [...this.characters.values()] + .filter((character) => character.visible) + .map((character) => { + const firstPage = this.visiblePagesFor(character.id)[0]; + if (!firstPage) return null; + return { + id: character.id, + slug: character.slug, + nameTh: character.nameTh, + nameEn: character.nameEn, + portraitMediaId: character.portraitMediaId, + element: character.element, + role: character.role, + rarity: character.rarity, + firstPageSlug: firstPage.slug, + } satisfies PublicCharacterCard; + }) + .filter((item): item is PublicCharacterCard => item !== null) + .sort((left, right) => { + const leftCharacter = this.characters.get(left.id)!; + const rightCharacter = this.characters.get(right.id)!; + return ( + leftCharacter.sortOrder - rightCharacter.sortOrder || + left.nameTh.localeCompare(right.nameTh, "th") + ); + }); + } + + resolvePublicRoute( + characterSlug: string, + pageSlug?: string, + ): PublicRouteResult { + const currentCharacter = [...this.characters.values()].find( + (character) => character.slug === characterSlug, + ); + const characterAlias = this.characterAliases.find( + (alias) => alias.oldSlug === characterSlug, + ); + const character = + currentCharacter ?? + (characterAlias + ? this.characters.get(characterAlias.characterId) + : undefined); + + if (!character?.visible) return { type: "not-found" }; + + const visiblePages = this.visiblePagesFor(character.id); + if (visiblePages.length === 0) return { type: "not-found" }; + + if (!pageSlug) { + return { + type: "redirect", + location: `/${character.slug}/${visiblePages[0].slug}`, + status: 308, + }; + } + + const page = visiblePages.find((candidate) => candidate.slug === pageSlug); + if (page) { + if (!currentCharacter) { + return { + type: "redirect", + location: `/${character.slug}/${page.slug}`, + status: 308, + }; + } + return { type: "page", snapshot: this.snapshotFor(page) }; + } + + const pageAlias = this.pageAliases.find( + (alias) => + alias.oldCharacterSlug === characterSlug && + alias.oldPageSlug === pageSlug, + ); + const target = pageAlias ? this.pages.get(pageAlias.pageId) : undefined; + if (target?.visible && target.characterId === character.id) { + return { + type: "redirect", + location: `/${character.slug}/${target.slug}`, + status: 308, + }; + } + + return { type: "not-found" }; + } + + isMediaPublic(mediaId: string): boolean { + for (const page of this.pages.values()) { + const character = this.characters.get(page.characterId); + if ( + character?.visible && + page.visible && + collectMediaIds(page.blocks.map((block) => block.config)).has(mediaId) + ) { + return true; + } + } + return false; + } + + isMediaRetained(mediaId: string): boolean { + if ( + [...this.pages.values()].some((page) => + collectMediaIds(page.blocks.map((block) => block.config)).has(mediaId), + ) + ) { + return true; + } + return [...this.revisions.values()].some((revision) => + revision.mediaIds.includes(mediaId), + ); + } + + private pageIdentity(page: StoredPage): PageIdentity { + const { blocks: _blocks, dependencies: _dependencies, ...identity } = page; + return identity; + } + + private visiblePagesFor(characterId: string): StoredPage[] { + return [...this.pages.values()] + .filter((page) => page.characterId === characterId && page.visible) + .sort( + (left, right) => + left.navOrder - right.navOrder || left.title.localeCompare(right.title, "th"), + ); + } + + private versionVector(page: StoredPage): Record { + return Object.fromEntries( + Object.entries(page.dependencies).map(([sourceId, pinnedVersion]) => { + const source = this.dataSources.get(sourceId); + return [sourceId, pinnedVersion ?? source?.currentVersion ?? 0]; + }), + ); + } + + private snapshotFor(page: StoredPage): PageSnapshot { + const character = this.characters.get(page.characterId); + if (!character) throw new Error(`Character does not exist: ${page.characterId}`); + const vector = this.versionVector(page); + const sources = Object.fromEntries( + Object.entries(vector).flatMap(([sourceId, version]) => { + const source = this.dataSources.get(sourceId)?.versions.get(version); + return source ? [[sourceId, copy(source)] as const] : []; + }), + ); + + return { + character: copy(character), + page: copy(this.pageIdentity(page)), + pages: [...this.pages.values()] + .filter((candidate) => candidate.characterId === page.characterId) + .sort((left, right) => left.navOrder - right.navOrder) + .map((candidate) => copy(this.pageIdentity(candidate))), + blocks: copy(page.blocks), + dataSources: sources, + versionVector: vector, + }; + } + + private captureRevision( + page: StoredPage, + kind: ContentRevision["kind"], + label: string | null, + authorId: string, + now: Date, + sourceRevisionId: string | null, + ): ContentRevision { + const checkpointHour = kind === "checkpoint" ? hourStart(now) : null; + return { + id: this.idFactory(), + pageId: page.id, + pageVersion: page.version, + kind, + label, + authorId, + sourceRevisionId, + checkpointHour: checkpointHour?.toISOString() ?? null, + snapshot: { + page: copy(this.pageIdentity(page)), + blocks: copy(page.blocks), + dependencies: copy(page.dependencies), + }, + dataSourceVersions: this.versionVector(page), + mediaIds: [ + ...collectMediaIds(page.blocks.map((block) => block.config)), + ], + createdAt: new Date(now), + expiresAt: + kind === "checkpoint" + ? new Date(now.getTime() + checkpointRetentionMs) + : null, + }; + } + + private upsertCheckpoint( + page: StoredPage, + authorId: string, + now: Date, + sourceRevisionId: string | null = null, + ): void { + const next = this.captureRevision( + page, + "checkpoint", + null, + authorId, + now, + sourceRevisionId, + ); + const existing = [...this.revisions.values()].find( + (revision) => + revision.pageId === page.id && + revision.kind === "checkpoint" && + revision.checkpointHour === next.checkpointHour, + ); + if (existing) this.revisions.delete(existing.id); + this.revisions.set(next.id, next); + } + + private enqueue( + topic: string, + aggregateId: string, + eventType: DomainOutboxEvent["eventType"], + version: number, + now: Date, + ): void { + this.outbox.push({ + id: this.nextOutboxId, + topic, + aggregateId, + eventType, + payload: { id: aggregateId, version }, + status: "pending", + attempts: 0, + availableAt: new Date(now), + createdAt: new Date(now), + }); + this.nextOutboxId += 1; + } +}