773 lines
22 KiB
TypeScript
773 lines
22 KiB
TypeScript
import * as 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 {
|
|
return {
|
|
id: page.id,
|
|
characterId: page.characterId,
|
|
slug: page.slug,
|
|
title: page.title,
|
|
navOrder: page.navOrder,
|
|
visible: page.visible,
|
|
publicNote: page.publicNote,
|
|
version: page.version,
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|