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