feat(db): migration and database setup

This commit is contained in:
2025-11-10 16:57:27 +02:00
parent 594b08a2d1
commit fbca02bec6
31 changed files with 2854 additions and 1928 deletions
+334 -253
View File
@@ -1,15 +1,14 @@
import { relations, sql } from "drizzle-orm";
import { check } from "drizzle-orm/gel-core";
import {
boolean,
check,
customType,
doublePrecision,
foreignKey,
index,
inet,
integer,
jsonb,
pgSequence,
pgEnum,
pgTable,
primaryKey,
text,
@@ -19,242 +18,181 @@ import {
varchar,
} from "drizzle-orm/pg-core";
const tsvector = customType<{ data: string; driverData: string }>({
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export const tsvector = customType<{ data: string; driverData: string }>({
dataType() {
return "tsvector";
},
});
export const refreshTokensIdSeq = pgSequence("refresh_tokens_id_seq", {
cache: "1",
cycle: false,
increment: "1",
maxValue: "9223372036854775807",
minValue: "1",
startWith: "1",
});
export const customJsonType = <T>() =>
customType<{ data: T }>({
dataType() {
return "jsonb";
},
fromDriver(value) {
return value as T;
},
toDriver(value) {
return value; // JSONB → just pass the object
},
});
// legacy table for doctrine migrations
export const doctrineMigrationVersions = pgTable("doctrine_migration_versions", {
executedAt: timestamp("executed_at", { mode: "string" }).default(sql`NULL`),
executionTime: integer("execution_time"),
version: varchar({ length: 191 }).primaryKey().notNull(),
});
export const biasEnum = pgEnum("bias", ["neutral", "slightly", "partisan", "extreme"]);
export const reliabilityEnum = pgEnum("reliability", [
"trusted",
"reliable",
"average",
"low_trust",
"unreliable",
]);
export const sentimentEnum = pgEnum("sentiment", ["positive", "neutral", "negative"]);
export const transparencyEnum = pgEnum("transparency", ["high", "medium", "low"]);
export const tokenPurposeEnum = pgEnum("token_purpose", [
"confirm_account",
"password_reset",
"unlock_account",
"delete_account",
]);
export const bookmark = pgTable(
"bookmark",
export type EmailAddress = string;
export type Link = string;
export type ReadingTime = number;
export type Role = "ROLE_USER" | "ROLE_ADMIN";
export type Roles = Role[];
export type Bias = (typeof biasEnum.enumValues)[number];
export type Reliability = (typeof reliabilityEnum.enumValues)[number];
export type Sentiment = (typeof sentimentEnum.enumValues)[number];
export type Transparency = (typeof transparencyEnum.enumValues)[number];
export type TokenPurpose = (typeof tokenPurposeEnum.enumValues)[number];
export type Credibility = {
bias: Bias;
reliability: Reliability;
transparency: Transparency;
};
export type TokenStatistics = {
title: number;
body: number;
categories: number;
excerpt: number;
total: number;
};
export type Device = {
operatingSystem?: string;
client?: string;
device?: string;
isBot: boolean;
};
export type GeoLocation = {
country?: string;
city?: string;
timeZone?: string;
longitude?: number;
latitude?: number;
accuracyRadius?: number;
};
export type ClientProfile = {
userIp?: string;
userAgent?: string;
hints: unknown[];
};
export type ArticleMetadata = {
title?: string;
description?: string;
image?: string;
};
export type DateRange = {
start: number; // unix timestamp (seconds)
end: number; // unix timestamp (seconds)
};
// Secrets
export type GeneratedToken = string;
export type GeneratedCode = string;
/* -------------------------------------------------------------------------- */
/* Tables */
/* -------------------------------------------------------------------------- */
export const user = pgTable(
"user",
{
createdAt: timestamp("created_at", { mode: "string" }).notNull(),
description: varchar({ length: 512 }).default(sql`NULL`),
createdAt: timestamp("created_at").defaultNow().notNull(),
email: varchar({ length: 255 }).$type<EmailAddress>().notNull(),
id: uuid().primaryKey().notNull(),
isPublic: boolean("is_public").default(false).notNull(),
isConfirmed: boolean("is_confirmed").default(false).notNull(),
isLocked: boolean("is_locked").default(false).notNull(),
name: varchar({ length: 255 }).notNull(),
updatedAt: timestamp("updated_at", { mode: "string" }).default(sql`NULL`),
userId: uuid("user_id").notNull(),
password: varchar({ length: 512 }).notNull(),
roles: varchar("roles", { length: 255 })
.$type<Roles>()
.array()
.notNull()
.default(["ROLE_USER"]),
updatedAt: timestamp("updated_at"),
},
(table) => [
index("idx_bookmark_user_created").using(
"btree",
table.userId.asc().nullsLast().op("timestamp_ops"),
table.createdAt.desc().nullsFirst().op("timestamp_ops"),
),
index("idx_da62921da76ed395").using("btree", table.userId.asc().nullsLast().op("uuid_ops")),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "fk_da62921da76ed395",
}).onDelete("cascade"),
(_table) => [
uniqueIndex("unq_user_email").using("btree", sql`lower((email)::text)`),
index("idx_user_created_at").using("btree", sql`created_at`),
sql`CONSTRAINT "chk_user_roles_json" CHECK (jsonb_typeof(roles) = 'array')`,
],
);
export const loginAttempt = pgTable(
"login_attempt",
export const source = pgTable(
"source",
{
createdAt: timestamp("created_at", { mode: "string" }).notNull(),
credibility: jsonb("credibility").$type<Credibility>(),
description: varchar({ length: 1024 }),
displayName: varchar("display_name", { length: 255 }),
id: uuid().primaryKey().notNull(),
userId: uuid("user_id").notNull(),
name: varchar({ length: 255 }).notNull(),
updatedAt: timestamp("updated_at"),
url: varchar({ length: 255 }).notNull(),
},
(table) => [
index("idx_8c11c1ba76ed395").using("btree", table.userId.asc().nullsLast().op("uuid_ops")),
index("idx_login_attempt_created_at").using(
"btree",
table.createdAt.desc().nullsFirst().op("timestamp_ops"),
),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "fk_8c11c1ba76ed395",
}).onDelete("cascade"),
],
);
export const loginHistory = pgTable(
"login_history",
{
createdAt: timestamp("created_at", { mode: "string" }).notNull(),
deviceClient: varchar("device_client", { length: 255 }).default(sql`NULL`),
deviceDevice: varchar("device_device", { length: 255 }).default(sql`NULL`),
deviceIsBot: boolean("device_is_bot").default(false).notNull(),
deviceOperatingSystem: varchar("device_operating_system", { length: 255 }).default(sql`NULL`),
id: uuid().primaryKey().notNull(),
ipAddress: inet("ip_address"),
locationAccuracyRadius: integer("location_accuracy_radius"),
locationLatitude: doublePrecision("location_latitude"),
locationLongitude: doublePrecision("location_longitude"),
locationTimeZone: varchar("location_time_zone", { length: 255 }).default(sql`NULL`),
userId: uuid("user_id").notNull(),
},
(table) => [
index("idx_37976e36a76ed395").using("btree", table.userId.asc().nullsLast().op("uuid_ops")),
index("idx_login_history_created_at").using(
"btree",
table.userId.asc().nullsLast().op("uuid_ops"),
table.createdAt.desc().nullsFirst().op("timestamp_ops"),
),
index("idx_login_history_ip_address").using(
"btree",
table.ipAddress.asc().nullsLast().op("inet_ops"),
),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "fk_37976e36a76ed395",
}).onDelete("cascade"),
],
);
export const verificationToken = pgTable(
"verification_token",
{
createdAt: timestamp("created_at", { mode: "string" }).notNull(),
id: uuid().primaryKey().notNull(),
purpose: varchar({ length: 255 }).notNull(),
token: varchar({ length: 60 }).default(sql`NULL`),
userId: uuid("user_id").notNull(),
},
(table) => [
index("idx_c1cc006ba76ed395").using("btree", table.userId.asc().nullsLast().op("uuid_ops")),
index("idx_verif_token_created_at").using(
"btree",
table.createdAt.desc().nullsFirst().op("timestamp_ops"),
),
uniqueIndex("unq_verif_user_purpose_token")
.using(
"btree",
table.userId.asc().nullsLast().op("text_ops"),
table.purpose.asc().nullsLast().op("text_ops"),
)
.where(sql`(token IS NOT NULL)`),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "fk_c1cc006ba76ed395",
}).onDelete("cascade"),
],
);
export const followedSource = pgTable(
"followed_source",
{
createdAt: timestamp("created_at", { mode: "string" }).notNull(),
followerId: uuid("follower_id").notNull(),
id: uuid().primaryKey().notNull(),
sourceId: uuid("source_id").notNull(),
},
(table) => [
index("idx_7a763a3e953c1c61").using("btree", table.sourceId.asc().nullsLast().op("uuid_ops")),
index("idx_7a763a3eac24f853").using("btree", table.followerId.asc().nullsLast().op("uuid_ops")),
index("idx_followed_source_follower_created").using(
"btree",
table.followerId.asc().nullsLast().op("timestamp_ops"),
table.createdAt.desc().nullsFirst().op("uuid_ops"),
),
foreignKey({
columns: [table.followerId],
foreignColumns: [user.id],
name: "fk_7a763a3eac24f853",
}).onDelete("cascade"),
foreignKey({
columns: [table.sourceId],
foreignColumns: [source.id],
name: "fk_7a763a3e953c1c61",
}).onDelete("cascade"),
],
);
export const comment = pgTable(
"comment",
{
articleId: uuid("article_id").notNull(),
content: varchar({ length: 512 }).notNull(),
createdAt: timestamp("created_at", { mode: "string" }).notNull(),
id: uuid().primaryKey().notNull(),
isSpam: boolean("is_spam").default(false).notNull(),
sentiment: varchar({ length: 30 }).default("neutral").notNull(),
userId: uuid("user_id").notNull(),
},
(table) => [
index("idx_9474526c7294869c").using("btree", table.articleId.asc().nullsLast().op("uuid_ops")),
index("idx_9474526ca76ed395").using("btree", table.userId.asc().nullsLast().op("uuid_ops")),
index("idx_comment_article_created").using(
"btree",
table.articleId.asc().nullsLast().op("timestamp_ops"),
table.createdAt.desc().nullsFirst().op("uuid_ops"),
),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "fk_9474526ca76ed395",
}).onDelete("cascade"),
foreignKey({
columns: [table.articleId],
foreignColumns: [article.id],
name: "fk_9474526c7294869c",
}).onDelete("cascade"),
],
);
export const refreshTokens = pgTable(
"refresh_tokens",
{
id: integer().primaryKey().notNull(),
refreshToken: varchar("refresh_token", { length: 128 }).notNull(),
username: varchar({ length: 255 }).notNull(),
valid: timestamp({ mode: "string" }).notNull(),
},
(table) => [
uniqueIndex("uniq_9bace7e1c74f2195").using(
"btree",
table.refreshToken.asc().nullsLast().op("text_ops"),
),
(_table) => [
uniqueIndex("unq_source_name").using("btree", sql`lower((name)::text)`),
uniqueIndex("unq_source_url").using("btree", sql`lower((url)::text)`),
],
);
export const article = pgTable(
"article",
{
bias: varchar({ length: 30 }).default("neutral").notNull(),
body: text().notNull(),
categories: text().array(),
crawledAt: timestamp("crawled_at", { mode: "string" }).notNull(),
crawledAt: timestamp("crawled_at").defaultNow().notNull(),
credibility: jsonb("credibility").$type<Credibility>(),
excerpt: varchar({ length: 255 }).generatedAlwaysAs(sql`("left"(body, 200) || '...'::text)`),
hash: varchar({ length: 32 }).notNull(),
id: uuid().primaryKey().notNull(),
image: varchar({ length: 1024 }).generatedAlwaysAs(sql`(metadata ->> 'image'::text)`),
link: varchar({ length: 1024 }).notNull(),
metadata: jsonb(),
publishedAt: timestamp("published_at", { mode: "string" }).notNull(),
metadata: jsonb("metadata").$type<ArticleMetadata>(),
publishedAt: timestamp("published_at").notNull(),
readingTime: integer("reading_time").default(1),
reliability: varchar({ length: 30 }).default("reliable").notNull(),
sentiment: varchar({ length: 30 }).default("neutral").notNull(),
sentiment: sentimentEnum("sentiment").notNull(),
sourceId: uuid("source_id").notNull(),
title: varchar({ length: 1024 }).notNull(),
tokenStatistics: jsonb("token_statistics"),
transparency: varchar({ length: 30 }).default("medium").notNull(),
tokenStatistics: jsonb("token_statistics").$type<TokenStatistics>(),
tsv: tsvector("tsv").generatedAlwaysAs(
sql`(setweight(to_tsvector('french'::regconfig, (COALESCE(title, ''::character varying))::text), 'A'::"char") || setweight(to_tsvector('french'::regconfig, COALESCE(body, ''::text)), 'B'::"char"))`,
sql`(
setweight(to_tsvector('french'::regconfig, COALESCE(title, '')::text), 'A'::"char")
|| setweight(to_tsvector('french'::regconfig, COALESCE(body, ''::text)), 'B'::"char")
)`,
),
updatedAt: timestamp("updated_at", { mode: "string" }).default(sql`NULL`),
updatedAt: timestamp("updated_at"),
},
(table) => [
index("gin_article_categories").using(
@@ -264,69 +202,57 @@ export const article = pgTable(
index("gin_article_link_trgm").using("gin", table.link.asc().nullsLast().op("gin_trgm_ops")),
index("gin_article_title_trgm").using("gin", table.title.asc().nullsLast().op("gin_trgm_ops")),
index("gin_article_tsv").using("gin", table.tsv.asc().nullsLast().op("tsvector_ops")),
index("idx_23a0e66953c1c61").using("btree", table.sourceId.asc().nullsLast().op("uuid_ops")),
index("idx_article_published_at").using(
index("idx_article_source_published_id").using(
"btree",
table.publishedAt.desc().nullsFirst().op("timestamp_ops"),
table.sourceId.asc().nullsLast(),
table.publishedAt.desc().nullsFirst(),
table.id.desc().nullsFirst(),
),
index("idx_article_published_id").using(
"btree",
table.publishedAt.desc().nullsFirst().op("timestamp_ops"),
table.id.desc().nullsFirst().op("uuid_ops"),
),
uniqueIndex("unq_article_hash").using("btree", table.hash.asc().nullsLast().op("text_ops")),
uniqueIndex("unq_article_hash").using("btree", table.hash.asc().nullsLast()),
foreignKey({
columns: [table.sourceId],
foreignColumns: [source.id],
name: "fk_23a0e66953c1c61",
name: "fk_article_source_id",
}).onDelete("cascade"),
check("chk_article_reading_time", sql`reading_time >= 0`),
check("chk_article_reading_time", sql`(reading_time >= 0)`),
check(
"chk_article_sentiment",
sql`(sentiment)::text = ANY ((ARRAY['positive'::character varying, 'neutral'::character varying, 'negative'::character varying])::text[])`,
sql`((sentiment)::text = ANY (ARRAY['positive'::text,'neutral'::text,'negative'::text]))`,
),
check(
"chk_article_metadata_json",
sql`(metadata IS NULL) OR (jsonb_typeof(metadata) = ANY (ARRAY['object'::text, 'array'::text]))`,
sql`((metadata IS NULL) OR (jsonb_typeof(metadata) IN ('object'::text,'array'::text)))`,
),
],
);
export const user = pgTable(
"user",
export const bookmark = pgTable(
"bookmark",
{
createdAt: timestamp("created_at", { mode: "string" }).notNull(),
email: varchar({ length: 255 }).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
description: varchar({ length: 512 }),
id: uuid().primaryKey().notNull(),
isConfirmed: boolean("is_confirmed").default(false).notNull(),
isLocked: boolean("is_locked").default(false).notNull(),
isPublic: boolean("is_public").default(false).notNull(),
name: varchar({ length: 255 }).notNull(),
password: varchar({ length: 512 }).notNull(),
roles: jsonb().notNull(),
updatedAt: timestamp("updated_at", { mode: "string" }).default(sql`NULL`),
updatedAt: timestamp("updated_at"),
userId: uuid("user_id").notNull(),
},
(_table) => [
uniqueIndex("unq_user_email").using("btree", sql`lower((email)::text)`),
check("chk_user_roles_json", sql`jsonb_typeof(roles) = 'array'::text`),
],
);
export const source = pgTable(
"source",
{
bias: varchar({ length: 30 }).default("neutral").notNull(),
description: varchar({ length: 1024 }).default(sql`NULL`),
displayName: varchar("display_name", { length: 255 }).default(sql`NULL`),
id: uuid().primaryKey().notNull(),
name: varchar({ length: 255 }).notNull(),
reliability: varchar({ length: 30 }).default("reliable").notNull(),
transparency: varchar({ length: 30 }).default("medium").notNull(),
updatedAt: timestamp("updated_at", { mode: "string" }).default(sql`NULL`),
url: varchar({ length: 255 }).notNull(),
},
(_table) => [
uniqueIndex("unq_source_name").using("btree", sql`lower((name)::text)`),
uniqueIndex("unq_source_url").using("btree", sql`lower((url)::text)`),
(table) => [
index("idx_bookmark_user_created").using(
"btree",
table.userId.asc().nullsLast(),
table.createdAt.desc().nullsFirst(),
),
uniqueIndex("unq_bookmark_user_name").using(
"btree",
table.userId.asc().nullsLast(),
sql`lower(${table.name})`,
),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "fk_bookmark_user_id",
}).onDelete("cascade"),
],
);
@@ -337,22 +263,177 @@ export const bookmarkArticle = pgTable(
bookmarkId: uuid("bookmark_id").notNull(),
},
(table) => [
index("idx_6fe2655d7294869c").using("btree", table.articleId.asc().nullsLast().op("uuid_ops")),
index("idx_6fe2655d92741d25").using("btree", table.bookmarkId.asc().nullsLast().op("uuid_ops")),
primaryKey({ columns: [table.bookmarkId, table.articleId], name: "bookmark_article_pkey" }),
index("idx_bookmark_article_bookmark_id").using("btree", table.bookmarkId.asc().nullsLast()),
foreignKey({
columns: [table.bookmarkId],
foreignColumns: [bookmark.id],
name: "fk_6fe2655d92741d25",
name: "fk_bookmark_article_bookmark_id",
}).onDelete("cascade"),
foreignKey({
columns: [table.articleId],
foreignColumns: [article.id],
name: "fk_6fe2655d7294869c",
name: "fk_bookmark_article_article_id",
}).onDelete("cascade"),
primaryKey({ columns: [table.bookmarkId, table.articleId], name: "bookmark_article_pkey" }),
],
);
export const loginAttempt = pgTable(
"login_attempt",
{
createdAt: timestamp("created_at").defaultNow().notNull(),
id: uuid().primaryKey().notNull(),
userId: uuid("user_id").notNull(),
},
(table) => [
index("idx_login_attempt_user_created").using(
"btree",
table.userId.asc().nullsLast(),
table.createdAt.desc().nullsFirst(),
),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "fk_login_attempt_user_id",
}).onDelete("cascade"),
],
);
export const loginHistory = pgTable(
"login_history",
{
createdAt: timestamp("created_at").defaultNow().notNull(),
device: jsonb("device").$type<Device>(),
id: uuid().primaryKey().notNull(),
ipAddress: inet("ip_address"),
location: jsonb("location").$type<GeoLocation>(),
userId: uuid("user_id").notNull(),
},
(table) => [
index("idx_login_history_user_created").using(
"btree",
table.userId.asc().nullsLast(),
table.createdAt.desc().nullsFirst(),
),
index("idx_login_history_ip_address").using("btree", table.ipAddress.asc().nullsLast()),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "fk_login_history_user_id",
}).onDelete("cascade"),
],
);
export const verificationToken = pgTable(
"verification_token",
{
createdAt: timestamp("created_at").defaultNow().notNull(),
id: uuid().primaryKey().notNull(),
purpose: tokenPurposeEnum("purpose").notNull(),
token: varchar({ length: 60 }), // nullable if you support "reservations" before issue
userId: uuid("user_id").notNull(),
},
(table) => [
index("idx_verif_token_created_at").using("btree", table.createdAt.desc().nullsFirst()),
uniqueIndex("unq_verif_user_purpose_token")
.using("btree", table.userId, table.purpose, table.token)
.where(sql`${table.token} IS NOT NULL`),
uniqueIndex("unq_verif_token_token")
.using("btree", table.token)
.where(sql`${table.token} IS NOT NULL`),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "fk_verification_token_user_id",
}).onDelete("cascade"),
],
);
export const followedSource = pgTable(
"followed_source",
{
createdAt: timestamp("created_at").defaultNow().notNull(),
followerId: uuid("follower_id").notNull(),
id: uuid().primaryKey().notNull(),
sourceId: uuid("source_id").notNull(),
},
(table) => [
index("idx_followed_source_source_id").using("btree", table.sourceId.asc().nullsLast()),
index("idx_followed_source_follower_id").using("btree", table.followerId.asc().nullsLast()),
index("idx_followed_source_follower_created").using(
"btree",
table.followerId.asc().nullsLast(),
table.createdAt.desc().nullsFirst(),
),
uniqueIndex("unq_followed_source_user_source").using(
"btree",
table.followerId.asc().nullsLast(),
table.sourceId.asc().nullsLast(),
),
foreignKey({
columns: [table.followerId],
foreignColumns: [user.id],
name: "fk_followed_source_follower_id",
}).onDelete("cascade"),
foreignKey({
columns: [table.sourceId],
foreignColumns: [source.id],
name: "fk_followed_source_source_id",
}).onDelete("cascade"),
],
);
export const comment = pgTable(
"comment",
{
articleId: uuid("article_id").notNull(),
content: varchar({ length: 512 }).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
id: uuid().primaryKey().notNull(),
isSpam: boolean("is_spam").default(false).notNull(),
sentiment: sentimentEnum("sentiment").notNull(),
userId: uuid("user_id").notNull(),
},
(table) => [
index("idx_comment_article_id").using("btree", table.articleId.asc().nullsLast()),
index("idx_comment_user_id").using("btree", table.userId.asc().nullsLast()),
index("idx_comment_article_created").using(
"btree",
table.articleId.asc().nullsLast(),
table.createdAt.desc().nullsFirst(),
),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "fk_comment_user_id",
}).onDelete("cascade"),
foreignKey({
columns: [table.articleId],
foreignColumns: [article.id],
name: "fk_comment_article_id",
}).onDelete("cascade"),
],
);
export const refreshToken = pgTable(
"refresh_token",
{
id: uuid().primaryKey().notNull(),
token: varchar("token", { length: 128 }).notNull(),
username: varchar({ length: 255 }).notNull(),
valid: timestamp().notNull(),
},
(table) => [
uniqueIndex("uniq_refresh_token_token").using("btree", table.token.asc().nullsLast()),
index("idx_refresh_token_valid").using("btree", table.valid.asc().nullsLast()),
index("idx_refresh_token_username").using("btree", sql`lower(${table.username})`),
],
);
/* -------------------------------------------------------------------------- */
/* Relations */
/* -------------------------------------------------------------------------- */
export const bookmarkRelations = relations(bookmark, ({ one, many }) => ({
bookmarkArticles: many(bookmarkArticle),
user: one(user, {