A merged child still holds its (parent_id, position) slot under the unique index, so skipping tombstones made every retry collide. Follow the tombstone to the surviving work instead. Co-Authored-By: Claude Opus 5.5 <[email protected]>
193 lines
7.1 KiB
TypeScript
193 lines
7.1 KiB
TypeScript
import { sql } from "kysely";
|
|
import type { WorkHint } from "@sttracker/plugin-sdk/types";
|
|
import type { Db } from "@sttracker/db/db";
|
|
import { newId, nowIso } from "@sttracker/db/ids";
|
|
import {
|
|
createWorkIn,
|
|
finalWorkId,
|
|
findWorkByExternalIdsIn,
|
|
normalizePosition,
|
|
resolveAliasIn,
|
|
toWork,
|
|
updateWorkFromHintIn,
|
|
type K,
|
|
} from "./works.ts";
|
|
|
|
export interface FuzzyInput {
|
|
title: string;
|
|
year?: number | null;
|
|
creators?: { name: string }[];
|
|
}
|
|
|
|
function normalize(s: string): string {
|
|
return s
|
|
.normalize("NFD")
|
|
.replace(/\p{M}+/gu, "")
|
|
.toLowerCase()
|
|
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
.trim()
|
|
.replace(/^(the|a|an)\s+/, "");
|
|
}
|
|
|
|
function tokens(s: string): string[] {
|
|
return normalize(s).split(" ").filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* 0..1 similarity. Title token-set Jaccard weighs 0.7; year 0.2 (±1 year counts half); any shared
|
|
* creator 0.1. A component missing on either side is left out and the weights renormalised, so a
|
|
* title-only comparison can still reach 1.
|
|
*/
|
|
export function fuzzyScore(a: FuzzyInput, b: FuzzyInput): number {
|
|
const ta = new Set(tokens(a.title));
|
|
const tb = new Set(tokens(b.title));
|
|
let inter = 0;
|
|
for (const t of ta) if (tb.has(t)) inter++;
|
|
const union = ta.size + tb.size - inter;
|
|
const jaccard = union === 0 ? 0 : inter / union;
|
|
let score = jaccard * 0.7;
|
|
let weight = 0.7;
|
|
if (a.year != null && b.year != null) {
|
|
const d = Math.abs(a.year - b.year);
|
|
score += d === 0 ? 0.2 : d === 1 ? 0.1 : 0;
|
|
weight += 0.2;
|
|
}
|
|
const ca = new Set((a.creators ?? []).map((c) => normalize(c.name)).filter(Boolean));
|
|
const cb = (b.creators ?? []).map((c) => normalize(c.name)).filter(Boolean);
|
|
if (ca.size > 0 && cb.length > 0) {
|
|
if (cb.some((n) => ca.has(n))) score += 0.1;
|
|
weight += 0.1;
|
|
}
|
|
return score / weight;
|
|
}
|
|
|
|
interface Resolved {
|
|
workId: string;
|
|
created: boolean;
|
|
draft: boolean;
|
|
}
|
|
|
|
async function resolve(k: K, userId: string | null, hint: WorkHint, actor: string): Promise<Resolved> {
|
|
// 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 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 };
|
|
}
|
|
|
|
// 2. Parent + position.
|
|
const parentId = hint.parent ? (await resolve(k, null, hint.parent, actor)).workId : null;
|
|
const position = normalizePosition(hint.position);
|
|
if (parentId && position) {
|
|
const child = await k
|
|
.selectFrom("works")
|
|
.select("id")
|
|
.where("parent_id", "=", parentId)
|
|
.where("position", "=", position)
|
|
.where("owner_user_id", "is", null)
|
|
.executeTakeFirst();
|
|
if (child) {
|
|
// A merged tombstone still holds its (parent_id, position) slot: follow it to the surviving work.
|
|
const workId = await finalWorkId(k, child.id);
|
|
await updateWorkFromHintIn(k, workId, hint, actor);
|
|
return { workId, created: false, draft: false };
|
|
}
|
|
return { workId: await createWorkIn(k, { hint, parentId }, actor), created: true, draft: false };
|
|
}
|
|
|
|
// 3. External IDs but no match: new shared work.
|
|
if (Object.keys(hint.externalIds).length > 0) {
|
|
return { workId: await createWorkIn(k, { hint, parentId }, actor), created: true, draft: false };
|
|
}
|
|
|
|
if (userId === null) {
|
|
// Parent of a shared child: reuse an identical shared work, else create one.
|
|
let q = k
|
|
.selectFrom("works")
|
|
.select("id")
|
|
.where("owner_user_id", "is", null)
|
|
.where("merged_into_id", "is", null)
|
|
.where("media_type", "=", hint.mediaType)
|
|
.where(sql<boolean>`lower(title) = ${hint.title.toLowerCase()}`);
|
|
q = hint.year === undefined ? q : q.where("year", "=", hint.year);
|
|
q = parentId ? q.where("parent_id", "=", parentId) : q.where("parent_id", "is", null);
|
|
const same = await q.executeTakeFirst();
|
|
if (same) return { workId: same.id, created: false, draft: false };
|
|
return { workId: await createWorkIn(k, { hint, parentId }, actor), created: true, draft: false };
|
|
}
|
|
|
|
// 4. Private draft plus fuzzy suggestions.
|
|
const workId = await createWorkIn(k, { hint, ownerUserId: userId }, actor);
|
|
await suggest(k, userId, workId, hint);
|
|
return { workId, created: true, draft: true };
|
|
}
|
|
|
|
async function suggest(k: K, userId: string, draftId: string, hint: WorkHint): Promise<void> {
|
|
// Bound the candidate set with LIKE on the significant title tokens (OR), so "The Expanse: Leviathan
|
|
// Wakes" still finds "Leviathan Wakes". Tokens of 3+ chars, first five; falls back to the first token.
|
|
const all = tokens(hint.title);
|
|
const sig = all.filter((t) => t.length >= 3).slice(0, 5);
|
|
const likeTokens = sig.length > 0 ? sig : all.slice(0, 1);
|
|
if (likeTokens.length === 0) return;
|
|
const rows = await k
|
|
.selectFrom("works")
|
|
.selectAll()
|
|
.where("merged_into_id", "is", null)
|
|
.where("id", "!=", draftId)
|
|
.where((eb) =>
|
|
eb.or([
|
|
eb.and([eb("owner_user_id", "is", null), eb("media_type", "=", hint.mediaType)]),
|
|
eb("owner_user_id", "=", userId),
|
|
]),
|
|
)
|
|
.where((eb) =>
|
|
eb.or(likeTokens.map((t) => eb(sql<string>`lower(title)`, "like", `%${t}%`))),
|
|
)
|
|
.limit(200)
|
|
.execute();
|
|
const scored = rows
|
|
.map(toWork)
|
|
.map((w) => ({ id: w.id, score: fuzzyScore(hint, w) }))
|
|
.filter((c) => c.score >= 0.6)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 5);
|
|
const now = nowIso();
|
|
for (const c of scored) {
|
|
await k
|
|
.insertInto("match_suggestions")
|
|
.values({ id: newId(), user_id: userId, work_id: draftId, candidate_work_id: c.id, score: c.score, status: "pending", created_at: now })
|
|
.execute();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|