feat(content): add revision and publication lifecycle

This commit is contained in:
2026-08-28 16:17:41 +00:00 Unverified
parent 7761cacd10
commit 30586a893a
2 changed files with 1143 additions and 0 deletions
+379
View File
@@ -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);
});
});
+764
View File
@@ -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;
}
}