merge: feat/phase1 into task/6
This commit is contained in:
commit
4c65939d61
63 files changed
+4092
-2
No files matched your search
@@ -0,0 +1,176 @@
|
||||
import { mkdir, rename, rm } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import type { AttachmentEntity, AttachmentsTable } from "@sttracker/db/schema";
|
||||
import { ValidationError } from "./errors.ts";
|
||||
|
||||
export type Attachment = AttachmentsTable;
|
||||
|
||||
const MAX_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
/** Lowercase and strip parameters (`; charset=...`). */
|
||||
function normalizeMime(mime: string): string {
|
||||
return (mime.split(";")[0] ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function mimeAllowed(mime: string): boolean {
|
||||
if (mime === "image/svg+xml") return false; // scriptable; never serve user SVG
|
||||
return mime.startsWith("image/") || mime === "application/pdf" || mime === "text/plain";
|
||||
}
|
||||
|
||||
const locks = new Map<string, Promise<void>>();
|
||||
|
||||
/** Serialise file mutations per sha within this process. */
|
||||
async function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = locks.get(key) ?? Promise.resolve();
|
||||
const run = prev.then(fn, fn);
|
||||
const tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
locks.set(key, tail);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (locks.get(key) === tail) locks.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function pathFor(dataDir: string, sha: string): string {
|
||||
return join(dataDir, "attachments", sha.slice(0, 2), sha.slice(2, 4), sha);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a file content-addressed by sha256 (deduplicated on disk) and link it to an entity.
|
||||
* Each call creates a new attachment row and link; identical bytes share one file.
|
||||
*/
|
||||
export async function storeAttachment(
|
||||
db: Db,
|
||||
dataDir: string,
|
||||
userId: string,
|
||||
file: { bytes: Uint8Array; mime: string; filename: string; caption?: string; takenAt?: string },
|
||||
link: { entity: AttachmentEntity; entityId: string },
|
||||
): Promise<Attachment> {
|
||||
const mime = normalizeMime(file.mime);
|
||||
if (!mimeAllowed(mime)) throw new ValidationError("mime", "unsupported file type");
|
||||
if (file.bytes.byteLength > MAX_BYTES) throw new ValidationError("file", "larger than 25 MB");
|
||||
|
||||
const sha256 = new Bun.CryptoHasher("sha256").update(file.bytes).digest("hex");
|
||||
const path = pathFor(dataDir, sha256);
|
||||
|
||||
const row: Attachment = {
|
||||
id: newId(),
|
||||
user_id: userId,
|
||||
sha256,
|
||||
mime,
|
||||
size: file.bytes.byteLength,
|
||||
filename: file.filename,
|
||||
caption: file.caption ?? null,
|
||||
taken_at: file.takenAt ?? null,
|
||||
visibility: null,
|
||||
created_at: nowIso(),
|
||||
};
|
||||
await withLock(sha256, async () => {
|
||||
if (!(await Bun.file(path).exists())) {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp-${newId()}`;
|
||||
try {
|
||||
await Bun.write(tmp, file.bytes);
|
||||
await rename(tmp, path);
|
||||
} catch (e) {
|
||||
await rm(tmp, { force: true });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
await db.k.transaction().execute(async (tx) => {
|
||||
await tx.insertInto("attachments").values(row).execute();
|
||||
await tx
|
||||
.insertInto("attachment_links")
|
||||
.values({ id: newId(), attachment_id: row.id, entity: link.entity, entity_id: link.entityId })
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Open an attachment's file. Returns null when it does not exist or belongs to another user. */
|
||||
export async function readAttachment(
|
||||
db: Db,
|
||||
dataDir: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<{ file: ReturnType<typeof Bun.file>; mime: string } | null> {
|
||||
const row = await db.k
|
||||
.selectFrom("attachments")
|
||||
.select(["sha256", "mime"])
|
||||
.where("id", "=", id)
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
if (!row) return null;
|
||||
return { file: Bun.file(pathFor(dataDir, row.sha256)), mime: row.mime };
|
||||
}
|
||||
|
||||
/** Attachments of `userId` linked to the given entity, oldest first. */
|
||||
export async function listAttachments(
|
||||
db: Db,
|
||||
userId: string,
|
||||
entity: AttachmentEntity,
|
||||
entityId: string,
|
||||
): Promise<Attachment[]> {
|
||||
return db.k
|
||||
.selectFrom("attachments")
|
||||
.innerJoin("attachment_links", "attachment_links.attachment_id", "attachments.id")
|
||||
.selectAll("attachments")
|
||||
.where("attachments.user_id", "=", userId)
|
||||
.where("attachment_links.entity", "=", entity)
|
||||
.where("attachment_links.entity_id", "=", entityId)
|
||||
.orderBy("attachments.created_at")
|
||||
.orderBy("attachments.id")
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an attachment's link to an entity. When no links remain the attachment row is
|
||||
* deleted, and the file too if no other row references its content. No-op for other users.
|
||||
*/
|
||||
export async function unlinkAttachment(
|
||||
db: Db,
|
||||
dataDir: string,
|
||||
userId: string,
|
||||
attachmentId: string,
|
||||
entity: AttachmentEntity,
|
||||
entityId: string,
|
||||
): Promise<void> {
|
||||
const att = await db.k
|
||||
.selectFrom("attachments")
|
||||
.select("sha256")
|
||||
.where("id", "=", attachmentId)
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
if (!att) return;
|
||||
await withLock(att.sha256, async () => {
|
||||
const orphan = await db.k.transaction().execute(async (tx) => {
|
||||
await tx
|
||||
.deleteFrom("attachment_links")
|
||||
.where("attachment_id", "=", attachmentId)
|
||||
.where("entity", "=", entity)
|
||||
.where("entity_id", "=", entityId)
|
||||
.execute();
|
||||
const left = await tx
|
||||
.selectFrom("attachment_links")
|
||||
.select("id")
|
||||
.where("attachment_id", "=", attachmentId)
|
||||
.executeTakeFirst();
|
||||
if (left) return false;
|
||||
await tx.deleteFrom("attachments").where("id", "=", attachmentId).execute();
|
||||
const shared = await tx
|
||||
.selectFrom("attachments")
|
||||
.select("id")
|
||||
.where("sha256", "=", att.sha256)
|
||||
.executeTakeFirst();
|
||||
return !shared;
|
||||
});
|
||||
if (orphan) await rm(pathFor(dataDir, att.sha256), { force: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { TokenScope } from "@sttracker/db/schema";
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import { fromJson, toJson } from "@sttracker/db/json";
|
||||
import { sql } from "kysely";
|
||||
import { AuthError } from "./errors.ts";
|
||||
|
||||
export type User = { id: string; username: string; displayName: string; role: "admin" | "member" };
|
||||
|
||||
const USERNAME_RE = /^[a-z0-9_]{2,32}$/;
|
||||
const DAY_MS = 86_400_000;
|
||||
const SESSION_DAYS = 30;
|
||||
|
||||
function sha256Hex(s: string): string {
|
||||
return new Bun.CryptoHasher("sha256").update(s).digest("hex");
|
||||
}
|
||||
|
||||
let dummy: Promise<string> | undefined;
|
||||
/** Fixed argon2id hash, computed once, so unknown-user logins cost the same as real ones. */
|
||||
function dummyHash(): Promise<string> {
|
||||
return (dummy ??= Bun.password.hash("sttracker-dummy-password", { algorithm: "argon2id" }));
|
||||
}
|
||||
|
||||
function randomToken(bytes = 32): string {
|
||||
return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true });
|
||||
}
|
||||
|
||||
function toUser(r: { id: string; username: string; display_name: string; role: "admin" | "member" }): User {
|
||||
return { id: r.id, username: r.username, displayName: r.display_name, role: r.role };
|
||||
}
|
||||
|
||||
export async function registerUser(
|
||||
db: Db,
|
||||
input: { username: string; password: string; displayName?: string; inviteCode?: string },
|
||||
): Promise<User> {
|
||||
const username = input.username.toLowerCase();
|
||||
if (!USERNAME_RE.test(username)) throw new AuthError("invalid_username");
|
||||
const passwordHash = await Bun.password.hash(input.password, { algorithm: "argon2id" });
|
||||
|
||||
return db.k.transaction().execute(async (tx) => {
|
||||
// Serialise registrations on Postgres so two first-time sign-ups cannot both become admin.
|
||||
// SQLite is already serialised by its single connection.
|
||||
if (db.dialect === "postgres") await sql`select pg_advisory_xact_lock(727401)`.execute(tx);
|
||||
|
||||
const anyUser = await tx.selectFrom("users").select("id").limit(1).executeTakeFirst();
|
||||
if (anyUser && !input.inviteCode) throw new AuthError("invite_required");
|
||||
|
||||
const existing = await tx.selectFrom("users").select("id").where("username", "=", username).executeTakeFirst();
|
||||
if (existing) throw new AuthError("username_taken");
|
||||
|
||||
const id = newId();
|
||||
const now = nowIso();
|
||||
|
||||
const role = anyUser ? "member" : "admin";
|
||||
const display_name = input.displayName ?? username;
|
||||
await tx
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id,
|
||||
username,
|
||||
display_name,
|
||||
password_hash: passwordHash,
|
||||
role,
|
||||
preferences: toJson({}),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// used_by references users.id, so claim after the insert; the tx rolls back on failure.
|
||||
if (anyUser) {
|
||||
const claimed = await tx
|
||||
.updateTable("invites")
|
||||
.set({ used_by: id })
|
||||
.where("code_hash", "=", sha256Hex(input.inviteCode!))
|
||||
.where("used_by", "is", null)
|
||||
.where("expires_at", ">", now)
|
||||
.executeTakeFirst();
|
||||
if (Number(claimed.numUpdatedRows) !== 1) throw new AuthError("invite_required");
|
||||
}
|
||||
return { id, username, displayName: display_name, role };
|
||||
});
|
||||
}
|
||||
|
||||
export async function login(
|
||||
db: Db,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ user: User; sessionToken: string }> {
|
||||
const row = await db.k
|
||||
.selectFrom("users")
|
||||
.selectAll()
|
||||
.where("username", "=", username.toLowerCase())
|
||||
.executeTakeFirst();
|
||||
const hash = row?.password_hash ?? (await dummyHash());
|
||||
const ok = await Bun.password.verify(password, hash);
|
||||
if (!row || !ok) {
|
||||
throw new AuthError("invalid_credentials");
|
||||
}
|
||||
const sessionToken = randomToken();
|
||||
await db.k
|
||||
.insertInto("sessions")
|
||||
.values({
|
||||
id: newId(),
|
||||
user_id: row.id,
|
||||
token_hash: sha256Hex(sessionToken),
|
||||
expires_at: new Date(Date.now() + SESSION_DAYS * DAY_MS).toISOString(),
|
||||
created_at: nowIso(),
|
||||
})
|
||||
.execute();
|
||||
return { user: toUser(row), sessionToken };
|
||||
}
|
||||
|
||||
export async function getSessionUser(db: Db, token: string): Promise<User | null> {
|
||||
const row = await db.k
|
||||
.selectFrom("sessions")
|
||||
.innerJoin("users", "users.id", "sessions.user_id")
|
||||
.select(["users.id", "users.username", "users.display_name", "users.role"])
|
||||
.where("sessions.token_hash", "=", sha256Hex(token))
|
||||
.where("sessions.expires_at", ">", nowIso())
|
||||
.executeTakeFirst();
|
||||
return row ? toUser(row) : null;
|
||||
}
|
||||
|
||||
export async function logout(db: Db, token: string): Promise<void> {
|
||||
await db.k.deleteFrom("sessions").where("token_hash", "=", sha256Hex(token)).execute();
|
||||
}
|
||||
|
||||
/** Admin-only. Returns the invite code (shown once; only its hash is stored). */
|
||||
export async function createInvite(db: Db, adminId: string, ttlDays = 7): Promise<string> {
|
||||
const admin = await db.k.selectFrom("users").select("role").where("id", "=", adminId).executeTakeFirst();
|
||||
if (admin?.role !== "admin") throw new AuthError("forbidden");
|
||||
const code = randomToken(16);
|
||||
await db.k
|
||||
.insertInto("invites")
|
||||
.values({
|
||||
id: newId(),
|
||||
code_hash: sha256Hex(code),
|
||||
created_by: adminId,
|
||||
used_by: null,
|
||||
expires_at: new Date(Date.now() + ttlDays * DAY_MS).toISOString(),
|
||||
created_at: nowIso(),
|
||||
})
|
||||
.execute();
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function createAppToken(
|
||||
db: Db,
|
||||
userId: string,
|
||||
name: string,
|
||||
scopes: TokenScope[],
|
||||
): Promise<{ id: string; token: string }> {
|
||||
const id = newId();
|
||||
const token = `stt_${randomToken()}`;
|
||||
await db.k
|
||||
.insertInto("app_tokens")
|
||||
.values({
|
||||
id,
|
||||
user_id: userId,
|
||||
name,
|
||||
token_hash: sha256Hex(token),
|
||||
scopes: toJson(scopes),
|
||||
last_used_at: null,
|
||||
revoked_at: null,
|
||||
created_at: nowIso(),
|
||||
})
|
||||
.execute();
|
||||
return { id, token };
|
||||
}
|
||||
|
||||
export async function verifyAppToken(db: Db, token: string, scope: TokenScope): Promise<User | null> {
|
||||
const row = await db.k
|
||||
.selectFrom("app_tokens")
|
||||
.innerJoin("users", "users.id", "app_tokens.user_id")
|
||||
.select([
|
||||
"app_tokens.id as token_id",
|
||||
"app_tokens.scopes",
|
||||
"users.id",
|
||||
"users.username",
|
||||
"users.display_name",
|
||||
"users.role",
|
||||
])
|
||||
.where("app_tokens.token_hash", "=", sha256Hex(token))
|
||||
.where("app_tokens.revoked_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
if (!row || !fromJson<string[]>(row.scopes, []).includes(scope)) return null;
|
||||
await db.k.updateTable("app_tokens").set({ last_used_at: nowIso() }).where("id", "=", row.token_id).execute();
|
||||
return toUser(row);
|
||||
}
|
||||
|
||||
export async function revokeAppToken(db: Db, userId: string, id: string): Promise<void> {
|
||||
await db.k
|
||||
.updateTable("app_tokens")
|
||||
.set({ revoked_at: nowIso() })
|
||||
.where("id", "=", id)
|
||||
.where("user_id", "=", userId)
|
||||
.where("revoked_at", "is", null)
|
||||
.execute();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CryptoError } from "./errors.ts";
|
||||
|
||||
const SALT = new TextEncoder().encode("sttracker-v1");
|
||||
const INFO = new TextEncoder().encode("sttracker-credentials");
|
||||
|
||||
function b64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString("base64");
|
||||
}
|
||||
|
||||
async function deriveKey(secret: string): Promise<CryptoKey> {
|
||||
const material = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), "HKDF", false, [
|
||||
"deriveKey",
|
||||
]);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: SALT, info: INFO },
|
||||
material,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
/** Encrypt a JSON value with AES-256-GCM. Returns `v1.<b64 iv>.<b64 ciphertext>`. */
|
||||
export async function encryptJson(secret: string, value: unknown): Promise<string> {
|
||||
const key = await deriveKey(secret);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const plain = new TextEncoder().encode(JSON.stringify(value));
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plain));
|
||||
return `v1.${b64(iv)}.${b64(ct)}`;
|
||||
}
|
||||
|
||||
/** Decrypt a blob from {@link encryptJson}. Throws `CryptoError` on a wrong secret or bad blob. */
|
||||
export async function decryptJson<T>(secret: string, blob: string): Promise<T> {
|
||||
const parts = blob.split(".");
|
||||
if (parts.length !== 3 || parts[0] !== "v1") throw new CryptoError("malformed credential blob");
|
||||
try {
|
||||
const iv = new Uint8Array(Buffer.from(parts[1]!, "base64"));
|
||||
const ct = new Uint8Array(Buffer.from(parts[2]!, "base64"));
|
||||
const key = await deriveKey(secret);
|
||||
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
|
||||
return JSON.parse(new TextDecoder().decode(plain)) as T;
|
||||
} catch (cause) {
|
||||
throw new CryptoError("decryption failed", { cause });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { sql, type Kysely } from "kysely";
|
||||
import type { MediaType, WorkHint } from "@sttracker/plugin-sdk/types";
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import { fromJson, toJson } from "@sttracker/db/json";
|
||||
import type { Database, WorksTable } from "@sttracker/db/schema";
|
||||
import { AuthError, NotFoundError, ValidationError } from "./errors.ts";
|
||||
|
||||
export type Creator = { name: string; role?: string };
|
||||
export type Position = { season?: number; episode?: number; index?: number };
|
||||
|
||||
/** A `works` row with JSON columns parsed. Other fields keep their column names. */
|
||||
export interface Work extends Omit<WorksTable, "creators" | "metadata" | "position"> {
|
||||
creators: Creator[];
|
||||
metadata: Record<string, unknown>;
|
||||
position: Position | null;
|
||||
}
|
||||
|
||||
/** Any Kysely handle: the root one or a transaction. */
|
||||
export type K = Kysely<Database>;
|
||||
|
||||
/** Normalised JSON for `works.position`: keys in a fixed order (season, episode, index), or null when empty. */
|
||||
export function normalizePosition(p: Position | undefined | null): string | null {
|
||||
if (!p) return null;
|
||||
const o: Position = {};
|
||||
if (p.season !== undefined) o.season = p.season;
|
||||
if (p.episode !== undefined) o.episode = p.episode;
|
||||
if (p.index !== undefined) o.index = p.index;
|
||||
return Object.keys(o).length === 0 ? null : toJson(o);
|
||||
}
|
||||
|
||||
export function toWork(r: WorksTable): Work {
|
||||
return {
|
||||
...r,
|
||||
creators: fromJson<Creator[]>(r.creators, []),
|
||||
metadata: fromJson<Record<string, unknown>>(r.metadata, {}),
|
||||
position: fromJson<Position | null>(r.position, null),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWorkIn(
|
||||
k: K,
|
||||
input: { hint: WorkHint; ownerUserId?: string | null; parentId?: string | null },
|
||||
actor: string,
|
||||
): Promise<string> {
|
||||
const { hint } = input;
|
||||
if (hint.title.trim() === "") throw new ValidationError("title", "required");
|
||||
if (input.ownerUserId && Object.keys(hint.externalIds).length > 0) {
|
||||
throw new ValidationError("externalIds", "private works cannot carry external ids");
|
||||
}
|
||||
const id = newId();
|
||||
const now = nowIso();
|
||||
await k
|
||||
.insertInto("works")
|
||||
.values({
|
||||
id,
|
||||
media_type: hint.mediaType,
|
||||
title: hint.title,
|
||||
original_title: "",
|
||||
year: hint.year ?? null,
|
||||
release_date: null,
|
||||
creators: toJson(hint.creators ?? []),
|
||||
description: hint.description ?? "",
|
||||
cover_url: hint.coverUrl ?? "",
|
||||
cover_attachment_id: null,
|
||||
metadata: "{}",
|
||||
parent_id: input.parentId ?? null,
|
||||
position: normalizePosition(hint.position),
|
||||
owner_user_id: input.ownerUserId ?? null,
|
||||
merged_into_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.execute();
|
||||
for (const [namespace, value] of Object.entries(hint.externalIds)) {
|
||||
await k.insertInto("external_ids").values({ id: newId(), work_id: id, namespace, value }).execute();
|
||||
}
|
||||
await k
|
||||
.insertInto("work_history")
|
||||
.values({ id: newId(), work_id: id, before: "{}", after: toJson({ created: true, title: hint.title }), actor, created_at: now })
|
||||
.execute();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Insert the work and its external IDs. */
|
||||
export function createWork(
|
||||
db: Db,
|
||||
input: { hint: WorkHint; ownerUserId?: string | null; parentId?: string | null },
|
||||
actor: string,
|
||||
): Promise<string> {
|
||||
return db.k.transaction().execute((tx) => createWorkIn(tx, input, actor));
|
||||
}
|
||||
|
||||
export async function getWorkIn(k: K, id: string): Promise<Work | null> {
|
||||
const seen = new Set<string>();
|
||||
let cur: string | null = id;
|
||||
while (cur && !seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
const row: WorksTable | undefined = await k.selectFrom("works").selectAll().where("id", "=", cur).executeTakeFirst();
|
||||
if (!row) return null;
|
||||
if (!row.merged_into_id) return toWork(row);
|
||||
cur = row.merged_into_id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fetch a work, following `merged_into_id` tombstones to the surviving one. */
|
||||
export function getWork(db: Db, id: string): Promise<Work | null> {
|
||||
return getWorkIn(db.k, id);
|
||||
}
|
||||
|
||||
/** Follow `merged_into_id` to the surviving id (the input id if it is not merged). */
|
||||
export async function finalWorkId(k: K, id: string): Promise<string> {
|
||||
return (await getWorkIn(k, id))?.id ?? id;
|
||||
}
|
||||
|
||||
/**
|
||||
* First work matching any (namespace, value) pair, following merges. Only works visible to `userId`
|
||||
* count (shared, or private to that user); with no user, only shared works. Others are treated as absent.
|
||||
*/
|
||||
export async function findWorkByExternalIdsIn(
|
||||
k: K,
|
||||
ids: Record<string, string>,
|
||||
userId: string | null = null,
|
||||
): Promise<string | null> {
|
||||
for (const [namespace, value] of Object.entries(ids)) {
|
||||
const row = await k
|
||||
.selectFrom("external_ids")
|
||||
.select("work_id")
|
||||
.where("namespace", "=", namespace)
|
||||
.where("value", "=", value)
|
||||
.executeTakeFirst();
|
||||
if (!row) continue;
|
||||
const work = await getWorkIn(k, row.work_id);
|
||||
if (work && (work.owner_user_id === null || work.owner_user_id === userId)) return work.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** First work visible to `userId` (shared works only when omitted) matching any (namespace, value) pair. */
|
||||
export function findWorkByExternalIds(db: Db, ids: Record<string, string>, userId: string | null = null): Promise<string | null> {
|
||||
return findWorkByExternalIdsIn(db.k, ids, userId);
|
||||
}
|
||||
|
||||
async function addMissingExternalIds(k: K, workId: string, ids: Record<string, string>): Promise<string[]> {
|
||||
const added: string[] = [];
|
||||
for (const [namespace, value] of Object.entries(ids)) {
|
||||
const exists = await k
|
||||
.selectFrom("external_ids")
|
||||
.select("id")
|
||||
.where("namespace", "=", namespace)
|
||||
.where("value", "=", value)
|
||||
.executeTakeFirst();
|
||||
if (exists) continue;
|
||||
// A concurrent sync may have added the same pair since the check: skip it rather than fail.
|
||||
const res = await k
|
||||
.insertInto("external_ids")
|
||||
.values({ id: newId(), work_id: workId, namespace, value })
|
||||
.onConflict((oc) => oc.columns(["namespace", "value"]).doNothing())
|
||||
.executeTakeFirst();
|
||||
if (Number(res.numInsertedOrUpdatedRows ?? 0n) > 0) added.push(`${namespace}:${value}`);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
export async function updateWorkFromHintIn(k: K, workId: string, hint: WorkHint, actor: string): Promise<void> {
|
||||
const row = await k.selectFrom("works").selectAll().where("id", "=", workId).executeTakeFirst();
|
||||
if (!row || row.owner_user_id !== null) return; // private works are never touched by syncs
|
||||
const before: Record<string, unknown> = {};
|
||||
const after: Record<string, unknown> = {};
|
||||
const patch: Partial<WorksTable> = {};
|
||||
const set = <F extends "title" | "year" | "creators" | "description" | "cover_url">(field: F, next: WorksTable[F] | undefined, empty: boolean) => {
|
||||
if (next === undefined || empty || next === row[field]) return;
|
||||
before[field] = row[field];
|
||||
after[field] = next;
|
||||
patch[field] = next;
|
||||
};
|
||||
set("title", hint.title, hint.title.trim() === "");
|
||||
set("year", hint.year, hint.year === undefined);
|
||||
set("creators", hint.creators ? toJson(hint.creators) : undefined, !hint.creators || hint.creators.length === 0);
|
||||
set("description", hint.description, !hint.description);
|
||||
set("cover_url", hint.coverUrl, !hint.coverUrl);
|
||||
const addedIds = await addMissingExternalIds(k, workId, hint.externalIds);
|
||||
if (addedIds.length > 0) after.externalIds = addedIds;
|
||||
if (Object.keys(after).length === 0) return;
|
||||
const now = nowIso();
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await k.updateTable("works").set({ ...patch, updated_at: now }).where("id", "=", workId).execute();
|
||||
}
|
||||
await k
|
||||
.insertInto("work_history")
|
||||
.values({ id: newId(), work_id: workId, before: toJson(before), after: toJson(after), actor, created_at: now })
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Fill or update a shared work's display fields from a hint, add missing external IDs, log to `work_history`. */
|
||||
export function updateWorkFromHint(db: Db, workId: string, hint: WorkHint, actor: string): Promise<void> {
|
||||
return db.k.transaction().execute((tx) => updateWorkFromHintIn(tx, workId, hint, actor));
|
||||
}
|
||||
|
||||
function escapeLike(s: string): string {
|
||||
return s.replace(/[!%_]/g, (c) => `!${c}`);
|
||||
}
|
||||
|
||||
/** Shared works plus the user's own private ones, case-insensitive substring match on title. */
|
||||
export async function searchWorks(
|
||||
db: Db,
|
||||
userId: string,
|
||||
q: string,
|
||||
opts: { mediaType?: MediaType; limit?: number } = {},
|
||||
): Promise<Work[]> {
|
||||
let qb = db.k
|
||||
.selectFrom("works")
|
||||
.selectAll()
|
||||
.where("merged_into_id", "is", null)
|
||||
.where((eb) => eb.or([eb("owner_user_id", "is", null), eb("owner_user_id", "=", userId)]))
|
||||
.where(sql<boolean>`lower(title) like ${`%${escapeLike(q.toLowerCase())}%`} escape '!'`);
|
||||
if (opts.mediaType) qb = qb.where("media_type", "=", opts.mediaType);
|
||||
const rows = await qb.orderBy("title").limit(opts.limit ?? 50).execute();
|
||||
return rows.map(toWork);
|
||||
}
|
||||
|
||||
/** Follow the user's aliases and global merge tombstones to the work id they should see. */
|
||||
export async function resolveAliasIn(k: K, userId: string, workId: string): Promise<string> {
|
||||
const seen = new Set<string>();
|
||||
let cur = workId;
|
||||
while (!seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
const alias = await k
|
||||
.selectFrom("work_aliases")
|
||||
.select("to_work_id")
|
||||
.where("user_id", "=", userId)
|
||||
.where("from_work_id", "=", cur)
|
||||
.executeTakeFirst();
|
||||
if (alias) {
|
||||
cur = alias.to_work_id;
|
||||
continue;
|
||||
}
|
||||
const w = await k.selectFrom("works").select("merged_into_id").where("id", "=", cur).executeTakeFirst();
|
||||
if (!w?.merged_into_id) break;
|
||||
cur = w.merged_into_id;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
export function resolveAlias(db: Db, userId: string, workId: string): Promise<string> {
|
||||
return resolveAliasIn(db.k, userId, workId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only global merge of shared work `fromId` into `toId`. Leaves a `merged_into_id` tombstone and
|
||||
* moves external IDs and the children whose position `toId` does not already have. Both works must
|
||||
* be shared. Users without an entry on `toId` get their entry repointed; users who already
|
||||
* have one keep their `from` entry and get a pending `merged` change event.
|
||||
*/
|
||||
export async function adminMergeWorks(db: Db, adminUserId: string, fromId: string, toId: string): Promise<void> {
|
||||
await db.k.transaction().execute(async (tx) => {
|
||||
const admin = await tx.selectFrom("users").select("role").where("id", "=", adminUserId).executeTakeFirst();
|
||||
if (admin?.role !== "admin") throw new AuthError("forbidden");
|
||||
if (fromId === toId) throw new ValidationError("toId", "cannot merge a work into itself");
|
||||
const from = await tx.selectFrom("works").selectAll().where("id", "=", fromId).executeTakeFirst();
|
||||
const to = await tx.selectFrom("works").selectAll().where("id", "=", toId).executeTakeFirst();
|
||||
if (!from) throw new NotFoundError("work");
|
||||
if (!to) throw new NotFoundError("work");
|
||||
if (from.merged_into_id) throw new ValidationError("fromId", "already merged");
|
||||
if (to.merged_into_id) throw new ValidationError("toId", "already merged");
|
||||
if (from.owner_user_id !== null) throw new ValidationError("fromId", "only shared works can be merged");
|
||||
if (to.owner_user_id !== null) throw new ValidationError("toId", "only shared works can be merged");
|
||||
const now = nowIso();
|
||||
|
||||
await tx.updateTable("works").set({ merged_into_id: toId, updated_at: now }).where("id", "=", fromId).execute();
|
||||
const moved = await tx.selectFrom("external_ids").select(["namespace", "value"]).where("work_id", "=", fromId).execute();
|
||||
await tx.updateTable("external_ids").set({ work_id: toId }).where("work_id", "=", fromId).execute();
|
||||
|
||||
// Repoint children so matching through `toId` finds them. A child whose position `toId` already
|
||||
// has stays under `fromId` (unique parent_id+position); it remains reachable via the tombstone.
|
||||
const toPositions = new Set(
|
||||
(await tx.selectFrom("works").select("position").where("parent_id", "=", toId).where("position", "is not", null).execute()).map(
|
||||
(r) => r.position,
|
||||
),
|
||||
);
|
||||
const children = await tx.selectFrom("works").select(["id", "position"]).where("parent_id", "=", fromId).execute();
|
||||
const movedChildren: string[] = [];
|
||||
for (const c of children) {
|
||||
if (c.position !== null && toPositions.has(c.position)) continue;
|
||||
await tx.updateTable("works").set({ parent_id: toId, updated_at: now }).where("id", "=", c.id).execute();
|
||||
movedChildren.push(c.id);
|
||||
}
|
||||
|
||||
const entries = await tx.selectFrom("entries").select(["id", "user_id"]).where("work_id", "=", fromId).execute();
|
||||
for (const e of entries) {
|
||||
const existing = await tx
|
||||
.selectFrom("entries")
|
||||
.select("id")
|
||||
.where("user_id", "=", e.user_id)
|
||||
.where("work_id", "=", toId)
|
||||
.executeTakeFirst();
|
||||
if (existing) {
|
||||
await tx
|
||||
.insertInto("change_events")
|
||||
.values({
|
||||
id: newId(),
|
||||
user_id: e.user_id,
|
||||
connection_id: null,
|
||||
run_id: null,
|
||||
record_id: null,
|
||||
type: "merged",
|
||||
status: "pending",
|
||||
target_entity: "entry",
|
||||
target_id: e.id,
|
||||
payload: toJson({ fromWorkId: fromId, toWorkId: toId }),
|
||||
created_at: now,
|
||||
resolved_at: null,
|
||||
})
|
||||
.execute();
|
||||
} else {
|
||||
await tx.updateTable("entries").set({ work_id: toId, updated_at: now }).where("id", "=", e.id).execute();
|
||||
}
|
||||
}
|
||||
|
||||
await tx
|
||||
.insertInto("work_history")
|
||||
.values({ id: newId(), work_id: fromId, before: toJson({ merged_into_id: null }), after: toJson({ merged_into_id: toId }), actor: adminUserId, created_at: now })
|
||||
.execute();
|
||||
await tx
|
||||
.insertInto("work_history")
|
||||
.values({ id: newId(), work_id: toId, before: "{}", after: toJson({ mergedFrom: fromId, externalIds: moved, children: movedChildren }), actor: adminUserId, created_at: now })
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user