feat(data): add transactional content repository
This commit is contained in:
@@ -636,8 +636,16 @@ export class ContentLifecycle {
|
||||
}
|
||||
|
||||
private pageIdentity(page: StoredPage): PageIdentity {
|
||||
const { blocks: _blocks, dependencies: _dependencies, ...identity } = page;
|
||||
return identity;
|
||||
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[] {
|
||||
|
||||
@@ -0,0 +1,762 @@
|
||||
import "server-only";
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { and, eq, inArray, isNull, lt, sql } from "drizzle-orm";
|
||||
|
||||
import { getDb } from "@/db";
|
||||
import {
|
||||
blocks,
|
||||
characters,
|
||||
dataSources,
|
||||
dataSourceVersions,
|
||||
media,
|
||||
mediaReferences,
|
||||
outboxEvents,
|
||||
pageDataSources,
|
||||
pages,
|
||||
publicSnapshots,
|
||||
revisions,
|
||||
slugAliases,
|
||||
} from "@/db/schema";
|
||||
import { validatePageBlocks, type SerializedBlock } from "@/lib/blocks";
|
||||
|
||||
import { dataSourceSnapshotSchema, evaluateDataSource } from "./data-source";
|
||||
import { slugSchema, type MutationResult, type SavePageInput } from "./lifecycle";
|
||||
import { getAdminPage } from "./queries";
|
||||
import type { DataSourceSnapshot, PageSnapshot } from "./types";
|
||||
|
||||
interface SavePageOptions {
|
||||
dependencyPins?: Record<string, number | null>;
|
||||
sourceRevisionId?: string | null;
|
||||
}
|
||||
|
||||
function collectNamedIds(
|
||||
value: unknown,
|
||||
keyName: "dataSourceId" | "mediaId",
|
||||
result = new Set<string>(),
|
||||
): Set<string> {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectNamedIds(item, keyName, result);
|
||||
return result;
|
||||
}
|
||||
if (!value || typeof value !== "object") return result;
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (key === keyName && typeof nested === "string") result.add(nested);
|
||||
collectNamedIds(nested, keyName, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function jsonObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function checkpointHour(date: Date): Date {
|
||||
const hour = new Date(date);
|
||||
hour.setUTCMinutes(0, 0, 0);
|
||||
return hour;
|
||||
}
|
||||
|
||||
function vectorHash(vector: Record<string, number>): string {
|
||||
const stableEntries = Object.entries(vector).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
return createHash("sha256").update(JSON.stringify(stableEntries)).digest("hex");
|
||||
}
|
||||
|
||||
function pageIdentity(page: typeof pages.$inferSelect) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function characterIdentity(character: typeof characters.$inferSelect) {
|
||||
return {
|
||||
id: character.id,
|
||||
slug: character.slug,
|
||||
nameTh: character.nameTh,
|
||||
nameEn: character.nameEn,
|
||||
description: character.description,
|
||||
portraitMediaId: character.portraitMediaId,
|
||||
element: character.element,
|
||||
role: character.role,
|
||||
rarity: character.rarity,
|
||||
sortOrder: character.sortOrder,
|
||||
visible: character.visible,
|
||||
};
|
||||
}
|
||||
|
||||
export async function savePageDraft(
|
||||
input: SavePageInput,
|
||||
options: SavePageOptions = {},
|
||||
): Promise<MutationResult<PageSnapshot>> {
|
||||
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}`,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
resolution.kind === "unsupported" &&
|
||||
!jsonObject(resolution.block.config)
|
||||
) {
|
||||
issues.push(`${resolution.block.type}: configuration must be an object.`);
|
||||
}
|
||||
}
|
||||
if (issues.length > 0 || !slugResult.success) {
|
||||
return { status: "validation-error", issues };
|
||||
}
|
||||
|
||||
const normalizedBlocks = resolutions.map((resolution) =>
|
||||
resolution.kind === "ready" ? resolution.block : resolution.block,
|
||||
);
|
||||
const dependencyIds = [
|
||||
...collectNamedIds(
|
||||
normalizedBlocks.map((block) => block.config),
|
||||
"dataSourceId",
|
||||
),
|
||||
];
|
||||
const mediaIds = [
|
||||
...collectNamedIds(
|
||||
normalizedBlocks.map((block) => block.config),
|
||||
"mediaId",
|
||||
),
|
||||
];
|
||||
const now = input.now ?? new Date();
|
||||
const db = getDb();
|
||||
|
||||
return db.transaction(async (tx): Promise<MutationResult<PageSnapshot>> => {
|
||||
const [current] = await tx
|
||||
.select({ page: pages, character: characters })
|
||||
.from(pages)
|
||||
.innerJoin(characters, eq(characters.id, pages.characterId))
|
||||
.where(eq(pages.id, input.pageId))
|
||||
.limit(1);
|
||||
if (!current) return { status: "not-found" };
|
||||
if (current.page.version !== input.expectedVersion) {
|
||||
return { status: "conflict", currentVersion: current.page.version };
|
||||
}
|
||||
|
||||
if (dependencyIds.length > 0) {
|
||||
const existingSources = await tx
|
||||
.select({ id: dataSources.id })
|
||||
.from(dataSources)
|
||||
.where(inArray(dataSources.id, dependencyIds));
|
||||
const existing = new Set(existingSources.map((source) => source.id));
|
||||
const missing = dependencyIds.filter((id) => !existing.has(id));
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
status: "validation-error",
|
||||
issues: missing.map((id) => `Referenced data source does not exist: ${id}`),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(pages)
|
||||
.set({
|
||||
slug: slugResult.data,
|
||||
title: input.title.trim(),
|
||||
visible: input.visible,
|
||||
publicNote: input.publicNote?.trim() || null,
|
||||
version: input.expectedVersion + 1,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(pages.id, input.pageId),
|
||||
eq(pages.version, input.expectedVersion),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!updated) {
|
||||
const [latest] = await tx
|
||||
.select({ version: pages.version })
|
||||
.from(pages)
|
||||
.where(eq(pages.id, input.pageId))
|
||||
.limit(1);
|
||||
return {
|
||||
status: "conflict",
|
||||
currentVersion: latest?.version ?? input.expectedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
if (current.page.slug !== updated.slug) {
|
||||
await tx
|
||||
.insert(slugAliases)
|
||||
.values({
|
||||
kind: "page",
|
||||
oldCharacterSlug: current.character.slug,
|
||||
oldPageSlug: current.page.slug,
|
||||
targetCharacterId: current.character.id,
|
||||
targetPageId: current.page.id,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
await tx.delete(blocks).where(eq(blocks.pageId, updated.id));
|
||||
if (normalizedBlocks.length > 0) {
|
||||
await tx.insert(blocks).values(
|
||||
normalizedBlocks.map((block) => ({
|
||||
id: block.id,
|
||||
pageId: updated.id,
|
||||
type: block.type,
|
||||
schemaVersion: block.schemaVersion,
|
||||
config: block.config as Record<string, unknown>,
|
||||
responsiveLayout: jsonObject(block.responsiveLayout)
|
||||
? block.responsiveLayout
|
||||
: {},
|
||||
sortOrder: block.sortOrder,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const existingDependencies = await tx
|
||||
.select()
|
||||
.from(pageDataSources)
|
||||
.where(eq(pageDataSources.pageId, updated.id));
|
||||
const previousPins = Object.fromEntries(
|
||||
existingDependencies.map((dependency) => [
|
||||
dependency.dataSourceId,
|
||||
dependency.pinnedVersion,
|
||||
]),
|
||||
);
|
||||
const requestedPins = {
|
||||
...previousPins,
|
||||
...options.dependencyPins,
|
||||
};
|
||||
await tx
|
||||
.delete(pageDataSources)
|
||||
.where(eq(pageDataSources.pageId, updated.id));
|
||||
if (dependencyIds.length > 0) {
|
||||
await tx.insert(pageDataSources).values(
|
||||
dependencyIds.map((dataSourceId) => ({
|
||||
pageId: updated.id,
|
||||
dataSourceId,
|
||||
pinnedVersion: requestedPins[dataSourceId] ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const sourceRows =
|
||||
dependencyIds.length === 0
|
||||
? []
|
||||
: await tx
|
||||
.select()
|
||||
.from(dataSources)
|
||||
.where(inArray(dataSources.id, dependencyIds));
|
||||
const versionVector = Object.fromEntries(
|
||||
sourceRows.map((source) => [
|
||||
source.id,
|
||||
requestedPins[source.id] ?? source.currentVersion,
|
||||
]),
|
||||
);
|
||||
const sourceSnapshots: Record<string, DataSourceSnapshot> = {};
|
||||
for (const source of sourceRows) {
|
||||
const version = versionVector[source.id];
|
||||
const [versionRow] = await tx
|
||||
.select()
|
||||
.from(dataSourceVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(dataSourceVersions.dataSourceId, source.id),
|
||||
eq(dataSourceVersions.version, version),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (versionRow) {
|
||||
sourceSnapshots[source.id] = {
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
version,
|
||||
fields: source.fields as DataSourceSnapshot["fields"],
|
||||
rows: versionRow.rows as DataSourceSnapshot["rows"],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const navigationRows = await tx
|
||||
.select()
|
||||
.from(pages)
|
||||
.where(eq(pages.characterId, updated.characterId))
|
||||
.orderBy(pages.navOrder, pages.title);
|
||||
const snapshot: PageSnapshot = {
|
||||
character: characterIdentity(current.character),
|
||||
page: pageIdentity(updated),
|
||||
pages: navigationRows.map(pageIdentity),
|
||||
blocks: normalizedBlocks,
|
||||
dataSources: sourceSnapshots,
|
||||
versionVector,
|
||||
};
|
||||
|
||||
const revisionSnapshot = {
|
||||
page: snapshot.page,
|
||||
blocks: snapshot.blocks,
|
||||
dependencies: Object.fromEntries(
|
||||
dependencyIds.map((id) => [id, requestedPins[id] ?? null]),
|
||||
),
|
||||
};
|
||||
const hour = checkpointHour(now);
|
||||
const [existingCheckpoint] = await tx
|
||||
.select({ id: revisions.id })
|
||||
.from(revisions)
|
||||
.where(
|
||||
and(
|
||||
eq(revisions.pageId, updated.id),
|
||||
eq(revisions.kind, "checkpoint"),
|
||||
eq(revisions.checkpointHour, hour),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const revisionValues = {
|
||||
pageVersion: updated.version,
|
||||
kind: "checkpoint",
|
||||
label: null,
|
||||
authorId: input.authorId,
|
||||
sourceRevisionId: options.sourceRevisionId ?? null,
|
||||
checkpointHour: hour,
|
||||
snapshot: revisionSnapshot as unknown as Record<string, unknown>,
|
||||
dataSourceVersions: versionVector,
|
||||
mediaIds,
|
||||
createdAt: now,
|
||||
expiresAt: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1_000),
|
||||
};
|
||||
const revisionId = existingCheckpoint
|
||||
? existingCheckpoint.id
|
||||
: crypto.randomUUID();
|
||||
if (existingCheckpoint) {
|
||||
await tx
|
||||
.update(revisions)
|
||||
.set(revisionValues)
|
||||
.where(eq(revisions.id, revisionId));
|
||||
} else {
|
||||
await tx.insert(revisions).values({
|
||||
id: revisionId,
|
||||
pageId: updated.id,
|
||||
...revisionValues,
|
||||
});
|
||||
}
|
||||
|
||||
const priorCurrentMedia = await tx
|
||||
.select({ mediaId: mediaReferences.mediaId })
|
||||
.from(mediaReferences)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaReferences.ownerType, "page"),
|
||||
eq(mediaReferences.ownerId, updated.id),
|
||||
eq(mediaReferences.referenceKind, "current"),
|
||||
),
|
||||
);
|
||||
const priorRevisionMedia = await tx
|
||||
.select({ mediaId: mediaReferences.mediaId })
|
||||
.from(mediaReferences)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaReferences.ownerType, "revision"),
|
||||
eq(mediaReferences.ownerId, revisionId),
|
||||
eq(mediaReferences.referenceKind, "revision"),
|
||||
),
|
||||
);
|
||||
await tx
|
||||
.delete(mediaReferences)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaReferences.ownerType, "page"),
|
||||
eq(mediaReferences.ownerId, updated.id),
|
||||
eq(mediaReferences.referenceKind, "current"),
|
||||
),
|
||||
);
|
||||
await tx
|
||||
.delete(mediaReferences)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaReferences.ownerType, "revision"),
|
||||
eq(mediaReferences.ownerId, revisionId),
|
||||
eq(mediaReferences.referenceKind, "revision"),
|
||||
),
|
||||
);
|
||||
if (mediaIds.length > 0) {
|
||||
await tx.insert(mediaReferences).values([
|
||||
...mediaIds.map((mediaId) => ({
|
||||
mediaId,
|
||||
ownerType: "page",
|
||||
ownerId: updated.id,
|
||||
referenceKind: "current",
|
||||
createdAt: now,
|
||||
})),
|
||||
...mediaIds.map((mediaId) => ({
|
||||
mediaId,
|
||||
ownerType: "revision",
|
||||
ownerId: revisionId,
|
||||
referenceKind: "revision",
|
||||
createdAt: now,
|
||||
})),
|
||||
]);
|
||||
}
|
||||
const affectedMediaIds = [
|
||||
...new Set([
|
||||
...mediaIds,
|
||||
...priorCurrentMedia.map((item) => item.mediaId),
|
||||
...priorRevisionMedia.map((item) => item.mediaId),
|
||||
]),
|
||||
];
|
||||
for (const mediaId of affectedMediaIds) {
|
||||
await tx
|
||||
.update(media)
|
||||
.set({
|
||||
currentReferenceCount: sql<number>`(
|
||||
select count(*)::int from ${mediaReferences}
|
||||
where ${mediaReferences.mediaId} = ${mediaId}
|
||||
and ${mediaReferences.referenceKind} = 'current'
|
||||
)`,
|
||||
revisionReferenceCount: sql<number>`(
|
||||
select count(*)::int from ${mediaReferences}
|
||||
where ${mediaReferences.mediaId} = ${mediaId}
|
||||
and ${mediaReferences.referenceKind} = 'revision'
|
||||
)`,
|
||||
})
|
||||
.where(eq(media.id, mediaId));
|
||||
}
|
||||
|
||||
const publicDto: PageSnapshot = {
|
||||
...snapshot,
|
||||
pages: snapshot.pages.filter((item) => item.visible),
|
||||
};
|
||||
await tx.insert(publicSnapshots).values({
|
||||
pageId: updated.id,
|
||||
pageVersion: updated.version,
|
||||
versionVector,
|
||||
vectorHash: vectorHash(versionVector),
|
||||
dto: publicDto as unknown as Record<string, unknown>,
|
||||
visible: current.character.visible && updated.visible,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await tx.insert(outboxEvents).values([
|
||||
{
|
||||
topic: `page:${updated.id}`,
|
||||
aggregateId: updated.id,
|
||||
eventType: "page.updated",
|
||||
payload: { id: updated.id, version: updated.version },
|
||||
createdAt: now,
|
||||
},
|
||||
{
|
||||
topic: "directory",
|
||||
aggregateId: updated.id,
|
||||
eventType: "directory.updated",
|
||||
payload: { id: updated.id, version: updated.version },
|
||||
createdAt: now,
|
||||
},
|
||||
{
|
||||
topic: "admin",
|
||||
aggregateId: updated.id,
|
||||
eventType: "admin.updated",
|
||||
payload: { id: updated.id, version: updated.version },
|
||||
createdAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
return { status: "accepted", value: snapshot };
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveDataSourceVersion(
|
||||
input: Omit<DataSourceSnapshot, "version"> & {
|
||||
expectedVersion: number;
|
||||
authorId: string;
|
||||
now?: Date;
|
||||
},
|
||||
): Promise<MutationResult<DataSourceSnapshot>> {
|
||||
const next: DataSourceSnapshot = {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
version: input.expectedVersion + 1,
|
||||
fields: input.fields,
|
||||
rows: input.rows,
|
||||
};
|
||||
const parsed = dataSourceSnapshotSchema.safeParse(next);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
status: "validation-error",
|
||||
issues: parsed.error.issues.map((issue) => issue.message),
|
||||
};
|
||||
}
|
||||
const evaluation = evaluateDataSource(parsed.data);
|
||||
const now = input.now ?? new Date();
|
||||
const db = getDb();
|
||||
|
||||
return db.transaction(async (tx): Promise<MutationResult<DataSourceSnapshot>> => {
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(dataSources)
|
||||
.where(eq(dataSources.id, input.id))
|
||||
.limit(1);
|
||||
if (!current) return { status: "not-found" };
|
||||
if (current.currentVersion !== input.expectedVersion) {
|
||||
return { status: "conflict", currentVersion: current.currentVersion };
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(dataSources)
|
||||
.set({
|
||||
name: parsed.data.name,
|
||||
fields: parsed.data.fields,
|
||||
currentVersion: parsed.data.version,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(dataSources.id, input.id),
|
||||
eq(dataSources.currentVersion, input.expectedVersion),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!updated) {
|
||||
return { status: "conflict", currentVersion: current.currentVersion };
|
||||
}
|
||||
|
||||
await tx.insert(dataSourceVersions).values({
|
||||
dataSourceId: input.id,
|
||||
version: parsed.data.version,
|
||||
rows: parsed.data.rows,
|
||||
evaluation: evaluation as unknown as Record<string, unknown>,
|
||||
createdById: input.authorId,
|
||||
createdAt: now,
|
||||
});
|
||||
const consumers = await tx
|
||||
.select({ pageId: pageDataSources.pageId, pageVersion: pages.version })
|
||||
.from(pageDataSources)
|
||||
.innerJoin(pages, eq(pages.id, pageDataSources.pageId))
|
||||
.where(
|
||||
and(
|
||||
eq(pageDataSources.dataSourceId, input.id),
|
||||
isNull(pageDataSources.pinnedVersion),
|
||||
),
|
||||
);
|
||||
await tx.insert(outboxEvents).values([
|
||||
{
|
||||
topic: `data-source:${input.id}`,
|
||||
aggregateId: input.id,
|
||||
eventType: "data-source.updated",
|
||||
payload: { id: input.id, version: parsed.data.version },
|
||||
createdAt: now,
|
||||
},
|
||||
...consumers.map((consumer) => ({
|
||||
topic: `page:${consumer.pageId}`,
|
||||
aggregateId: consumer.pageId,
|
||||
eventType: "page.updated",
|
||||
payload: { id: consumer.pageId, version: consumer.pageVersion },
|
||||
createdAt: now,
|
||||
})),
|
||||
{
|
||||
topic: "admin",
|
||||
aggregateId: input.id,
|
||||
eventType: "admin.updated",
|
||||
payload: { id: input.id, version: parsed.data.version },
|
||||
createdAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
return { status: "accepted", value: parsed.data };
|
||||
});
|
||||
}
|
||||
|
||||
export async function createNamedRevision(
|
||||
pageId: string,
|
||||
label: string,
|
||||
authorId: string,
|
||||
now = new Date(),
|
||||
): Promise<string | null> {
|
||||
const snapshot = await getAdminPageById(pageId);
|
||||
if (!snapshot) return null;
|
||||
const mediaIds = [
|
||||
...collectNamedIds(
|
||||
snapshot.blocks.map((block) => block.config),
|
||||
"mediaId",
|
||||
),
|
||||
];
|
||||
const id = crypto.randomUUID();
|
||||
const dependencies = Object.fromEntries(
|
||||
Object.keys(snapshot.versionVector).map((sourceId) => [sourceId, null]),
|
||||
);
|
||||
const db = getDb();
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(revisions).values({
|
||||
id,
|
||||
pageId,
|
||||
pageVersion: snapshot.page.version,
|
||||
kind: "named",
|
||||
label: label.trim() || "รุ่นที่บันทึก",
|
||||
authorId,
|
||||
snapshot: {
|
||||
page: snapshot.page,
|
||||
blocks: snapshot.blocks,
|
||||
dependencies,
|
||||
} as unknown as Record<string, unknown>,
|
||||
dataSourceVersions: snapshot.versionVector,
|
||||
mediaIds,
|
||||
createdAt: now,
|
||||
expiresAt: null,
|
||||
});
|
||||
if (mediaIds.length > 0) {
|
||||
await tx.insert(mediaReferences).values(
|
||||
mediaIds.map((mediaId) => ({
|
||||
mediaId,
|
||||
ownerType: "revision",
|
||||
ownerId: id,
|
||||
referenceKind: "revision",
|
||||
createdAt: now,
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function restoreRevision(
|
||||
revisionId: string,
|
||||
expectedVersion: number,
|
||||
authorId: string,
|
||||
): Promise<MutationResult<PageSnapshot>> {
|
||||
const db = getDb();
|
||||
const [revision] = await db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, revisionId))
|
||||
.limit(1);
|
||||
if (!revision) return { status: "not-found" };
|
||||
const snapshot = revision.snapshot as {
|
||||
page: PageSnapshot["page"];
|
||||
blocks: SerializedBlock[];
|
||||
dependencies: Record<string, number | null>;
|
||||
};
|
||||
|
||||
return savePageDraft(
|
||||
{
|
||||
pageId: revision.pageId,
|
||||
expectedVersion,
|
||||
authorId,
|
||||
title: snapshot.page.title,
|
||||
slug: snapshot.page.slug,
|
||||
visible: snapshot.page.visible,
|
||||
publicNote: snapshot.page.publicNote,
|
||||
blocks: snapshot.blocks,
|
||||
},
|
||||
{
|
||||
dependencyPins: revision.dataSourceVersions,
|
||||
sourceRevisionId: revision.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function useLatestDataSource(
|
||||
pageId: string,
|
||||
dataSourceId: string,
|
||||
expectedVersion: number,
|
||||
authorId: string,
|
||||
): Promise<MutationResult<PageSnapshot>> {
|
||||
const snapshot = await getAdminPageById(pageId);
|
||||
if (!snapshot) return { status: "not-found" };
|
||||
if (snapshot.page.version !== expectedVersion) {
|
||||
return { status: "conflict", currentVersion: snapshot.page.version };
|
||||
}
|
||||
if (!Object.hasOwn(snapshot.versionVector, dataSourceId)) {
|
||||
return {
|
||||
status: "validation-error",
|
||||
issues: ["The page does not reference this data source."],
|
||||
};
|
||||
}
|
||||
return savePageDraft(
|
||||
{
|
||||
pageId,
|
||||
expectedVersion,
|
||||
authorId,
|
||||
title: snapshot.page.title,
|
||||
slug: snapshot.page.slug,
|
||||
visible: snapshot.page.visible,
|
||||
publicNote: snapshot.page.publicNote,
|
||||
blocks: snapshot.blocks as SerializedBlock[],
|
||||
},
|
||||
{ dependencyPins: { [dataSourceId]: null } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function expireOldCheckpoints(now = new Date()): Promise<string[]> {
|
||||
const db = getDb();
|
||||
return db.transaction(async (tx) => {
|
||||
const expired = await tx
|
||||
.select({ id: revisions.id })
|
||||
.from(revisions)
|
||||
.where(
|
||||
and(
|
||||
eq(revisions.kind, "checkpoint"),
|
||||
lt(revisions.expiresAt, now),
|
||||
),
|
||||
);
|
||||
if (expired.length === 0) return [];
|
||||
const ids = expired.map((revision) => revision.id);
|
||||
const affected = await tx
|
||||
.select({ mediaId: mediaReferences.mediaId })
|
||||
.from(mediaReferences)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaReferences.ownerType, "revision"),
|
||||
inArray(mediaReferences.ownerId, ids),
|
||||
),
|
||||
);
|
||||
await tx
|
||||
.delete(mediaReferences)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaReferences.ownerType, "revision"),
|
||||
inArray(mediaReferences.ownerId, ids),
|
||||
),
|
||||
);
|
||||
await tx.delete(revisions).where(inArray(revisions.id, ids));
|
||||
for (const mediaId of new Set(affected.map((item) => item.mediaId))) {
|
||||
await tx
|
||||
.update(media)
|
||||
.set({
|
||||
revisionReferenceCount: sql<number>`(
|
||||
select count(*)::int from ${mediaReferences}
|
||||
where ${mediaReferences.mediaId} = ${mediaId}
|
||||
and ${mediaReferences.referenceKind} = 'revision'
|
||||
)`,
|
||||
})
|
||||
.where(eq(media.id, mediaId));
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
}
|
||||
|
||||
async function getAdminPageById(pageId: string): Promise<PageSnapshot | null> {
|
||||
const db = getDb();
|
||||
const [identity] = await db
|
||||
.select({ characterSlug: characters.slug, pageSlug: pages.slug })
|
||||
.from(pages)
|
||||
.innerJoin(characters, eq(characters.id, pages.characterId))
|
||||
.where(eq(pages.id, pageId))
|
||||
.limit(1);
|
||||
return identity
|
||||
? getAdminPage(identity.characterSlug, identity.pageSlug)
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
|
||||
import { getDb } from "@/db";
|
||||
import {
|
||||
blocks,
|
||||
characters,
|
||||
dataSources,
|
||||
dataSourceVersions,
|
||||
pageDataSources,
|
||||
pages,
|
||||
slugAliases,
|
||||
templates,
|
||||
} from "@/db/schema";
|
||||
|
||||
import type {
|
||||
CharacterIdentity,
|
||||
DataSourceSnapshot,
|
||||
PageIdentity,
|
||||
PageSnapshot,
|
||||
PublicCharacterCard,
|
||||
} from "./types";
|
||||
|
||||
export type PageLookup =
|
||||
| { type: "page"; snapshot: PageSnapshot }
|
||||
| { type: "redirect"; location: string }
|
||||
| { type: "not-found" };
|
||||
|
||||
function characterIdentity(
|
||||
character: typeof characters.$inferSelect,
|
||||
): CharacterIdentity {
|
||||
return {
|
||||
id: character.id,
|
||||
slug: character.slug,
|
||||
nameTh: character.nameTh,
|
||||
nameEn: character.nameEn,
|
||||
description: character.description,
|
||||
portraitMediaId: character.portraitMediaId,
|
||||
element: character.element,
|
||||
role: character.role,
|
||||
rarity: character.rarity,
|
||||
sortOrder: character.sortOrder,
|
||||
visible: character.visible,
|
||||
};
|
||||
}
|
||||
|
||||
function pageIdentity(page: typeof pages.$inferSelect): 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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPublicCharacters(): Promise<PublicCharacterCard[]> {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select({ character: characters, page: pages })
|
||||
.from(characters)
|
||||
.innerJoin(
|
||||
pages,
|
||||
and(eq(pages.characterId, characters.id), eq(pages.visible, true)),
|
||||
)
|
||||
.where(eq(characters.visible, true))
|
||||
.orderBy(asc(characters.sortOrder), asc(pages.navOrder), asc(pages.title));
|
||||
|
||||
const directory = new Map<string, PublicCharacterCard>();
|
||||
for (const { character, page } of rows) {
|
||||
if (directory.has(character.id)) continue;
|
||||
directory.set(character.id, {
|
||||
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: page.slug,
|
||||
});
|
||||
}
|
||||
return [...directory.values()];
|
||||
}
|
||||
|
||||
async function buildPageSnapshot(
|
||||
character: typeof characters.$inferSelect,
|
||||
page: typeof pages.$inferSelect,
|
||||
includeHidden: boolean,
|
||||
): Promise<PageSnapshot> {
|
||||
const db = getDb();
|
||||
const [navigation, blockRows, dependencies] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(pages)
|
||||
.where(
|
||||
includeHidden
|
||||
? eq(pages.characterId, character.id)
|
||||
: and(eq(pages.characterId, character.id), eq(pages.visible, true)),
|
||||
)
|
||||
.orderBy(asc(pages.navOrder), asc(pages.title)),
|
||||
db
|
||||
.select()
|
||||
.from(blocks)
|
||||
.where(eq(blocks.pageId, page.id))
|
||||
.orderBy(asc(blocks.sortOrder)),
|
||||
db
|
||||
.select({
|
||||
source: dataSources,
|
||||
pinnedVersion: pageDataSources.pinnedVersion,
|
||||
})
|
||||
.from(pageDataSources)
|
||||
.innerJoin(dataSources, eq(dataSources.id, pageDataSources.dataSourceId))
|
||||
.where(eq(pageDataSources.pageId, page.id)),
|
||||
]);
|
||||
|
||||
const versionVector = Object.fromEntries(
|
||||
dependencies.map(({ source, pinnedVersion }) => [
|
||||
source.id,
|
||||
pinnedVersion ?? source.currentVersion,
|
||||
]),
|
||||
);
|
||||
const sourceSnapshots: Record<string, DataSourceSnapshot> = {};
|
||||
|
||||
await Promise.all(
|
||||
dependencies.map(async ({ source, pinnedVersion }) => {
|
||||
const version = pinnedVersion ?? source.currentVersion;
|
||||
const [versionRow] = await db
|
||||
.select()
|
||||
.from(dataSourceVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(dataSourceVersions.dataSourceId, source.id),
|
||||
eq(dataSourceVersions.version, version),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!versionRow) return;
|
||||
sourceSnapshots[source.id] = {
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
version,
|
||||
fields: source.fields as DataSourceSnapshot["fields"],
|
||||
rows: versionRow.rows as DataSourceSnapshot["rows"],
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
character: characterIdentity(character),
|
||||
page: pageIdentity(page),
|
||||
pages: navigation.map(pageIdentity),
|
||||
blocks: blockRows.map((block) => ({
|
||||
id: block.id,
|
||||
type: block.type,
|
||||
schemaVersion: block.schemaVersion,
|
||||
config: block.config,
|
||||
responsiveLayout: block.responsiveLayout,
|
||||
sortOrder: block.sortOrder,
|
||||
})),
|
||||
dataSources: sourceSnapshots,
|
||||
versionVector,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPublicPage(
|
||||
requestedCharacterSlug: string,
|
||||
requestedPageSlug?: string,
|
||||
): Promise<PageLookup> {
|
||||
const db = getDb();
|
||||
let [character] = await db
|
||||
.select()
|
||||
.from(characters)
|
||||
.where(
|
||||
and(
|
||||
eq(characters.slug, requestedCharacterSlug),
|
||||
eq(characters.visible, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const characterIsAlias = !character;
|
||||
|
||||
if (!character) {
|
||||
const [alias] = await db
|
||||
.select({ target: characters })
|
||||
.from(slugAliases)
|
||||
.innerJoin(
|
||||
characters,
|
||||
eq(characters.id, slugAliases.targetCharacterId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(slugAliases.kind, "character"),
|
||||
eq(slugAliases.oldCharacterSlug, requestedCharacterSlug),
|
||||
eq(characters.visible, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
character = alias?.target;
|
||||
}
|
||||
|
||||
if (!character) return { type: "not-found" };
|
||||
|
||||
const visiblePages = await db
|
||||
.select()
|
||||
.from(pages)
|
||||
.where(
|
||||
and(eq(pages.characterId, character.id), eq(pages.visible, true)),
|
||||
)
|
||||
.orderBy(asc(pages.navOrder), asc(pages.title));
|
||||
|
||||
if (visiblePages.length === 0) return { type: "not-found" };
|
||||
if (!requestedPageSlug) {
|
||||
return {
|
||||
type: "redirect",
|
||||
location: `/${character.slug}/${visiblePages[0].slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
const page = visiblePages.find((item) => item.slug === requestedPageSlug);
|
||||
if (page) {
|
||||
if (characterIsAlias) {
|
||||
return {
|
||||
type: "redirect",
|
||||
location: `/${character.slug}/${page.slug}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "page",
|
||||
snapshot: await buildPageSnapshot(character, page, false),
|
||||
};
|
||||
}
|
||||
|
||||
const [alias] = await db
|
||||
.select({ target: pages })
|
||||
.from(slugAliases)
|
||||
.innerJoin(pages, eq(pages.id, slugAliases.targetPageId))
|
||||
.where(
|
||||
and(
|
||||
eq(slugAliases.kind, "page"),
|
||||
eq(slugAliases.oldCharacterSlug, requestedCharacterSlug),
|
||||
eq(slugAliases.oldPageSlug, requestedPageSlug),
|
||||
eq(pages.visible, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!alias?.target || alias.target.characterId !== character.id) {
|
||||
return { type: "not-found" };
|
||||
}
|
||||
return {
|
||||
type: "redirect",
|
||||
location: `/${character.slug}/${alias.target.slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAdminPage(
|
||||
characterSlug: string,
|
||||
pageSlug: string,
|
||||
): Promise<PageSnapshot | null> {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select({ character: characters, page: pages })
|
||||
.from(characters)
|
||||
.innerJoin(pages, eq(pages.characterId, characters.id))
|
||||
.where(
|
||||
and(eq(characters.slug, characterSlug), eq(pages.slug, pageSlug)),
|
||||
)
|
||||
.limit(1);
|
||||
return row
|
||||
? buildPageSnapshot(row.character, row.page, true)
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function getAdminNavigation() {
|
||||
const db = getDb();
|
||||
const [characterRows, pageRows, templateRows] = await Promise.all([
|
||||
db.select().from(characters).orderBy(asc(characters.sortOrder), asc(characters.nameTh)),
|
||||
db.select().from(pages).orderBy(asc(pages.navOrder), asc(pages.title)),
|
||||
db.select().from(templates).orderBy(asc(templates.name)),
|
||||
]);
|
||||
return {
|
||||
characters: characterRows.map((character) => ({
|
||||
...characterIdentity(character),
|
||||
pages: pageRows
|
||||
.filter((page) => page.characterId === character.id)
|
||||
.map(pageIdentity),
|
||||
})),
|
||||
templates: templateRows.map((template) => ({
|
||||
id: template.id,
|
||||
slug: template.slug,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
builtIn: template.builtIn,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMediaVisibility(mediaId: string): Promise<{
|
||||
public: boolean;
|
||||
retained: boolean;
|
||||
}> {
|
||||
const db = getDb();
|
||||
const referencedBlocks = await db
|
||||
.select({ block: blocks, page: pages, character: characters })
|
||||
.from(blocks)
|
||||
.innerJoin(pages, eq(pages.id, blocks.pageId))
|
||||
.innerJoin(characters, eq(characters.id, pages.characterId));
|
||||
|
||||
const hasMediaId = (value: unknown): boolean => {
|
||||
if (Array.isArray(value)) return value.some(hasMediaId);
|
||||
if (!value || typeof value !== "object") return false;
|
||||
return Object.entries(value).some(
|
||||
([key, nested]) =>
|
||||
(key === "mediaId" && nested === mediaId) || hasMediaId(nested),
|
||||
);
|
||||
};
|
||||
const references = referencedBlocks.filter(({ block }) =>
|
||||
hasMediaId(block.config),
|
||||
);
|
||||
return {
|
||||
public: references.some(
|
||||
({ page, character }) => page.visible && character.visible,
|
||||
),
|
||||
retained: references.length > 0,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user