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 { // 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`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 { // 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`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 { 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; } } }