feat(data): establish PostgreSQL content schema
This commit is contained in:
+464
@@ -0,0 +1,464 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
const timestamps = {
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
};
|
||||
|
||||
// Better Auth core tables. Application authorization still compares the
|
||||
// verified session email with ADMIN_EMAIL at every protected boundary.
|
||||
export const users = pgTable(
|
||||
"user",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [uniqueIndex("user_email_unique").on(table.email)],
|
||||
);
|
||||
|
||||
export const sessions = pgTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
token: text("token").notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("session_token_unique").on(table.token),
|
||||
index("session_user_id_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const accounts = pgTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at", {
|
||||
withTimezone: true,
|
||||
}),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", {
|
||||
withTimezone: true,
|
||||
}),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("account_provider_unique").on(
|
||||
table.providerId,
|
||||
table.accountId,
|
||||
),
|
||||
index("account_user_id_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const verifications = pgTable(
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
);
|
||||
|
||||
export const media = pgTable(
|
||||
"media",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
objectKey: text("object_key").notNull(),
|
||||
fileName: text("file_name").notNull(),
|
||||
mimeType: varchar("mime_type", { length: 64 }).notNull(),
|
||||
byteSize: bigint("byte_size", { mode: "number" }).notNull(),
|
||||
checksum: varchar("checksum", { length: 128 }),
|
||||
width: integer("width"),
|
||||
height: integer("height"),
|
||||
status: varchar("status", { length: 24 }).default("pending").notNull(),
|
||||
currentReferenceCount: integer("current_reference_count")
|
||||
.default(0)
|
||||
.notNull(),
|
||||
revisionReferenceCount: integer("revision_reference_count")
|
||||
.default(0)
|
||||
.notNull(),
|
||||
createdById: text("created_by_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("media_object_key_unique").on(table.objectKey),
|
||||
index("media_visibility_idx").on(
|
||||
table.status,
|
||||
table.currentReferenceCount,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const characters = pgTable(
|
||||
"character",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
slug: varchar("slug", { length: 120 }).notNull(),
|
||||
nameTh: text("name_th").notNull(),
|
||||
nameEn: text("name_en"),
|
||||
description: text("description"),
|
||||
portraitMediaId: uuid("portrait_media_id").references(() => media.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
element: varchar("element", { length: 48 }),
|
||||
role: varchar("role", { length: 80 }),
|
||||
rarity: integer("rarity"),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
visible: boolean("visible").default(false).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("character_slug_unique").on(table.slug),
|
||||
index("character_directory_idx").on(table.visible, table.sortOrder),
|
||||
],
|
||||
);
|
||||
|
||||
export const pages = pgTable(
|
||||
"page",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
characterId: uuid("character_id")
|
||||
.notNull()
|
||||
.references(() => characters.id, { onDelete: "cascade" }),
|
||||
slug: varchar("slug", { length: 120 }).notNull(),
|
||||
title: text("title").notNull(),
|
||||
navOrder: integer("nav_order").default(0).notNull(),
|
||||
visible: boolean("visible").default(false).notNull(),
|
||||
publicNote: text("public_note"),
|
||||
version: integer("version").default(1).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("page_character_slug_unique").on(
|
||||
table.characterId,
|
||||
table.slug,
|
||||
),
|
||||
index("page_navigation_idx").on(
|
||||
table.characterId,
|
||||
table.visible,
|
||||
table.navOrder,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const blocks = pgTable(
|
||||
"block",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
pageId: uuid("page_id")
|
||||
.notNull()
|
||||
.references(() => pages.id, { onDelete: "cascade" }),
|
||||
type: varchar("type", { length: 64 }).notNull(),
|
||||
schemaVersion: integer("schema_version").default(1).notNull(),
|
||||
config: jsonb("config").$type<Record<string, unknown>>().notNull(),
|
||||
responsiveLayout: jsonb("responsive_layout")
|
||||
.$type<Record<string, unknown>>()
|
||||
.default({})
|
||||
.notNull(),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [index("block_page_order_idx").on(table.pageId, table.sortOrder)],
|
||||
);
|
||||
|
||||
export const dataSources = pgTable(
|
||||
"data_source",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
fields: jsonb("fields").$type<unknown[]>().default([]).notNull(),
|
||||
currentVersion: integer("current_version").default(1).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [index("data_source_name_idx").on(table.name)],
|
||||
);
|
||||
|
||||
export const dataSourceVersions = pgTable(
|
||||
"data_source_version",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
dataSourceId: uuid("data_source_id")
|
||||
.notNull()
|
||||
.references(() => dataSources.id, { onDelete: "cascade" }),
|
||||
version: integer("version").notNull(),
|
||||
rows: jsonb("rows").$type<unknown[]>().default([]).notNull(),
|
||||
evaluation: jsonb("evaluation").$type<Record<string, unknown>>(),
|
||||
createdById: text("created_by_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("data_source_version_unique").on(
|
||||
table.dataSourceId,
|
||||
table.version,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const pageDataSources = pgTable(
|
||||
"page_data_source",
|
||||
{
|
||||
pageId: uuid("page_id")
|
||||
.notNull()
|
||||
.references(() => pages.id, { onDelete: "cascade" }),
|
||||
dataSourceId: uuid("data_source_id")
|
||||
.notNull()
|
||||
.references(() => dataSources.id, { onDelete: "restrict" }),
|
||||
pinnedVersion: integer("pinned_version"),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.pageId, table.dataSourceId] }),
|
||||
index("page_data_source_dependency_idx").on(table.dataSourceId),
|
||||
],
|
||||
);
|
||||
|
||||
export const templates = pgTable(
|
||||
"template",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
slug: varchar("slug", { length: 120 }).notNull(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
category: varchar("category", { length: 64 }).notNull(),
|
||||
structure: jsonb("structure").$type<Record<string, unknown>>().notNull(),
|
||||
builtIn: boolean("built_in").default(false).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [uniqueIndex("template_slug_unique").on(table.slug)],
|
||||
);
|
||||
|
||||
export const revisions = pgTable(
|
||||
"revision",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
pageId: uuid("page_id")
|
||||
.notNull()
|
||||
.references(() => pages.id, { onDelete: "cascade" }),
|
||||
pageVersion: integer("page_version").notNull(),
|
||||
kind: varchar("kind", { length: 24 }).notNull(),
|
||||
label: text("label"),
|
||||
authorId: text("author_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
sourceRevisionId: uuid("source_revision_id"),
|
||||
checkpointHour: timestamp("checkpoint_hour", { withTimezone: true }),
|
||||
snapshot: jsonb("snapshot").$type<Record<string, unknown>>().notNull(),
|
||||
dataSourceVersions: jsonb("data_source_versions")
|
||||
.$type<Record<string, number>>()
|
||||
.default({})
|
||||
.notNull(),
|
||||
mediaIds: jsonb("media_ids").$type<string[]>().default([]).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => [
|
||||
index("revision_page_created_idx").on(table.pageId, table.createdAt),
|
||||
uniqueIndex("revision_checkpoint_hour_unique")
|
||||
.on(table.pageId, table.checkpointHour)
|
||||
.where(sql`${table.kind} = 'checkpoint'`),
|
||||
],
|
||||
);
|
||||
|
||||
export const publicSnapshots = pgTable(
|
||||
"public_snapshot",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
pageId: uuid("page_id")
|
||||
.notNull()
|
||||
.references(() => pages.id, { onDelete: "cascade" }),
|
||||
pageVersion: integer("page_version").notNull(),
|
||||
versionVector: jsonb("version_vector")
|
||||
.$type<Record<string, number>>()
|
||||
.default({})
|
||||
.notNull(),
|
||||
vectorHash: varchar("vector_hash", { length: 64 }).notNull(),
|
||||
dto: jsonb("dto").$type<Record<string, unknown>>().notNull(),
|
||||
visible: boolean("visible").default(false).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("public_snapshot_version_unique").on(
|
||||
table.pageId,
|
||||
table.pageVersion,
|
||||
table.vectorHash,
|
||||
),
|
||||
index("public_snapshot_lookup_idx").on(
|
||||
table.pageId,
|
||||
table.visible,
|
||||
table.createdAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const slugAliases = pgTable(
|
||||
"slug_alias",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
kind: varchar("kind", { length: 24 }).notNull(),
|
||||
oldCharacterSlug: varchar("old_character_slug", { length: 120 }).notNull(),
|
||||
oldPageSlug: varchar("old_page_slug", { length: 120 }),
|
||||
targetCharacterId: uuid("target_character_id")
|
||||
.notNull()
|
||||
.references(() => characters.id, { onDelete: "cascade" }),
|
||||
targetPageId: uuid("target_page_id").references(() => pages.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("slug_alias_path_unique").on(
|
||||
table.oldCharacterSlug,
|
||||
table.oldPageSlug,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const mediaReferences = pgTable(
|
||||
"media_reference",
|
||||
{
|
||||
mediaId: uuid("media_id")
|
||||
.notNull()
|
||||
.references(() => media.id, { onDelete: "cascade" }),
|
||||
ownerType: varchar("owner_type", { length: 24 }).notNull(),
|
||||
ownerId: uuid("owner_id").notNull(),
|
||||
referenceKind: varchar("reference_kind", { length: 24 }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [
|
||||
table.mediaId,
|
||||
table.ownerType,
|
||||
table.ownerId,
|
||||
table.referenceKind,
|
||||
],
|
||||
}),
|
||||
index("media_reference_owner_idx").on(table.ownerType, table.ownerId),
|
||||
],
|
||||
);
|
||||
|
||||
export const outboxEvents = pgTable(
|
||||
"outbox_event",
|
||||
{
|
||||
id: bigint("id", { mode: "number" })
|
||||
.primaryKey()
|
||||
.generatedAlwaysAsIdentity(),
|
||||
topic: varchar("topic", { length: 160 }).notNull(),
|
||||
aggregateId: uuid("aggregate_id").notNull(),
|
||||
eventType: varchar("event_type", { length: 64 }).notNull(),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
|
||||
status: varchar("status", { length: 24 }).default("pending").notNull(),
|
||||
attempts: integer("attempts").default(0).notNull(),
|
||||
availableAt: timestamp("available_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
processedAt: timestamp("processed_at", { withTimezone: true }),
|
||||
lastError: text("last_error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("outbox_pending_idx").on(
|
||||
table.status,
|
||||
table.availableAt,
|
||||
table.id,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const characterRelations = relations(characters, ({ many, one }) => ({
|
||||
pages: many(pages),
|
||||
portrait: one(media, {
|
||||
fields: [characters.portraitMediaId],
|
||||
references: [media.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const pageRelations = relations(pages, ({ many, one }) => ({
|
||||
character: one(characters, {
|
||||
fields: [pages.characterId],
|
||||
references: [characters.id],
|
||||
}),
|
||||
blocks: many(blocks),
|
||||
dependencies: many(pageDataSources),
|
||||
revisions: many(revisions),
|
||||
}));
|
||||
|
||||
export const blockRelations = relations(blocks, ({ one }) => ({
|
||||
page: one(pages, {
|
||||
fields: [blocks.pageId],
|
||||
references: [pages.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const dataSourceRelations = relations(dataSources, ({ many }) => ({
|
||||
versions: many(dataSourceVersions),
|
||||
pageDependencies: many(pageDataSources),
|
||||
}));
|
||||
|
||||
export const dataSourceVersionRelations = relations(
|
||||
dataSourceVersions,
|
||||
({ one }) => ({
|
||||
dataSource: one(dataSources, {
|
||||
fields: [dataSourceVersions.dataSourceId],
|
||||
references: [dataSources.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user