feat(base): enforce unique property names per base

This commit is contained in:
Philipinho
2026-04-24 02:34:38 +01:00
parent 8c0071ee23
commit 464bd701ba
4 changed files with 65 additions and 4 deletions
@@ -88,6 +88,8 @@ export class BasePropertyService {
async create(workspaceId: string, dto: CreatePropertyDto, actorId?: string) {
const type = dto.type as BasePropertyTypeValue;
await this.ensureNameUnique(dto.baseId, dto.name);
let validatedTypeOptions: unknown;
if (type === 'formula') {
const sourceCandidate = (dto.typeOptions as any)?.source;
@@ -192,6 +194,10 @@ export class BasePropertyService {
throw new BadRequestException('Property does not belong to this base');
}
if (dto.name !== undefined) {
await this.ensureNameUnique(dto.baseId, dto.name, dto.propertyId);
}
// Block concurrent type changes — the worker still owns the previous
// conversion, and letting a second one through would race on `type`.
if (property.pendingType) {
@@ -418,6 +424,27 @@ export class BasePropertyService {
* has to happen after the outer transaction commits so socket consumers
* never race ahead of visibility.
*/
private async ensureNameUnique(
baseId: string,
candidate: string,
excludePropertyId?: string,
): Promise<void> {
const trimmed = candidate.trim();
if (!trimmed) return;
const existing = await this.basePropertyRepo.findByBaseId(baseId);
const lower = trimmed.toLowerCase();
const clash = existing.find(
(p) =>
p.id !== excludePropertyId &&
p.name.trim().toLowerCase() === lower,
);
if (clash) {
throw new BadRequestException(
`A property named "${trimmed}" already exists in this base`,
);
}
}
private async loadAndEmit(
dto: UpdatePropertyDto,
workspaceId: string,
@@ -0,0 +1,20 @@
import { type Kysely, sql } from "kysely";
/*
* Enforce one property name per base (case-insensitive, excluding soft-deleted).
* Formulas reference properties by name via `prop("Name")`, and the resolver
* builds a `Map<name, id>` — duplicates would silently clobber and make
* references non-deterministic. Belt-and-suspenders against races that slip
* past service-layer validation.
*/
export async function up(db: Kysely<any>): Promise<void> {
await sql`
CREATE UNIQUE INDEX base_properties_name_unique
ON base_properties (base_id, lower(name))
WHERE deleted_at IS NULL
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX IF EXISTS base_properties_name_unique`.execute(db);
}