feat(content): add revision and publication lifecycle
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, number | null>;
|
||||
}
|
||||
|
||||
interface StoredDataSource {
|
||||
id: string;
|
||||
name: string;
|
||||
fields: DataSourceSnapshot["fields"];
|
||||
currentVersion: number;
|
||||
versions: Map<number, DataSourceSnapshot>;
|
||||
}
|
||||
|
||||
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<string, number | null>;
|
||||
};
|
||||
dataSourceVersions: Record<string, number>;
|
||||
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<T> =
|
||||
| { 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<T>(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<string>()): Set<string> {
|
||||
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<string>(),
|
||||
): Set<string> {
|
||||
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<string, CharacterIdentity>();
|
||||
private readonly pages = new Map<string, StoredPage>();
|
||||
private readonly dataSources = new Map<string, StoredDataSource>();
|
||||
private readonly revisions = new Map<string, ContentRevision>();
|
||||
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<CharacterIdentity, "visible"> & { 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<PageIdentity, "visible" | "version"> & {
|
||||
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<PageSnapshot> {
|
||||
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<DataSourceSnapshot, "version"> & {
|
||||
expectedVersion: number;
|
||||
now?: Date;
|
||||
},
|
||||
): MutationResult<DataSourceSnapshot> {
|
||||
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<PageSnapshot> {
|
||||
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<PageSnapshot> {
|
||||
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<string, number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user