migration: init
jump to
@@ -0,0 +1,36 @@
+# dependencies (bun install) +node_modules +data +members + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store
@@ -0,0 +1,10 @@
+import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + out: "./drizzle", + schema: "./src/database/schema/index.ts", + dialect: "sqlite", + dbCredentials: { + url: "./data/db.sqlite", + }, +});
@@ -0,0 +1,22 @@
+{ + "name": "@stealth-developers/migration", + "private": true, + "type": "module", + "module": "index.ts", + "devDependencies": { + "@types/bun": "latest", + "drizzle-kit": "^1.0.0-rc.4-5d5b77c" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@libsql/client": "^0.17.4", + "@purrkit/ratelimit": "^1.0.1", + "@purrkit/rest": "^1.1.1", + "@purrkit/types": "^1.1.1", + "@stealth-developers/config": "workspace:*", + "@stealth-developers/db": "workspace:*", + "drizzle-orm": "^1.0.0-rc.4-5d5b77c" + } +}
@@ -0,0 +1,26 @@
+[ + { + id: 511, + discordId: "1350116449611677770", + displayName: "Stealth Developers", + username: "Stealth Developers", + avatar: "https://images-ext-1.discordapp.net/external/j7H9Ep50cCN5igmEKGwFnv7frj2590Xg2Z4hvHGFPPo/%3Fsize%3D4096/https/cdn.discordapp.com/icons/895998762630127616/36c6e14d0933eb338826599b632df818.png?format=webp&quality=lossless&width=852&height=852", + acceptedPrivacyPolicy: false, + acceptedTermsOfService: false, + createdAt: "2026-06-28T19:13:33.000Z", + updatedAt: "2026-06-28T19:13:33.000Z", + } + { + id: 511, + guildId: 1, + userId: 511, + discordId: "1350116449611677770", + displayName: "Stealth Developers", + username: "Stealth Developers", + avatar: "https://images-ext-1.discordapp.net/external/j7H9Ep50cCN5igmEKGwFnv7frj2590Xg2Z4hvHGFPPo/%3Fsize%3D4096/https/cdn.discordapp.com/icons/895998762630127616/36c6e14d0933eb338826599b632df818.png?format=webp&quality=lossless&width=852&height=852", + moderator: true, + createdAt: "2026-06-28T19:13:33.000Z", + updatedAt: "2026-06-28T19:13:33.000Z", + deletedAt: null, + } +]
@@ -0,0 +1,39 @@
+import type { GuildMemberResponse } from "@purrkit/types"; + +import fs from "node:fs"; + +import { db, tickets } from "./old-db"; + +const users = await db + .select({ + subjectId: tickets.authorId, + closerId: tickets.closedBy, + }) + .from(tickets) + .execute(); // { subjectId, closerId }[] + +const activeUserIds = new Set<string>(); +for (const row of users) { + if (row.subjectId) activeUserIds.add(row.subjectId); + if (row.closerId) activeUserIds.add(row.closerId); +} + +const membersArray = JSON.parse( + fs.readFileSync("members/members.json", "utf-8"), +) as GuildMemberResponse[]; + +const allMembersLookup = new Map<string, GuildMemberResponse>( + membersArray.map((member) => [member.user.id, member]), +); + +const membersMap: Record<string, GuildMemberResponse> = {}; +const missingUserIds: string[] = []; + +for (const id of activeUserIds) { + const member = allMembersLookup.get(id); + if (member) { + membersMap[id] = member; + } else { + missingUserIds.push(id); + } +}
@@ -0,0 +1,3 @@
+export const SYSTEM_USER_ID = 511; +export const SYSTEM_ACTOR_ID = 511; +export const GUILD_ID = 1;
@@ -0,0 +1,122 @@
+import type { UserResponse } from "@purrkit/types"; + +import { RateLimitManager } from "@purrkit/ratelimit"; +import { RESTClient } from "@purrkit/rest"; + +const limiter = new RateLimitManager({}); + +const TOKEN = process.env.TOKEN!; + +const rest = RESTClient({ + token: TOKEN, + fetch: limiter.fetch, +}); + +const missingUserIds: string[] = [ + "1782593884", + "795061786255687711", + "904144997559975937", + "578420334110179333", + "1413504630586998997", + "1437781069779832954", + "1029008522588987443", + "835909579497799691", + "1223644746896117791", + "493127296270467072", + "1329636666763116628", + "1324912280890118174", + "1350148294151503984", + "1258548326912098477", + "1475011243722018968", + "466382971251982357", + "1048430495094022205", + "1400279059765657630", + "717297081215352872", + "1431735081705275506", + "550840432997957633", + "504437490711527424", + "1281578414951632928", + "1438881999338213457", + "1243327172714958861", + "1455011612472643667", + "835703930091864094", + "786644500030029835", + "1069335764166524978", + "1365420920423841873", + "1319940559506378763", + "709796139611979879", + "1252497096129642506", + "872560135258669100", + "926198568715436063", + "1388237404376269016", + "733250938378125374", + "1346451794557009972", + "1457403097008832512", + "1387565913041010688", + "1338023988181467267", + "1482884859872673813", + "1484755143378473101", + "908069295337111572", + "1104329197742194739", + "836829335628414997", + "772837851255275530", + "805911623968489492", + "1307061098159013971", + "211104933028560896", + "1162930129954144256", + "993513174408114176", + "840586677516369951", + "1482060412076949597", + "1111315394406387765", + "866852794443366411", + "345391810472050689", + "722126325048934522", + "602335935543115776", + "1037074050100506634", + "573585328288563213", + "957637851569356902", + "988651720756301824", + "1298713042330062879", + "796477858037366784", + "496036367151661066", + "1471423782983041074", + "1091124167736299551", + "1115003796595163216", + "1130254575551664341", + "1069248360114028544", + "1388100438326378498", + "723208823765991490", + "1497641855343530198", + "1012951474629783614", + "1509220340637761556", + "792016188338077747", + "1073360202583253075", + "164569267243712512", + "525936042012835841", + "1343299975291080734", + "1386813477318037665", + "1213514366910074900", + "1265039531132850247", + "1162445027826475089", + "1120382975591338004", + "1241411863829549221", + "768001665054801930", +]; +// const users: UserResponse[] = []; +const out: { users: UserResponse[]; errors: any[] } = { + users: [], + errors: [], +}; + +for (const userId of missingUserIds) { + const user = await rest.users(userId).get(); + if (user.ok) { + out.users.push(user.data); + console.log(`Fetched user ${userId}`); + } else { + console.error(`Failed to fetch user ${userId}:`, user.status, user.data); + out.errors.push({ userId, status: user.status, data: user.data }); + } +} + +console.log(JSON.stringify(out, null, 2));
@@ -0,0 +1,39 @@
+import { RESTClient } from "@purrkit/rest"; +import fs from "node:fs"; + +const TOKEN = process.env.TOKEN!; +const GUILD_ID = process.env.GUILD_ID!; + +const rest = RESTClient({ + token: TOKEN, +}); + +let lastUserId: number | undefined; +const allMembers: unknown[] = []; + +while (true) { + const response = await rest.guilds(GUILD_ID).members.get({ + query: { + limit: 1000, + after: lastUserId, + }, + }); + + if (!response.ok || response.data.length === 0) { + console.log(JSON.stringify(response.data)); + break; + } + + lastUserId = response.data[response.data.length - 1]!.user.id; + + fs.writeFileSync(`members/members.${lastUserId}.json`, JSON.stringify(response.data)); + allMembers.push(...response.data); + + console.log( + `fetched ${response.data.length} members page (total ${allMembers.length + response.data.length})`, + ); + + if (response.data.length < 1000) break; +} + +fs.writeFileSync(`members/members.json`, JSON.stringify(allMembers));
@@ -0,0 +1,7 @@
+import { Database } from "bun:sqlite"; +import { drizzle } from "drizzle-orm/bun-sqlite"; + +const sqlite = new Database("./data/old.sqlite"); +export const db = drizzle({ client: sqlite }); + +export * from "./schema";
@@ -0,0 +1,33 @@
+import { sql } from "drizzle-orm"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const attachments = sqliteTable( + "attachments", + { + id: text("id").primaryKey(), + + ownerType: text("owner_type", { + enum: ["ticket", "ticket_message", "bug"], + }).notNull(), + ownerId: integer("owner_id").notNull(), + + authorId: text("author_id"), + fileName: text("file_name").notNull(), + contentType: text("content_type").notNull(), + size: integer("size").notNull(), + + bucket: text("bucket").notNull(), + key: text("key").notNull(), + url: text("url").notNull(), + + uploadedAt: integer("uploaded_at", { mode: "timestamp" }) + .default(sql`(unixepoch())`) + .notNull(), + }, + (t) => [ + index("attachments_owner_idx").on(t.ownerType, t.ownerId), + index("attachments_author_idx").on(t.authorId), + ], +); + +export type Attachment = typeof attachments.$inferSelect;
@@ -0,0 +1,6 @@
+import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; + +export const automodHashes = sqliteTable("automod_hashes", { + hash: text("hash").primaryKey(), + addedAt: integer("added_at").notNull(), +});
@@ -0,0 +1,44 @@
+import { defineRelations, sql } from "drizzle-orm"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const bugs = sqliteTable( + "bugs", + { + id: integer("id").primaryKey({ autoIncrement: true }), + guildId: text("guild_id").notNull(), + bugNumber: integer("bug_number").notNull(), + + authorId: text("author_id").notNull(), + status: text("status", { enum: ["open", "closed"] }) + .default("open") + .notNull(), + + title: text("title").notNull(), + description: text("description").notNull(), + + projects: text("projects", { mode: "json" }) + .$type<string[]>() + .default([]) + .notNull(), + + sent: integer("sent", { mode: "boolean" }).default(false).notNull(), + messageId: text("message_id"), + threadId: text("thread_id"), + + createdAt: integer("created_at", { mode: "timestamp" }) + .default(sql`(unixepoch())`) + .notNull(), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .default(sql`(unixepoch())`) + .notNull(), + closedAt: integer("closed_at", { mode: "timestamp" }), + }, + (t) => [ + index("bugs_guild_idx").on(t.guildId), + index("bugs_author_idx").on(t.authorId), + index("bugs_status_idx").on(t.status), + index("bugs_guild_number_idx").on(t.guildId, t.bugNumber), + ], +); + +export type Bug = typeof bugs.$inferSelect;
@@ -0,0 +1,35 @@
+import { defineRelations } from "drizzle-orm"; +import { sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const manager = sqliteTable("manager", { + roleId: text("role_id").primaryKey(), + guildId: text("guild_id").notNull(), +}); + +export const guild = sqliteTable("guild", { + guildId: text("guild_id").notNull().primaryKey(), + + suggestion_forum_id: text("suggestion_forum_id"), + bug_channel_id: text("bug_channel_id"), + highlights_channel_id: text("highlights_channel_id"), + commands_channel_id: text("commands_channel_id"), + + ticket_channel_id: text("ticket_channel_id"), + ticket_category_id: text("ticket_category_id"), + ticket_message: text("ticket_message"), +}); + +export const GuildRelations = defineRelations({ manager, guild }, (r) => ({ + manager: { + guild: r.one.guild({ + from: r.manager.guildId, + to: r.guild.guildId, + }), + }, + guild: { + managers: r.many.manager(), + }, +})); + +export type Guild = typeof guild.$inferSelect; +export type Manager = typeof manager.$inferSelect;
@@ -0,0 +1,8 @@
+export * from "./tickets"; +export * from "./guild"; +export * from "./attachments"; +export * from "./bugs"; +export * from "./moderators"; +export * from "./userPreferences"; +export * from "./sessions"; +export * from "./automod";
@@ -0,0 +1,10 @@
+import { sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const moderators = sqliteTable("moderators", { + user_id: text("user_id").primaryKey(), + subjective: text("subjective").notNull(), + objective: text("objective").notNull(), + possessiveDeterminer: text("possessive_determiner").notNull(), + possessive: text("possessive").notNull(), + reflexive: text("reflexive").notNull(), +});
@@ -0,0 +1,11 @@
+import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; + +export const sessions = sqliteTable("sessions", { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + username: text("username").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .default(sql`(unixepoch())`) + .notNull(), +});
@@ -0,0 +1,134 @@
+import { defineRelations, sql } from "drizzle-orm"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { randomUUID } from "node:crypto"; + +export const tickets = sqliteTable( + "tickets", + { + id: integer("id").primaryKey({ autoIncrement: true }), + guildId: text("guild_id").notNull(), + ticketNumber: integer("ticket_number"), + publicId: text("public_id") + .$defaultFn(() => randomUUID()) + .unique() + .notNull(), + + channelId: text("channel_id"), + + authorId: text("author_id").notNull(), + anonymousId: text("anonymous_id").notNull(), + claimedBy: text("claimed_by"), + claimedAt: integer("claimed_at", { mode: "timestamp" }), + + addedUsers: text("added_users", { mode: "json" }).$type<string[]>().default([]).notNull(), + + type: text("type").notNull(), + status: text("status", { enum: ["open", "closed", "archived"] }) + .default("open") + .notNull(), + + topic: text("topic"), + openedBy: text("opened_by"), + + createdAt: integer("created_at", { mode: "timestamp" }) + .default(sql`(unixepoch())`) + .notNull(), + closedAt: integer("closed_at", { mode: "timestamp" }), + closedBy: text("closed_by"), + + closeReason: text("close_reason"), + privateReason: text("private_reason"), + + thankedAt: integer("thanked_at", { + mode: "timestamp", + }).$type<Date | null>(), + warnedAt: integer("warned_at", { mode: "timestamp" }).$type<Date | null>(), + }, + (t) => [ + index("tickets_guild_idx").on(t.guildId), + index("tickets_author_idx").on(t.authorId), + index("tickets_status_idx").on(t.status), + index("tickets_public_id_idx").on(t.publicId), + ], +); + +export const ticketMessages = sqliteTable( + "ticket_messages", + { + id: integer("id").primaryKey({ autoIncrement: true }), + ticketId: integer("ticket_id") + .references(() => tickets.id, { onDelete: "cascade" }) + .notNull(), + publicId: text("public_id") + .$defaultFn(() => randomUUID()) + .unique() + .notNull(), + + messageId: text("message_id"), + authorId: text("author_id").notNull(), + + authorType: text("author_type", { enum: ["user", "staff", "system"] }) + .default("user") + .notNull(), + + content: text("content").notNull(), + + createdAt: integer("created_at", { mode: "timestamp" }) + .default(sql`(unixepoch())`) + .notNull(), + editedAt: integer("edited_at", { mode: "timestamp" }).$type<Date | null>(), + deletedAt: integer("deleted_at", { + mode: "timestamp", + }).$type<Date | null>(), + }, + (t) => [index("ticket_msgs_ticket_idx").on(t.ticketId)], +); + +export const ticketAccessLogs = sqliteTable( + "ticket_access_logs", + { + id: integer("id").primaryKey({ autoIncrement: true }), + ticketId: integer("ticket_id") + .references(() => tickets.id, { onDelete: "cascade" }) + .notNull(), + + userId: text("user_id").notNull(), + action: text("action", { enum: ["view_transcript"] }).notNull(), + + createdAt: integer("created_at", { mode: "timestamp" }) + .default(sql`(unixepoch())`) + .notNull(), + }, + (t) => [index("ticket_access_logs_ticket_idx").on(t.ticketId)], +); + +export const TicketRelations = defineRelations( + { tickets, ticketMessages, ticketAccessLogs }, + (r) => ({ + tickets: { + messages: r.many.ticketMessages({ + from: r.tickets.id, + to: r.ticketMessages.ticketId, + }), + accessLogs: r.many.ticketAccessLogs({ + from: r.tickets.id, + to: r.ticketAccessLogs.ticketId, + }), + }, + ticketMessages: { + ticket: r.one.tickets({ + from: r.ticketMessages.ticketId, + to: r.tickets.id, + }), + }, + ticketAccessLogs: { + ticket: r.one.tickets({ + from: r.ticketAccessLogs.ticketId, + to: r.tickets.id, + }), + }, + }), +); + +export type Ticket = typeof tickets.$inferSelect; +export type TicketMessage = typeof ticketMessages.$inferSelect;
@@ -0,0 +1,18 @@
+import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const userPreferences = sqliteTable("user_preferences", { + userId: text("user_id").primaryKey(), + uwuifyMessages: text("uwuify_messages", { enum: ["enabled", "disabled"] }) + .default("disabled") + .notNull(), +}); + +export const uwuifyRateLimit = sqliteTable("uwuify_rate_limit", { + userId: text("user_id").primaryKey(), + count: integer("count").default(0).notNull(), + resetAt: integer("reset_at", { mode: "timestamp" }).notNull(), +}); + +export type UserPreference = typeof userPreferences.$inferSelect; +export type UwuifyRateLimit = typeof uwuifyRateLimit.$inferSelect;
@@ -0,0 +1,91 @@
+import type { GuildMemberResponse, Snowflake, UserResponse } from "@purrkit/types"; + +import db, { actors, users } from "@stealth-developers/db"; +import fs from "node:fs"; + +type Data = Record<Snowflake, GuildMemberResponse>; + +const file = fs.readFileSync("./members/filtered-map.json", "utf-8"); +const memberData: Data = JSON.parse(file); +const GUILD_ID = 1; + +for (const member of Object.values(memberData)) { + const [user] = await db + .insert(users) + .values({ + discordId: member.user.id, + displayName: member.nick ?? member.user.global_name, + username: member.user.username, + avatar: member.user.avatar, + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoNothing() + .returning(); + if (user) { + const [actor] = await db + .insert(actors) + .values({ + userId: user.id, + guildId: GUILD_ID, + + discordId: user.discordId, + username: user.username, + displayName: user.displayName, + avatar: user.avatar, + + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoNothing() + .returning(); + + if (actor) { + console.log(`actor inserted for member ${member.user.id}`); + } + } else { + console.warn(`no user inserted for member ${member.user.id}`); + } +} + +const userFile = fs.readFileSync("./members/filtered-users.json", "utf-8"); +const userData: UserResponse[] = JSON.parse(userFile); + +for (const user of userData) { + const [dbUser] = await db + .insert(users) + .values({ + discordId: user.id, + displayName: user.global_name, + username: user.username, + avatar: user.avatar, + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoNothing() + .returning(); + if (dbUser) { + const [actor] = await db + .insert(actors) + .values({ + userId: dbUser.id, + guildId: GUILD_ID, + discordId: dbUser.discordId, + username: dbUser.username, + displayName: dbUser.displayName, + avatar: dbUser.avatar, + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoNothing() + .returning(); + + if (actor) { + console.log(`actor inserted for member ${user.id}`); + } else { + console.warn(`no actor inserted for member ${user.id}`); + } + } else { + console.warn(`no user inserted for member ${user.id}`); + } +}
@@ -0,0 +1,461 @@
+import * as newDb from "@stealth-developers/db"; + +import { GUILD_ID, SYSTEM_ACTOR_ID } from "./constants"; +import * as oldDb from "./old-db"; + +const [guild] = await newDb.db + .select() + .from(newDb.guilds) + .where(newDb.eq(newDb.guilds.id, GUILD_ID)) + .limit(1); + +async function migrate() { + if (!guild) throw new Error("guild not found"); + + console.log("fetching old data..."); + const allTickets = await oldDb.db.select().from(oldDb.tickets); + const allMessages = await oldDb.db.select().from(oldDb.ticketMessages); + const allAttachments = await oldDb.db.select().from(oldDb.attachments); + + console.log( + `got ${allTickets.length} tickets, ${allMessages.length} messages, and ${allAttachments.length} attachments.`, + ); + + const discordIds = new Set<string>(); + const addId = (id: string | null | undefined) => { + if (!id) return; + if (id === "system" || id === "0" || id === "reporter") return; + discordIds.add(id); + }; + + for (const t of allTickets) { + addId(t.authorId); + addId(t.openedBy); + addId(t.closedBy); + addId(t.claimedBy); + } + + for (const m of allMessages) { + addId(m.authorId); + } + + for (const a of allAttachments) { + addId(a.authorId); + } + + const actorMap = new Map<string, number>(); + const discordIdArray = Array.from(discordIds); + + if (discordIdArray.length > 0) { + console.log("resolving existing actors..."); + const existingActors = await newDb.db + .select() + .from(newDb.actors) + .where(newDb.inArray(newDb.actors.discordId, discordIdArray)); + + for (const actor of existingActors) { + actorMap.set(actor.discordId!, actor.id); + } + } + + const resolveActorId = ( + discordId: string | null | undefined, + fallbackOpenedByActorId?: number, + ): number => { + if (!discordId) return SYSTEM_ACTOR_ID; + if (discordId === "system" || discordId === "0") { + return SYSTEM_ACTOR_ID; + } + if (discordId === "reporter") { + return fallbackOpenedByActorId ?? SYSTEM_ACTOR_ID; + } + const id = actorMap.get(discordId); + return id ?? SYSTEM_ACTOR_ID; + }; + + const resolveNullableActorId = ( + discordId: string | null | undefined, + fallbackOpenedByActorId?: number, + ): number | null => { + if (!discordId) return null; + if (discordId === "system" || discordId === "0") { + return SYSTEM_ACTOR_ID; + } + if (discordId === "reporter") { + return fallbackOpenedByActorId ?? SYSTEM_ACTOR_ID; + } + const id = actorMap.get(discordId); + return id ?? SYSTEM_ACTOR_ID; + }; + + const cleanDate = (d: any): Date | null => { + if (!d) return null; + const date = d instanceof Date ? d : new Date(d); + if (isNaN(date.getTime())) { + return null; + } + return date; + }; + + const cleanRequiredDate = (d: any, fallback = new Date()): Date => { + return cleanDate(d) ?? fallback; + }; + + const isUniqueConstraintError = (err: any): boolean => { + const msg = (err.message || "").toLowerCase(); + return ( + msg.includes("unique constraint failed") || + msg.includes("sqlite_constraint") || + msg.includes("unique index") || + err.code === "SQLITE_CONSTRAINT" + ); + }; + + // precalc statistics from messages to fill metadata in tickets + const ticketStatsMap = new Map< + number, + { + lastMessageAt: Date | null; + hasModeratorMessage: boolean; + hasAuthorMessage: boolean; + } + >(); + + for (const t of allTickets) { + ticketStatsMap.set(t.id, { + lastMessageAt: null, + hasModeratorMessage: false, + hasAuthorMessage: false, + }); + } + + for (const m of allMessages) { + const mDeletedAt = cleanDate(m.deletedAt); + if (mDeletedAt) continue; + + const stats = ticketStatsMap.get(m.ticketId); + if (!stats) continue; + + const ticket = allTickets.find((t) => t.id === m.ticketId); + if (!ticket) continue; + + const subjectActorId = resolveActorId(ticket.authorId); + const actorId = resolveActorId(m.authorId); + + const mCreatedAt = cleanDate(m.createdAt); + if (mCreatedAt) { + if (!stats.lastMessageAt || mCreatedAt > stats.lastMessageAt) { + stats.lastMessageAt = mCreatedAt; + } + } + + if (m.authorType === "staff") { + stats.hasModeratorMessage = true; + } else if (m.authorType === "user" && actorId === subjectActorId) { + stats.hasAuthorMessage = true; + } + } + + // map & insert tickets and participants + const oldTicketIdToPublicId = new Map<number, string>(); + const oldTicketIdToOpenedByActorId = new Map<number, number>(); + const oldTicketIdToSubjectActorId = new Map<number, number>(); + + const ticketsToInsert: newDb.NewTicket[] = []; + const participantsToInsert: newDb.NewTicketParticipant[] = []; + + for (const t of allTickets) { + const openedByActorId = resolveActorId(t.openedBy); + const subjectActorId = resolveActorId(t.authorId); + const closedByActorId = resolveNullableActorId(t.closedBy, openedByActorId); + const claimedByActorId = resolveNullableActorId(t.claimedBy); + + oldTicketIdToPublicId.set(t.id, t.publicId); + oldTicketIdToOpenedByActorId.set(t.id, openedByActorId); + oldTicketIdToSubjectActorId.set(t.id, subjectActorId); + + const stats = ticketStatsMap.get(t.id) || { + lastMessageAt: null, + hasModeratorMessage: false, + hasAuthorMessage: false, + }; + + const numbers = { + openedByActorId, + subjectActorId, + closedByActorId, + claimedByActorId, + + id: t.id, + guildId: guild.id, + localId: t.id, + }; + + for (const [key, value] of Object.entries(numbers)) { + if (value && (isNaN(value) || value === Infinity)) { + throw new Error(`Invalid actor ID for ticket ${t.id}: ${key}`); + } + } + + ticketsToInsert.push({ + id: t.publicId, + guildId: guild.id, + localId: t.id, + + status: t.status, + channelId: t.channelId, + threadId: null, + + closeReason: t.closeReason, + privateReason: t.privateReason, + + openedAt: cleanRequiredDate(t.createdAt), + closedAt: cleanDate(t.closedAt), + closedBy: closedByActorId, + + openedBy: openedByActorId, + openReason: undefined, + + subject: subjectActorId, + + claimedBy: claimedByActorId, + claimedAt: cleanDate(t.claimedAt), + + thankedAt: cleanDate(t.thankedAt), + warnedAt: cleanDate(t.warnedAt), + lastMessageAt: cleanDate(stats.lastMessageAt), + hasModeratorMessage: stats.hasModeratorMessage, + hasAuthorMessage: stats.hasAuthorMessage, + }); + + participantsToInsert.push({ + ticketId: t.publicId, + actorId: subjectActorId, + role: "subject", + reason: "Ticket Subject", + createdAt: cleanRequiredDate(t.createdAt), + }); + + if (openedByActorId !== subjectActorId) { + participantsToInsert.push({ + ticketId: t.publicId, + actorId: openedByActorId, + role: "participant", + reason: "Ticket Opener", + createdAt: cleanRequiredDate(t.createdAt), + }); + } + + if ( + claimedByActorId && + claimedByActorId !== subjectActorId && + claimedByActorId !== openedByActorId + ) { + participantsToInsert.push({ + ticketId: t.publicId, + actorId: claimedByActorId, + role: "moderator", + reason: "Claimed Ticket", + createdAt: cleanRequiredDate(t.claimedAt, cleanRequiredDate(t.createdAt)), + }); + } + } + + console.log(`Inserting ${ticketsToInsert.length} tickets...`); + const processedTicketIds = new Set<string>(); + let insertedTickets = 0; + let skippedTickets = 0; + + for (const ticket of ticketsToInsert) { + if (processedTicketIds.has(ticket.id)) { + console.warn(`[DEDUPE] skipping duplicate ticket ID in memory: ${ticket.id}`); + skippedTickets++; + continue; + } + processedTicketIds.add(ticket.id); + + try { + await newDb.db.insert(newDb.tickets).values(ticket); + insertedTickets++; + } catch (err: any) { + if (isUniqueConstraintError(err)) { + console.warn(`[WARNING] skipping ticket ID conflict in DB: ${ticket.id}`); + skippedTickets++; + continue; + } + console.error( + `\n--- ERROR INSERTING TICKET (ID: ${ticket.id}, localId: ${ticket.localId}) ---`, + ); + console.error(err.message || err); + console.log("ticket payload:", JSON.stringify(ticket, null, 2)); + throw err; + } + } + console.log( + `tickets status: migrated ${insertedTickets}, skipped ${skippedTickets} duplicate/conflict rows.`, + ); + + console.log(`inserting ${participantsToInsert.length} ticket participants...`); + let insertedParticipants = 0; + let skippedParticipants = 0; + + for (const participant of participantsToInsert) { + try { + await newDb.db.insert(newDb.ticketParticipants).values(participant); + insertedParticipants++; + } catch (err: any) { + if (isUniqueConstraintError(err)) { + skippedParticipants++; + continue; + } + console.error(`\n--- ERROR INSERTING PARTICIPANT (ticket ID: ${participant.ticketId}) ---`); + console.error(err.message || err); + console.log("participant payload:", JSON.stringify(participant, null, 2)); + throw err; + } + } + console.log( + `participants status: migrated ${insertedParticipants}, skipped ${skippedParticipants} duplicates.`, + ); + + // map & insert tickets + const oldMessageIdToPublicId = new Map<number, string>(); + const oldMessageIdToTicketId = new Map<number, number>(); + const messagesToInsert: newDb.NewTicketMessage[] = []; + + console.log("Processing messages..."); + for (const m of allMessages) { + const ticketPublicId = oldTicketIdToPublicId.get(m.ticketId); + if (!ticketPublicId) continue; + + const subjectActorId = oldTicketIdToSubjectActorId.get(m.ticketId); + const actorId = resolveActorId(m.authorId); + + oldMessageIdToPublicId.set(m.id, m.publicId); + oldMessageIdToTicketId.set(m.id, m.ticketId); + + let actorRole: "participant" | "moderator" | "subject" | "bot" = "participant"; + if (m.authorType === "system") { + actorRole = "bot"; + } else if (m.authorType === "staff") { + actorRole = "moderator"; + } else if (m.authorType === "user") { + actorRole = actorId === subjectActorId ? "subject" : "participant"; + } + + messagesToInsert.push({ + id: m.publicId, + messageId: m.messageId, + ticketId: ticketPublicId, + actorId: actorId, + actorRole: actorRole, + isPrivate: false, + content: m.content, + embeds: null, + components: null, + createdAt: cleanRequiredDate(m.createdAt), + editedAt: cleanDate(m.editedAt), + deletedAt: cleanDate(m.deletedAt), + }); + } + + console.log(`inserting ${messagesToInsert.length} messages...`); + const processedMessageIds = new Set<string>(); + let insertedMessages = 0; + let skippedMessages = 0; + + for (const message of messagesToInsert) { + if (processedMessageIds.has(message.id)) { + skippedMessages++; + continue; + } + processedMessageIds.add(message.id); + + try { + await newDb.db.insert(newDb.ticketMessages).values(message); + insertedMessages++; + } catch (err: any) { + if (isUniqueConstraintError(err)) { + skippedMessages++; + continue; + } + console.error( + `\n--- ERROR INSERTING MESSAGE (ID: ${message.id}, MessageID: ${message.messageId}) ---`, + ); + console.error(err.message || err); + console.log("message payload:", JSON.stringify(message, null, 2)); + throw err; + } + } + console.log( + `messages status: migrated ${insertedMessages}, skipped ${skippedMessages} duplicates.`, + ); + + const attachmentsToInsert: newDb.NewTicketAttachment[] = []; + + console.log("Processing attachments..."); + for (const a of allAttachments) { + if (a.ownerType === "bug") continue; + + let ticketId: string | undefined; + let messageId: string | null = null; + + if (a.ownerType === "ticket_message") { + const mPublicId = oldMessageIdToPublicId.get(a.ownerId); + const oldTicketId = oldMessageIdToTicketId.get(a.ownerId); + if (mPublicId && oldTicketId) { + messageId = mPublicId; + ticketId = oldTicketIdToPublicId.get(oldTicketId); + } + } else if (a.ownerType === "ticket") { + ticketId = oldTicketIdToPublicId.get(a.ownerId); + } + + if (!ticketId) continue; + const actorId = resolveActorId(a.authorId); + + attachmentsToInsert.push({ + id: a.id, + messageId, + ticketId, + actorId, + fileName: a.fileName, + fileType: a.contentType, + fileSize: a.size, + }); + } + + console.log(`inserting ${attachmentsToInsert.length} attachments...`); + const processedAttachmentIds = new Set<string>(); + let insertedAttachments = 0; + let skippedAttachments = 0; + + for (const attachment of attachmentsToInsert) { + if (processedAttachmentIds.has(attachment.id)) { + skippedAttachments++; + continue; + } + processedAttachmentIds.add(attachment.id); + + try { + await newDb.db.insert(newDb.ticketAttachments).values(attachment); + insertedAttachments++; + } catch (err: any) { + if (isUniqueConstraintError(err)) { + skippedAttachments++; + continue; + } + console.error(`\n--- ERROR INSERTING ATTACHMENT (ID: ${attachment.id}) ---`); + console.error(err.message || err); + console.log("attachment payload:", JSON.stringify(attachment, null, 2)); + throw err; + } + } + console.log( + `attachments status: migrated ${insertedAttachments}, skipped ${skippedAttachments} duplicates.`, + ); + + console.log("migration completed successfully."); +} + +migrate().catch(console.error);
@@ -0,0 +1,30 @@
+{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}
@@ -55,6 +55,25 @@ "@types/ws": "^8.18.1",
"typescript": "~5.4.5", }, }, + "apps/migration": { + "name": "@stealth-developers/migration", + "dependencies": { + "@libsql/client": "^0.17.4", + "@purrkit/ratelimit": "^1.0.1", + "@purrkit/rest": "^1.1.1", + "@purrkit/types": "^1.1.1", + "@stealth-developers/config": "workspace:*", + "@stealth-developers/db": "workspace:*", + "drizzle-orm": "^1.0.0-rc.4-5d5b77c", + }, + "devDependencies": { + "@types/bun": "latest", + "drizzle-kit": "^1.0.0-rc.4-5d5b77c", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, "pkgs/config": { "name": "@stealth-developers/config", "dependencies": {@@ -265,8 +284,14 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.68.0", "", { "os": "win32", "cpu": "x64" }, "sha512-zg5pA+84AlU6XHJ3ruiRxziO71QTrz8nLsk6u01JGS5+tL9/bnlakFiklFrcy4R1/V7ktWtaNitN3JZWmKnf6g=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@purrkit/ratelimit": ["@purrkit/ratelimit@1.0.1", "", { "dependencies": { "@purrkit/types": "1.1.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-WVDaDO/BzlLAJkmJUcAMzqfTF30Ha5oANyfqUNa4U4LJl5yG6w4JYE7Rg90EFBoLnjypScxfLH86Bbr6n0URDQ=="], + + "@purrkit/rest": ["@purrkit/rest@1.1.1", "", { "dependencies": { "@purrkit/types": "1.1.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-VbOjHDTG650NcZJDYpVWmkZo/mfueXl/UJtkpgmToNNITi+gJEqjwjcK2FoBylAxOqAu6x1lh7KBLwv61FDKBA=="], + "@purrkit/router": ["@purrkit/router@1.3.0", "", { "peerDependencies": { "discord.js": "^14.26.4", "typescript": "^5" } }, "sha512-exP3od2+Stw86Z8sA/8Oetk/u/cNNU7zu2K64eOavC1qpqZZ1+x/++egLXZ1PZnDy9+QSQrv1/lL6TkUyqGXqQ=="], + "@purrkit/types": ["@purrkit/types@1.1.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Zey+Ceq6oasA1GvV2KnZX8zO9aJyXEZoDpvyO6ZoP1vIDMEoSapnBQAVYnS+uSaWh65lHWzHnXsXuFiMMPKGjg=="], + "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="], "@sapphire/cli": ["@sapphire/cli@1.9.3", "", { "dependencies": { "@favware/colorette-spinner": "^1.0.1", "@sapphire/node-utilities": "^1.0.2", "@sapphire/result": "^2.6.6", "colorette": "^2.0.20", "commander": "^11.1.0", "execa": "^8.0.1", "find-up": "^5.0.0", "js-yaml": "^4.1.0", "prompts": "^2.4.2", "tslib": "^2.6.2" }, "bin": { "sapphire": "./dist/cli.js" } }, "sha512-78sw7JN4FKDY7lDE3Ld+SavQmww7EEHGe/79pom3dF7r/vO+6lxH3ffcnqZKy3Hd/onBe5cfm26DygEOstGnxw=="],@@ -314,6 +339,8 @@
"@stealth-developers/config": ["@stealth-developers/config@workspace:pkgs/config"], "@stealth-developers/db": ["@stealth-developers/db@workspace:pkgs/db"], + + "@stealth-developers/migration": ["@stealth-developers/migration@workspace:apps/migration"], "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],@@ -530,6 +557,10 @@
"@sapphire/pieces/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], "@stealth-developers/bot/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], + + "@stealth-developers/migration/drizzle-kit": ["drizzle-kit@1.0.0-rc.4-ca0f029", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-hM1K72ptIPOpJDScHo7cAF+K8A8Tr3q2yHvBJvOZWvLn2lN+G4RDUvG3rT3ohlSE2O3BN/s7g3e3JiQLStt8iQ=="], + + "@stealth-developers/migration/drizzle-orm": ["drizzle-orm@1.0.0-rc.4-5d5b77c", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-pg": ">=4.0.0-beta.58 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.58 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-Oq9W4B11PracWY9cuCLTFT+JA5kXqR6rBmWcM8oh0xwLgCviVl9wh6ZuWX7Zpv1Jir/8W+M37syvjSYtj1f5iA=="], "@types/ws/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
@@ -13,10 +13,18 @@ transcript_channel: z.string(),
}), }); -const dbSchema = z.object({ - url: z.string(), - auth: z.string(), -}); +const dbSchema = z.discriminatedUnion("dialect", [ + z.object({ + dialect: z.literal("turso"), + url: z.string(), + auth: z.string(), + }), + z.object({ + dialect: z.literal("sqlite"), + url: z.string(), + auth: z.string().optional(), + }), +]); const apiSchema = z.object({ port: z.number().default(3000),
@@ -4,15 +4,16 @@ const config = {
db: { url: process.env.DATABASE_URL!, auth: process.env.DATABASE_AUTH!, + dialect: process.env.DATABASE_DIALECT! as "turso" | "sqlite", }, }; export default defineConfig({ out: "./drizzle", schema: "./src/schemas/index.ts", - dialect: "turso", + dialect: config.db.dialect, dbCredentials: { url: config.db.url, - authToken: config.db.auth, + authToken: config.db.dialect === "turso" ? config.db.auth : undefined, }, });
@@ -5,6 +5,7 @@ env: {
PATH: process.env.PATH, DATABASE_URL: config.db.url, DATABASE_AUTH: config.db.auth, + DATABASE_DIALECT: config.db.dialect, }, stdout: "inherit", stderr: "inherit",
@@ -7,10 +7,9 @@ import * as relations from "./schemas/relations";
const client = createClient({ url: config.db.url, - authToken: config.db.auth, }); -const db = drizzle({ +export const db = drizzle({ client, schema, relations: relations.default,