evOo-local/packages/db/src/user-profile.ts
Claude 516d1d4eb6
fix(adapters): write learned facts under the profile's lock and limit
Profile consolidation wrote facts beside the user profile repository: without
its per-user lock, its duplicate check at write time, or its limit of 500
facts. A long-running profile could grow past the limit and then refuse the
user's own additions. Consolidation now takes the same lock, drops facts the
profile gained since the run read it, and keeps only what still fits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 23:07:44 +00:00

387 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
UserFact,
UserFactCategory,
UserFactSource,
UserFactStatus,
UserProfile,
UserProfileState,
} from "@rakazo/contracts";
import { SENSITIVE_USER_FACT_CATEGORIES } from "@rakazo/contracts";
import type { PrismaClient } from "./client.js";
import { Prisma } from "./client.js";
/** Upper bound on facts one user keeps, so a runaway writer cannot grow the profile without end. */
export const USER_FACT_LIMIT = 500;
export class UserFactNotFoundError extends Error {
constructor() {
super("Fact not found");
this.name = "UserFactNotFoundError";
}
}
export class InvalidUserFactError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidUserFactError";
}
}
export interface NewUserFact {
category: UserFactCategory;
value: string;
source: UserFactSource;
confidence?: number;
/** Omitted or null: visible in every space. */
spaceIds?: string[] | null;
sourceBotId?: string | null;
sourceThreadId?: string | null;
sourceMessageId?: string | null;
}
export interface UserFactEdit {
id: string;
category?: UserFactCategory;
value?: string;
/** null: visible in every space. */
spaceIds?: string[] | null;
}
export interface UserProfileUpdate {
name?: string;
timeZone?: string | null;
locale?: string | null;
language?: string | null;
communicationStyle?: string;
summary?: string;
addFacts?: NewUserFact[];
editFacts?: UserFactEdit[];
confirmFactIds?: string[];
}
/** Outcome for one added fact, in input order. */
export interface AddedUserFact {
id: string;
status: UserFactStatus;
/** False when the statement was already known and the existing fact was kept. */
created: boolean;
}
export type UserFactForgetTarget =
| { factIds: string[] }
| { category: UserFactCategory }
| { all: true };
const SENSITIVE_CATEGORIES = new Set<UserFactCategory>(SENSITIVE_USER_FACT_CATEGORIES);
export function isSensitiveUserFactCategory(category: UserFactCategory): boolean {
return SENSITIVE_CATEGORIES.has(category);
}
/** Comparison key for the same statement written twice: case, spacing, quotes and end punctuation differ. */
export function normalizeUserFactValue(value: string): string {
return value
.normalize("NFKC")
.toLowerCase()
.replace(/\s+/g, " ")
.replace(/^["'“”„‘’«»\s]+|["'“”„‘’«».!?;:,\s]+$/gu, "");
}
const factSelect = {
id: true,
category: true,
value: true,
confidence: true,
source: true,
status: true,
sensitive: true,
allSpaces: true,
sourceBotId: true,
sourceThreadId: true,
createdAt: true,
updatedAt: true,
visibleSpaces: { select: { spaceId: true }, orderBy: { spaceId: "asc" } },
} as const;
type FactRow = Prisma.UserFactGetPayload<{ select: typeof factSelect }>;
type ProfileRow = {
timeZone: string | null;
locale: string | null;
language: string | null;
communicationStyle: string;
summary: string;
consolidatedAt: Date | null;
updatedAt: Date;
};
function factDto(row: FactRow): UserFact {
return {
id: row.id,
category: row.category as UserFactCategory,
value: row.value,
confidence: row.confidence,
source: row.source as UserFactSource,
status: row.status as UserFactStatus,
sensitive: row.sensitive,
allSpaces: row.allSpaces,
spaceIds: row.allSpaces ? [] : row.visibleSpaces.map((space) => space.spaceId),
sourceBotId: row.sourceBotId,
sourceThreadId: row.sourceThreadId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function profileDto(row: ProfileRow | null): UserProfile {
return {
timeZone: row?.timeZone ?? null,
locale: row?.locale ?? null,
language: row?.language ?? null,
communicationStyle: row?.communicationStyle ?? "",
summary: row?.summary ?? "",
consolidatedAt: row?.consolidatedAt?.toISOString() ?? null,
updatedAt: row?.updatedAt.toISOString() ?? null,
};
}
/** The user's profile and every fact, pending ones included, newest first. */
export async function getUserProfileState(
prisma: PrismaClient,
userId: string,
): Promise<UserProfileState> {
const [profile, facts] = await Promise.all([
prisma.userProfile.findUnique({ where: { userId } }),
prisma.userFact.findMany({
where: { userId },
orderBy: [{ createdAt: "desc" }, { id: "asc" }],
select: factSelect,
}),
]);
return { profile: profileDto(profile), facts: facts.map(factDto) };
}
/**
* Serializes one user's profile writes so duplicate checks and the fact limit hold under
* concurrency. Every writer of facts takes it inside its transaction.
*/
export async function lockUserProfile(tx: Prisma.TransactionClient, userId: string): Promise<void> {
const key = `user-profile:${userId}`;
await tx.$queryRaw(Prisma.sql`
SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))::text AS "lock"
`);
}
async function assertUserSpaces(
tx: Prisma.TransactionClient,
userId: string,
spaceIds: string[],
): Promise<void> {
const unique = [...new Set(spaceIds)];
if (unique.length === 0) return;
const memberships = await tx.spaceMember.count({
where: { userId, spaceId: { in: unique } },
});
if (memberships !== unique.length) throw new InvalidUserFactError("Space not found");
}
function visibleSpacesOf(spaceIds: string[] | null | undefined): string[] | null {
return spaceIds && spaceIds.length > 0 ? [...new Set(spaceIds)] : null;
}
async function addFacts(
tx: Prisma.TransactionClient,
userId: string,
facts: NewUserFact[],
): Promise<AddedUserFact[]> {
if (facts.length === 0) return [];
const existing = await tx.userFact.findMany({
where: { userId },
select: { id: true, value: true, status: true },
});
const known = new Map(existing.map((fact) => [normalizeUserFactValue(fact.value), fact]));
let total = existing.length;
const results: AddedUserFact[] = [];
for (const fact of facts) {
const value = fact.value.trim();
const key = normalizeUserFactValue(value);
const match = known.get(key);
if (match) {
// The user stating a pending fact themselves is consent.
if (match.status === "pending" && fact.source === "user") {
await tx.userFact.update({
where: { id: match.id },
data: { status: "active", confirmedAt: new Date() },
});
match.status = "active";
}
results.push({ id: match.id, status: match.status as UserFactStatus, created: false });
continue;
}
if (total >= USER_FACT_LIMIT) {
throw new InvalidUserFactError(`A profile keeps at most ${USER_FACT_LIMIT} facts`);
}
const sensitive = isSensitiveUserFactCategory(fact.category);
const userAuthored = fact.source === "user";
const status: UserFactStatus = sensitive && !userAuthored ? "pending" : "active";
const spaces = visibleSpacesOf(fact.spaceIds);
const created = await tx.userFact.create({
data: {
userId,
category: fact.category,
value,
confidence: fact.confidence ?? 1,
source: fact.source,
status,
sensitive,
allSpaces: spaces === null,
sourceBotId: fact.sourceBotId ?? null,
sourceThreadId: fact.sourceThreadId ?? null,
sourceMessageId: fact.sourceMessageId ?? null,
confirmedAt: userAuthored ? new Date() : null,
...(spaces ? { visibleSpaces: { create: spaces.map((spaceId) => ({ spaceId })) } } : {}),
},
select: { id: true, value: true, status: true },
});
known.set(key, created);
total += 1;
results.push({ id: created.id, status, created: true });
}
return results;
}
async function editFact(
tx: Prisma.TransactionClient,
userId: string,
edit: UserFactEdit,
): Promise<void> {
const fact = await tx.userFact.findFirst({
where: { id: edit.id, userId },
select: { id: true },
});
if (!fact) throw new UserFactNotFoundError();
const data: Prisma.UserFactUpdateInput = {};
if (edit.category !== undefined) {
data.category = edit.category;
data.sensitive = isSensitiveUserFactCategory(edit.category);
}
if (edit.value !== undefined) {
data.value = edit.value.trim();
// The stored embedding described the old wording.
data.embedding = [];
data.embeddingModel = null;
}
if (edit.spaceIds !== undefined) {
const spaces = visibleSpacesOf(edit.spaceIds);
data.allSpaces = spaces === null;
data.visibleSpaces = {
deleteMany: {},
...(spaces ? { create: spaces.map((spaceId) => ({ spaceId })) } : {}),
};
}
await tx.userFact.update({ where: { id: fact.id }, data });
}
async function confirmFacts(
tx: Prisma.TransactionClient,
userId: string,
factIds: string[],
): Promise<void> {
const unique = [...new Set(factIds)];
if (unique.length === 0) return;
const owned = await tx.userFact.count({ where: { userId, id: { in: unique } } });
if (owned !== unique.length) throw new UserFactNotFoundError();
await tx.userFact.updateMany({
where: { userId, id: { in: unique }, status: "pending" },
data: { status: "active", confirmedAt: new Date() },
});
}
function profileFields(update: UserProfileUpdate) {
const fields = {
...(update.timeZone !== undefined ? { timeZone: update.timeZone } : {}),
...(update.locale !== undefined ? { locale: update.locale } : {}),
...(update.language !== undefined ? { language: update.language } : {}),
...(update.communicationStyle !== undefined
? { communicationStyle: update.communicationStyle }
: {}),
...(update.summary !== undefined ? { summary: update.summary } : {}),
};
return Object.keys(fields).length > 0 ? fields : null;
}
/**
* Applies one profile change atomically: account name, profile fields, new facts, fact edits
* and confirmations. Facts in sensitive categories wait for the user's confirmation unless the
* user wrote them, and a statement the profile already holds is not stored twice.
*/
export async function updateUserProfile(
prisma: PrismaClient,
userId: string,
update: UserProfileUpdate,
): Promise<AddedUserFact[]> {
return prisma.$transaction(async (tx) => {
await lockUserProfile(tx, userId);
await assertUserSpaces(
tx,
userId,
[...(update.addFacts ?? []), ...(update.editFacts ?? [])].flatMap(
(fact) => fact.spaceIds ?? [],
),
);
if (update.name !== undefined) {
await tx.user.update({ where: { id: userId }, data: { name: update.name } });
}
const fields = profileFields(update);
if (fields) {
await tx.userProfile.upsert({
where: { userId },
create: { userId, ...fields },
update: fields,
});
}
const added = await addFacts(tx, userId, update.addFacts ?? []);
for (const edit of update.editFacts ?? []) await editFact(tx, userId, edit);
await confirmFacts(tx, userId, update.confirmFactIds ?? []);
return added;
});
}
/**
* Hard-deletes facts. Because the summary may restate them and chats not yet consolidated could
* teach them again, forgetting also clears the summary and marks earlier chats as consolidated.
* Forgetting everything clears the remaining profile fields too.
*/
export async function forgetUserFacts(
prisma: PrismaClient,
userId: string,
target: UserFactForgetTarget,
): Promise<number> {
return prisma.$transaction(async (tx) => {
await lockUserProfile(tx, userId);
const where: Prisma.UserFactWhereInput =
"factIds" in target
? { userId, id: { in: target.factIds } }
: "category" in target
? { userId, category: target.category }
: { userId };
const { count } = await tx.userFact.deleteMany({ where });
const everything = "all" in target;
if (count > 0 || everything) {
const reset = {
summary: "",
consolidatedAt: new Date(),
...(everything
? { timeZone: null, locale: null, language: null, communicationStyle: "" }
: {}),
};
await tx.userProfile.upsert({
where: { userId },
create: { userId, ...reset },
update: reset,
});
}
return count;
});
}