feat(documents): ensureEntityFolder (concurrent-safe + suffix on collision)
Idempotent per-entity subfolder creation under the matching system root. Fast-path SELECT short-circuits the common case. Inserts race safely via uniq_document_folders_entity (partial unique on port_id+entity_type+entity_id) — the loser re-SELECTs the winner's row. Sibling-name collisions between two entities with the same display name append (2), (3), … to the new folder; existing folders never rename. Exports EntityType for use by downstream tasks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,9 @@ import { and, asc, eq } from 'drizzle-orm';
|
|||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { documentFolders, documents, type DocumentFolder } from '@/lib/db/schema/documents';
|
import { documentFolders, documents, type DocumentFolder } from '@/lib/db/schema/documents';
|
||||||
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
|
import { companies } from '@/lib/db/schema/companies';
|
||||||
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog } from '@/lib/audit';
|
||||||
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
|
|
||||||
@@ -343,3 +346,161 @@ export async function ensureSystemRoots(portId: string, userId: string): Promise
|
|||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── ensureEntityFolder ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type EntityType = 'client' | 'company' | 'yacht';
|
||||||
|
const ENTITY_TYPES = new Set<EntityType>(['client', 'company', 'yacht']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the display name for an entity used to label its subfolder.
|
||||||
|
* Clients use `fullName` verbatim (matching rep-facing list views).
|
||||||
|
* Companies and yachts use their `name` column verbatim.
|
||||||
|
*/
|
||||||
|
async function resolveEntityDisplayName(
|
||||||
|
portId: string,
|
||||||
|
entityType: EntityType,
|
||||||
|
entityId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
if (entityType === 'client') {
|
||||||
|
const c = await db.query.clients.findFirst({
|
||||||
|
where: and(eq(clients.id, entityId), eq(clients.portId, portId)),
|
||||||
|
columns: { fullName: true },
|
||||||
|
});
|
||||||
|
if (!c) throw new NotFoundError('Client');
|
||||||
|
return c.fullName;
|
||||||
|
}
|
||||||
|
if (entityType === 'company') {
|
||||||
|
const co = await db.query.companies.findFirst({
|
||||||
|
where: and(eq(companies.id, entityId), eq(companies.portId, portId)),
|
||||||
|
columns: { name: true },
|
||||||
|
});
|
||||||
|
if (!co) throw new NotFoundError('Company');
|
||||||
|
return co.name;
|
||||||
|
}
|
||||||
|
// yacht
|
||||||
|
const y = await db.query.yachts.findFirst({
|
||||||
|
where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)),
|
||||||
|
columns: { name: true },
|
||||||
|
});
|
||||||
|
if (!y) throw new NotFoundError('Yacht');
|
||||||
|
return y.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the error is a Postgres unique-violation (SQLSTATE 23505)
|
||||||
|
* on the per-entity unique index (`uniq_document_folders_entity`). Narrowing
|
||||||
|
* to this specific constraint lets callers distinguish a concurrent-winner
|
||||||
|
* race (same entity_id inserted twice) from a sibling-name collision.
|
||||||
|
*/
|
||||||
|
function isEntityFolderConflict(err: unknown): boolean {
|
||||||
|
if (!err || typeof err !== 'object') return false;
|
||||||
|
const e = err as {
|
||||||
|
code?: unknown;
|
||||||
|
constraint_name?: unknown;
|
||||||
|
constraint?: unknown;
|
||||||
|
cause?: { code?: unknown; constraint_name?: unknown; constraint?: unknown };
|
||||||
|
};
|
||||||
|
const code = e.code ?? e.cause?.code;
|
||||||
|
if (code !== '23505') return false;
|
||||||
|
const constraint =
|
||||||
|
e.constraint_name ?? e.constraint ?? e.cause?.constraint_name ?? e.cause?.constraint;
|
||||||
|
return constraint === 'uniq_document_folders_entity';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotently create the per-entity subfolder under the matching system
|
||||||
|
* root (`Clients/` / `Companies/` / `Yachts/`). Returns the folder row
|
||||||
|
* regardless of whether it was newly created or already existed.
|
||||||
|
*
|
||||||
|
* Concurrent callers race safely via the partial unique index
|
||||||
|
* `uniq_document_folders_entity` — the loser INSERT errors and the
|
||||||
|
* re-SELECT returns the winner's row.
|
||||||
|
*
|
||||||
|
* On sibling-name collision (two entities want the same display name),
|
||||||
|
* appends a numeric suffix `(2)`, `(3)`, …, until the insert succeeds.
|
||||||
|
* The `system_managed` flag stays true on the suffixed folder.
|
||||||
|
*
|
||||||
|
* Note: this helper deliberately does NOT use the no-args
|
||||||
|
* onConflictDoNothing() pattern from ensureSystemRoots. Entity inserts
|
||||||
|
* can conflict on EITHER uniq_document_folders_entity (race winner
|
||||||
|
* already inserted the same entity_id) OR uniq_document_folders_sibling_name
|
||||||
|
* (different entity, same display name). We need to discriminate so we
|
||||||
|
* can retry-with-suffix on the name collision and re-SELECT-and-return
|
||||||
|
* on the entity-id collision.
|
||||||
|
*/
|
||||||
|
export async function ensureEntityFolder(
|
||||||
|
portId: string,
|
||||||
|
entityType: EntityType,
|
||||||
|
entityId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<DocumentFolder> {
|
||||||
|
if (!ENTITY_TYPES.has(entityType)) {
|
||||||
|
throw new ValidationError(`Unknown entity type: ${entityType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: row already exists.
|
||||||
|
const existing = await db.query.documentFolders.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(documentFolders.portId, portId),
|
||||||
|
eq(documentFolders.entityType, entityType),
|
||||||
|
eq(documentFolders.entityId, entityId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
// Locate the system root for this entity type.
|
||||||
|
const rootName: SystemRootName =
|
||||||
|
entityType === 'client' ? 'Clients' : entityType === 'company' ? 'Companies' : 'Yachts';
|
||||||
|
const root = await db.query.documentFolders.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(documentFolders.portId, portId),
|
||||||
|
eq(documentFolders.entityType, 'root'),
|
||||||
|
eq(documentFolders.name, rootName),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!root) {
|
||||||
|
// Self-heal: the port-init hook may have been skipped (legacy port).
|
||||||
|
await ensureSystemRoots(portId, userId);
|
||||||
|
return ensureEntityFolder(portId, entityType, entityId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseName = await resolveEntityDisplayName(portId, entityType, entityId);
|
||||||
|
|
||||||
|
// Try the base name first; on sibling-name collision, append (2), (3)...
|
||||||
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
const candidate = attempt === 0 ? baseName : `${baseName} (${attempt + 1})`;
|
||||||
|
try {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(documentFolders)
|
||||||
|
.values({
|
||||||
|
portId,
|
||||||
|
parentId: root.id,
|
||||||
|
name: candidate,
|
||||||
|
systemManaged: true,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
createdBy: userId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!row) throw new Error('ensureEntityFolder: insert returned no row');
|
||||||
|
return row;
|
||||||
|
} catch (err) {
|
||||||
|
// If another caller won the entity-id race, re-SELECT and return their row.
|
||||||
|
if (isEntityFolderConflict(err)) {
|
||||||
|
const winner = await db.query.documentFolders.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(documentFolders.portId, portId),
|
||||||
|
eq(documentFolders.entityType, entityType),
|
||||||
|
eq(documentFolders.entityId, entityId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (winner) return winner;
|
||||||
|
}
|
||||||
|
// Sibling-name collision (different entity, same name) → bump suffix and retry.
|
||||||
|
if (isSiblingNameConflict(err)) continue;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Task 2 — ensureSystemRoots (TDD).
|
* Task 2 — ensureSystemRoots (TDD).
|
||||||
|
* Task 3 — ensureEntityFolder (TDD).
|
||||||
*
|
*
|
||||||
* Fixture convention: makePort from helpers/factories (async DB insert);
|
* Fixture convention: makePort from helpers/factories (async DB insert);
|
||||||
* TEST_USER_ID resolved once via beforeAll from a seeded user — same pattern
|
* TEST_USER_ID resolved once via beforeAll from a seeded user — same pattern
|
||||||
@@ -11,8 +12,9 @@ import { and, eq } from 'drizzle-orm';
|
|||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { documentFolders } from '@/lib/db/schema/documents';
|
import { documentFolders } from '@/lib/db/schema/documents';
|
||||||
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
import { user } from '@/lib/db/schema/users';
|
import { user } from '@/lib/db/schema/users';
|
||||||
import { ensureSystemRoots } from '@/lib/services/document-folders.service';
|
import { ensureSystemRoots, ensureEntityFolder } from '@/lib/services/document-folders.service';
|
||||||
import { makePort } from '../helpers/factories';
|
import { makePort } from '../helpers/factories';
|
||||||
|
|
||||||
let TEST_USER_ID = '';
|
let TEST_USER_ID = '';
|
||||||
@@ -61,3 +63,78 @@ describe('document-folders service · ensureSystemRoots', () => {
|
|||||||
expect(roots.map((r) => r.name)).toEqual(['Clients', 'Companies', 'Yachts']);
|
expect(roots.map((r) => r.name)).toEqual(['Clients', 'Companies', 'Yachts']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('document-folders service · ensureEntityFolder', () => {
|
||||||
|
let portId: string;
|
||||||
|
let clientId: string;
|
||||||
|
let rootId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
portId = port.id;
|
||||||
|
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||||||
|
const roots = await ensureSystemRoots(portId, TEST_USER_ID);
|
||||||
|
rootId = roots.find((r) => r.name === 'Clients')!.id;
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.insert(clients)
|
||||||
|
.values({
|
||||||
|
portId,
|
||||||
|
fullName: `Smith, John ${crypto.randomUUID().slice(0, 8)}`,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
clientId = client!.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a subfolder under the matching system root with system_managed=true', async () => {
|
||||||
|
const folder = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||||||
|
expect(folder.systemManaged).toBe(true);
|
||||||
|
expect(folder.entityType).toBe('client');
|
||||||
|
expect(folder.entityId).toBe(clientId);
|
||||||
|
expect(folder.parentId).toBe(rootId);
|
||||||
|
// name is the client's fullName verbatim
|
||||||
|
const [row] = await db.select().from(clients).where(eq(clients.id, clientId));
|
||||||
|
expect(folder.name).toBe(row!.fullName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — returns the same row on second call', async () => {
|
||||||
|
const a = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||||||
|
const b = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||||||
|
expect(a.id).toBe(b.id);
|
||||||
|
const all = await db
|
||||||
|
.select()
|
||||||
|
.from(documentFolders)
|
||||||
|
.where(
|
||||||
|
and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
|
||||||
|
);
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends a numeric suffix on name collision with an existing folder', async () => {
|
||||||
|
// Insert a second client with the exact same fullName as the first.
|
||||||
|
const [firstClient] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, clientId));
|
||||||
|
const sharedName = firstClient!.fullName;
|
||||||
|
|
||||||
|
const [collidingClient] = await db
|
||||||
|
.insert(clients)
|
||||||
|
.values({
|
||||||
|
portId,
|
||||||
|
fullName: sharedName,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||||||
|
const second = await ensureEntityFolder(portId, 'client', collidingClient!.id, TEST_USER_ID);
|
||||||
|
expect(second.name).toBe(`${sharedName} (2)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown entity types', async () => {
|
||||||
|
await expect(
|
||||||
|
// @ts-expect-error -- runtime check
|
||||||
|
ensureEntityFolder(portId, 'boat', clientId, TEST_USER_ID),
|
||||||
|
).rejects.toThrow(/entity type/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user