From 6bb3b5d8b6246eac408b23187e66401aecdb2c89 Mon Sep 17 00:00:00 2001 From: MichaelUnkey Date: Tue, 2 Sep 2025 09:22:19 -0400 Subject: [PATCH 1/2] script to backfill slugs --- tools/migrate/backfill-workspace-slugs.ts | 128 ++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tools/migrate/backfill-workspace-slugs.ts diff --git a/tools/migrate/backfill-workspace-slugs.ts b/tools/migrate/backfill-workspace-slugs.ts new file mode 100644 index 0000000000..f9ba0772e1 --- /dev/null +++ b/tools/migrate/backfill-workspace-slugs.ts @@ -0,0 +1,128 @@ +import { eq, mysqlDrizzle, schema } from "@unkey/db"; +import mysql from "mysql2/promise"; + +/** + * Backfill script for workspace slugs + * + * This script will: + * 1. Find all workspaces that don't have a slug + * 2. Generate a slug from the workspace name following these rules: + * - Convert to lowercase + * - Replace spaces with hyphens + * - Remove all special characters except hyphens + * - Remove leading and trailing hyphens + * 3. Update the database with the generated slug + */ + +function generateSlug(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/\s+/g, "-") // Replace spaces with hyphens + .replace(/[^a-z0-9-]/g, "") // Remove all special characters except hyphens + .replace(/-+/g, "-") // Replace multiple consecutive hyphens with single hyphen + .replace(/^-+|-+$/g, ""); // Remove leading and trailing hyphens +} + +async function main() { + const conn = await mysql.createConnection( + `mysql://${process.env.DATABASE_USERNAME}:${process.env.DATABASE_PASSWORD}@${process.env.DATABASE_HOST}:3306/unkey?ssl={}`, + ); + + await conn.ping(); + const db = mysqlDrizzle(conn, { schema, mode: "default" }); + + console.log("Starting workspace slug backfill migration..."); + + let cursor = ""; + let processed = 0; + let updated = 0; + let errors = 0; + + do { + // Find workspaces without slugs, ordered by ID for pagination + const workspaces = await db.query.workspaces.findMany({ + where: (table, { isNull, gt, and }) => and(isNull(table.slug), gt(table.id, cursor)), + limit: 1000, + orderBy: (table, { asc }) => asc(table.id), + }); + + if (workspaces.length === 0) { + break; + } + + cursor = workspaces.at(-1)?.id ?? ""; + console.info({ + cursor, + workspaces: workspaces.length, + processed, + updated, + errors, + }); + + // Process each workspace + for (const workspace of workspaces) { + try { + const slug = generateSlug(workspace.name); + + if (!slug) { + console.warn( + `Workspace ${workspace.id} (${workspace.name}) generated empty slug, skipping`, + ); + continue; + } + + // Check if slug already exists and find the next available number + let finalSlug = slug; + let counter = 1; + + while (true) { + const existingWorkspace = await db.query.workspaces.findFirst({ + where: (table, { eq }) => eq(table.slug, finalSlug), + }); + + if (!existingWorkspace || existingWorkspace.id === workspace.id) { + break; // Slug is available or belongs to this workspace + } + + // Slug exists, try with next number + finalSlug = `${slug}-${counter}`; + counter++; + } + + if (counter > 1) { + console.warn( + `Slug '${slug}' already exists, using '${finalSlug}' for workspace ${workspace.id}`, + ); + } + + await db + .update(schema.workspaces) + .set({ slug: finalSlug }) + .where(eq(schema.workspaces.id, workspace.id)); + + updated++; + console.log(`Updated workspace ${workspace.id}: "${workspace.name}" -> "${slug}"`); + } catch (error) { + errors++; + console.error(`Error processing workspace ${workspace.id}:`, error); + } + } + + processed += workspaces.length; + } while (cursor); + + await conn.end(); + + console.info("Migration completed!"); + console.info({ + totalProcessed: processed, + totalUpdated: updated, + totalErrors: errors, + }); +} + +main().catch((error) => { + console.error("Migration failed:", error); + process.exit(1); +}); From 6da9eff839c43723d1f6d00520114e13714ba668 Mon Sep 17 00:00:00 2001 From: MichaelUnkey Date: Tue, 2 Sep 2025 10:34:11 -0400 Subject: [PATCH 2/2] refine from comments --- tools/migrate/backfill-workspace-slugs.ts | 59 +++++++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/tools/migrate/backfill-workspace-slugs.ts b/tools/migrate/backfill-workspace-slugs.ts index f9ba0772e1..703439cf03 100644 --- a/tools/migrate/backfill-workspace-slugs.ts +++ b/tools/migrate/backfill-workspace-slugs.ts @@ -1,4 +1,4 @@ -import { eq, mysqlDrizzle, schema } from "@unkey/db"; +import { and, eq, isNull, mysqlDrizzle, schema } from "@unkey/db"; import mysql from "mysql2/promise"; /** @@ -15,13 +15,21 @@ import mysql from "mysql2/promise"; */ function generateSlug(name: string): string { - return name + // 1) normalize Unicode, strip diacritics; 2) canonicalize; 3) trim edges + const base = name .toLowerCase() + .normalize("NFKD") .trim() .replace(/\s+/g, "-") // Replace spaces with hyphens .replace(/[^a-z0-9-]/g, "") // Remove all special characters except hyphens - .replace(/-+/g, "-") // Replace multiple consecutive hyphens with single hyphen - .replace(/^-+|-+$/g, ""); // Remove leading and trailing hyphens + .replace(/-+/g, "-") // Collapse multiple hyphens + .replace(/^-+|-+$/g, ""); // Trim leading/trailing hyphens + + // reserve space for a "-" suffix later; limit to 61 chars for the base + const MAX_BASE = 61; + const trimmed = base.slice(0, MAX_BASE); + + return trimmed; } async function main() { @@ -85,8 +93,26 @@ async function main() { break; // Slug is available or belongs to this workspace } - // Slug exists, try with next number - finalSlug = `${slug}-${counter}`; + // Parse existing slug to extract base and numeric suffix + const slugMatch = finalSlug.match(/^(.+?)(?:-(\d+))?$/); + const base = slugMatch?.[1] || slug; + const existingSuffix = slugMatch?.[2] ? Number.parseInt(slugMatch[2], 10) : 0; + + // Increment the numeric suffix + const nextSuffix = existingSuffix + 1; + + // Calculate available space for base portion + // Reserve space for "-" where number can be up to 999999 + const suffixLength = `-${nextSuffix}`.length; + const maxBaseLength = 64 - suffixLength; + + // Truncate base if needed to fit within total length limit + const truncatedBase = base.slice(0, maxBaseLength); + + // Ensure we don't end with a hyphen + const cleanBase = truncatedBase.replace(/-+$/, ""); + + finalSlug = `${cleanBase}-${nextSuffix}`; counter++; } @@ -96,13 +122,26 @@ async function main() { ); } + // Update only rows where slug IS NULL to prevent TOCTOU race conditions await db .update(schema.workspaces) .set({ slug: finalSlug }) - .where(eq(schema.workspaces.id, workspace.id)); - - updated++; - console.log(`Updated workspace ${workspace.id}: "${workspace.name}" -> "${slug}"`); + .where(and(eq(schema.workspaces.id, workspace.id), isNull(schema.workspaces.slug))); + + // Check if the update actually affected a row by verifying the database state + const updatedWorkspace = await db.query.workspaces.findFirst({ + where: (table, { eq }) => eq(table.id, workspace.id), + }); + const rowUpdated = updatedWorkspace?.slug === finalSlug; + + if (rowUpdated) { + updated++; + console.log(`Updated workspace ${workspace.id}: "${workspace.name}" -> "${finalSlug}"`); + } else { + console.warn( + `Workspace ${workspace.id} was not updated (slug may have been set concurrently)`, + ); + } } catch (error) { errors++; console.error(`Error processing workspace ${workspace.id}:`, error);