Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions tools/migrate/backfill-workspace-slugs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { and, eq, isNull, mysqlDrizzle, schema } from "@unkey/db";
import mysql from "mysql2/promise";

Comment on lines +1 to +3
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Imports: add Zod

Required for the env validation above.

 import { and, eq, isNull, mysqlDrizzle, schema } from "@unkey/db";
 import mysql from "mysql2/promise";
+import { z } from "zod";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { and, eq, isNull, mysqlDrizzle, schema } from "@unkey/db";
import mysql from "mysql2/promise";
import { and, eq, isNull, mysqlDrizzle, schema } from "@unkey/db";
import mysql from "mysql2/promise";
import { z } from "zod";
🤖 Prompt for AI Agents
In tools/migrate/backfill-workspace-slugs.ts around lines 1 to 3, the file uses
Zod for env validation but does not import it; add the Zod import (for example
import { z } from "zod") to the existing imports at the top of the file so the
env validation code can reference Zod.

/**
* 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 {
// 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, "-") // Collapse multiple hyphens
.replace(/^-+|-+$/g, ""); // Trim leading/trailing hyphens

// reserve space for a "-<n>" 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() {
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" });

Comment on lines +36 to +42
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Env vars naming + validation; connection config should not embed credentials in a URL

  • Violates guideline: envs must be UNKEY__VARNAME.
  • No runtime validation; undefined yields malformed URL.
  • Passing ?ssl={} is brittle; prefer typed options.

Apply:

+import { z } from "zod";
@@
-  const conn = await mysql.createConnection(
-    `mysql://${process.env.DATABASE_USERNAME}:${process.env.DATABASE_PASSWORD}@${process.env.DATABASE_HOST}:3306/unkey?ssl={}`,
-  );
+  const Env = z
+    .object({
+      UNKEY_DB_HOST: z.string().min(1),
+      UNKEY_DB_USERNAME: z.string().min(1),
+      UNKEY_DB_PASSWORD: z.string().min(1),
+      UNKEY_DB_NAME: z.string().min(1).default("unkey"),
+    })
+    .parse(process.env);
+
+  const conn = await mysql.createConnection({
+    host: Env.UNKEY_DB_HOST,
+    user: Env.UNKEY_DB_USERNAME,
+    password: Env.UNKEY_DB_PASSWORD,
+    database: Env.UNKEY_DB_NAME,
+    // TODO: configure SSL per deployment; placeholder enables TLS negotiation without CA
+    ssl: {},
+  });

Verification to run:


🏁 Script executed:

#!/bin/bash
# Ensure no legacy env names remain
rg -nP 'process\.env\.(DATABASE_(USERNAME|PASSWORD|HOST)|DATABASE_URL)'

Length of output: 1386


Refactor all migration scripts to use validated UNKEY_DB_ env vars and object-based MySQL connections*

  • Rename legacy process.env.DATABASE_USERNAME, DATABASE_PASSWORD, DATABASE_HOST, (and any DATABASE_URL) in tools/migrate/*.ts to UNKEY_DB_USERNAME, UNKEY_DB_PASSWORD, UNKEY_DB_HOST, UNKEY_DB_NAME and validate them (e.g., via zod).
  • Replace string-based URL connections
    mysql.createConnection(
      `mysql://${user}:${pass}@${host}:3306/${db}?ssl={}`,
    )
    with object config:
    mysql.createConnection({
      host: Env.UNKEY_DB_HOST,
      user: Env.UNKEY_DB_USERNAME,
      password: Env.UNKEY_DB_PASSWORD,
      database: Env.UNKEY_DB_NAME,
      ssl: {}, // configure per deployment
    });
🤖 Prompt for AI Agents
In tools/migrate/backfill-workspace-slugs.ts around lines 28 to 34, the script
still uses legacy process.env.DATABASE_* vars and a string-based MySQL URL;
update it to read and validate UNKEY_DB_USERNAME, UNKEY_DB_PASSWORD,
UNKEY_DB_HOST, and UNKEY_DB_NAME (use a small zod schema or equivalent
validation and throw with a clear message on failure), then call
mysql.createConnection with an object config (host, user, password, database,
ssl) using the validated Env values instead of composing a connection string;
ensure ssl is passed as an empty object or appropriate config per deployment.

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;
}

Comment on lines +71 to +82
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Empty slugs: consider deterministic fallback instead of skipping

Currently logs and skips; that leaves holes. Prefer workspace-<shortId> or trunc(id) to guarantee progress.

Example change within this block after if (!slug) { ... }:

-        if (!slug) {
-          console.warn(
-            `Workspace ${workspace.id} (${workspace.name}) generated empty slug, skipping`,
-          );
-          continue;
-        }
+        if (!slug) {
+          // deterministic fallback from id
+          const fallback = `workspace-${workspace.id.slice(0, 8).toLowerCase().replace(/[^a-z0-9]/g, "")}`;
+          console.warn(`Empty slug from name; falling back to '${fallback}' for ${workspace.id}`);
+          // proceed with fallback
+          (slug as string) = fallback;
+        }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In tools/migrate/backfill-workspace-slugs.ts around lines 63 to 74, the script
currently logs and skips workspaces when generateSlug returns an empty value,
leaving gaps; instead generate a deterministic fallback slug (e.g.,
"workspace-<shortId>" using a stable prefix plus a truncated or hashed form of
workspace.id) and use that value so the migration always assigns a slug; ensure
the fallback is collision-resistant enough for your system (truncate id to 8-12
chars or hash+encode), and replace the continue with assigning the fallback slug
and proceeding with the existing save/update logic.

// 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
}

// 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 "-<number>" 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++;
}

if (counter > 1) {
console.warn(
`Slug '${slug}' already exists, using '${finalSlug}' for workspace ${workspace.id}`,
);
}

// Update only rows where slug IS NULL to prevent TOCTOU race conditions
await db
.update(schema.workspaces)
.set({ slug: finalSlug })
.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);
}
}

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);
});
Comment on lines +154 to +167
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Always close the DB connection with finally

If an error throws before Line 115, the connection is never closed.

Apply:

-async function main() {
+async function main() {
-  const conn = await mysql.createConnection( /* ... */ );
-  await conn.ping();
-  const db = mysqlDrizzle(conn, { schema, mode: "default" });
-  // work...
-  await conn.end();
+  const conn = await mysql.createConnection( /* ... */ );
+  try {
+    await conn.ping();
+    const db = mysqlDrizzle(conn, { schema, mode: "default" });
+    // work...
+  } finally {
+    await conn.end().catch(() => {});
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
});
async function main() {
const conn = await mysql.createConnection( /* ... */ );
try {
await conn.ping();
const db = mysqlDrizzle(conn, { schema, mode: "default" });
// work...
} finally {
// Always attempt to close the connection, but don't let errors here mask the original one
await conn.end().catch(() => {});
}
console.info("Migration completed!");
console.info({
totalProcessed: processed,
totalUpdated: updated,
totalErrors: errors,
});
}
main().catch((error) => {
console.error("Migration failed:", error);
process.exit(1);
});
🤖 Prompt for AI Agents
In tools/migrate/backfill-workspace-slugs.ts around lines 115-128, the DB
connection is only closed after normal completion so if an error is thrown
earlier the connection is leaked; wrap the main logic that opens and uses conn
in a try/catch/finally (or add a finally to main()) and in the finally block
check that conn is defined and call await conn.end() (guarding against multiple
closes), then rethrow or handle errors as appropriate so the existing
main().catch(...) still logs and exits.