feat(db): enforce globally unique issue prefixes and identifiers
Derive issue_prefix from first 3 letters of company name with deterministic suffixes on collision. Migration rebuilds existing prefixes, reassigns issue numbers, and adds unique indexes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
51
packages/db/src/migrations/0017_tiresome_gabe_jones.sql
Normal file
51
packages/db/src/migrations/0017_tiresome_gabe_jones.sql
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
DROP INDEX "issues_company_identifier_idx";--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Rebuild issue prefixes to be company-specific and globally unique.
|
||||||
|
-- Base prefix is first 3 letters of company name (A-Z only), fallback "CMP".
|
||||||
|
-- Duplicate bases receive deterministic letter suffixes: PAP, PAPA, PAPAA, ...
|
||||||
|
WITH ranked_companies AS (
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
COALESCE(NULLIF(SUBSTRING(REGEXP_REPLACE(UPPER(c.name), '[^A-Z]', '', 'g') FROM 1 FOR 3), ''), 'CMP') AS base_prefix,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY COALESCE(NULLIF(SUBSTRING(REGEXP_REPLACE(UPPER(c.name), '[^A-Z]', '', 'g') FROM 1 FOR 3), ''), 'CMP')
|
||||||
|
ORDER BY c.created_at, c.id
|
||||||
|
) AS prefix_rank
|
||||||
|
FROM companies c
|
||||||
|
)
|
||||||
|
UPDATE companies c
|
||||||
|
SET issue_prefix = CASE
|
||||||
|
WHEN ranked_companies.prefix_rank = 1 THEN ranked_companies.base_prefix
|
||||||
|
ELSE ranked_companies.base_prefix || REPEAT('A', ranked_companies.prefix_rank - 1)
|
||||||
|
END
|
||||||
|
FROM ranked_companies
|
||||||
|
WHERE c.id = ranked_companies.id;--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Reassign issue numbers sequentially per company to guarantee uniqueness.
|
||||||
|
WITH numbered_issues AS (
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY i.company_id ORDER BY i.created_at, i.id) AS issue_number
|
||||||
|
FROM issues i
|
||||||
|
)
|
||||||
|
UPDATE issues i
|
||||||
|
SET issue_number = numbered_issues.issue_number
|
||||||
|
FROM numbered_issues
|
||||||
|
WHERE i.id = numbered_issues.id;--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Rebuild identifiers from normalized prefix + issue number.
|
||||||
|
UPDATE issues i
|
||||||
|
SET identifier = c.issue_prefix || '-' || i.issue_number
|
||||||
|
FROM companies c
|
||||||
|
WHERE c.id = i.company_id;--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Sync counters to the largest issue number currently assigned per company.
|
||||||
|
UPDATE companies c
|
||||||
|
SET issue_counter = COALESCE((
|
||||||
|
SELECT MAX(i.issue_number)
|
||||||
|
FROM issues i
|
||||||
|
WHERE i.company_id = c.id
|
||||||
|
), 0);--> statement-breakpoint
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "companies_issue_prefix_idx" ON "companies" USING btree ("issue_prefix");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "issues_identifier_idx" ON "issues" USING btree ("identifier");
|
||||||
5215
packages/db/src/migrations/meta/0017_snapshot.json
Normal file
5215
packages/db/src/migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
|||||||
"when": 1771955900000,
|
"when": 1771955900000,
|
||||||
"tag": "0016_agent_icon",
|
"tag": "0016_agent_icon",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771883888199,
|
||||||
|
"tag": "0017_tiresome_gabe_jones",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
import { pgTable, uuid, text, integer, timestamp, boolean } from "drizzle-orm/pg-core";
|
import { pgTable, uuid, text, integer, timestamp, boolean, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const companies = pgTable("companies", {
|
export const companies = pgTable(
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
"companies",
|
||||||
name: text("name").notNull(),
|
{
|
||||||
description: text("description"),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
status: text("status").notNull().default("active"),
|
name: text("name").notNull(),
|
||||||
issuePrefix: text("issue_prefix").notNull().default("PAP"),
|
description: text("description"),
|
||||||
issueCounter: integer("issue_counter").notNull().default(0),
|
status: text("status").notNull().default("active"),
|
||||||
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
|
issuePrefix: text("issue_prefix").notNull().default("PAP"),
|
||||||
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
|
issueCounter: integer("issue_counter").notNull().default(0),
|
||||||
requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents")
|
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
|
||||||
.notNull()
|
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
|
||||||
.default(true),
|
requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents")
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
.notNull()
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
.default(true),
|
||||||
});
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
issuePrefixUniqueIdx: uniqueIndex("companies_issue_prefix_idx").on(table.issuePrefix),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -59,6 +59,6 @@ export const issues = pgTable(
|
|||||||
),
|
),
|
||||||
parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId),
|
parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId),
|
||||||
projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId),
|
projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId),
|
||||||
identifierIdx: uniqueIndex("issues_company_identifier_idx").on(table.companyId, table.identifier),
|
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq, sql, count } from "drizzle-orm";
|
import { eq, count } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclip/db";
|
import type { Db } from "@paperclip/db";
|
||||||
import {
|
import {
|
||||||
companies,
|
companies,
|
||||||
@@ -25,6 +25,50 @@ import {
|
|||||||
} from "@paperclip/db";
|
} from "@paperclip/db";
|
||||||
|
|
||||||
export function companyService(db: Db) {
|
export function companyService(db: Db) {
|
||||||
|
const ISSUE_PREFIX_FALLBACK = "CMP";
|
||||||
|
|
||||||
|
function deriveIssuePrefixBase(name: string) {
|
||||||
|
const normalized = name.toUpperCase().replace(/[^A-Z]/g, "");
|
||||||
|
return normalized.slice(0, 3) || ISSUE_PREFIX_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
function suffixForAttempt(attempt: number) {
|
||||||
|
if (attempt <= 1) return "";
|
||||||
|
return "A".repeat(attempt - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIssuePrefixConflict(error: unknown) {
|
||||||
|
const constraint = typeof error === "object" && error !== null && "constraint" in error
|
||||||
|
? (error as { constraint?: string }).constraint
|
||||||
|
: typeof error === "object" && error !== null && "constraint_name" in error
|
||||||
|
? (error as { constraint_name?: string }).constraint_name
|
||||||
|
: undefined;
|
||||||
|
return typeof error === "object"
|
||||||
|
&& error !== null
|
||||||
|
&& "code" in error
|
||||||
|
&& (error as { code?: string }).code === "23505"
|
||||||
|
&& constraint === "companies_issue_prefix_idx";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCompanyWithUniquePrefix(data: typeof companies.$inferInsert) {
|
||||||
|
const base = deriveIssuePrefixBase(data.name);
|
||||||
|
let suffix = 1;
|
||||||
|
while (suffix < 10000) {
|
||||||
|
const candidate = `${base}${suffixForAttempt(suffix)}`;
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.insert(companies)
|
||||||
|
.values({ ...data, issuePrefix: candidate })
|
||||||
|
.returning();
|
||||||
|
return rows[0];
|
||||||
|
} catch (error) {
|
||||||
|
if (!isIssuePrefixConflict(error)) throw error;
|
||||||
|
}
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
throw new Error("Unable to allocate unique issue prefix");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list: () => db.select().from(companies),
|
list: () => db.select().from(companies),
|
||||||
|
|
||||||
@@ -35,12 +79,7 @@ export function companyService(db: Db) {
|
|||||||
.where(eq(companies.id, id))
|
.where(eq(companies.id, id))
|
||||||
.then((rows) => rows[0] ?? null),
|
.then((rows) => rows[0] ?? null),
|
||||||
|
|
||||||
create: (data: typeof companies.$inferInsert) =>
|
create: async (data: typeof companies.$inferInsert) => createCompanyWithUniquePrefix(data),
|
||||||
db
|
|
||||||
.insert(companies)
|
|
||||||
.values(data)
|
|
||||||
.returning()
|
|
||||||
.then((rows) => rows[0]),
|
|
||||||
|
|
||||||
update: (id: string, data: Partial<typeof companies.$inferInsert>) =>
|
update: (id: string, data: Partial<typeof companies.$inferInsert>) =>
|
||||||
db
|
db
|
||||||
|
|||||||
Reference in New Issue
Block a user