fix(core): race-safe matching, visibility-checked ID matches, child-preserving merges

- migration 0002: unique index on works (parent_id, position)
- matchWork retries the whole transaction on a unique violation (3 attempts)
- addMissingExternalIds uses ON CONFLICT DO NOTHING
- ID matches only return shared works or the user's own; private works cannot carry external ids
- adminMergeWorks requires shared works and repoints non-colliding children
- draft is computed for the aliased work id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 19:29:48 +01:00
1 parent a46fb60eee
commit 437ca143e5
9 files changed
+270 -18

No files matched your search

+30 -6
View File
@@ -67,12 +67,12 @@ interface Resolved {
}
async function resolve(k: K, userId: string | null, hint: WorkHint, actor: string): Promise<Resolved> {
// 1. Any external ID.
const byId = await findWorkByExternalIdsIn(k, hint.externalIds);
// 1. Any external ID on a work this user may see (parents resolve with userId null: shared only).
const byId = await findWorkByExternalIdsIn(k, hint.externalIds, userId);
if (byId) {
await updateWorkFromHintIn(k, byId, hint, actor);
const owner = await k.selectFrom("works").select("owner_user_id").where("id", "=", byId).executeTakeFirstOrThrow();
const workId = userId ? await resolveAliasIn(k, userId, byId) : byId;
const owner = await k.selectFrom("works").select("owner_user_id").where("id", "=", workId).executeTakeFirstOrThrow();
return { workId, created: false, draft: owner.owner_user_id !== null };
}
@@ -160,7 +160,31 @@ async function suggest(k: K, userId: string, draftId: string, hint: WorkHint): P
}
}
/** Resolve a hint to a work per spec §8.4. Never auto-links on fuzzy matches; those become suggestions. */
export function matchWork(db: Db, userId: string, hint: WorkHint, actor: string): Promise<Resolved> {
return db.k.transaction().execute((tx) => resolve(tx, userId, hint, actor));
const MAX_ATTEMPTS = 3;
/** SQLite "UNIQUE constraint failed", or Postgres SQLSTATE 23505, anywhere in the cause chain. */
export function isUniqueViolation(err: unknown): boolean {
for (let e: unknown = err, depth = 0; e && typeof e === "object" && depth < 5; e = (e as { cause?: unknown }).cause, depth++) {
const { code, errno, message } = e as { code?: unknown; errno?: unknown; message?: unknown };
if (code === "23505" || errno === "23505") return true;
if (typeof message === "string" && (message.includes("UNIQUE constraint failed") || message.includes("duplicate key"))) {
return true;
}
}
return false;
}
/**
* Resolve a hint to a work per spec §8.4. Never auto-links on fuzzy matches; those become suggestions.
* A concurrent match may create the same work first (unique external id or parent+position); the losing
* transaction is discarded and the whole resolve re-run, which then finds the winner's rows.
*/
export async function matchWork(db: Db, userId: string, hint: WorkHint, actor: string): Promise<Resolved> {
for (let attempt = 1; ; attempt++) {
try {
return await db.k.transaction().execute((tx) => resolve(tx, userId, hint, actor));
} catch (err) {
if (attempt >= MAX_ATTEMPTS || !isUniqueViolation(err)) throw err;
}
}
}